Skip to content

Commit 388ff9e

Browse files
authored
Merge pull request #188 from tnull/2026-04-move-mcp-into-workspace
2 parents bf4d8bd + 540bd3c commit 388ff9e

29 files changed

Lines changed: 3328 additions & 196 deletions

File tree

.github/workflows/mcp.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: MCP Checks
2+
3+
on: [ push, pull_request ]
4+
5+
permissions:
6+
contents: read
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
mcp-unit:
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- name: Checkout source code
18+
uses: actions/checkout@v6
19+
20+
- name: Install Rust stable toolchain
21+
run: |
22+
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable
23+
rustup override set stable
24+
rustup component add clippy
25+
26+
- name: Check MCP crate builds
27+
run: cargo check -p ldk-server-mcp
28+
29+
- name: Run MCP crate tests
30+
run: cargo test -p ldk-server-mcp
31+
32+
- name: Run MCP crate clippy
33+
run: cargo clippy -p ldk-server-mcp --all-targets -- -D warnings

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[workspace]
22
resolver = "2"
3-
members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server"]
3+
members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server", "ldk-server-mcp"]
44
exclude = ["e2e-tests"]
55

66
[profile.release]

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ The primary goal of LDK Server is to provide an efficient, stable, and API-first
99
a Lightning Network node. With its streamlined setup, LDK Server enables users to easily set up, configure, and run
1010
a Lightning node while exposing a robust, language-agnostic API via [Protocol Buffers (Protobuf)](https://protobuf.dev/).
1111

12+
## Workspace Crates
13+
14+
- `ldk-server`: daemon that runs the Lightning node and exposes the API
15+
- `ldk-server-cli`: CLI client for the server API
16+
- `ldk-server-client`: Rust client library for authenticated TLS gRPC calls
17+
- `ldk-server-grpc`: generated protobuf and shared gRPC types
18+
- `ldk-server-mcp`: stdio MCP bridge exposing unary `ldk-server` RPCs as MCP tools
19+
1220
### Features
1321

1422
- **Out-of-the-Box Lightning Node**:
@@ -58,6 +66,18 @@ See [Getting Started](docs/getting-started.md) for a full walkthrough.
5866
The canonical API definitions are in [`ldk-server-grpc/src/proto/`](ldk-server-grpc/src/proto/). A ready-made
5967
Rust client library is provided in [`ldk-server-client/`](ldk-server-client/).
6068

69+
### MCP Bridge
70+
71+
The workspace also includes `ldk-server-mcp`, a stdio [Model Context Protocol](https://spec.modelcontextprotocol.io/) server
72+
that lets MCP-compatible clients call the unary `ldk-server` RPC surface as tools.
73+
74+
Run it directly from the workspace:
75+
```bash
76+
cargo run -p ldk-server-mcp -- --config /path/to/config.toml
77+
```
78+
79+
It is covered by both crate-local tests and an `e2e-tests` sanity suite against a live `ldk-server` instance.
80+
6181
### Contributing
6282

6383
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on building, testing, code style, and development workflow.

e2e-tests/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e-tests/build.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ fn main() {
1111
.expect("e2e-tests must be inside workspace")
1212
.to_path_buf();
1313

14+
let outer_target_dir = env::var_os("CARGO_TARGET_DIR")
15+
.map(PathBuf::from)
16+
.map(|path| if path.is_absolute() { path } else { workspace_root.join(path) })
17+
.unwrap_or_else(|| workspace_root.join("target"));
18+
1419
// Use a separate target directory so the inner cargo build doesn't deadlock
1520
// waiting for the build directory lock held by the outer cargo.
16-
let target_dir = workspace_root.join("target").join("e2e-deps");
21+
let target_dir = outer_target_dir.join("e2e-deps");
1722

1823
let status = Command::new(&cargo)
1924
.args([
@@ -24,21 +29,25 @@ fn main() {
2429
"experimental-lsps2-support",
2530
"-p",
2631
"ldk-server-cli",
32+
"-p",
33+
"ldk-server-mcp",
2734
])
2835
.current_dir(&workspace_root)
2936
.env("CARGO_TARGET_DIR", &target_dir)
3037
.env_remove("CARGO_ENCODED_RUSTFLAGS")
3138
.status()
3239
.expect("failed to run cargo build");
3340

34-
assert!(status.success(), "cargo build of ldk-server / ldk-server-cli failed");
41+
assert!(status.success(), "cargo build of ldk-server / ldk-server-cli / ldk-server-mcp failed");
3542

3643
let bin_dir = target_dir.join(&profile);
3744
let server_bin = bin_dir.join("ldk-server");
3845
let cli_bin = bin_dir.join("ldk-server-cli");
46+
let mcp_bin = bin_dir.join("ldk-server-mcp");
3947

4048
println!("cargo:rustc-env=LDK_SERVER_BIN={}", server_bin.display());
4149
println!("cargo:rustc-env=LDK_SERVER_CLI_BIN={}", cli_bin.display());
50+
println!("cargo:rustc-env=LDK_SERVER_MCP_BIN={}", mcp_bin.display());
4251

4352
// Rebuild when server or CLI source changes
4453
println!("cargo:rerun-if-changed=../ldk-server/src");
@@ -47,4 +56,6 @@ fn main() {
4756
println!("cargo:rerun-if-changed=../ldk-server-cli/Cargo.toml");
4857
println!("cargo:rerun-if-changed=../ldk-server-grpc/src");
4958
println!("cargo:rerun-if-changed=../ldk-server-grpc/Cargo.toml");
59+
println!("cargo:rerun-if-changed=../ldk-server-mcp/src");
60+
println!("cargo:rerun-if-changed=../ldk-server-mcp/Cargo.toml");
5061
}

e2e-tests/src/lib.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// You may not use this file except in accordance with one or both of these
88
// licenses.
99

10-
use std::io::{BufRead, BufReader};
10+
use std::io::{BufRead, BufReader, Write};
1111
use std::net::TcpListener;
1212
use std::path::{Path, PathBuf};
1313
use std::process::{Child, Command, Stdio};
@@ -16,6 +16,7 @@ use std::time::Duration;
1616
use corepc_node::Node;
1717
use hex_conservative::DisplayHex;
1818
use ldk_server_client::client::LdkServerClient;
19+
use serde_json::Value;
1920
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
2021
use ldk_server_grpc::api::{
2122
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
@@ -291,6 +292,69 @@ pub fn cli_binary_path() -> PathBuf {
291292
PathBuf::from(env!("LDK_SERVER_CLI_BIN"))
292293
}
293294

295+
/// Returns the path to the ldk-server-mcp binary (built automatically by build.rs).
296+
pub fn mcp_binary_path() -> PathBuf {
297+
PathBuf::from(env!("LDK_SERVER_MCP_BIN"))
298+
}
299+
300+
/// Handle to a running ldk-server-mcp child process.
301+
pub struct McpHandle {
302+
child: Option<Child>,
303+
stdin: std::process::ChildStdin,
304+
stdout: BufReader<std::process::ChildStdout>,
305+
}
306+
307+
impl McpHandle {
308+
pub fn start(server: &LdkServerHandle) -> Self {
309+
let mcp_path = mcp_binary_path();
310+
let mut child = Command::new(&mcp_path)
311+
.env("LDK_BASE_URL", server.base_url())
312+
.env("LDK_API_KEY", &server.api_key)
313+
.env("LDK_TLS_CERT_PATH", server.tls_cert_path.to_str().unwrap())
314+
.stdin(Stdio::piped())
315+
.stdout(Stdio::piped())
316+
.stderr(Stdio::piped())
317+
.spawn()
318+
.unwrap_or_else(|e| panic!("Failed to run MCP server at {:?}: {}", mcp_path, e));
319+
320+
let stdin = child.stdin.take().unwrap();
321+
let stdout = BufReader::new(child.stdout.take().unwrap());
322+
323+
Self { child: Some(child), stdin, stdout }
324+
}
325+
326+
pub fn send(&mut self, request: &Value) {
327+
let line = serde_json::to_string(request).unwrap();
328+
writeln!(self.stdin, "{}", line).unwrap();
329+
self.stdin.flush().unwrap();
330+
}
331+
332+
pub fn recv(&mut self) -> Value {
333+
let mut line = String::new();
334+
self.stdout.read_line(&mut line).expect("Failed to read MCP stdout");
335+
serde_json::from_str(line.trim()).expect("Failed to parse MCP response")
336+
}
337+
338+
pub fn call(&mut self, id: u64, method: &str, params: Value) -> Value {
339+
self.send(&serde_json::json!({
340+
"jsonrpc": "2.0",
341+
"id": id,
342+
"method": method,
343+
"params": params,
344+
}));
345+
self.recv()
346+
}
347+
}
348+
349+
impl Drop for McpHandle {
350+
fn drop(&mut self) {
351+
if let Some(mut child) = self.child.take() {
352+
let _ = child.kill();
353+
let _ = child.wait();
354+
}
355+
}
356+
}
357+
294358
/// Run a CLI command against the given server handle and return raw stdout as a string.
295359
pub fn run_cli_raw(handle: &LdkServerHandle, args: &[&str]) -> String {
296360
let cli_path = cli_binary_path();

e2e-tests/tests/mcp.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// This file is Copyright its original authors, visible in version control
2+
// history.
3+
//
4+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
5+
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6+
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
7+
// You may not use this file except in accordance with one or both of these
8+
// licenses.
9+
10+
use e2e_tests::{LdkServerHandle, McpHandle, TestBitcoind};
11+
use ldk_server_client::ldk_server_grpc::api::Bolt11ReceiveRequest;
12+
use ldk_server_client::ldk_server_grpc::types::{
13+
bolt11_invoice_description, Bolt11InvoiceDescription,
14+
};
15+
use serde_json::json;
16+
17+
#[tokio::test]
18+
async fn test_mcp_initialize_and_list_tools() {
19+
let bitcoind = TestBitcoind::new();
20+
let server = LdkServerHandle::start(&bitcoind).await;
21+
let mut mcp = McpHandle::start(&server);
22+
23+
let initialize = mcp.call(
24+
1,
25+
"initialize",
26+
json!({
27+
"protocolVersion": "2025-11-25",
28+
"capabilities": {},
29+
"clientInfo": {"name": "e2e-test", "version": "0.1"}
30+
}),
31+
);
32+
assert_eq!(initialize["result"]["protocolVersion"], "2025-11-25");
33+
assert!(initialize["result"]["capabilities"]["tools"].is_object());
34+
35+
let tools = mcp.call(2, "tools/list", json!({}));
36+
let tool_names = tools["result"]["tools"].as_array().unwrap();
37+
assert!(tool_names.iter().any(|tool| tool["name"] == "get_node_info"));
38+
assert!(tool_names.iter().any(|tool| tool["name"] == "onchain_receive"));
39+
assert!(tool_names.iter().any(|tool| tool["name"] == "decode_invoice"));
40+
}
41+
42+
#[tokio::test]
43+
async fn test_mcp_live_tool_calls() {
44+
let bitcoind = TestBitcoind::new();
45+
let server = LdkServerHandle::start(&bitcoind).await;
46+
let mut mcp = McpHandle::start(&server);
47+
48+
let node_info = mcp.call(1, "tools/call", json!({
49+
"name": "get_node_info",
50+
"arguments": {}
51+
}));
52+
let node_info_text = node_info["result"]["content"][0]["text"].as_str().unwrap();
53+
let node_info_json: serde_json::Value = serde_json::from_str(node_info_text).unwrap();
54+
assert_eq!(node_info_json["node_id"], server.node_id());
55+
56+
let onchain_receive = mcp.call(2, "tools/call", json!({
57+
"name": "onchain_receive",
58+
"arguments": {}
59+
}));
60+
let onchain_receive_text = onchain_receive["result"]["content"][0]["text"].as_str().unwrap();
61+
let onchain_receive_json: serde_json::Value =
62+
serde_json::from_str(onchain_receive_text).unwrap();
63+
assert!(onchain_receive_json["address"].as_str().unwrap().starts_with("bcrt1"));
64+
65+
let invoice = server
66+
.client()
67+
.bolt11_receive(Bolt11ReceiveRequest {
68+
amount_msat: Some(50_000_000),
69+
description: Some(Bolt11InvoiceDescription {
70+
kind: Some(bolt11_invoice_description::Kind::Direct("mcp decode".to_string())),
71+
}),
72+
expiry_secs: 3600,
73+
})
74+
.await
75+
.unwrap();
76+
77+
let decode_invoice = mcp.call(3, "tools/call", json!({
78+
"name": "decode_invoice",
79+
"arguments": { "invoice": invoice.invoice }
80+
}));
81+
let decode_invoice_text = decode_invoice["result"]["content"][0]["text"].as_str().unwrap();
82+
let decode_invoice_json: serde_json::Value =
83+
serde_json::from_str(decode_invoice_text).unwrap();
84+
assert_eq!(decode_invoice_json["destination"], server.node_id());
85+
assert_eq!(decode_invoice_json["description"], "mcp decode");
86+
assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64);
87+
}

0 commit comments

Comments
 (0)