Skip to content

Commit 9670f57

Browse files
committed
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.
1 parent 3e9e537 commit 9670f57

8 files changed

Lines changed: 291 additions & 25 deletions

File tree

e2e-tests/tests/mcp.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,27 @@ use ldk_server_client::ldk_server_grpc::types::{
1515
use serde_json::json;
1616

1717
#[tokio::test]
18-
async fn test_mcp_initialize_and_list_tools() {
18+
async fn test_mcp_discover_initialize_and_list_tools() {
1919
let bitcoind = TestBitcoind::new();
2020
let server = LdkServerHandle::start(&bitcoind).await;
2121
let mut mcp = McpHandle::start(&server);
22+
let discover = mcp.call(
23+
1,
24+
"server/discover",
25+
json!({
26+
"_meta": {
27+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
28+
"io.modelcontextprotocol/clientCapabilities": {},
29+
"io.modelcontextprotocol/clientInfo": {"name": "e2e-test", "version": "0.1"}
30+
}
31+
}),
32+
);
33+
assert_eq!(discover["result"]["supportedVersions"][0], "2026-07-28");
34+
assert_eq!(discover["result"]["resultType"], "complete");
35+
assert!(discover["result"]["capabilities"]["tools"].is_object());
2236

2337
let initialize = mcp.call(
24-
1,
38+
2,
2539
"initialize",
2640
json!({
2741
"protocolVersion": "2025-11-25",
@@ -32,7 +46,17 @@ async fn test_mcp_initialize_and_list_tools() {
3246
assert_eq!(initialize["result"]["protocolVersion"], "2025-11-25");
3347
assert!(initialize["result"]["capabilities"]["tools"].is_object());
3448

35-
let tools = mcp.call(2, "tools/list", json!({}));
49+
let tools = mcp.call(
50+
3,
51+
"tools/list",
52+
json!({
53+
"_meta": {
54+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
55+
"io.modelcontextprotocol/clientCapabilities": {}
56+
}
57+
}),
58+
);
59+
assert_eq!(tools["result"]["resultType"], "complete");
3660
let tool_names = tools["result"]["tools"].as_array().unwrap();
3761
assert!(tool_names.iter().any(|tool| tool["name"] == "get_node_info"));
3862
assert!(tool_names.iter().any(|tool| tool["name"] == "onchain_receive"));

ldk-server-mcp/CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ src/
3232

3333
## MCP Protocol
3434

35-
- **Version**: `2025-11-25`
36-
- **Spec**: https://spec.modelcontextprotocol.io/
35+
- **Versions**: `2026-07-28` (current), `2025-11-25` (legacy compatibility)
36+
- **Spec**: https://modelcontextprotocol.io/specification/2026-07-28
3737
- **Transport**: stdio (one JSON-RPC 2.0 message per line)
38-
- **Methods implemented**: `initialize`, `tools/list`, `tools/call`, `ping`
38+
- **Current methods implemented**: `server/discover`, `tools/list`, `tools/call`
39+
- **Legacy methods implemented**: `initialize`, `ping`
3940
- **Notifications handled**: `notifications/initialized` (ignored, no response)
4041

4142
## Config

ldk-server-mcp/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,14 @@ Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `me
9999

100100
## MCP Protocol
101101

102-
- **Protocol version**: `2025-11-25`
102+
- **Protocol versions**: `2026-07-28` (current), `2025-11-25` (legacy compatibility)
103103
- **Transport**: stdio (one JSON-RPC 2.0 message per line)
104-
- **Methods**: `initialize`, `tools/list`, `tools/call`, `ping`
104+
- **Current methods**: `server/discover`, `tools/list`, `tools/call`
105+
- **Legacy methods**: `initialize`, `ping`
106+
107+
For `2026-07-28`, every request includes the protocol version and client capabilities in
108+
`params._meta`. Existing clients that use the `2025-11-25` initialization handshake remain
109+
supported.
105110

106111
## Testing
107112

ldk-server-mcp/src/main.rs

Lines changed: 141 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ use ldk_server_client::ldk_server_grpc::api::GetNodeInfoRequest;
1717
use serde_json::Value;
1818
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1919

20-
use crate::mcp::InitializeResult;
20+
use crate::mcp::{
21+
InitializeResult, LEGACY_PROTOCOL_VERSION, PROTOCOL_VERSION, SERVER_NAME, SERVER_VERSION,
22+
};
2123
use crate::protocol::{
2224
JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse, INVALID_PARAMS, METHOD_NOT_FOUND,
23-
PARSE_ERROR,
25+
PARSE_ERROR, UNSUPPORTED_PROTOCOL_VERSION,
2426
};
2527
use crate::tools::build_tool_registry;
2628

@@ -62,7 +64,7 @@ async fn main() {
6264

6365
// Probe the server so misconfiguration surfaces on startup rather than on
6466
// the first tool call. We warn instead of exiting so the MCP protocol loop
65-
// still answers `initialize` and `tools/list` even when the server is
67+
// still answers discovery and tool-list requests even when the server is
6668
// temporarily unreachable.
6769
if let Err(e) = client.get_node_info(GetNodeInfoRequest {}).await {
6870
eprintln!("Warning: Failed to reach ldk-server on startup: {e}");
@@ -113,30 +115,136 @@ async fn main() {
113115

114116
let id = request.id.unwrap();
115117

118+
let protocol_version = request
119+
.params
120+
.as_ref()
121+
.and_then(|params| params.get("_meta"))
122+
.and_then(|meta| meta.get("io.modelcontextprotocol/protocolVersion"))
123+
.and_then(Value::as_str)
124+
.map(str::to_owned);
125+
126+
if let Some(requested) = protocol_version.as_deref() {
127+
if requested != PROTOCOL_VERSION && requested != LEGACY_PROTOCOL_VERSION {
128+
let err = JsonRpcErrorResponse::with_data(
129+
id,
130+
UNSUPPORTED_PROTOCOL_VERSION,
131+
format!("Unsupported protocol version: {requested}"),
132+
serde_json::json!({
133+
"supported": [PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION],
134+
"requested": requested,
135+
}),
136+
);
137+
write_response(&mut stdout, serde_json::to_string(&err).unwrap()).await;
138+
continue;
139+
}
140+
141+
let has_capabilities = request
142+
.params
143+
.as_ref()
144+
.and_then(|params| params.get("_meta"))
145+
.and_then(|meta| meta.get("io.modelcontextprotocol/clientCapabilities"))
146+
.is_some_and(Value::is_object);
147+
if requested == PROTOCOL_VERSION && !has_capabilities {
148+
let err = JsonRpcErrorResponse::new(
149+
id,
150+
INVALID_PARAMS,
151+
"Missing required request metadata: io.modelcontextprotocol/clientCapabilities"
152+
.to_string(),
153+
);
154+
write_response(&mut stdout, serde_json::to_string(&err).unwrap()).await;
155+
continue;
156+
}
157+
}
158+
159+
let latest_protocol = protocol_version.as_deref() == Some(PROTOCOL_VERSION);
116160
let response_str = match request.method.as_str() {
117161
"initialize" => {
118-
let result = InitializeResult::new();
119-
let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap());
120-
serde_json::to_string(&resp).unwrap()
162+
if request
163+
.params
164+
.as_ref()
165+
.and_then(|params| params.get("protocolVersion"))
166+
.and_then(Value::as_str)
167+
== Some(PROTOCOL_VERSION)
168+
{
169+
let err = JsonRpcErrorResponse::new(
170+
id,
171+
METHOD_NOT_FOUND,
172+
"Method not found: initialize".to_string(),
173+
);
174+
serde_json::to_string(&err).unwrap()
175+
} else {
176+
let result = InitializeResult::new();
177+
let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap());
178+
serde_json::to_string(&resp).unwrap()
179+
}
180+
},
181+
"server/discover" => {
182+
if !latest_protocol {
183+
let err = JsonRpcErrorResponse::new(
184+
id,
185+
INVALID_PARAMS,
186+
"server/discover requires 2026-07-28 request metadata".to_string(),
187+
);
188+
serde_json::to_string(&err).unwrap()
189+
} else {
190+
let result = latest_result(serde_json::json!({
191+
"supportedVersions": [PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION],
192+
"capabilities": { "tools": {} },
193+
"instructions": "Use the available tools to operate an LDK Server node.",
194+
"ttlMs": 300_000,
195+
"cacheScope": "public",
196+
}));
197+
let resp = JsonRpcResponse::new(id, result);
198+
serde_json::to_string(&resp).unwrap()
199+
}
121200
},
122201
"tools/list" => {
123202
let tools = registry.list_tools();
124-
let resp = JsonRpcResponse::new(id, serde_json::json!({ "tools": tools }));
203+
let result = if latest_protocol {
204+
latest_result(serde_json::json!({
205+
"tools": tools,
206+
"ttlMs": 300_000,
207+
"cacheScope": "public",
208+
}))
209+
} else {
210+
serde_json::json!({ "tools": tools })
211+
};
212+
let resp = JsonRpcResponse::new(id, result);
125213
serde_json::to_string(&resp).unwrap()
126214
},
127215
"ping" => {
128-
// Per the MCP spec, a ping must be answered with an empty result object.
129-
let resp = JsonRpcResponse::new(id, serde_json::json!({}));
130-
serde_json::to_string(&resp).unwrap()
216+
if latest_protocol {
217+
let err = JsonRpcErrorResponse::new(
218+
id,
219+
METHOD_NOT_FOUND,
220+
"Method not found: ping".to_string(),
221+
);
222+
serde_json::to_string(&err).unwrap()
223+
} else {
224+
let resp = JsonRpcResponse::new(id, serde_json::json!({}));
225+
serde_json::to_string(&resp).unwrap()
226+
}
131227
},
132228
"tools/call" => {
133229
let params = request.params.unwrap_or(Value::Null);
134230
match params.get("name").and_then(|v| v.as_str()) {
231+
Some(tool_name) if latest_protocol && !registry.has_tool(tool_name) => {
232+
let err = JsonRpcErrorResponse::new(
233+
id,
234+
INVALID_PARAMS,
235+
format!("Unknown tool: {tool_name}"),
236+
);
237+
serde_json::to_string(&err).unwrap()
238+
},
135239
Some(tool_name) => {
136240
let tool_args =
137241
params.get("arguments").cloned().unwrap_or(serde_json::json!({}));
138242
let result = registry.call_tool(&client, tool_name, tool_args).await;
139-
let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap());
243+
let mut result = serde_json::to_value(result).unwrap();
244+
if latest_protocol {
245+
result = latest_result(result);
246+
}
247+
let resp = JsonRpcResponse::new(id, result);
140248
serde_json::to_string(&resp).unwrap()
141249
},
142250
None => {
@@ -159,8 +267,27 @@ async fn main() {
159267
},
160268
};
161269

162-
let _ = stdout.write_all(response_str.as_bytes()).await;
163-
let _ = stdout.write_all(b"\n").await;
164-
let _ = stdout.flush().await;
270+
write_response(&mut stdout, response_str).await;
165271
}
166272
}
273+
274+
fn latest_result(mut result: Value) -> Value {
275+
let object = result.as_object_mut().expect("MCP results must be JSON objects");
276+
object.insert("resultType".to_string(), Value::String("complete".to_string()));
277+
object.insert(
278+
"_meta".to_string(),
279+
serde_json::json!({
280+
"io.modelcontextprotocol/serverInfo": {
281+
"name": SERVER_NAME,
282+
"version": SERVER_VERSION,
283+
}
284+
}),
285+
);
286+
result
287+
}
288+
289+
async fn write_response(stdout: &mut tokio::io::Stdout, response: String) {
290+
let _ = stdout.write_all(response.as_bytes()).await;
291+
let _ = stdout.write_all(b"\n").await;
292+
let _ = stdout.flush().await;
293+
}

ldk-server-mcp/src/mcp.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
use serde::Serialize;
1111
use serde_json::Value;
1212

13-
pub const PROTOCOL_VERSION: &str = "2025-11-25";
13+
pub const PROTOCOL_VERSION: &str = "2026-07-28";
14+
pub const LEGACY_PROTOCOL_VERSION: &str = "2025-11-25";
1415
pub const SERVER_NAME: &str = "ldk-server-mcp";
15-
pub const SERVER_VERSION: &str = "0.1.0";
16+
pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
1617

1718
#[derive(Debug, Serialize)]
1819
#[serde(rename_all = "camelCase")]
@@ -39,7 +40,7 @@ pub struct ServerInfo {
3940
impl InitializeResult {
4041
pub fn new() -> Self {
4142
Self {
42-
protocol_version: PROTOCOL_VERSION.to_string(),
43+
protocol_version: LEGACY_PROTOCOL_VERSION.to_string(),
4344
capabilities: Capabilities { tools: ToolsCapability {} },
4445
server_info: ServerInfo {
4546
name: SERVER_NAME.to_string(),

ldk-server-mcp/src/protocol.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub const PARSE_ERROR: i64 = -32700;
1515
pub const METHOD_NOT_FOUND: i64 = -32601;
1616
pub const INVALID_PARAMS: i64 = -32602;
1717
pub const INTERNAL_ERROR: i64 = -32603;
18+
pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;
1819

1920
/// Classified error produced by MCP tool handlers. The `code` is reused for JSON-RPC error
2021
/// responses at the envelope level, and for categorising the error text that gets surfaced
@@ -97,4 +98,12 @@ impl JsonRpcErrorResponse {
9798
pub fn new(id: Value, code: i64, message: String) -> Self {
9899
Self { jsonrpc: "2.0".to_string(), id, error: JsonRpcError { code, message, data: None } }
99100
}
101+
102+
pub fn with_data(id: Value, code: i64, message: String, data: Value) -> Self {
103+
Self {
104+
jsonrpc: "2.0".to_string(),
105+
id,
106+
error: JsonRpcError { code, message, data: Some(data) },
107+
}
108+
}
100109
}

ldk-server-mcp/src/tools/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ impl ToolRegistry {
5050
&self.definitions
5151
}
5252

53+
pub fn has_tool(&self, name: &str) -> bool {
54+
self.handlers.contains_key(name)
55+
}
56+
5357
pub async fn call_tool(
5458
&self, client: &LdkServerClient, name: &str, args: Value,
5559
) -> ToolCallResult {

0 commit comments

Comments
 (0)