use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
pub id: Option<Value>,
pub method: String,
#[serde(default)]
pub params: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
pub id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub protocol_version: String,
pub capabilities: ClientCapabilities,
pub client_info: ClientInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientCapabilities {
#[serde(default)]
pub roots: Option<Value>,
#[serde(default)]
pub sampling: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientInfo {
pub name: String,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResult {
pub protocol_version: String,
pub capabilities: ServerCapabilities,
pub server_info: ServerInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourcesCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompts: Option<PromptsCapability>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsCapability {
#[serde(default, rename = "listChanged")]
pub list_changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcesCapability {
#[serde(default)]
pub subscribe: bool,
#[serde(default, rename = "listChanged")]
pub list_changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptsCapability {
#[serde(default, rename = "listChanged")]
pub list_changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: Value,
}
pub const AI_OUTPUT_MARKING: &str = "exo-mcp-ai-generated-v1";
pub const AI_OUTPUT_GENERATOR: &str = "exo-mcp";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolResultMetadata {
pub output_marking: String,
pub generated_by: String,
}
impl ToolResultMetadata {
#[must_use]
pub fn ai_generated() -> Self {
Self {
output_marking: AI_OUTPUT_MARKING.to_owned(),
generated_by: AI_OUTPUT_GENERATOR.to_owned(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub content: Vec<ToolContent>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ToolResultMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ToolContent {
#[serde(rename = "text")]
Text { text: String },
}
impl ToolContent {
#[must_use]
#[allow(dead_code)] pub fn text(&self) -> &str {
match self {
ToolContent::Text { text } => text,
}
}
}
impl ToolResult {
#[must_use]
pub fn success(text: impl Into<String>) -> Self {
Self {
content: vec![ToolContent::Text { text: text.into() }],
is_error: false,
metadata: Some(ToolResultMetadata::ai_generated()),
}
}
#[must_use]
pub fn error(text: impl Into<String>) -> Self {
Self {
content: vec![ToolContent::Text { text: text.into() }],
is_error: true,
metadata: Some(ToolResultMetadata::ai_generated()),
}
}
#[must_use]
pub fn json_success<T>(payload: &T) -> Self
where
T: Serialize + ?Sized,
{
match serde_json::to_string_pretty(payload) {
Ok(text) => Self::success(text),
Err(err) => Self::error(
serde_json::json!({
"error": "mcp_tool_result_serialization_failed",
"message": err.to_string(),
})
.to_string(),
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] pub struct ResourceDefinition {
pub uri: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] pub struct ResourceContent {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
impl ResourceContent {
#[must_use]
pub fn json<T>(uri: impl Into<String>, payload: &T) -> Self
where
T: Serialize + ?Sized,
{
let uri = uri.into();
let text = match serde_json::to_string_pretty(payload) {
Ok(text) => text,
Err(err) => serde_json::json!({
"error": "mcp_resource_serialization_failed",
"uri": uri.clone(),
"message": err.to_string(),
})
.to_string(),
};
Self {
uri,
mime_type: Some("application/json".into()),
text: Some(text),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] pub struct PromptDefinition {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub arguments: Vec<PromptArgument>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] pub struct PromptArgument {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] pub struct PromptMessage {
pub role: String,
pub content: PromptContent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PromptContent {
#[serde(rename = "text")]
Text { text: String },
}
impl PromptContent {
#[must_use]
#[allow(dead_code)] pub fn text(&self) -> &str {
match self {
PromptContent::Text { text } => text,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] pub struct PromptResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub messages: Vec<PromptMessage>,
}
impl JsonRpcResponse {
#[must_use]
pub fn success(id: Option<Value>, result: Value) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: Some(result),
error: None,
}
}
#[must_use]
pub fn error(id: Option<Value>, code: i32, message: String) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError {
code,
message,
data: None,
}),
}
}
#[must_use]
#[allow(dead_code)]
pub fn error_with_data(id: Option<Value>, code: i32, message: String, data: Value) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError {
code,
message,
data: Some(data),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_deserialize_request() {
let req = JsonRpcRequest {
jsonrpc: "2.0".into(),
id: Some(Value::Number(1.into())),
method: "tools/list".into(),
params: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: JsonRpcRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.jsonrpc, "2.0");
assert_eq!(parsed.method, "tools/list");
assert_eq!(parsed.id, Some(Value::Number(1.into())));
assert!(parsed.params.is_none());
}
#[test]
fn serialize_deserialize_request_with_params() {
let json = r#"{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"test"}}"#;
let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.method, "tools/call");
assert!(req.params.is_some());
}
#[test]
fn serialize_success_response() {
let resp = JsonRpcResponse::success(
Some(Value::Number(1.into())),
serde_json::json!({"status": "ok"}),
);
let json = serde_json::to_string(&resp).unwrap();
let parsed: JsonRpcResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.result.is_some());
assert!(parsed.error.is_none());
}
#[test]
fn serialize_error_response() {
let resp = JsonRpcResponse::error(
Some(Value::Number(1.into())),
METHOD_NOT_FOUND,
"method not found".into(),
);
let json = serde_json::to_string(&resp).unwrap();
let parsed: JsonRpcResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.result.is_none());
assert!(parsed.error.is_some());
let err = parsed.error.unwrap();
assert_eq!(err.code, METHOD_NOT_FOUND);
assert_eq!(err.message, "method not found");
assert!(err.data.is_none());
}
#[test]
fn serialize_error_response_with_data() {
let resp = JsonRpcResponse::error_with_data(
Some(Value::Number(1.into())),
INVALID_PARAMS,
"invalid params".into(),
serde_json::json!({"field": "name"}),
);
let json = serde_json::to_string(&resp).unwrap();
let parsed: JsonRpcResponse = serde_json::from_str(&json).unwrap();
let err = parsed.error.unwrap();
assert_eq!(err.code, INVALID_PARAMS);
assert!(err.data.is_some());
}
#[test]
fn initialize_result_serialization() {
let result = InitializeResult {
protocol_version: "2024-11-05".into(),
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: false,
}),
resources: Some(ResourcesCapability {
subscribe: false,
list_changed: false,
}),
prompts: None,
},
server_info: ServerInfo {
name: "exochain-mcp".into(),
version: "0.1.0".into(),
},
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["protocolVersion"], "2024-11-05");
assert_eq!(json["serverInfo"]["name"], "exochain-mcp");
assert!(json.get("capabilities").unwrap().get("prompts").is_none());
}
#[test]
fn tool_definition_serialization() {
let tool = ToolDefinition {
name: "test_tool".into(),
description: "A test tool".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
};
let json = serde_json::to_value(&tool).unwrap();
assert_eq!(json["name"], "test_tool");
assert_eq!(json["inputSchema"]["type"], "object");
}
#[test]
fn tool_result_no_error_skips_field() {
let result = ToolResult::success("hello");
let json = serde_json::to_string(&result).unwrap();
assert!(!json.contains("is_error"));
assert!(!json.contains("isError"));
}
#[test]
fn tool_result_with_error() {
let result = ToolResult::error("error occurred");
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["is_error"], true);
}
struct FailingSerialize;
impl serde::Serialize for FailingSerialize {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Err(serde::ser::Error::custom(
"intentional serialization failure",
))
}
}
#[test]
fn tool_result_json_success_fails_closed_on_serialization_error() {
let result = ToolResult::json_success(&FailingSerialize);
assert!(result.is_error);
let text = result.content[0].text();
assert!(text.contains("mcp_tool_result_serialization_failed"));
assert!(text.contains("intentional serialization failure"));
assert_ne!(text, "{}");
}
#[test]
fn resource_content_json_fails_closed_on_serialization_error() {
let content = ResourceContent::json("exochain://test", &FailingSerialize);
let text = content.text.expect("error JSON present");
assert!(text.contains("mcp_resource_serialization_failed"));
assert!(text.contains("exochain://test"));
assert!(text.contains("intentional serialization failure"));
assert_ne!(text, "{}");
}
#[test]
fn mcp_json_emitters_do_not_fallback_to_empty_objects() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for path in [
"src/mcp/tools/node.rs",
"src/mcp/resources/node_status.rs",
"src/mcp/resources/invariants.rs",
"src/mcp/resources/mcp_rules.rs",
"src/mcp/resources/tools_summary.rs",
] {
let source = std::fs::read_to_string(manifest_dir.join(path)).unwrap();
assert!(
!source.contains("unwrap_or_else(|_| \"{}\".to_string())"),
"{path} must fail closed instead of suppressing serialization errors as {{}}"
);
}
}
#[test]
fn tool_content_text_tag() {
let content = ToolContent::Text {
text: "hello".into(),
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
}
#[test]
fn resource_definition_optional_fields() {
let resource = ResourceDefinition {
uri: "exochain://node/status".into(),
name: "Node Status".into(),
description: None,
mime_type: None,
};
let json = serde_json::to_string(&resource).unwrap();
assert!(!json.contains("description"));
assert!(!json.contains("mimeType"));
}
#[test]
fn request_without_id_is_notification() {
let json = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
assert!(req.id.is_none());
assert_eq!(req.method, "notifications/initialized");
}
}