Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions e2e-tests/tests/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"));
Expand Down
7 changes: 4 additions & 3 deletions ldk-server-mcp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions ldk-server-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
155 changes: 141 additions & 14 deletions ldk-server-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -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 => {
Expand All @@ -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;
}
7 changes: 4 additions & 3 deletions ldk-server-mcp/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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(),
Expand Down
9 changes: 9 additions & 0 deletions ldk-server-mcp/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) },
}
}
}
4 changes: 4 additions & 0 deletions ldk-server-mcp/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading