From 9670f57586523751f7b56fa035114b702256d56f Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 9 Aug 2026 21:39:16 -0500 Subject: [PATCH] Update MCP to 2026-07-28 protocol Add stateless discovery and per-request protocol negotiation. Return the required result metadata and cache hints while retaining compatibility with the legacy initialization flow. Developed with assistance from OpenAI Codex. --- e2e-tests/tests/mcp.rs | 30 +++++- ldk-server-mcp/CLAUDE.md | 7 +- ldk-server-mcp/README.md | 9 +- ldk-server-mcp/src/main.rs | 155 +++++++++++++++++++++++++--- ldk-server-mcp/src/mcp.rs | 7 +- ldk-server-mcp/src/protocol.rs | 9 ++ ldk-server-mcp/src/tools/mod.rs | 4 + ldk-server-mcp/tests/integration.rs | 95 +++++++++++++++++ 8 files changed, 291 insertions(+), 25 deletions(-) diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 6fe137e8..023d637c 100644 --- a/e2e-tests/tests/mcp.rs +++ b/e2e-tests/tests/mcp.rs @@ -15,13 +15,27 @@ 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_initialize_and_list_tools() { let bitcoind = TestBitcoind::new(); let server = LdkServerHandle::start(&bitcoind).await; let mut mcp = McpHandle::start(&server); + let discover = mcp.call( + 1, + "server/discover", + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "e2e-test", "version": "0.1"} + } + }), + ); + assert_eq!(discover["result"]["supportedVersions"][0], "2026-07-28"); + assert_eq!(discover["result"]["resultType"], "complete"); + assert!(discover["result"]["capabilities"]["tools"].is_object()); let initialize = mcp.call( - 1, + 2, "initialize", json!({ "protocolVersion": "2025-11-25", @@ -32,7 +46,17 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!(initialize["result"]["protocolVersion"], "2025-11-25"); assert!(initialize["result"]["capabilities"]["tools"].is_object()); - let tools = mcp.call(2, "tools/list", json!({})); + let tools = mcp.call( + 3, + "tools/list", + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + }), + ); + assert_eq!(tools["result"]["resultType"], "complete"); 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")); diff --git a/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md index 0a17e8b8..ccd07bef 100644 --- a/ldk-server-mcp/CLAUDE.md +++ b/ldk-server-mcp/CLAUDE.md @@ -32,10 +32,11 @@ src/ ## MCP Protocol -- **Version**: `2025-11-25` -- **Spec**: https://spec.modelcontextprotocol.io/ +- **Versions**: `2026-07-28` (current), `2025-11-25` (legacy compatibility) +- **Spec**: https://modelcontextprotocol.io/specification/2026-07-28 - **Transport**: stdio (one JSON-RPC 2.0 message per line) -- **Methods implemented**: `initialize`, `tools/list`, `tools/call`, `ping` +- **Current methods implemented**: `server/discover`, `tools/list`, `tools/call` +- **Legacy methods implemented**: `initialize`, `ping` - **Notifications handled**: `notifications/initialized` (ignored, no response) ## Config diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md index 3d958a3d..16e2c611 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -99,9 +99,14 @@ Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `me ## MCP Protocol -- **Protocol version**: `2025-11-25` +- **Protocol versions**: `2026-07-28` (current), `2025-11-25` (legacy compatibility) - **Transport**: stdio (one JSON-RPC 2.0 message per line) -- **Methods**: `initialize`, `tools/list`, `tools/call`, `ping` +- **Current methods**: `server/discover`, `tools/list`, `tools/call` +- **Legacy methods**: `initialize`, `ping` + +For `2026-07-28`, every request includes the protocol version and client capabilities in +`params._meta`. Existing clients that use the `2025-11-25` initialization handshake remain +supported. ## Testing diff --git a/ldk-server-mcp/src/main.rs b/ldk-server-mcp/src/main.rs index 0f48f6aa..9ed96d32 100644 --- a/ldk-server-mcp/src/main.rs +++ b/ldk-server-mcp/src/main.rs @@ -17,10 +17,12 @@ use ldk_server_client::ldk_server_grpc::api::GetNodeInfoRequest; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use crate::mcp::InitializeResult; +use crate::mcp::{ + InitializeResult, LEGACY_PROTOCOL_VERSION, PROTOCOL_VERSION, SERVER_NAME, SERVER_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; @@ -62,7 +64,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 discovery and tool-list requests 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}"); @@ -113,30 +115,136 @@ async fn main() { let id = request.id.unwrap(); + let protocol_version = request + .params + .as_ref() + .and_then(|params| params.get("_meta")) + .and_then(|meta| meta.get("io.modelcontextprotocol/protocolVersion")) + .and_then(Value::as_str) + .map(str::to_owned); + + if let Some(requested) = protocol_version.as_deref() { + if requested != PROTOCOL_VERSION && requested != LEGACY_PROTOCOL_VERSION { + let err = JsonRpcErrorResponse::with_data( + id, + UNSUPPORTED_PROTOCOL_VERSION, + format!("Unsupported protocol version: {requested}"), + serde_json::json!({ + "supported": [PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION], + "requested": requested, + }), + ); + write_response(&mut stdout, serde_json::to_string(&err).unwrap()).await; + continue; + } + + let has_capabilities = request + .params + .as_ref() + .and_then(|params| params.get("_meta")) + .and_then(|meta| meta.get("io.modelcontextprotocol/clientCapabilities")) + .is_some_and(Value::is_object); + if requested == PROTOCOL_VERSION && !has_capabilities { + let err = JsonRpcErrorResponse::new( + id, + INVALID_PARAMS, + "Missing required request metadata: io.modelcontextprotocol/clientCapabilities" + .to_string(), + ); + write_response(&mut stdout, serde_json::to_string(&err).unwrap()).await; + continue; + } + } + + let latest_protocol = protocol_version.as_deref() == Some(PROTOCOL_VERSION); 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() + if request + .params + .as_ref() + .and_then(|params| params.get("protocolVersion")) + .and_then(Value::as_str) + == Some(PROTOCOL_VERSION) + { + let err = JsonRpcErrorResponse::new( + id, + METHOD_NOT_FOUND, + "Method not found: initialize".to_string(), + ); + serde_json::to_string(&err).unwrap() + } else { + let result = InitializeResult::new(); + let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap()); + serde_json::to_string(&resp).unwrap() + } + }, + "server/discover" => { + if !latest_protocol { + let err = JsonRpcErrorResponse::new( + id, + INVALID_PARAMS, + "server/discover requires 2026-07-28 request metadata".to_string(), + ); + serde_json::to_string(&err).unwrap() + } else { + let result = latest_result(serde_json::json!({ + "supportedVersions": [PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION], + "capabilities": { "tools": {} }, + "instructions": "Use the available tools to operate an LDK Server node.", + "ttlMs": 300_000, + "cacheScope": "public", + })); + let resp = JsonRpcResponse::new(id, result); + serde_json::to_string(&resp).unwrap() + } }, "tools/list" => { let tools = registry.list_tools(); - let resp = JsonRpcResponse::new(id, serde_json::json!({ "tools": tools })); + let result = if latest_protocol { + latest_result(serde_json::json!({ + "tools": tools, + "ttlMs": 300_000, + "cacheScope": "public", + })) + } else { + serde_json::json!({ "tools": tools }) + }; + let resp = JsonRpcResponse::new(id, result); 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() + if latest_protocol { + let err = JsonRpcErrorResponse::new( + id, + METHOD_NOT_FOUND, + "Method not found: ping".to_string(), + ); + serde_json::to_string(&err).unwrap() + } else { + 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) if latest_protocol && !registry.has_tool(tool_name) => { + let err = JsonRpcErrorResponse::new( + id, + INVALID_PARAMS, + format!("Unknown tool: {tool_name}"), + ); + serde_json::to_string(&err).unwrap() + }, 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()); + let mut result = serde_json::to_value(result).unwrap(); + if latest_protocol { + result = latest_result(result); + } + let resp = JsonRpcResponse::new(id, result); serde_json::to_string(&resp).unwrap() }, None => { @@ -159,8 +267,27 @@ async fn main() { }, }; - let _ = stdout.write_all(response_str.as_bytes()).await; - let _ = stdout.write_all(b"\n").await; - let _ = stdout.flush().await; + write_response(&mut stdout, response_str).await; } } + +fn latest_result(mut result: Value) -> Value { + let object = result.as_object_mut().expect("MCP results must be JSON objects"); + object.insert("resultType".to_string(), Value::String("complete".to_string())); + object.insert( + "_meta".to_string(), + serde_json::json!({ + "io.modelcontextprotocol/serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + } + }), + ); + result +} + +async fn write_response(stdout: &mut tokio::io::Stdout, response: String) { + 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..d03845a6 100644 --- a/ldk-server-mcp/src/mcp.rs +++ b/ldk-server-mcp/src/mcp.rs @@ -10,9 +10,10 @@ 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 LEGACY_PROTOCOL_VERSION: &str = "2025-11-25"; pub const SERVER_NAME: &str = "ldk-server-mcp"; -pub const SERVER_VERSION: &str = "0.1.0"; +pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -39,7 +40,7 @@ pub struct ServerInfo { impl InitializeResult { pub fn new() -> Self { Self { - protocol_version: PROTOCOL_VERSION.to_string(), + protocol_version: LEGACY_PROTOCOL_VERSION.to_string(), capabilities: Capabilities { tools: ToolsCapability {} }, server_info: ServerInfo { name: SERVER_NAME.to_string(), diff --git a/ldk-server-mcp/src/protocol.rs b/ldk-server-mcp/src/protocol.rs index d9d08e94..1ed6d29e 100644 --- a/ldk-server-mcp/src/protocol.rs +++ b/ldk-server-mcp/src/protocol.rs @@ -15,6 +15,7 @@ pub const PARSE_ERROR: i64 = -32700; 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; /// 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 @@ -97,4 +98,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..891d014b 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -50,6 +50,10 @@ impl ToolRegistry { &self.definitions } + pub fn has_tool(&self, name: &str) -> bool { + self.handlers.contains_key(name) + } + pub async fn call_tool( &self, client: &LdkServerClient, name: &str, args: Value, ) -> ToolCallResult { diff --git a/ldk-server-mcp/tests/integration.rs b/ldk-server-mcp/tests/integration.rs index 891956c3..c70c0056 100644 --- a/ldk-server-mcp/tests/integration.rs +++ b/ldk-server-mcp/tests/integration.rs @@ -150,6 +150,101 @@ fn test_initialize() { assert_eq!(resp["result"]["serverInfo"]["version"], "0.1.0"); } +#[test] +fn test_discover_latest_protocol() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0.1"} + } + } + })); + + let result = proc.recv()["result"].clone(); + assert_eq!(result["resultType"], "complete"); + assert_eq!(result["supportedVersions"][0], "2026-07-28"); + assert!(result["supportedVersions"].as_array().unwrap().contains(&json!("2025-11-25"))); + assert!(result["capabilities"]["tools"].is_object()); + assert_eq!(result["ttlMs"], 300_000); + assert_eq!(result["cacheScope"], "public"); + assert_eq!(result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "ldk-server-mcp"); +} + +#[test] +fn test_latest_tools_list_result_fields() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })); + + let result = proc.recv()["result"].clone(); + assert_eq!(result["resultType"], "complete"); + assert_eq!(result["ttlMs"], 300_000); + assert_eq!(result["cacheScope"], "public"); + assert_eq!(result["tools"].as_array().unwrap().len(), NUM_TOOLS); +} + +#[test] +fn test_latest_protocol_rejects_unknown_tool_as_protocol_error() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "nonexistent_tool", + "arguments": {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32602); + assert!(resp["error"]["message"].as_str().unwrap().contains("Unknown tool")); +} + +#[test] +fn test_rejects_unsupported_protocol_version() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2099-01-01", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })); + + let resp = proc.recv(); + assert_eq!(resp["error"]["code"], -32022); + assert_eq!(resp["error"]["data"]["requested"], "2099-01-01"); + assert!(resp["error"]["data"]["supported"].as_array().unwrap().contains(&json!("2026-07-28"))); +} + #[test] fn test_tools_list() { let mut proc = McpProcess::spawn();