Skip to content

Commit d59daf4

Browse files
committed
Add end-to-end sanity checks for ldk-server-mcp
Build the MCP binary in the e2e harness and exercise its stdio protocol against a live ldk-server so basic MCP functionality is verified without involving an agent. Co-Authored-By: HAL 9000
1 parent d2dd80a commit d59daf4

3 files changed

Lines changed: 165 additions & 3 deletions

File tree

e2e-tests/build.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ 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+
.unwrap_or_else(|| workspace_root.join("target"));
17+
1418
// Use a separate target directory so the inner cargo build doesn't deadlock
1519
// waiting for the build directory lock held by the outer cargo.
16-
let target_dir = workspace_root.join("target").join("e2e-deps");
20+
let target_dir = outer_target_dir.join("e2e-deps");
1721

1822
let status = Command::new(&cargo)
1923
.args([
@@ -24,21 +28,25 @@ fn main() {
2428
"experimental-lsps2-support",
2529
"-p",
2630
"ldk-server-cli",
31+
"-p",
32+
"ldk-server-mcp",
2733
])
2834
.current_dir(&workspace_root)
2935
.env("CARGO_TARGET_DIR", &target_dir)
3036
.env_remove("CARGO_ENCODED_RUSTFLAGS")
3137
.status()
3238
.expect("failed to run cargo build");
3339

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

3642
let bin_dir = target_dir.join(&profile);
3743
let server_bin = bin_dir.join("ldk-server");
3844
let cli_bin = bin_dir.join("ldk-server-cli");
45+
let mcp_bin = bin_dir.join("ldk-server-mcp");
3946

4047
println!("cargo:rustc-env=LDK_SERVER_BIN={}", server_bin.display());
4148
println!("cargo:rustc-env=LDK_SERVER_CLI_BIN={}", cli_bin.display());
49+
println!("cargo:rustc-env=LDK_SERVER_MCP_BIN={}", mcp_bin.display());
4250

4351
// Rebuild when server or CLI source changes
4452
println!("cargo:rerun-if-changed=../ldk-server/src");
@@ -47,4 +55,7 @@ fn main() {
4755
println!("cargo:rerun-if-changed=../ldk-server-cli/Cargo.toml");
4856
println!("cargo:rerun-if-changed=../ldk-server-grpc/src");
4957
println!("cargo:rerun-if-changed=../ldk-server-grpc/Cargo.toml");
58+
println!("cargo:rerun-if-changed=../ldk-server-mcp/src");
59+
println!("cargo:rerun-if-changed=../ldk-server-mcp/tests");
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": "2024-11-05",
28+
"capabilities": {},
29+
"clientInfo": {"name": "e2e-test", "version": "0.1"}
30+
}),
31+
);
32+
assert_eq!(initialize["result"]["protocolVersion"], "2024-11-05");
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)