Skip to content
Open
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
33 changes: 33 additions & 0 deletions .github/workflows/mcp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: MCP Checks

on: [ push, pull_request ]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
mcp-unit:
runs-on: ubuntu-latest

steps:
- name: Checkout source code
uses: actions/checkout@v6

- name: Install Rust stable toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable
rustup override set stable
rustup component add clippy

- name: Check MCP crate builds
run: cargo check -p ldk-server-mcp

- name: Run MCP crate tests
run: cargo test -p ldk-server-mcp

- name: Run MCP crate clippy
run: cargo clippy -p ldk-server-mcp --all-targets -- -D warnings
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server"]
members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server", "ldk-server-mcp"]
exclude = ["e2e-tests"]

[profile.release]
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ The primary goal of LDK Server is to provide an efficient, stable, and API-first
a Lightning Network node. With its streamlined setup, LDK Server enables users to easily set up, configure, and run
a Lightning node while exposing a robust, language-agnostic API via [Protocol Buffers (Protobuf)](https://protobuf.dev/).

## Workspace Crates

- `ldk-server`: daemon that runs the Lightning node and exposes the API
- `ldk-server-cli`: CLI client for the server API
- `ldk-server-client`: Rust client library for authenticated TLS gRPC calls
- `ldk-server-grpc`: generated protobuf and shared gRPC types
- `ldk-server-mcp`: stdio MCP bridge exposing unary `ldk-server` RPCs as MCP tools

### Features

- **Out-of-the-Box Lightning Node**:
Expand Down Expand Up @@ -46,6 +54,11 @@ git clone https://github.com/lightningdevkit/ldk-server.git
cargo build
```

Build just the MCP bridge:
```
cargo build -p ldk-server-mcp
```

### Running
- Using a config file:
```
Expand Down Expand Up @@ -87,6 +100,18 @@ eval "$(ldk-server-cli completions zsh)"
ldk-server-cli completions fish | source
```

## MCP Bridge

The workspace also includes `ldk-server-mcp`, a stdio [Model Context Protocol](https://spec.modelcontextprotocol.io/) server
that lets MCP-compatible clients call the unary `ldk-server` RPC surface as tools.

Run it directly from the workspace:
```bash
cargo run -p ldk-server-mcp -- --config /path/to/config.toml
```

It is covered by both crate-local tests and an `e2e-tests` sanity suite against a live `ldk-server` instance.

## Contributing

Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on building, testing, code style, and development workflow.
14 changes: 12 additions & 2 deletions e2e-tests/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ fn main() {
.expect("e2e-tests must be inside workspace")
.to_path_buf();

let outer_target_dir = env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| workspace_root.join("target"));

// Use a separate target directory so the inner cargo build doesn't deadlock
// waiting for the build directory lock held by the outer cargo.
let target_dir = workspace_root.join("target").join("e2e-deps");
let target_dir = outer_target_dir.join("e2e-deps");

let status = Command::new(&cargo)
.args([
Expand All @@ -24,21 +28,25 @@ fn main() {
"experimental-lsps2-support",
"-p",
"ldk-server-cli",
"-p",
"ldk-server-mcp",
])
.current_dir(&workspace_root)
.env("CARGO_TARGET_DIR", &target_dir)
.env_remove("CARGO_ENCODED_RUSTFLAGS")
.status()
.expect("failed to run cargo build");

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

let bin_dir = target_dir.join(&profile);
let server_bin = bin_dir.join("ldk-server");
let cli_bin = bin_dir.join("ldk-server-cli");
let mcp_bin = bin_dir.join("ldk-server-mcp");

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

// Rebuild when server or CLI source changes
println!("cargo:rerun-if-changed=../ldk-server/src");
Expand All @@ -47,4 +55,6 @@ fn main() {
println!("cargo:rerun-if-changed=../ldk-server-cli/Cargo.toml");
println!("cargo:rerun-if-changed=../ldk-server-grpc/src");
println!("cargo:rerun-if-changed=../ldk-server-grpc/Cargo.toml");
println!("cargo:rerun-if-changed=../ldk-server-mcp/src");
println!("cargo:rerun-if-changed=../ldk-server-mcp/Cargo.toml");
}
66 changes: 65 additions & 1 deletion e2e-tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// You may not use this file except in accordance with one or both of these
// licenses.

use std::io::{BufRead, BufReader};
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
Expand All @@ -16,6 +16,7 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
Expand Down Expand Up @@ -291,6 +292,69 @@ pub fn cli_binary_path() -> PathBuf {
PathBuf::from(env!("LDK_SERVER_CLI_BIN"))
}

/// Returns the path to the ldk-server-mcp binary (built automatically by build.rs).
pub fn mcp_binary_path() -> PathBuf {
PathBuf::from(env!("LDK_SERVER_MCP_BIN"))
}

/// Handle to a running ldk-server-mcp child process.
pub struct McpHandle {
child: Option<Child>,
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
}

impl McpHandle {
pub fn start(server: &LdkServerHandle) -> Self {
let mcp_path = mcp_binary_path();
let mut child = Command::new(&mcp_path)
.env("LDK_BASE_URL", server.base_url())
.env("LDK_API_KEY", &server.api_key)
.env("LDK_TLS_CERT_PATH", server.tls_cert_path.to_str().unwrap())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("Failed to run MCP server at {:?}: {}", mcp_path, e));

let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());

Self { child: Some(child), stdin, stdout }
}

pub fn send(&mut self, request: &Value) {
let line = serde_json::to_string(request).unwrap();
writeln!(self.stdin, "{}", line).unwrap();
self.stdin.flush().unwrap();
}

pub fn recv(&mut self) -> Value {
let mut line = String::new();
self.stdout.read_line(&mut line).expect("Failed to read MCP stdout");
serde_json::from_str(line.trim()).expect("Failed to parse MCP response")
}

pub fn call(&mut self, id: u64, method: &str, params: Value) -> Value {
self.send(&serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}));
self.recv()
}
}

impl Drop for McpHandle {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}

/// Run a CLI command against the given server handle and return raw stdout as a string.
pub fn run_cli_raw(handle: &LdkServerHandle, args: &[&str]) -> String {
let cli_path = cli_binary_path();
Expand Down
87 changes: 87 additions & 0 deletions e2e-tests/tests/mcp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// This file is Copyright its original authors, visible in version control
// history.
//
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
// You may not use this file except in accordance with one or both of these
// licenses.

use e2e_tests::{LdkServerHandle, McpHandle, TestBitcoind};
use ldk_server_client::ldk_server_grpc::api::Bolt11ReceiveRequest;
use ldk_server_client::ldk_server_grpc::types::{
bolt11_invoice_description, Bolt11InvoiceDescription,
};
use serde_json::json;

#[tokio::test]
async fn test_mcp_initialize_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": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "e2e-test", "version": "0.1"}
}),
);
assert_eq!(initialize["result"]["protocolVersion"], "2024-11-05");
assert!(initialize["result"]["capabilities"]["tools"].is_object());

let tools = mcp.call(2, "tools/list", json!({}));
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"));
assert!(tool_names.iter().any(|tool| tool["name"] == "decode_invoice"));
}

#[tokio::test]
async fn test_mcp_live_tool_calls() {
let bitcoind = TestBitcoind::new();
let server = LdkServerHandle::start(&bitcoind).await;
let mut mcp = McpHandle::start(&server);

let node_info = mcp.call(1, "tools/call", json!({
"name": "get_node_info",
"arguments": {}
}));
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());

let onchain_receive = mcp.call(2, "tools/call", json!({
"name": "onchain_receive",
"arguments": {}
}));
let onchain_receive_text = onchain_receive["result"]["content"][0]["text"].as_str().unwrap();
let onchain_receive_json: serde_json::Value =
serde_json::from_str(onchain_receive_text).unwrap();
assert!(onchain_receive_json["address"].as_str().unwrap().starts_with("bcrt1"));

let invoice = server
.client()
.bolt11_receive(Bolt11ReceiveRequest {
amount_msat: Some(50_000_000),
description: Some(Bolt11InvoiceDescription {
kind: Some(bolt11_invoice_description::Kind::Direct("mcp decode".to_string())),
}),
expiry_secs: 3600,
})
.await
.unwrap();

let decode_invoice = mcp.call(3, "tools/call", json!({
"name": "decode_invoice",
"arguments": { "invoice": invoice.invoice }
}));
let decode_invoice_text = decode_invoice["result"]["content"][0]["text"].as_str().unwrap();
let decode_invoice_json: serde_json::Value =
serde_json::from_str(decode_invoice_text).unwrap();
assert_eq!(decode_invoice_json["destination"], server.node_id());
assert_eq!(decode_invoice_json["description"], "mcp decode");
assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64);
}
12 changes: 4 additions & 8 deletions ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ use ldk_server_client::ldk_server_grpc::types::{
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken,
RouteParametersConfig,
};
use ldk_server_client::{
DEFAULT_EXPIRY_SECS, DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF, DEFAULT_MAX_PATH_COUNT,
DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA,
};
use serde::Serialize;
use serde_json::{json, Value};
use types::{
Expand All @@ -58,14 +62,6 @@ use types::{
mod config;
mod types;

// Having these default values as constants in the Proto file and
// importing/reusing them here might be better, but Proto3 removed
// the ability to set default values.
const DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA: u32 = 1008;
const DEFAULT_MAX_PATH_COUNT: u32 = 10;
const DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF: u32 = 2;
const DEFAULT_EXPIRY_SECS: u32 = 86_400;

const DEFAULT_DIR: &str = if cfg!(target_os = "macos") {
"~/Library/Application Support/ldk-server"
} else if cfg!(target_os = "windows") {
Expand Down
9 changes: 9 additions & 0 deletions ldk-server-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,12 @@ pub mod error;

/// Request/Response structs required for interacting with the ldk-ldk-server-client.
pub use ldk_server_grpc;

/// Default maximum total CLTV expiry delta for payment routing.
pub const DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA: u32 = 1008;
/// Default maximum number of payment paths.
pub const DEFAULT_MAX_PATH_COUNT: u32 = 10;
/// Default maximum channel saturation power of half.
pub const DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF: u32 = 2;
/// Default BOLT11 invoice expiry in seconds (24 hours).
pub const DEFAULT_EXPIRY_SECS: u32 = 86_400;
Loading