From 1a887b9ed6c86b1fd603554f57745b5b0646d322 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sat, 7 Mar 2026 17:05:11 +0100 Subject: [PATCH] Add LSPS2 JIT invoice tools Expose the new LSPS2 JIT invoice endpoints through MCP so agents can request both fixed-amount and variable-amount invoices without falling back to the CLI. Update to the branch head so the MCP server tracks the new API surface. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 4 +- src/tools/handlers.rs | 96 +++++++++++++++++++++++++++++++++++-------- src/tools/mod.rs | 16 ++++++++ src/tools/schema.rs | 54 ++++++++++++++++++++++++ tests/integration.rs | 51 ++++++++++++++++++++++- 7 files changed, 204 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b24edfe..8e813fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -544,7 +544,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "ldk-server-client" version = "0.1.0" -source = "git+https://github.com/tnull/ldk-server?rev=4d90c74#4d90c747872e7d0721351b9ed29da7c9611e0e3b" +source = "git+https://github.com/tnull/ldk-server?rev=1cfe65e80f3d3324941a3c44b26ef7d0cbbe6377#1cfe65e80f3d3324941a3c44b26ef7d0cbbe6377" dependencies = [ "bitcoin_hashes", "ldk-server-protos", @@ -566,7 +566,7 @@ dependencies = [ [[package]] name = "ldk-server-protos" version = "0.1.0" -source = "git+https://github.com/tnull/ldk-server?rev=4d90c74#4d90c747872e7d0721351b9ed29da7c9611e0e3b" +source = "git+https://github.com/tnull/ldk-server?rev=1cfe65e80f3d3324941a3c44b26ef7d0cbbe6377#1cfe65e80f3d3324941a3c44b26ef7d0cbbe6377" dependencies = [ "bytes", "prost", diff --git a/Cargo.toml b/Cargo.toml index 3aa6c63..ddbfe6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 50e3387..d76c522 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 | diff --git a/src/tools/handlers.rs b/src/tools/handlers.rs index e2ccd75..1733a25 100644 --- a/src/tools/handlers.rs +++ b/src/tools/handlers.rs @@ -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, @@ -93,6 +94,27 @@ fn build_channel_config(args: &Value) -> Option { }) } +fn build_bolt11_invoice_description( + args: &Value, +) -> Result, 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 { let response = client.get_node_info(GetNodeInfoRequest {}).await.map_err(|e| e.message.clone())?; @@ -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 { 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") @@ -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 { + 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 { + 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 { let invoice = args .get("invoice") diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 67cd009..2ec101c 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -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", diff --git a/src/tools/schema.rs b/src/tools/schema.rs index 178db74..a2b5f50 100644 --- a/src/tools/schema.rs +++ b/src/tools/schema.rs @@ -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", diff --git a/tests/integration.rs b/tests/integration.rs index fed26df..f9d81b9 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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")) @@ -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();