Skip to content
This repository was archived by the owner on May 7, 2026. It is now read-only.
Merged
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
4 changes: 2 additions & 2 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
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"

[dependencies]
ldk-server-client = { git = "https://github.com/tnull/ldk-server", rev = "4d90c74", features = ["serde"] }
ldk-server-client = { git = "https://github.com/tnull/ldk-server", rev = "1cfe65e80f3d3324941a3c44b26ef7d0cbbe6377", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.38.0", features = ["rt-multi-thread", "macros", "io-util", "io-std"] }
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Add to your Claude Code MCP settings (`.claude/settings.json`):

## Available Tools

The server exposes all 24 LDK Server operations as MCP tools:
The server exposes all 30 LDK Server operations as MCP tools:

### Node
| Tool | Description |
Expand All @@ -102,6 +102,8 @@ The server exposes all 24 LDK Server operations as MCP tools:
| Tool | Description |
|------|-------------|
| `bolt11_receive` | Create a BOLT11 Lightning invoice to receive a payment |
| `bolt11_receive_via_jit_channel` | Create a BOLT11 Lightning invoice to receive via an LSPS2 JIT channel |
| `bolt11_receive_variable_amount_via_jit_channel` | Create a variable-amount BOLT11 Lightning invoice to receive via an LSPS2 JIT channel |
| `bolt11_send` | Pay a BOLT11 Lightning invoice |
| `bolt12_receive` | Create a BOLT12 offer for receiving Lightning payments |
| `bolt12_send` | Pay a BOLT12 Lightning offer |
Expand Down
96 changes: 78 additions & 18 deletions src/tools/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@

use ldk_server_client::client::LdkServerClient;
use ldk_server_client::ldk_server_protos::api::{
Bolt11ReceiveRequest, Bolt12ReceiveRequest, Bolt12SendRequest, CloseChannelRequest,
ConnectPeerRequest, DisconnectPeerRequest, ExportPathfindingScoresRequest,
Bolt11ReceiveRequest, Bolt11ReceiveVariableAmountViaJitChannelRequest,
Bolt11ReceiveViaJitChannelRequest, Bolt12ReceiveRequest, Bolt12SendRequest,
CloseChannelRequest, ConnectPeerRequest, DisconnectPeerRequest, ExportPathfindingScoresRequest,
ForceCloseChannelRequest, GetBalancesRequest, GetNodeInfoRequest, GetPaymentDetailsRequest,
GraphGetChannelRequest, GraphGetNodeRequest, GraphListChannelsRequest, GraphListNodesRequest,
ListChannelsRequest, ListForwardedPaymentsRequest, ListPaymentsRequest, OnchainReceiveRequest,
Expand Down Expand Up @@ -93,6 +94,27 @@ fn build_channel_config(args: &Value) -> Option<ChannelConfig> {
})
}

fn build_bolt11_invoice_description(
args: &Value,
) -> Result<Option<Bolt11InvoiceDescription>, String> {
let description_str = args.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
let description_hash =
args.get("description_hash").and_then(|v| v.as_str()).map(|s| s.to_string());

match (description_str, description_hash) {
(Some(desc), None) => Ok(Some(Bolt11InvoiceDescription {
kind: Some(bolt11_invoice_description::Kind::Direct(desc)),
})),
(None, Some(hash)) => Ok(Some(Bolt11InvoiceDescription {
kind: Some(bolt11_invoice_description::Kind::Hash(hash)),
})),
(Some(_), Some(_)) => {
Err("Only one of description or description_hash can be set".to_string())
},
(None, None) => Ok(None),
}
}

pub async fn handle_get_node_info(client: &LdkServerClient, _args: Value) -> Result<Value, String> {
let response =
client.get_node_info(GetNodeInfoRequest {}).await.map_err(|e| e.message.clone())?;
Expand Down Expand Up @@ -132,22 +154,7 @@ pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Resul

pub async fn handle_bolt11_receive(client: &LdkServerClient, args: Value) -> Result<Value, String> {
let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64());
let description_str = args.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
let description_hash =
args.get("description_hash").and_then(|v| v.as_str()).map(|s| s.to_string());

let invoice_description = match (description_str, description_hash) {
(Some(desc), None) => Some(Bolt11InvoiceDescription {
kind: Some(bolt11_invoice_description::Kind::Direct(desc)),
}),
(None, Some(hash)) => Some(Bolt11InvoiceDescription {
kind: Some(bolt11_invoice_description::Kind::Hash(hash)),
}),
(Some(_), Some(_)) => {
return Err("Only one of description or description_hash can be set".to_string());
},
(None, None) => None,
};
let invoice_description = build_bolt11_invoice_description(&args)?;

let expiry_secs = args
.get("expiry_secs")
Expand All @@ -166,6 +173,59 @@ pub async fn handle_bolt11_receive(client: &LdkServerClient, args: Value) -> Res
serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}"))
}

pub async fn handle_bolt11_receive_via_jit_channel(
client: &LdkServerClient, args: Value,
) -> Result<Value, String> {
let amount_msat = args
.get("amount_msat")
.and_then(|v| v.as_u64())
.ok_or("Missing required parameter: amount_msat")?;
let description = build_bolt11_invoice_description(&args)?;
let expiry_secs = args
.get("expiry_secs")
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(DEFAULT_EXPIRY_SECS);
let max_total_lsp_fee_limit_msat =
args.get("max_total_lsp_fee_limit_msat").and_then(|v| v.as_u64());

let response = client
.bolt11_receive_via_jit_channel(Bolt11ReceiveViaJitChannelRequest {
amount_msat,
description,
expiry_secs,
max_total_lsp_fee_limit_msat,
})
.await
.map_err(|e| e.message.clone())?;
serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}"))
}

pub async fn handle_bolt11_receive_variable_amount_via_jit_channel(
client: &LdkServerClient, args: Value,
) -> Result<Value, String> {
let description = build_bolt11_invoice_description(&args)?;
let expiry_secs = args
.get("expiry_secs")
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(DEFAULT_EXPIRY_SECS);
let max_proportional_lsp_fee_limit_ppm_msat =
args.get("max_proportional_lsp_fee_limit_ppm_msat").and_then(|v| v.as_u64());

let response = client
.bolt11_receive_variable_amount_via_jit_channel(
Bolt11ReceiveVariableAmountViaJitChannelRequest {
description,
expiry_secs,
max_proportional_lsp_fee_limit_ppm_msat,
},
)
.await
.map_err(|e| e.message.clone())?;
serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}"))
}

pub async fn handle_bolt11_send(client: &LdkServerClient, args: Value) -> Result<Value, String> {
let invoice = args
.get("invoice")
Expand Down
16 changes: 16 additions & 0 deletions src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ pub fn build_tool_registry() -> ToolRegistry {
|c, a| Box::pin(handlers::handle_bolt11_receive(c, a)),
);

register(
&mut tools,
"bolt11_receive_via_jit_channel",
"Create a BOLT11 Lightning invoice to receive via an LSPS2 JIT channel",
schema::bolt11_receive_via_jit_channel_schema(),
|c, a| Box::pin(handlers::handle_bolt11_receive_via_jit_channel(c, a)),
);

register(
&mut tools,
"bolt11_receive_variable_amount_via_jit_channel",
"Create a variable-amount BOLT11 Lightning invoice to receive via an LSPS2 JIT channel",
schema::bolt11_receive_variable_amount_via_jit_channel_schema(),
|c, a| Box::pin(handlers::handle_bolt11_receive_variable_amount_via_jit_channel(c, a)),
);

register(
&mut tools,
"bolt11_send",
Expand Down
54 changes: 54 additions & 0 deletions src/tools/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,60 @@ pub fn bolt11_receive_schema() -> Value {
})
}

pub fn bolt11_receive_via_jit_channel_schema() -> Value {
json!({
"type": "object",
"properties": {
"amount_msat": {
"type": "integer",
"description": "The amount in millisatoshis to request"
},
"description": {
"type": "string",
"description": "Description to attach to the invoice. Mutually exclusive with description_hash"
},
"description_hash": {
"type": "string",
"description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description"
},
"expiry_secs": {
"type": "integer",
"description": "Invoice expiry time in seconds (default: 86400)"
},
"max_total_lsp_fee_limit_msat": {
"type": "integer",
"description": "Optional upper bound for the total fee an LSP may deduct when opening the JIT channel"
}
},
"required": ["amount_msat"]
})
}

pub fn bolt11_receive_variable_amount_via_jit_channel_schema() -> Value {
json!({
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Description to attach to the invoice. Mutually exclusive with description_hash"
},
"description_hash": {
"type": "string",
"description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description"
},
"expiry_secs": {
"type": "integer",
"description": "Invoice expiry time in seconds (default: 86400)"
},
"max_proportional_lsp_fee_limit_ppm_msat": {
"type": "integer",
"description": "Optional upper bound for the proportional fee, in parts-per-million millisatoshis, that an LSP may deduct when opening the JIT channel"
}
},
"required": []
})
}

pub fn bolt11_send_schema() -> Value {
json!({
"type": "object",
Expand Down
51 changes: 50 additions & 1 deletion tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::process::{Command, Stdio};

use serde_json::{json, Value};

const NUM_TOOLS: usize = 28;
const NUM_TOOLS: usize = 30;

fn test_cert_path() -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
Expand Down Expand Up @@ -184,6 +184,55 @@ fn test_tools_call_unreachable_server() {
assert!(!text.is_empty(), "Expected non-empty error message");
}

#[test]
fn test_bolt11_receive_via_jit_channel_unreachable() {
let mut proc = McpProcess::spawn();

proc.send(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "bolt11_receive_via_jit_channel",
"arguments": {
"amount_msat": 1000,
"description": "test jit"
}
}
}));

let resp = proc.recv();
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], 1);
assert_eq!(resp["result"]["isError"], true);
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(!text.is_empty(), "Expected non-empty error message");
}

#[test]
fn test_bolt11_receive_variable_amount_via_jit_channel_unreachable() {
let mut proc = McpProcess::spawn();

proc.send(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "bolt11_receive_variable_amount_via_jit_channel",
"arguments": {
"description": "test jit"
}
}
}));

let resp = proc.recv();
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], 1);
assert_eq!(resp["result"]["isError"], true);
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(!text.is_empty(), "Expected non-empty error message");
}

#[test]
fn test_notification_no_response() {
let mut proc = McpProcess::spawn();
Expand Down