From 20aa0daa5f99bf9196680ba86cd131b5fe305a4e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 10 Aug 2026 16:21:42 +0200 Subject: [PATCH 1/3] Adopt stateless MCP protocol Require self-describing 2026-07-28 requests and expose server discovery so callers no longer depend on initialization state. Return cache and result metadata required by the new wire contract, and reject legacy or malformed requests with actionable errors. Co-Authored-By: HAL 9000 --- ldk-server-mcp/src/main.rs | 148 ++++++++++-------- ldk-server-mcp/src/mcp.rs | 106 +++++++++++-- ldk-server-mcp/src/protocol.rs | 74 ++++++++- ldk-server-mcp/src/tools/mod.rs | 16 +- ldk-server-mcp/tests/integration.rs | 235 ++++++++++++++++++++++++---- 5 files changed, 457 insertions(+), 122 deletions(-) diff --git a/ldk-server-mcp/src/main.rs b/ldk-server-mcp/src/main.rs index 0f48f6aa..a20a15bc 100644 --- a/ldk-server-mcp/src/main.rs +++ b/ldk-server-mcp/src/main.rs @@ -14,15 +14,83 @@ mod tools; use ldk_server_client::client::LdkServerClient; use ldk_server_client::ldk_server_grpc::api::GetNodeInfoRequest; +use serde::Serialize; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use crate::mcp::InitializeResult; +use crate::mcp::{request_protocol_version, DiscoverResult, ListToolsResult, PROTOCOL_VERSION}; use crate::protocol::{ JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse, INVALID_PARAMS, METHOD_NOT_FOUND, - PARSE_ERROR, + PARSE_ERROR, UNSUPPORTED_PROTOCOL_VERSION, }; -use crate::tools::build_tool_registry; +use crate::tools::{build_tool_registry, ToolRegistry}; + +fn result_response(id: Value, result: impl Serialize) -> Value { + serde_json::to_value(JsonRpcResponse::new(id, serde_json::to_value(result).unwrap())).unwrap() +} + +fn error_response(id: Value, code: i64, message: impl Into) -> Value { + serde_json::to_value(JsonRpcErrorResponse::new(id, code, message.into())).unwrap() +} + +fn error_response_with_data( + id: Value, code: i64, message: impl Into, data: Value, +) -> Value { + serde_json::to_value(JsonRpcErrorResponse::with_data(id, code, message.into(), data)).unwrap() +} + +async fn handle_request( + request: JsonRpcRequest, client: &LdkServerClient, registry: &ToolRegistry, +) -> Value { + let id = request.id.clone(); + if request.method == "initialize" { + return error_response_with_data( + id, + METHOD_NOT_FOUND, + format!("initialize is not supported; this server requires MCP {PROTOCOL_VERSION}"), + serde_json::json!({ "supported": [PROTOCOL_VERSION] }), + ); + } + + let protocol_version = match request_protocol_version(request.params.as_ref()) { + Ok(protocol_version) => protocol_version, + Err(message) => return error_response(id, INVALID_PARAMS, message), + }; + if protocol_version != PROTOCOL_VERSION { + return error_response_with_data( + id, + UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + serde_json::json!({ + "supported": [PROTOCOL_VERSION], + "requested": protocol_version, + }), + ); + } + + match request.method.as_str() { + "server/discover" => result_response(id, DiscoverResult::new()), + "tools/list" => result_response(id, ListToolsResult::new(registry.list_tools())), + "tools/call" => { + let params = request.params.as_ref().unwrap(); + let Some(tool_name) = params.get("name").and_then(Value::as_str) else { + return error_response(id, INVALID_PARAMS, "Missing required parameter: name"); + }; + let tool_args = match params.get("arguments") { + Some(arguments) if !arguments.is_object() => { + return error_response(id, INVALID_PARAMS, "arguments must be an object"); + }, + Some(arguments) => arguments.clone(), + None => serde_json::json!({}), + }; + match registry.call_tool(client, tool_name, tool_args).await { + Ok(result) => result_response(id, result), + Err(e) => error_response(id, e.code, e.message), + } + }, + _ => error_response(id, METHOD_NOT_FOUND, format!("Method not found: {}", request.method)), + } +} #[tokio::main] async fn main() { @@ -62,7 +130,7 @@ async fn main() { // Probe the server so misconfiguration surfaces on startup rather than on // the first tool call. We warn instead of exiting so the MCP protocol loop - // still answers `initialize` and `tools/list` even when the server is + // still answers `server/discover` and `tools/list` even when the server is // temporarily unreachable. if let Err(e) = client.get_node_info(GetNodeInfoRequest {}).await { eprintln!("Warning: Failed to reach ldk-server on startup: {e}"); @@ -93,73 +161,17 @@ async fn main() { continue; } - let request: JsonRpcRequest = match serde_json::from_str(trimmed) { - Ok(r) => r, - Err(_) => { - let err = - JsonRpcErrorResponse::new(Value::Null, PARSE_ERROR, "Parse error".to_string()); - let resp = serde_json::to_string(&err).unwrap(); - let _ = stdout.write_all(resp.as_bytes()).await; - let _ = stdout.write_all(b"\n").await; - let _ = stdout.flush().await; - continue; - }, - }; - - // Notifications have no id — do not respond - if request.id.is_none() { - continue; - } - - let id = request.id.unwrap(); - - let response_str = match request.method.as_str() { - "initialize" => { - let result = InitializeResult::new(); - let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap()); - serde_json::to_string(&resp).unwrap() - }, - "tools/list" => { - let tools = registry.list_tools(); - let resp = JsonRpcResponse::new(id, serde_json::json!({ "tools": tools })); - serde_json::to_string(&resp).unwrap() - }, - "ping" => { - // Per the MCP spec, a ping must be answered with an empty result object. - let resp = JsonRpcResponse::new(id, serde_json::json!({})); - serde_json::to_string(&resp).unwrap() - }, - "tools/call" => { - let params = request.params.unwrap_or(Value::Null); - match params.get("name").and_then(|v| v.as_str()) { - Some(tool_name) => { - let tool_args = - params.get("arguments").cloned().unwrap_or(serde_json::json!({})); - let result = registry.call_tool(&client, tool_name, tool_args).await; - let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap()); - serde_json::to_string(&resp).unwrap() - }, - None => { - let err = JsonRpcErrorResponse::new( - id, - INVALID_PARAMS, - "Missing required parameter: name".to_string(), - ); - serde_json::to_string(&err).unwrap() - }, - } - }, - _ => { - let err = JsonRpcErrorResponse::new( - id, - METHOD_NOT_FOUND, - format!("Method not found: {}", request.method), - ); - serde_json::to_string(&err).unwrap() + let response = match serde_json::from_str(trimmed) { + Ok(message) => match JsonRpcRequest::from_value(message) { + Ok(Some(request)) => handle_request(request, &client, ®istry).await, + Ok(None) => continue, + Err(err) => serde_json::to_value(err).unwrap(), }, + Err(_) => error_response(Value::Null, PARSE_ERROR, "Parse error"), }; - let _ = stdout.write_all(response_str.as_bytes()).await; + let response = serde_json::to_string(&response).unwrap(); + let _ = stdout.write_all(response.as_bytes()).await; let _ = stdout.write_all(b"\n").await; let _ = stdout.flush().await; } diff --git a/ldk-server-mcp/src/mcp.rs b/ldk-server-mcp/src/mcp.rs index fb5fd786..1e63d6f3 100644 --- a/ldk-server-mcp/src/mcp.rs +++ b/ldk-server-mcp/src/mcp.rs @@ -10,16 +10,21 @@ use serde::Serialize; use serde_json::Value; -pub const PROTOCOL_VERSION: &str = "2025-11-25"; +pub const PROTOCOL_VERSION: &str = "2026-07-28"; pub const SERVER_NAME: &str = "ldk-server-mcp"; pub const SERVER_VERSION: &str = "0.1.0"; +pub const CACHE_TTL_MS: u64 = 3_600_000; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct InitializeResult { - pub protocol_version: String, +pub struct DiscoverResult { + pub result_type: &'static str, + pub supported_versions: [&'static str; 1], pub capabilities: Capabilities, - pub server_info: ServerInfo, + #[serde(rename = "_meta")] + pub metadata: ResultMetadata, + pub ttl_ms: u64, + pub cache_scope: &'static str, } #[derive(Debug, Serialize)] @@ -36,19 +41,68 @@ pub struct ServerInfo { pub version: String, } -impl InitializeResult { +#[derive(Debug, Serialize)] +pub struct ResultMetadata { + #[serde(rename = "io.modelcontextprotocol/serverInfo")] + pub server_info: ServerInfo, +} + +impl ServerInfo { + pub fn new() -> Self { + Self { name: SERVER_NAME.to_string(), version: SERVER_VERSION.to_string() } + } +} + +impl ResultMetadata { + pub fn new() -> Self { + Self { server_info: ServerInfo::new() } + } +} + +impl DiscoverResult { pub fn new() -> Self { Self { - protocol_version: PROTOCOL_VERSION.to_string(), + result_type: "complete", + supported_versions: [PROTOCOL_VERSION], capabilities: Capabilities { tools: ToolsCapability {} }, - server_info: ServerInfo { - name: SERVER_NAME.to_string(), - version: SERVER_VERSION.to_string(), - }, + metadata: ResultMetadata::new(), + ttl_ms: CACHE_TTL_MS, + cache_scope: "public", } } } +pub fn request_protocol_version(params: Option<&Value>) -> Result<&str, String> { + let params = params.and_then(Value::as_object).ok_or("params must be an object")?; + let metadata = params + .get("_meta") + .and_then(Value::as_object) + .ok_or("Missing or invalid required parameter: _meta")?; + + let protocol_version = metadata + .get("io.modelcontextprotocol/protocolVersion") + .and_then(Value::as_str) + .ok_or("Missing or invalid _meta.io.modelcontextprotocol/protocolVersion")?; + + if !metadata.get("io.modelcontextprotocol/clientCapabilities").is_some_and(Value::is_object) { + return Err( + "Missing or invalid _meta.io.modelcontextprotocol/clientCapabilities".to_string() + ); + } + + if let Some(client_info) = metadata.get("io.modelcontextprotocol/clientInfo") { + let valid = client_info.as_object().is_some_and(|client_info| { + client_info.get("name").is_some_and(Value::is_string) + && client_info.get("version").is_some_and(Value::is_string) + }); + if !valid { + return Err("Invalid _meta.io.modelcontextprotocol/clientInfo".to_string()); + } + } + + Ok(protocol_version) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ToolDefinition { @@ -57,12 +111,38 @@ pub struct ToolDefinition { pub input_schema: Value, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListToolsResult<'a> { + pub result_type: &'static str, + pub tools: &'a [ToolDefinition], + pub ttl_ms: u64, + pub cache_scope: &'static str, + #[serde(rename = "_meta")] + pub metadata: ResultMetadata, +} + +impl<'a> ListToolsResult<'a> { + pub fn new(tools: &'a [ToolDefinition]) -> Self { + Self { + result_type: "complete", + tools, + ttl_ms: CACHE_TTL_MS, + cache_scope: "public", + metadata: ResultMetadata::new(), + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ToolCallResult { + pub result_type: &'static str, pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, + #[serde(rename = "_meta")] + pub metadata: ResultMetadata, } #[derive(Debug, Serialize)] @@ -75,15 +155,19 @@ pub struct ToolContent { impl ToolCallResult { pub fn success(text: String) -> Self { Self { + result_type: "complete", content: vec![ToolContent { content_type: "text".to_string(), text }], is_error: None, + metadata: ResultMetadata::new(), } } - pub fn error(text: String) -> Self { + pub fn execution_error(text: String) -> Self { Self { + result_type: "complete", content: vec![ToolContent { content_type: "text".to_string(), text }], is_error: Some(true), + metadata: ResultMetadata::new(), } } } diff --git a/ldk-server-mcp/src/protocol.rs b/ldk-server-mcp/src/protocol.rs index d9d08e94..9478634c 100644 --- a/ldk-server-mcp/src/protocol.rs +++ b/ldk-server-mcp/src/protocol.rs @@ -8,13 +8,21 @@ // licenses. use ldk_server_client::error::{LdkServerError, LdkServerErrorCode}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::Value; pub const PARSE_ERROR: i64 = -32700; +pub const INVALID_REQUEST: i64 = -32600; pub const METHOD_NOT_FOUND: i64 = -32601; pub const INVALID_PARAMS: i64 = -32602; pub const INTERNAL_ERROR: i64 = -32603; +pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022; + +#[derive(Debug, PartialEq)] +enum McpErrorKind { + Protocol, + ToolExecution, +} /// Classified error produced by MCP tool handlers. The `code` is reused for JSON-RPC error /// responses at the envelope level, and for categorising the error text that gets surfaced @@ -23,15 +31,20 @@ pub const INTERNAL_ERROR: i64 = -32603; pub struct McpError { pub code: i64, pub message: String, + kind: McpErrorKind, } impl McpError { pub fn invalid_params(message: impl Into) -> Self { - Self { code: INVALID_PARAMS, message: message.into() } + Self { code: INVALID_PARAMS, message: message.into(), kind: McpErrorKind::Protocol } } pub fn internal(message: impl Into) -> Self { - Self { code: INTERNAL_ERROR, message: message.into() } + Self { code: INTERNAL_ERROR, message: message.into(), kind: McpErrorKind::Protocol } + } + + pub fn is_tool_execution(&self) -> bool { + self.kind == McpErrorKind::ToolExecution } pub fn category(&self) -> &'static str { @@ -52,19 +65,56 @@ impl From for McpError { | LdkServerErrorCode::InternalServerError | LdkServerErrorCode::InternalError => INTERNAL_ERROR, }; - Self { code, message: e.message } + Self { code, message: e.message, kind: McpErrorKind::ToolExecution } } } -#[derive(Debug, Deserialize)] +#[derive(Debug)] pub struct JsonRpcRequest { - #[allow(dead_code)] - pub jsonrpc: String, - pub id: Option, + pub id: Value, pub method: String, pub params: Option, } +impl JsonRpcRequest { + pub fn from_value(value: Value) -> Result, JsonRpcErrorResponse> { + let Some(message) = value.as_object() else { + return Err(JsonRpcErrorResponse::new( + Value::Null, + INVALID_REQUEST, + "Invalid Request".to_string(), + )); + }; + + let id = message.get("id").cloned(); + let response_id = id + .as_ref() + .filter(|id| id.is_string() || id.is_number()) + .cloned() + .unwrap_or(Value::Null); + let valid = message.get("jsonrpc").and_then(Value::as_str) == Some("2.0") + && message.get("method").is_some_and(Value::is_string) + && message.get("params").is_none_or(Value::is_object) + && id.as_ref().is_none_or(|id| id.is_string() || id.is_number()); + if !valid { + return Err(JsonRpcErrorResponse::new( + response_id, + INVALID_REQUEST, + "Invalid Request".to_string(), + )); + } + + let Some(id) = id else { + return Ok(None); + }; + Ok(Some(Self { + id, + method: message.get("method").and_then(Value::as_str).unwrap().to_string(), + params: message.get("params").cloned(), + })) + } +} + #[derive(Debug, Serialize)] pub struct JsonRpcResponse { pub jsonrpc: String, @@ -97,4 +147,12 @@ impl JsonRpcErrorResponse { pub fn new(id: Value, code: i64, message: String) -> Self { Self { jsonrpc: "2.0".to_string(), id, error: JsonRpcError { code, message, data: None } } } + + pub fn with_data(id: Value, code: i64, message: String, data: Value) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id, + error: JsonRpcError { code, message, data: Some(data) }, + } + } } diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs index f689d3d9..25fd158f 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -52,17 +52,21 @@ impl ToolRegistry { pub async fn call_tool( &self, client: &LdkServerClient, name: &str, args: Value, - ) -> ToolCallResult { + ) -> Result { let Some(handler) = self.handlers.get(name) else { - return ToolCallResult::error(format!("Unknown tool: {name}")); + return Err(McpError::invalid_params(format!("Unknown tool: {name}"))); }; match handler(client, args).await { Ok(value) => { - let text = serde_json::to_string(&value) - .unwrap_or_else(|e| format!("Failed to serialize response: {e}")); - ToolCallResult::success(text) + let text = serde_json::to_string(&value).map_err(|e| { + McpError::internal(format!("Failed to serialize response: {e}")) + })?; + Ok(ToolCallResult::success(text)) }, - Err(e) => ToolCallResult::error(format!("{}: {}", e.category(), e.message)), + Err(e) if e.is_tool_execution() => { + Ok(ToolCallResult::execution_error(format!("{}: {}", e.category(), e.message))) + }, + Err(e) => Err(e), } } } diff --git a/ldk-server-mcp/tests/integration.rs b/ldk-server-mcp/tests/integration.rs index 891956c3..196e1dba 100644 --- a/ldk-server-mcp/tests/integration.rs +++ b/ldk-server-mcp/tests/integration.rs @@ -11,6 +11,7 @@ use std::io::{BufRead, BufReader, Write}; use serde_json::{json, Value}; +const PROTOCOL_VERSION: &str = "2026-07-28"; const NUM_TOOLS: usize = 37; const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "bolt11_claim_for_hash", @@ -86,6 +87,24 @@ impl McpProcess { } fn send(&mut self, msg: &Value) { + let mut msg = msg.clone(); + if msg.get("id").is_some() { + let params = msg.as_object_mut().unwrap().entry("params").or_insert_with(|| json!({})); + params.as_object_mut().unwrap().entry("_meta").or_insert_with(|| { + json!({ + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { + "name": "ldk-server-mcp-test", + "version": "0.1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }) + }); + } + self.send_raw(&msg); + } + + fn send_raw(&mut self, msg: &Value) { let line = serde_json::to_string(msg).unwrap(); writeln!(self.stdin, "{}", line).expect("Failed to write to stdin"); self.stdin.flush().expect("Failed to flush stdin"); @@ -127,27 +146,29 @@ fn assert_unreachable_tool(tool_name: &str, arguments: Value) { } #[test] -fn test_initialize() { +fn test_server_discover() { let mut proc = McpProcess::spawn(); proc.send(&json!({ "jsonrpc": "2.0", "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "0.1"} - } + "method": "server/discover", + "params": {} })); let resp = proc.recv(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 1); - assert_eq!(resp["result"]["protocolVersion"], "2025-11-25"); + assert_eq!(resp["result"]["resultType"], "complete"); + assert_eq!(resp["result"]["supportedVersions"], json!([PROTOCOL_VERSION])); assert!(resp["result"]["capabilities"]["tools"].is_object()); - assert_eq!(resp["result"]["serverInfo"]["name"], "ldk-server-mcp"); - assert_eq!(resp["result"]["serverInfo"]["version"], "0.1.0"); + assert_eq!( + resp["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "ldk-server-mcp" + ); + assert_eq!(resp["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["version"], "0.1.0"); + assert_eq!(resp["result"]["ttlMs"], 3_600_000); + assert_eq!(resp["result"]["cacheScope"], "public"); } #[test] @@ -164,6 +185,13 @@ fn test_tools_list() { let resp = proc.recv(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 1); + assert_eq!(resp["result"]["resultType"], "complete"); + assert_eq!(resp["result"]["ttlMs"], 3_600_000); + assert_eq!(resp["result"]["cacheScope"], "public"); + assert_eq!( + resp["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "ldk-server-mcp" + ); let tools = resp["result"]["tools"].as_array().unwrap(); assert_eq!(tools.len(), NUM_TOOLS, "Expected {NUM_TOOLS} tools, got {}", tools.len()); @@ -186,7 +214,7 @@ fn test_tools_list() { } #[test] -fn test_ping() { +fn test_removed_ping_returns_method_not_found() { let mut proc = McpProcess::spawn(); proc.send(&json!({ @@ -198,10 +226,143 @@ fn test_ping() { let resp = proc.recv(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 1); - // Per the MCP spec, ping is answered with an empty result object. - assert!(resp["result"].is_object(), "Expected result object, got: {}", resp["result"]); - assert_eq!(resp["result"].as_object().unwrap().len(), 0, "Expected empty result object"); - assert!(resp.get("error").is_none(), "Ping must not return an error"); + assert_eq!(resp["error"]["code"], -32601); + assert!(resp["error"]["message"].as_str().unwrap().contains("ping")); +} + +#[test] +fn test_initialize_reports_supported_modern_version() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "legacy-test", "version": "0.1.0"} + } + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32601); + assert_eq!(resp["error"]["data"]["supported"], json!([PROTOCOL_VERSION])); +} + +#[test] +fn test_missing_request_metadata_is_invalid_params() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32602); + assert!(resp["error"]["message"].as_str().unwrap().contains("_meta")); +} + +#[test] +fn test_unsupported_protocol_version_reports_supported_versions() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "1900-01-01", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32022); + assert_eq!(resp["error"]["data"]["supported"], json!([PROTOCOL_VERSION])); + assert_eq!(resp["error"]["data"]["requested"], "1900-01-01"); +} + +#[test] +fn test_missing_client_capabilities_is_invalid_params() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION + } + } + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32602); + assert!(resp["error"]["message"].as_str().unwrap().contains("clientCapabilities")); +} + +#[test] +fn test_malformed_client_info_is_invalid_params() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": {"name": "missing-version"}, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32602); + assert!(resp["error"]["message"].as_str().unwrap().contains("clientInfo")); +} + +#[test] +fn test_invalid_json_rpc_envelope_is_invalid_request() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!({ + "jsonrpc": "1.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32600); +} + +#[test] +fn test_non_object_params_are_invalid_request() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": [] + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32600); } #[test] @@ -221,9 +382,8 @@ fn test_tools_call_unknown_tool() { let resp = proc.recv(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 1); - assert_eq!(resp["result"]["isError"], true); - let text = resp["result"]["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("Unknown tool"), "Expected 'Unknown tool' in error, got: {text}"); + assert_eq!(resp["error"]["code"], -32602); + assert!(resp["error"]["message"].as_str().unwrap().contains("Unknown tool")); } #[test] @@ -243,7 +403,12 @@ fn test_tools_call_unreachable_server() { let resp = proc.recv(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 1); + assert_eq!(resp["result"]["resultType"], "complete"); assert_eq!(resp["result"]["isError"], true); + assert_eq!( + resp["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "ldk-server-mcp" + ); let text = resp["result"]["content"][0]["text"].as_str().unwrap(); assert!(!text.is_empty(), "Expected non-empty error message"); } @@ -260,7 +425,7 @@ fn test_bolt11_receive_via_jit_channel_unreachable() { "name": "bolt11_receive_via_jit_channel", "arguments": { "amount_msat": 1000, - "description": "test jit" + "description": {"kind": {"direct": "test jit"}} } } })); @@ -277,7 +442,7 @@ fn test_bolt11_receive_via_jit_channel_unreachable() { fn test_bolt11_receive_variable_amount_via_jit_channel_unreachable() { assert_unreachable_tool( "bolt11_receive_variable_amount_via_jit_channel", - json!({ "description": "test jit" }), + json!({ "description": {"kind": {"direct": "test jit"}} }), ); } @@ -287,7 +452,7 @@ fn test_bolt11_receive_for_hash_unreachable() { "bolt11_receive_for_hash", json!({ "payment_hash": "00".repeat(32), - "description": "test hodl" + "description": {"kind": {"direct": "test hodl"}} }), ); } @@ -332,22 +497,19 @@ fn test_decode_offer_unreachable() { fn test_notification_no_response() { let mut proc = McpProcess::spawn(); - // Send a notification (no id) - should produce no response + // Send a notification (no id) - should produce no response. proc.send(&json!({ "jsonrpc": "2.0", - "method": "notifications/initialized" + "method": "notifications/cancelled", + "params": {"requestId": 999} })); // Send a real request after the notification proc.send(&json!({ "jsonrpc": "2.0", "id": 42, - "method": "initialize", - "params": { - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "0.1"} - } + "method": "tools/list", + "params": {} })); // The first response we get should be for id 42, not for the notification @@ -355,6 +517,21 @@ fn test_notification_no_response() { assert_eq!(resp["id"], 42); } +#[test] +fn test_json_rpc_batch_is_invalid_request() { + let mut proc = McpProcess::spawn(); + + proc.send_raw(&json!([{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + }])); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32600); +} + #[test] fn test_graph_list_channels_unreachable() { let mut proc = McpProcess::spawn(); From 64822764311b60f3e424c021be79c9dff6a7c768 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 10 Aug 2026 16:25:11 +0200 Subject: [PATCH 2/3] Exercise stateless MCP end to end Send modern request metadata from the live test harness and replace the legacy initialization check with server discovery coverage. Verify cache and server metadata on discovery, listing, and live tool responses so the stateless wire contract is exercised against a node. Co-Authored-By: HAL 9000 --- e2e-tests/src/lib.rs | 12 ++++++++++++ e2e-tests/tests/mcp.rs | 29 ++++++++++++++++++----------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 93a29ac5..1c7e72fb 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -556,6 +556,18 @@ impl McpHandle { } pub fn call(&mut self, id: u64, method: &str, params: Value) -> Value { + let mut params = params; + params.as_object_mut().unwrap().insert( + "_meta".to_string(), + serde_json::json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ldk-server-e2e-tests", + "version": "0.1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }), + ); self.send(&serde_json::json!({ "jsonrpc": "2.0", "id": id, diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 6fe137e8..2d848a71 100644 --- a/e2e-tests/tests/mcp.rs +++ b/e2e-tests/tests/mcp.rs @@ -15,24 +15,26 @@ use ldk_server_client::ldk_server_grpc::types::{ use serde_json::json; #[tokio::test] -async fn test_mcp_initialize_and_list_tools() { +async fn test_mcp_discover_and_list_tools() { let bitcoind = TestBitcoind::new(); let server = LdkServerHandle::start(&bitcoind).await; let mut mcp = McpHandle::start(&server); - let initialize = mcp.call( - 1, - "initialize", - json!({ - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": {"name": "e2e-test", "version": "0.1"} - }), + let discover = mcp.call(1, "server/discover", json!({})); + assert_eq!(discover["result"]["resultType"], "complete"); + assert_eq!(discover["result"]["supportedVersions"], json!(["2026-07-28"])); + assert!(discover["result"]["capabilities"]["tools"].is_object()); + assert_eq!( + discover["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "ldk-server-mcp" ); - assert_eq!(initialize["result"]["protocolVersion"], "2025-11-25"); - assert!(initialize["result"]["capabilities"]["tools"].is_object()); + assert_eq!(discover["result"]["ttlMs"], 3_600_000); + assert_eq!(discover["result"]["cacheScope"], "public"); let tools = mcp.call(2, "tools/list", json!({})); + assert_eq!(tools["result"]["resultType"], "complete"); + assert_eq!(tools["result"]["ttlMs"], 3_600_000); + assert_eq!(tools["result"]["cacheScope"], "public"); let tool_names = tools["result"]["tools"].as_array().unwrap(); assert!(tool_names.iter().any(|tool| tool["name"] == "get_node_info")); assert!(tool_names.iter().any(|tool| tool["name"] == "onchain_receive")); @@ -49,6 +51,11 @@ async fn test_mcp_live_tool_calls() { "name": "get_node_info", "arguments": {} })); + assert_eq!(node_info["result"]["resultType"], "complete"); + assert_eq!( + node_info["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "ldk-server-mcp" + ); let node_info_text = node_info["result"]["content"][0]["text"].as_str().unwrap(); let node_info_json: serde_json::Value = serde_json::from_str(node_info_text).unwrap(); assert_eq!(node_info_json["node_id"], server.node_id()); From 09cdb76c8efd2a4103560d3e3c8b944689c2b35a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 10 Aug 2026 16:26:14 +0200 Subject: [PATCH 3/3] Document stateless MCP requests Describe the required per-request metadata and discovery flow so MCP clients use the server without the removed initialization handshake. Call out the latest-only compatibility boundary and supported stdio method surface. Co-Authored-By: HAL 9000 --- ldk-server-mcp/CLAUDE.md | 19 +++++++++++++----- ldk-server-mcp/README.md | 42 +++++++++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md index 0a17e8b8..8d02dd1e 100644 --- a/ldk-server-mcp/CLAUDE.md +++ b/ldk-server-mcp/CLAUDE.md @@ -22,8 +22,8 @@ cargo test --manifest-path e2e-tests/Cargo.toml mcp -- --nocapture src/ main.rs — Entry point: arg parsing, config, stdio JSON-RPC loop, method dispatch config.rs — Config loading (TOML + env vars), mirrors ldk-server-cli config - protocol.rs — JSON-RPC 2.0 request/response types - mcp.rs — MCP protocol types (InitializeResult, ToolDefinition, ToolCallResult) + protocol.rs — JSON-RPC 2.0 validation and request/response types + mcp.rs — MCP request metadata, discovery, tool, and result types tools/ mod.rs — ToolRegistry: build_tool_registry(), list_tools(), call_tool() schema.rs — JSON Schema definitions for all tool inputs @@ -32,11 +32,20 @@ src/ ## MCP Protocol -- **Version**: `2025-11-25` +- **Version**: `2026-07-28` - **Spec**: https://spec.modelcontextprotocol.io/ - **Transport**: stdio (one JSON-RPC 2.0 message per line) -- **Methods implemented**: `initialize`, `tools/list`, `tools/call`, `ping` -- **Notifications handled**: `notifications/initialized` (ignored, no response) +- **Methods implemented**: `server/discover`, `tools/list`, `tools/call` +- **Lifecycle**: stateless; there is no initialization handshake or session +- **Compatibility**: latest-only; legacy `initialize` and `ping` are unsupported +- **Notifications**: ignored without a response; no subscriptions are advertised + +Every request must include `params._meta` with the +`io.modelcontextprotocol/protocolVersion` string and an +`io.modelcontextprotocol/clientCapabilities` object. The optional +`io.modelcontextprotocol/clientInfo` value must contain string `name` and `version` +fields when present. Successful responses include `resultType` and server identity +metadata. Discovery and tool-list results also include public cache metadata. ## Config diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md index 3d958a3d..0de6600e 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -1,9 +1,11 @@ # ldk-server-mcp An [MCP (Model Context Protocol)](https://spec.modelcontextprotocol.io/) server that -exposes [LDK Server](https://github.com/lightningdevkit/ldk-server) operations as tools for AI agents. It communicates -over JSON-RPC 2.0 via stdio and connects to an LDK Server instance over TLS using the [ -`ldk-server-client`](https://github.com/lightningdevkit/ldk-server/tree/main/ldk-server-client) library. +exposes [LDK Server](https://github.com/lightningdevkit/ldk-server) operations as +tools for AI agents. It implements the stateless MCP `2026-07-28` protocol over +JSON-RPC 2.0 via stdio and connects to an LDK Server instance over TLS using the +[`ldk-server-client`](https://github.com/lightningdevkit/ldk-server/tree/main/ldk-server-client) +library. This crate lives inside the `ldk-server` workspace. @@ -99,9 +101,39 @@ Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `me ## MCP Protocol -- **Protocol version**: `2025-11-25` +- **Protocol version**: `2026-07-28` - **Transport**: stdio (one JSON-RPC 2.0 message per line) -- **Methods**: `initialize`, `tools/list`, `tools/call`, `ping` +- **Methods**: `server/discover`, `tools/list`, `tools/call` + +The protocol is stateless: there is no initialization handshake or server-side +session. Every request must carry the protocol version and client capabilities in +`params._meta`. Client information is optional. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "example-client", + "version": "1.0.0" + } + } + } +} +``` + +`server/discover` reports the supported version and server capabilities. +`server/discover` and `tools/list` are public-cacheable for the returned `ttlMs`; +all successful results include `resultType` and server information in `_meta`. + +This server intentionally supports only MCP `2026-07-28`. The legacy `initialize` +and `ping` methods are not implemented, so clients using the stateful MCP lifecycle +must be upgraded before connecting. ## Testing