Skip to content
Draft
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
12 changes: 12 additions & 0 deletions e2e-tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 18 additions & 11 deletions e2e-tests/tests/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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());
Expand Down
19 changes: 14 additions & 5 deletions ldk-server-mcp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
42 changes: 37 additions & 5 deletions ldk-server-mcp/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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

Expand Down
148 changes: 80 additions & 68 deletions ldk-server-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Value {
serde_json::to_value(JsonRpcErrorResponse::new(id, code, message.into())).unwrap()
}

fn error_response_with_data(
id: Value, code: i64, message: impl Into<String>, 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() {
Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -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, &registry).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;
}
Expand Down
Loading