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()); 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 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();