diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml new file mode 100644 index 00000000..46a7f5d6 --- /dev/null +++ b/.github/workflows/mcp.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 061ac6b6..122f7a48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1288,6 +1288,7 @@ name = "ldk-server-client" version = "0.1.0" dependencies = [ "bitcoin_hashes 0.14.0", + "hex-conservative 0.2.1", "hyper 0.14.32", "hyper-rustls 0.24.2", "ldk-server-grpc", @@ -1295,7 +1296,9 @@ dependencies = [ "reqwest 0.11.27", "rustls 0.21.12", "rustls-pemfile", + "serde", "tokio", + "toml", ] [[package]] @@ -1316,6 +1319,17 @@ dependencies = [ "tonic", ] +[[package]] +name = "ldk-server-mcp" +version = "0.1.0" +dependencies = [ + "hex-conservative 0.2.1", + "ldk-server-client", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "libc" version = "0.2.177" diff --git a/Cargo.toml b/Cargo.toml index 6b09eb62..f9ccb552 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/README.md b/README.md index 93a74aea..9c7bac8d 100644 --- a/README.md +++ b/README.md @@ -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**: @@ -58,6 +66,18 @@ See [Getting Started](docs/getting-started.md) for a full walkthrough. The canonical API definitions are in [`ldk-server-grpc/src/proto/`](ldk-server-grpc/src/proto/). A ready-made Rust client library is provided in [`ldk-server-client/`](ldk-server-client/). +### 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. diff --git a/e2e-tests/Cargo.lock b/e2e-tests/Cargo.lock index e4a3de5d..0b864dd0 100644 --- a/e2e-tests/Cargo.lock +++ b/e2e-tests/Cargo.lock @@ -1252,6 +1252,7 @@ name = "ldk-server-client" version = "0.1.0" dependencies = [ "bitcoin_hashes", + "hex-conservative", "hyper 0.14.32", "hyper-rustls 0.24.2", "ldk-server-grpc", diff --git a/e2e-tests/build.rs b/e2e-tests/build.rs index b8841d3d..d21fb16c 100644 --- a/e2e-tests/build.rs +++ b/e2e-tests/build.rs @@ -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([ @@ -24,6 +28,8 @@ fn main() { "experimental-lsps2-support", "-p", "ldk-server-cli", + "-p", + "ldk-server-mcp", ]) .current_dir(&workspace_root) .env("CARGO_TARGET_DIR", &target_dir) @@ -31,14 +37,16 @@ fn main() { .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"); @@ -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"); } diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 1cd1a884..a2d5df9a 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -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}; @@ -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, @@ -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, + stdin: std::process::ChildStdin, + stdout: BufReader, +} + +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(); diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs new file mode 100644 index 00000000..6fe137e8 --- /dev/null +++ b/e2e-tests/tests/mcp.rs @@ -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 or the MIT license +// , 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": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "e2e-test", "version": "0.1"} + }), + ); + assert_eq!(initialize["result"]["protocolVersion"], "2025-11-25"); + 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); +} diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index f001d871..b0c346df 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -12,13 +12,12 @@ use std::path::PathBuf; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; -use config::{ - api_key_path_for_storage_dir, cert_path_for_storage_dir, get_default_api_key_path, - get_default_cert_path, get_default_config_path, load_config, resolve_base_url, - DEFAULT_GRPC_SERVICE_ADDRESS, -}; use hex_conservative::DisplayHex; use ldk_server_client::client::LdkServerClient; +use ldk_server_client::config::{ + get_default_config_path, load_config, resolve_api_key, resolve_base_url, resolve_cert_path, + DEFAULT_GRPC_SERVICE_ADDRESS, +}; use ldk_server_client::error::LdkServerError; use ldk_server_client::error::LdkServerErrorCode::{ AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError, @@ -49,23 +48,18 @@ 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::{ Amount, CliListForwardedPaymentsResponse, CliListPaymentsResponse, CliPaginatedResponse, }; -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") { @@ -570,43 +564,15 @@ async fn main() { let config_path = cli.config.map(PathBuf::from).or_else(get_default_config_path); let config = config_path.as_ref().and_then(|p| load_config(p).ok()); - let storage_dir = - config.as_ref().and_then(|c| c.storage.as_ref()?.disk.as_ref()?.dir_path.as_deref()); - // Get API key from argument, then from api_key file in storage dir, then from default location - let api_key = cli - .api_key - .or_else(|| { - let network = - config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string()); - storage_dir - .map(|dir| api_key_path_for_storage_dir(dir, &network)) - .and_then(|path| std::fs::read(&path).ok()) - .or_else(|| { - get_default_api_key_path(&network) - .and_then(|path| std::fs::read(&path).ok()) - }) - .map(|bytes| bytes.to_lower_hex_string()) - }) - .unwrap_or_else(|| { - eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at {DEFAULT_DIR}/[network]/api_key"); - std::process::exit(1); - }); + let api_key = resolve_api_key(cli.api_key, config.as_ref()).unwrap_or_else(|| { + eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at {DEFAULT_DIR}/[network]/api_key"); + std::process::exit(1); + }); - // Get base URL from argument then from config file let base_url = resolve_base_url(cli.base_url, config.as_ref()); - // Get TLS cert path from argument, then from config tls.cert_path, then from storage dir, - // then try default location. - let tls_cert_path = cli.tls_cert.map(PathBuf::from).or_else(|| { - config - .as_ref() - .and_then(|c| c.tls.as_ref().and_then(|t| t.cert_path.as_ref().map(PathBuf::from))) - .or_else(|| { - storage_dir.map(cert_path_for_storage_dir).filter(|path| path.exists()) - }) - .or_else(get_default_cert_path) - }) + let tls_cert_path = resolve_cert_path(cli.tls_cert.map(PathBuf::from), config.as_ref()) .unwrap_or_else(|| { eprintln!("TLS cert path not provided. Use --tls-cert or ensure config file exists at {DEFAULT_DIR}/config.toml"); std::process::exit(1); diff --git a/ldk-server-client/Cargo.toml b/ldk-server-client/Cargo.toml index 9768ac52..b3d1a91b 100644 --- a/ldk-server-client/Cargo.toml +++ b/ldk-server-client/Cargo.toml @@ -5,17 +5,20 @@ edition = "2021" [features] default = [] -serde = ["ldk-server-grpc/serde"] +serde = ["dep:serde", "dep:toml", "ldk-server-grpc/serde"] [dependencies] ldk-server-grpc = { path = "../ldk-server-grpc" } reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls"] } prost = { version = "0.11.6", default-features = false, features = ["std", "prost-derive"] } bitcoin_hashes = "0.14" +hex-conservative = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "0.14", default-features = false, features = ["client", "http2", "runtime", "tcp"] } hyper-rustls = { version = "0.24", default-features = false, features = ["http2", "tls12", "tokio-runtime"] } rustls = "0.21" rustls-pemfile = "1" +serde = { version = "1.0", features = ["derive"], optional = true } +toml = { version = "0.8", default-features = false, features = ["parse"], optional = true } [dev-dependencies] tokio = { version = "1", default-features = false, features = ["macros", "rt"] } diff --git a/ldk-server-cli/src/config.rs b/ldk-server-client/src/config.rs similarity index 54% rename from ldk-server-cli/src/config.rs rename to ldk-server-client/src/config.rs index 2071f344..f6f023ce 100644 --- a/ldk-server-cli/src/config.rs +++ b/ldk-server-client/src/config.rs @@ -7,15 +7,25 @@ // You may not use this file except in accordance with one or both of these // licenses. +//! Shared `ldk-server` client configuration. +//! +//! Parses the TOML configuration file used by the `ldk-server` daemon and exposes helpers for +//! locating the server's TLS certificate and API key on disk, so multiple clients (CLI, MCP +//! bridge, etc.) can resolve connection credentials in a consistent way. + use std::path::PathBuf; +use hex_conservative::DisplayHex; use serde::{Deserialize, Serialize}; const DEFAULT_CONFIG_FILE: &str = "config.toml"; const DEFAULT_CERT_FILE: &str = "tls.crt"; const API_KEY_FILE: &str = "api_key"; + +/// Default address of the `ldk-server` gRPC endpoint when no explicit value is configured. pub const DEFAULT_GRPC_SERVICE_ADDRESS: &str = "127.0.0.1:3536"; +/// Returns the OS-specific default data directory used by `ldk-server`. pub fn get_default_data_dir() -> Option { #[cfg(target_os = "macos")] { @@ -33,56 +43,74 @@ pub fn get_default_data_dir() -> Option { } } +/// Default path of the `ldk-server` configuration TOML file inside the default data directory. pub fn get_default_config_path() -> Option { get_default_data_dir().map(|dir| dir.join(DEFAULT_CONFIG_FILE)) } +/// Default path of the server's TLS certificate inside the default data directory. pub fn get_default_cert_path() -> Option { get_default_data_dir().map(|path| path.join(DEFAULT_CERT_FILE)) } +/// Default path of the network-scoped API key file inside the default data directory. pub fn get_default_api_key_path(network: &str) -> Option { get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE)) } +/// Path of the network-scoped API key file inside the given storage directory. pub fn api_key_path_for_storage_dir(storage_dir: &str, network: &str) -> PathBuf { PathBuf::from(storage_dir).join(network).join(API_KEY_FILE) } +/// Path of the server's TLS certificate inside the given storage directory. pub fn cert_path_for_storage_dir(storage_dir: &str) -> PathBuf { PathBuf::from(storage_dir).join(DEFAULT_CERT_FILE) } +/// Top-level structure of the `ldk-server` configuration TOML file. #[derive(Debug, Deserialize)] pub struct Config { + /// Node-level configuration. pub node: NodeConfig, + /// Optional TLS configuration. pub tls: Option, + /// Optional storage configuration. pub storage: Option, } +/// `[tls]` section of the configuration file. #[derive(Debug, Deserialize, Serialize)] pub struct TlsConfig { + /// Path to the server's TLS certificate in PEM format. pub cert_path: Option, } +/// `[node]` section of the configuration file. #[derive(Debug, Deserialize)] pub struct NodeConfig { + /// Address of the `ldk-server` gRPC service. #[serde(default = "default_grpc_service_address")] pub grpc_service_address: String, network: String, } +/// `[storage]` section of the configuration file. #[derive(Debug, Deserialize)] pub struct StorageConfig { + /// On-disk storage configuration. pub disk: Option, } +/// `[storage.disk]` section of the configuration file. #[derive(Debug, Deserialize)] pub struct DiskConfig { + /// Directory used by the server to store its persistent data. pub dir_path: Option, } impl Config { + /// Returns the normalized Bitcoin network name configured for the node. pub fn network(&self) -> Result { match self.node.network.as_str() { "bitcoin" | "mainnet" => Ok("bitcoin".to_string()), @@ -95,6 +123,7 @@ impl Config { } } +/// Reads and parses the `ldk-server` configuration file at `path`. pub fn load_config(path: &PathBuf) -> Result { let contents = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?; @@ -102,12 +131,56 @@ pub fn load_config(path: &PathBuf) -> Result { .map_err(|e| format!("Failed to parse config file '{}': {}", path.display(), e)) } -pub fn resolve_base_url(cli_base_url: Option, config: Option<&Config>) -> String { - cli_base_url +/// Resolves the base URL of the `ldk-server` gRPC endpoint. +/// +/// Prefers `override_url`, falls back to the configuration file, and finally to +/// [`DEFAULT_GRPC_SERVICE_ADDRESS`]. +pub fn resolve_base_url(override_url: Option, config: Option<&Config>) -> String { + override_url .or_else(|| config.map(|config| config.node.grpc_service_address.clone())) .unwrap_or_else(default_grpc_service_address) } +/// Resolves the API key used to authenticate against the `ldk-server` gRPC endpoint. +/// +/// Prefers `override_key`, falls back to reading the API key file from the configured storage +/// directory, and finally from the OS-specific default data directory. The raw bytes read from +/// disk are lower-hex encoded before being returned. +pub fn resolve_api_key(override_key: Option, config: Option<&Config>) -> Option { + override_key.or_else(|| { + let network = + config.and_then(|c| c.network().ok()).unwrap_or_else(|| "bitcoin".to_string()); + storage_dir(config) + .map(|dir| api_key_path_for_storage_dir(dir, &network)) + .and_then(|path| std::fs::read(&path).ok()) + .or_else(|| { + get_default_api_key_path(&network).and_then(|path| std::fs::read(&path).ok()) + }) + .map(|bytes| bytes.to_lower_hex_string()) + }) +} + +/// Resolves the path to the server's TLS certificate (PEM). +/// +/// Prefers `override_path`, falls back to `tls.cert_path` in the configuration file, then to the +/// certificate inside the configured storage directory (if present), and finally to the +/// OS-specific default path. +pub fn resolve_cert_path( + override_path: Option, config: Option<&Config>, +) -> Option { + override_path + .or_else(|| { + config + .and_then(|c| c.tls.as_ref().and_then(|t| t.cert_path.as_ref().map(PathBuf::from))) + }) + .or_else(|| storage_dir(config).map(cert_path_for_storage_dir).filter(|p| p.exists())) + .or_else(get_default_cert_path) +} + +fn storage_dir(config: Option<&Config>) -> Option<&str> { + config.and_then(|c| c.storage.as_ref()?.disk.as_ref()?.dir_path.as_deref()) +} + fn default_grpc_service_address() -> String { DEFAULT_GRPC_SERVICE_ADDRESS.to_string() } diff --git a/ldk-server-client/src/lib.rs b/ldk-server-client/src/lib.rs index 4959f127..ff67cd9e 100644 --- a/ldk-server-client/src/lib.rs +++ b/ldk-server-client/src/lib.rs @@ -15,8 +15,21 @@ /// Implements a [`LdkServerClient`](client::LdkServerClient) to access a hosted instance of LDK Server. pub mod client; +/// Shared configuration loading and credential resolution logic reused by `ldk-server` clients. +#[cfg(feature = "serde")] +pub mod config; + /// Implements the error type ([`LdkServerError`](error::LdkServerError)) returned on interacting with [`LdkServerClient`](client::LdkServerClient). pub mod error; /// Request/Response structs required for interacting with the 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; diff --git a/ldk-server-grpc/build.rs b/ldk-server-grpc/build.rs index f32fe938..9cfc1205 100644 --- a/ldk-server-grpc/build.rs +++ b/ldk-server-grpc/build.rs @@ -38,9 +38,10 @@ fn generate_protos() { .bytes(&["."]) .type_attribute( ".", - "#[cfg_attr(feature = \"serde\", derive(serde::Serialize))]", + "#[cfg_attr(feature = \"serde\", derive(serde::Serialize, serde::Deserialize))]", ) .type_attribute(".", "#[cfg_attr(feature = \"serde\", serde(rename_all = \"snake_case\"))]") + .message_attribute(".", "#[cfg_attr(feature = \"serde\", serde(default))]") .field_attribute( "types.Bolt11.secret", "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_opt_bytes_hex\"))]", diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index 0cc3e159..1d80b13e 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -11,14 +11,16 @@ /// See more: /// - /// - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetNodeInfoRequest {} /// The response for the `GetNodeInfo` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetNodeInfoResponse { @@ -82,14 +84,16 @@ pub struct GetNodeInfoResponse { } /// Retrieve a new on-chain funding address. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OnchainReceiveRequest {} /// The response for the `OnchainReceive` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OnchainReceiveResponse { @@ -98,8 +102,9 @@ pub struct OnchainReceiveResponse { pub address: ::prost::alloc::string::String, } /// Send an on-chain payment to the given address. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OnchainSendRequest { @@ -127,8 +132,9 @@ pub struct OnchainSendRequest { pub fee_rate_sat_per_vb: ::core::option::Option, } /// The response for the `OnchainSend` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OnchainSendResponse { @@ -142,8 +148,9 @@ pub struct OnchainSendResponse { /// See more: /// - /// - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveRequest { @@ -159,8 +166,9 @@ pub struct Bolt11ReceiveRequest { pub expiry_secs: u32, } /// The response for the `Bolt11Receive` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveResponse { @@ -183,8 +191,9 @@ pub struct Bolt11ReceiveResponse { /// See more: /// - /// - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveForHashRequest { @@ -203,8 +212,9 @@ pub struct Bolt11ReceiveForHashRequest { pub payment_hash: ::prost::alloc::string::String, } /// The response for the `Bolt11ReceiveForHash` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveForHashResponse { @@ -217,8 +227,9 @@ pub struct Bolt11ReceiveForHashResponse { /// Manually claim a payment for a given payment hash with the corresponding preimage. /// This should be used to claim payments created via `Bolt11ReceiveForHash`. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ClaimForHashRequest { @@ -235,16 +246,18 @@ pub struct Bolt11ClaimForHashRequest { pub preimage: ::prost::alloc::string::String, } /// The response for the `Bolt11ClaimForHash` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ClaimForHashResponse {} /// Manually fail a payment for a given payment hash. /// This should be used to reject payments created via `Bolt11ReceiveForHash`. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11FailForHashRequest { @@ -253,16 +266,18 @@ pub struct Bolt11FailForHashRequest { pub payment_hash: ::prost::alloc::string::String, } /// The response for the `Bolt11FailForHash` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11FailForHashResponse {} /// Return a BOLT11 payable invoice that can be used to request and receive a payment via an /// LSPS2 just-in-time channel. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveViaJitChannelRequest { @@ -281,8 +296,9 @@ pub struct Bolt11ReceiveViaJitChannelRequest { pub max_total_lsp_fee_limit_msat: ::core::option::Option, } /// The response for the `Bolt11ReceiveViaJitChannel` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveViaJitChannelResponse { @@ -293,8 +309,9 @@ pub struct Bolt11ReceiveViaJitChannelResponse { /// Return a variable-amount BOLT11 invoice that can be used to receive a payment via an LSPS2 /// just-in-time channel. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveVariableAmountViaJitChannelRequest { @@ -311,8 +328,9 @@ pub struct Bolt11ReceiveVariableAmountViaJitChannelRequest { pub max_proportional_lsp_fee_limit_ppm_msat: ::core::option::Option, } /// The response for the `Bolt11ReceiveVariableAmountViaJitChannel` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11ReceiveVariableAmountViaJitChannelResponse { @@ -322,8 +340,9 @@ pub struct Bolt11ReceiveVariableAmountViaJitChannelResponse { } /// Send a payment for a BOLT11 invoice. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11SendRequest { @@ -340,8 +359,9 @@ pub struct Bolt11SendRequest { pub route_parameters: ::core::option::Option, } /// The response for the `Bolt11Send` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11SendResponse { @@ -354,8 +374,9 @@ pub struct Bolt11SendResponse { /// See more: /// - /// - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt12ReceiveRequest { @@ -374,8 +395,9 @@ pub struct Bolt12ReceiveRequest { pub quantity: ::core::option::Option, } /// The response for the `Bolt12Receive` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt12ReceiveResponse { @@ -392,8 +414,9 @@ pub struct Bolt12ReceiveResponse { /// See more: /// - /// - -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt12SendRequest { @@ -416,8 +439,9 @@ pub struct Bolt12SendRequest { pub route_parameters: ::core::option::Option, } /// The response for the `Bolt12Send` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt12SendResponse { @@ -427,8 +451,9 @@ pub struct Bolt12SendResponse { } /// Send a spontaneous payment, also known as "keysend", to a node. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SpontaneousSendRequest { @@ -443,8 +468,9 @@ pub struct SpontaneousSendRequest { pub route_parameters: ::core::option::Option, } /// The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SpontaneousSendResponse { @@ -454,8 +480,9 @@ pub struct SpontaneousSendResponse { } /// Creates a new outbound channel to the given remote node. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OpenChannelRequest { @@ -483,8 +510,9 @@ pub struct OpenChannelRequest { pub disable_counterparty_reserve: bool, } /// The response for the `OpenChannel` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OpenChannelResponse { @@ -494,8 +522,9 @@ pub struct OpenChannelResponse { } /// Increases the channel balance by the given amount. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SpliceInRequest { @@ -510,15 +539,17 @@ pub struct SpliceInRequest { pub splice_amount_sats: u64, } /// The response for the `SpliceIn` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SpliceInResponse {} /// Decreases the channel balance by the given amount. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SpliceOutRequest { @@ -538,8 +569,9 @@ pub struct SpliceOutRequest { pub splice_amount_sats: u64, } /// The response for the `SpliceOut` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SpliceOutResponse { @@ -549,8 +581,9 @@ pub struct SpliceOutResponse { } /// Update the config for a previously opened channel. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateChannelConfigRequest { @@ -565,15 +598,17 @@ pub struct UpdateChannelConfigRequest { pub channel_config: ::core::option::Option, } /// The response for the `UpdateChannelConfig` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateChannelConfigResponse {} /// Closes the channel specified by given request. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct CloseChannelRequest { @@ -585,15 +620,17 @@ pub struct CloseChannelRequest { pub counterparty_node_id: ::prost::alloc::string::String, } /// The response for the `CloseChannel` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct CloseChannelResponse {} /// Force closes the channel specified by given request. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ForceCloseChannelRequest { @@ -608,21 +645,24 @@ pub struct ForceCloseChannelRequest { pub force_close_reason: ::core::option::Option<::prost::alloc::string::String>, } /// The response for the `ForceCloseChannel` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ForceCloseChannelResponse {} /// Returns a list of known channels. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListChannelsRequest {} /// The response for the `ListChannels` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListChannelsResponse { @@ -632,8 +672,9 @@ pub struct ListChannelsResponse { } /// Returns payment details for a given payment_id. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetPaymentDetailsRequest { @@ -642,8 +683,9 @@ pub struct GetPaymentDetailsRequest { pub payment_id: ::prost::alloc::string::String, } /// The response for the `GetPaymentDetails` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetPaymentDetailsResponse { @@ -654,8 +696,9 @@ pub struct GetPaymentDetailsResponse { } /// Retrieves list of all payments. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListPaymentsRequest { @@ -669,8 +712,9 @@ pub struct ListPaymentsRequest { pub page_token: ::core::option::Option, } /// The response for the `ListPayments` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListPaymentsResponse { @@ -695,8 +739,9 @@ pub struct ListPaymentsResponse { } /// Retrieves list of all forwarded payments. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListForwardedPaymentsRequest { @@ -710,8 +755,9 @@ pub struct ListForwardedPaymentsRequest { pub page_token: ::core::option::Option, } /// The response for the `ListForwardedPayments` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListForwardedPaymentsResponse { @@ -736,8 +782,9 @@ pub struct ListForwardedPaymentsResponse { } /// Sign a message with the node's secret key. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignMessageRequest { @@ -746,8 +793,9 @@ pub struct SignMessageRequest { pub message: ::prost::bytes::Bytes, } /// The response for the `SignMessage` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignMessageResponse { @@ -757,8 +805,9 @@ pub struct SignMessageResponse { } /// Verify a signature against a message and public key. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct VerifySignatureRequest { @@ -773,8 +822,9 @@ pub struct VerifySignatureRequest { pub public_key: ::prost::alloc::string::String, } /// The response for the `VerifySignature` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct VerifySignatureResponse { @@ -784,14 +834,16 @@ pub struct VerifySignatureResponse { } /// Export the pathfinding scores used by the router. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ExportPathfindingScoresRequest {} /// The response for the `ExportPathfindingScores` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ExportPathfindingScoresResponse { @@ -801,14 +853,16 @@ pub struct ExportPathfindingScoresResponse { } /// Retrieves an overview of all known balances. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetBalancesRequest {} /// The response for the `GetBalances` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetBalancesResponse { @@ -853,8 +907,9 @@ pub struct GetBalancesResponse { } /// Connect to a peer on the Lightning Network. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ConnectPeerRequest { @@ -871,15 +926,17 @@ pub struct ConnectPeerRequest { pub persist: bool, } /// The response for the `ConnectPeer` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ConnectPeerResponse {} /// Disconnect from a peer and remove it from the peer store. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct DisconnectPeerRequest { @@ -888,21 +945,24 @@ pub struct DisconnectPeerRequest { pub node_pubkey: ::prost::alloc::string::String, } /// The response for the `DisconnectPeer` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct DisconnectPeerResponse {} /// Returns a list of peers. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListPeersRequest {} /// The response for the `ListPeers` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListPeersResponse { @@ -912,14 +972,16 @@ pub struct ListPeersResponse { } /// Returns a list of all known short channel IDs in the network graph. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphListChannelsRequest {} /// The response for the `GraphListChannels` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphListChannelsResponse { @@ -929,8 +991,9 @@ pub struct GraphListChannelsResponse { } /// Returns information on a channel with the given short channel ID from the network graph. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphGetChannelRequest { @@ -939,8 +1002,9 @@ pub struct GraphGetChannelRequest { pub short_channel_id: u64, } /// The response for the `GraphGetChannel` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphGetChannelResponse { @@ -950,14 +1014,16 @@ pub struct GraphGetChannelResponse { } /// Returns a list of all known node IDs in the network graph. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphListNodesRequest {} /// The response for the `GraphListNodes` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphListNodesResponse { @@ -971,8 +1037,9 @@ pub struct GraphListNodesResponse { /// has an offer and/or invoice, it will try to pay the offer first followed by the invoice. /// If they both fail, the on-chain payment will be paid. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct UnifiedSendRequest { @@ -987,8 +1054,9 @@ pub struct UnifiedSendRequest { pub route_parameters: ::core::option::Option, } /// The response for the `UnifiedSend` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct UnifiedSendResponse { @@ -998,7 +1066,7 @@ pub struct UnifiedSendResponse { } /// Nested message and enum types in `UnifiedSendResponse`. pub mod unified_send_response { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -1016,8 +1084,9 @@ pub mod unified_send_response { } /// Returns information on a node with the given ID from the network graph. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphGetNodeRequest { @@ -1026,8 +1095,9 @@ pub struct GraphGetNodeRequest { pub node_id: ::prost::alloc::string::String, } /// The response for the `GraphGetNode` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphGetNodeResponse { @@ -1037,8 +1107,9 @@ pub struct GraphGetNodeResponse { } /// Decode a BOLT11 invoice and return its parsed fields. /// This does not require a running node — it only parses the invoice string. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct DecodeInvoiceRequest { @@ -1047,8 +1118,9 @@ pub struct DecodeInvoiceRequest { pub invoice: ::prost::alloc::string::String, } /// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct DecodeInvoiceResponse { @@ -1100,8 +1172,9 @@ pub struct DecodeInvoiceResponse { } /// Decode a BOLT12 offer and return its parsed fields. /// This does not require a running node — it only parses the offer string. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct DecodeOfferRequest { @@ -1110,8 +1183,9 @@ pub struct DecodeOfferRequest { pub offer: ::prost::alloc::string::String, } /// The response for the `DecodeOffer` RPC. On failure, a gRPC error status is returned. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct DecodeOfferResponse { @@ -1153,8 +1227,9 @@ pub struct DecodeOfferResponse { pub is_expired: bool, } /// Subscribe to a stream of server events. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SubscribeEventsRequest {} diff --git a/ldk-server-grpc/src/error.rs b/ldk-server-grpc/src/error.rs index f9862ecd..f92ef336 100644 --- a/ldk-server-grpc/src/error.rs +++ b/ldk-server-grpc/src/error.rs @@ -9,8 +9,9 @@ /// When HttpStatusCode is not ok (200), the response `content` contains a serialized `ErrorResponse` /// with the relevant ErrorCode and `message` -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ErrorResponse { @@ -28,7 +29,7 @@ pub struct ErrorResponse { #[prost(enumeration = "ErrorCode", tag = "2")] pub error_code: i32, } -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] diff --git a/ldk-server-grpc/src/events.rs b/ldk-server-grpc/src/events.rs index c2e74fcc..b1f64929 100644 --- a/ldk-server-grpc/src/events.rs +++ b/ldk-server-grpc/src/events.rs @@ -8,8 +8,9 @@ // licenses. /// EventEnvelope wraps different event types in a single message to be used by EventPublisher. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct EventEnvelope { @@ -18,7 +19,7 @@ pub struct EventEnvelope { } /// Nested message and enum types in `EventEnvelope`. pub mod event_envelope { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -36,8 +37,9 @@ pub mod event_envelope { } } /// PaymentReceived indicates a payment has been received. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PaymentReceived { @@ -46,8 +48,9 @@ pub struct PaymentReceived { pub payment: ::core::option::Option, } /// PaymentSuccessful indicates a sent payment was successful. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PaymentSuccessful { @@ -56,8 +59,9 @@ pub struct PaymentSuccessful { pub payment: ::core::option::Option, } /// PaymentFailed indicates a sent payment has failed. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PaymentFailed { @@ -67,8 +71,9 @@ pub struct PaymentFailed { } /// PaymentClaimable indicates a payment has arrived and is waiting to be manually claimed or failed. /// This event is only emitted for payments created via `Bolt11ReceiveForHash`. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PaymentClaimable { @@ -77,8 +82,9 @@ pub struct PaymentClaimable { pub payment: ::core::option::Option, } /// PaymentForwarded indicates a payment was forwarded through the node. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PaymentForwarded { diff --git a/ldk-server-grpc/src/types.rs b/ldk-server-grpc/src/types.rs index cd12627b..c4616fd6 100644 --- a/ldk-server-grpc/src/types.rs +++ b/ldk-server-grpc/src/types.rs @@ -9,8 +9,9 @@ /// Represents a payment. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Payment { @@ -47,8 +48,9 @@ pub struct Payment { #[prost(uint64, tag = "6")] pub latest_update_timestamp: u64, } -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PaymentKind { @@ -57,7 +59,7 @@ pub struct PaymentKind { } /// Nested message and enum types in `PaymentKind`. pub mod payment_kind { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -77,8 +79,9 @@ pub mod payment_kind { } } /// Represents an on-chain payment. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Onchain { @@ -89,8 +92,9 @@ pub struct Onchain { #[prost(message, optional, tag = "2")] pub status: ::core::option::Option, } -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ConfirmationStatus { @@ -99,7 +103,7 @@ pub struct ConfirmationStatus { } /// Nested message and enum types in `ConfirmationStatus`. pub mod confirmation_status { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -111,8 +115,9 @@ pub mod confirmation_status { } } /// The on-chain transaction is confirmed in the best chain. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Confirmed { @@ -127,14 +132,16 @@ pub struct Confirmed { pub timestamp: u64, } /// The on-chain transaction is unconfirmed. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Unconfirmed {} /// Represents a BOLT 11 payment. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11 { @@ -153,8 +160,9 @@ pub struct Bolt11 { pub secret: ::core::option::Option<::prost::bytes::Bytes>, } /// Represents a BOLT 11 payment intended to open an LSPS 2 just-in-time channel. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11Jit { @@ -187,8 +195,9 @@ pub struct Bolt11Jit { pub counterparty_skimmed_fee_msat: ::core::option::Option, } /// Represents a BOLT 12 ‘offer’ payment, i.e., a payment for an Offer. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt12Offer { @@ -220,8 +229,9 @@ pub struct Bolt12Offer { pub quantity: ::core::option::Option, } /// Represents a BOLT 12 ‘refund’ payment, i.e., a payment for a Refund. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt12Refund { @@ -250,8 +260,9 @@ pub struct Bolt12Refund { pub quantity: ::core::option::Option, } /// Represents a spontaneous (“keysend”) payment. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Spontaneous { @@ -266,8 +277,9 @@ pub struct Spontaneous { /// See \[`LdkChannelConfig::accept_underpaying_htlcs`\] for more information. /// /// \[`LdkChannelConfig::accept_underpaying_htlcs`\]: lightning::util::config::ChannelConfig::accept_underpaying_htlcs -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct LspFeeLimits { @@ -282,8 +294,9 @@ pub struct LspFeeLimits { } /// A forwarded payment through our node. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ForwardedPayment { @@ -334,8 +347,9 @@ pub struct ForwardedPayment { #[prost(uint64, optional, tag = "8")] pub outbound_amount_forwarded_msat: ::core::option::Option, } -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Channel { @@ -468,8 +482,9 @@ pub struct Channel { } /// ChannelConfig represents the configuration settings for a channel in a Lightning Network node. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ChannelConfig { @@ -513,7 +528,7 @@ pub mod channel_config { /// and fees on commitment transaction(s) broadcasted by our counterparty in excess of /// our own fee estimate. /// See more: - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -529,8 +544,9 @@ pub mod channel_config { } } /// Represent a transaction outpoint. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OutPoint { @@ -541,8 +557,9 @@ pub struct OutPoint { #[prost(uint32, tag = "2")] pub vout: u32, } -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct BestBlock { @@ -554,8 +571,9 @@ pub struct BestBlock { pub height: u32, } /// Details about the status of a known Lightning balance. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct LightningBalance { @@ -564,7 +582,7 @@ pub struct LightningBalance { } /// Nested message and enum types in `LightningBalance`. pub mod lightning_balance { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -586,8 +604,9 @@ pub mod lightning_balance { /// The channel is not yet closed (or the commitment or closing transaction has not yet appeared in a block). /// The given balance is claimable (less on-chain fees) if the channel is force-closed now. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ClaimableOnChannelClose { @@ -643,8 +662,9 @@ pub struct ClaimableOnChannelClose { } /// The channel has been closed, and the given balance is ours but awaiting confirmations until we consider it spendable. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ClaimableAwaitingConfirmations { @@ -676,8 +696,9 @@ pub struct ClaimableAwaitingConfirmations { /// Once the spending transaction confirms, before it has reached enough confirmations to be considered safe from chain /// reorganizations, the balance will instead be provided via `LightningBalance::ClaimableAwaitingConfirmations`. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ContentiousClaimable { @@ -704,8 +725,9 @@ pub struct ContentiousClaimable { /// HTLCs which we sent to our counterparty which are claimable after a timeout (less on-chain fees) if the counterparty /// does not know the preimage for the HTLCs. These are somewhat likely to be claimed by our counterparty before we do. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct MaybeTimeoutClaimableHtlc { @@ -733,8 +755,9 @@ pub struct MaybeTimeoutClaimableHtlc { /// This will only be claimable if we receive the preimage from the node to which we forwarded this HTLC before the /// timeout. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct MaybePreimageClaimableHtlc { @@ -761,8 +784,9 @@ pub struct MaybePreimageClaimableHtlc { /// Thus, we’re able to claim all outputs in the commitment transaction, one of which has the following amount. /// /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct CounterpartyRevokedOutputClaimable { @@ -777,8 +801,9 @@ pub struct CounterpartyRevokedOutputClaimable { pub amount_satoshis: u64, } /// Details about the status of a known balance currently being swept to our on-chain wallet. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PendingSweepBalance { @@ -787,7 +812,7 @@ pub struct PendingSweepBalance { } /// Nested message and enum types in `PendingSweepBalance`. pub mod pending_sweep_balance { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -802,8 +827,9 @@ pub mod pending_sweep_balance { } /// The spendable output is about to be swept, but a spending transaction has yet to be generated and broadcast. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PendingBroadcast { @@ -816,8 +842,9 @@ pub struct PendingBroadcast { } /// A spending transaction has been generated and broadcast and is awaiting confirmation on-chain. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct BroadcastAwaitingConfirmation { @@ -838,8 +865,9 @@ pub struct BroadcastAwaitingConfirmation { /// /// It will be considered irrevocably confirmed after reaching `ANTI_REORG_DELAY`. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct AwaitingThresholdConfirmations { @@ -860,8 +888,9 @@ pub struct AwaitingThresholdConfirmations { pub amount_satoshis: u64, } /// Token used to determine start of next page in paginated APIs. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct PageToken { @@ -870,8 +899,9 @@ pub struct PageToken { #[prost(int64, tag = "2")] pub index: i64, } -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11InvoiceDescription { @@ -880,7 +910,7 @@ pub struct Bolt11InvoiceDescription { } /// Nested message and enum types in `Bolt11InvoiceDescription`. pub mod bolt11_invoice_description { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -893,8 +923,9 @@ pub mod bolt11_invoice_description { } /// Configuration options for payment routing and pathfinding. /// See for more details on each field. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct RouteParametersConfig { @@ -917,8 +948,9 @@ pub struct RouteParametersConfig { pub max_channel_saturation_power_of_half: u32, } /// Routing fees for a channel as part of the network graph. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphRoutingFees { @@ -931,8 +963,9 @@ pub struct GraphRoutingFees { } /// Details about one direction of a channel in the network graph, /// as received within a `ChannelUpdate`. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphChannelUpdate { @@ -958,8 +991,9 @@ pub struct GraphChannelUpdate { } /// Details about a channel in the network graph (both directions). /// Received within a channel announcement. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphChannel { @@ -980,8 +1014,9 @@ pub struct GraphChannel { pub two_to_one: ::core::option::Option, } /// Information received in the latest node_announcement from this node. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphNodeAnnouncement { @@ -1002,8 +1037,9 @@ pub struct GraphNodeAnnouncement { } /// Details of a known Lightning peer. /// See more: -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Peer { @@ -1021,8 +1057,9 @@ pub struct Peer { pub is_connected: bool, } /// Details about a node in the network graph, known from the network announcement. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GraphNode { @@ -1036,8 +1073,9 @@ pub struct GraphNode { pub announcement_info: ::core::option::Option, } /// Route hint for finding a path to the payee in a BOLT11 invoice. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11RouteHint { @@ -1046,8 +1084,9 @@ pub struct Bolt11RouteHint { pub hop_hints: ::prost::alloc::vec::Vec, } /// A hop in a BOLT11 route hint. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11HopHint { @@ -1068,8 +1107,9 @@ pub struct Bolt11HopHint { pub cltv_expiry_delta: u32, } /// The amount specified in a BOLT12 offer. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OfferAmount { @@ -1078,7 +1118,7 @@ pub struct OfferAmount { } /// Nested message and enum types in `OfferAmount`. pub mod offer_amount { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -1092,8 +1132,9 @@ pub mod offer_amount { } } /// A non-Bitcoin currency amount. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct CurrencyAmount { @@ -1105,8 +1146,9 @@ pub struct CurrencyAmount { pub amount: u64, } /// The quantity of items supported by a BOLT12 offer. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct OfferQuantity { @@ -1115,7 +1157,7 @@ pub struct OfferQuantity { } /// Nested message and enum types in `OfferQuantity`. pub mod offer_quantity { - #[cfg_attr(feature = "serde", derive(serde::Serialize))] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -1132,8 +1174,9 @@ pub mod offer_quantity { } } /// A blinded path to the offer recipient. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct BlindedPath { @@ -1153,8 +1196,9 @@ pub struct BlindedPath { pub introduction_scid: ::core::option::Option, } /// A feature bit advertised in a BOLT11 invoice. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bolt11Feature { @@ -1169,7 +1213,7 @@ pub struct Bolt11Feature { pub is_known: bool, } /// Represents the direction of a payment. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] @@ -1200,7 +1244,7 @@ impl PaymentDirection { } } /// Represents the current status of a payment. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] @@ -1236,7 +1280,7 @@ impl PaymentStatus { } /// Indicates whether the balance is derived from a cooperative close, a force-close (for holder or counterparty), /// or whether it is for an HTLC. -#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] diff --git a/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md new file mode 100644 index 00000000..af791b91 --- /dev/null +++ b/ldk-server-mcp/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md — ldk-server-mcp + +MCP (Model Context Protocol) server that exposes LDK Server operations as tools for AI agents. + +This crate is a member of the `ldk-server` workspace and should be kept green under the workspace-wide checks. + +## Build / Test Commands + +```bash +cargo fmt --all +cargo check +cargo test -p ldk-server-mcp +cargo clippy -p ldk-server-mcp --all-targets -- -D warnings + +# MCP sanity checks against a live ldk-server instance +cargo test --manifest-path e2e-tests/Cargo.toml mcp -- --nocapture +``` + +## Architecture + +``` +src/ + main.rs — Entry point: arg parsing, config, stdio JSON-RPC loop, method dispatch + config.rs — Config loading (TOML + env vars), mirrors ldk-server-cli config + protocol.rs — JSON-RPC 2.0 request/response types + mcp.rs — MCP protocol types (InitializeResult, ToolDefinition, ToolCallResult) + tools/ + mod.rs — ToolRegistry: build_tool_registry(), list_tools(), call_tool() + schema.rs — JSON Schema definitions for all tool inputs + handlers.rs — Handler functions: JSON args -> ldk-server-client call -> JSON result +``` + +## MCP Protocol + +- **Version**: `2025-11-25` +- **Spec**: https://spec.modelcontextprotocol.io/ +- **Transport**: stdio (one JSON-RPC 2.0 message per line) +- **Methods implemented**: `initialize`, `tools/list`, `tools/call` +- **Notifications handled**: `notifications/initialized` (ignored, no response) + +## Config + +The server reads configuration in this precedence order (highest first): + +1. **Environment variables**: `LDK_BASE_URL`, `LDK_API_KEY`, `LDK_TLS_CERT_PATH` +2. **CLI argument**: `--config ` pointing to a TOML file +3. **Default paths**: `~/.ldk-server/config.toml`, `~/.ldk-server/tls.crt`, `~/.ldk-server/{network}/api_key` + +If no config path is provided explicitly, the crate uses the default `ldk-server` config location at +`~/.ldk-server/config.toml`. + +TOML config format (same as ldk-server-cli): +```toml +[node] +grpc_service_address = "127.0.0.1:3536" +network = "bitcoin" + +[tls] +cert_path = "/path/to/tls.crt" +``` + +## Adding a New Tool + +When a new endpoint is added to `ldk-server-client`: + +1. Add a JSON schema function in `src/tools/schema.rs` (follow existing pattern) +2. Add a handler function in `src/tools/handlers.rs` +3. Register in `build_tool_registry()` in `src/tools/mod.rs` +4. Update the expected tool surface in `tests/integration.rs` +5. Add or update helper-level coverage in `src/tools/handlers.rs` when parsing or validation changes +6. If the tool is suitable for live validation, extend `e2e-tests/tests/mcp.rs` diff --git a/ldk-server-mcp/Cargo.toml b/ldk-server-mcp/Cargo.toml new file mode 100644 index 00000000..3a3459ed --- /dev/null +++ b/ldk-server-mcp/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ldk-server-mcp" +version = "0.1.0" +edition = "2021" + +[dependencies] +hex-conservative = { version = "0.2", default-features = false, features = ["std"] } +ldk-server-client = { path = "../ldk-server-client", 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/ldk-server-mcp/README.md b/ldk-server-mcp/README.md new file mode 100644 index 00000000..e5dc7fb5 --- /dev/null +++ b/ldk-server-mcp/README.md @@ -0,0 +1,118 @@ +# ldk-server-mcp + +An [MCP (Model Context Protocol)](https://spec.modelcontextprotocol.io/) server that exposes [LDK Server](https://github.com/lightningdevkit/ldk-server) operations as tools for AI agents. It communicates over JSON-RPC 2.0 via stdio and connects to an LDK Server instance over TLS using the [`ldk-server-client`](https://github.com/lightningdevkit/ldk-server/tree/main/ldk-server-client) library. + +This crate lives inside the `ldk-server` workspace. + +## Building + +```bash +cargo build -p ldk-server-mcp --release +``` + +## Configuration + +The server reads configuration in this precedence order (highest wins): + +1. **Environment variables**: `LDK_BASE_URL`, `LDK_API_KEY`, `LDK_TLS_CERT_PATH` +2. **CLI argument**: `--config ` pointing to a TOML config file +3. **Default paths**: `~/.ldk-server/config.toml`, `~/.ldk-server/tls.crt`, `~/.ldk-server/{network}/api_key` + +The TOML config format is the same as used by [`ldk-server-cli`](https://github.com/lightningdevkit/ldk-server/tree/main/ldk-server-cli): + +```toml +[node] +grpc_service_address = "127.0.0.1:3536" +network = "signet" + +[tls] +cert_path = "/path/to/tls.crt" +``` + +## Usage + +### Standalone + +```bash +export LDK_BASE_URL="localhost:3000" +export LDK_API_KEY="your_hex_encoded_api_key" +export LDK_TLS_CERT_PATH="/path/to/tls.crt" +cargo run -p ldk-server-mcp --release +``` + +Or using a config file: + +```bash +cargo run -p ldk-server-mcp -- --config /path/to/config.toml +``` + +If `--config` is omitted, `ldk-server-mcp` falls back to the same default config path as +`ldk-server` and `ldk-server-cli`: `~/.ldk-server/config.toml`. + +### With Claude Desktop + +Add the following to your Claude Desktop MCP configuration (`claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "ldk-server": { + "command": "/path/to/ldk-server-mcp", + "env": { + "LDK_BASE_URL": "localhost:3000", + "LDK_API_KEY": "your_hex_encoded_api_key", + "LDK_TLS_CERT_PATH": "/path/to/tls.crt" + } + } + } +} +``` + +### With Claude Code + +Add to your Claude Code MCP settings (`.claude/settings.json`): + +```json +{ + "mcpServers": { + "ldk-server": { + "command": "/path/to/ldk-server-mcp", + "env": { + "LDK_BASE_URL": "localhost:3000", + "LDK_API_KEY": "your_hex_encoded_api_key", + "LDK_TLS_CERT_PATH": "/path/to/tls.crt" + } + } + } +} +``` + +## Available Tools + +All unary LDK Server RPCs are exposed as MCP tools. Use `tools/list` to discover the current set. + +Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `metrics` are not exposed as tools. + +## MCP Protocol + +- **Protocol version**: `2025-11-25` +- **Transport**: stdio (one JSON-RPC 2.0 message per line) +- **Methods**: `initialize`, `tools/list`, `tools/call` + +## Testing + +```bash +cargo test -p ldk-server-mcp + +# MCP end-to-end sanity checks against a live ldk-server +cargo test --manifest-path e2e-tests/Cargo.toml mcp -- --nocapture +``` + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. diff --git a/ldk-server-mcp/src/config.rs b/ldk-server-mcp/src/config.rs new file mode 100644 index 00000000..f3882a87 --- /dev/null +++ b/ldk-server-mcp/src/config.rs @@ -0,0 +1,169 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::path::PathBuf; + +use ldk_server_client::config::{ + get_default_config_path, load_config, resolve_api_key, resolve_base_url, resolve_cert_path, +}; + +pub struct ResolvedConfig { + pub base_url: String, + pub api_key: String, + pub tls_cert_pem: Vec, +} + +pub fn resolve_config(config_path: Option) -> Result { + let config_path = config_path.map(PathBuf::from).or_else(get_default_config_path); + let config = match config_path { + Some(ref path) if path.exists() => Some(load_config(path)?), + _ => None, + }; + + let base_url = resolve_base_url(std::env::var("LDK_BASE_URL").ok(), config.as_ref()); + + let api_key = resolve_api_key(std::env::var("LDK_API_KEY").ok(), config.as_ref()).ok_or_else( + || "API key not provided. Set LDK_API_KEY or ensure the api_key file exists at ~/.ldk-server/[network]/api_key".to_string() + )?; + + let tls_cert_path = resolve_cert_path( + std::env::var("LDK_TLS_CERT_PATH").ok().map(PathBuf::from), + config.as_ref(), + ) + .ok_or_else(|| { + "TLS cert path not provided. Set LDK_TLS_CERT_PATH or ensure config file exists at ~/.ldk-server/config.toml" + .to_string() + })?; + + let tls_cert_pem = std::fs::read(&tls_cert_path).map_err(|e| { + format!("Failed to read server certificate file '{}': {}", tls_cert_path.display(), e) + })?; + + Ok(ResolvedConfig { base_url, api_key, tls_cert_pem }) +} + +#[cfg(test)] +mod tests { + use super::resolve_config; + use ldk_server_client::config::DEFAULT_GRPC_SERVICE_ADDRESS; + use std::sync::Mutex; + + // Tests that call resolve_config manipulate process-global environment + // variables, so they must not run in parallel. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn resolve_config_uses_grpc_service_address_from_config() { + let _lock = ENV_LOCK.lock().unwrap(); + + let temp_dir = + std::env::temp_dir().join(format!("ldk-server-mcp-config-test-{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let config_path = temp_dir.join("config.toml"); + let cert_path = temp_dir.join("tls.crt"); + std::fs::write(&cert_path, b"test-cert").unwrap(); + std::fs::write( + &config_path, + format!( + r#" + [node] + network = "regtest" + grpc_service_address = "127.0.0.1:4242" + + [tls] + cert_path = "{}" + "#, + cert_path.display() + ), + ) + .unwrap(); + + std::env::set_var("LDK_API_KEY", "deadbeef"); + std::env::set_var("LDK_TLS_CERT_PATH", &cert_path); + std::env::remove_var("LDK_BASE_URL"); + let resolved = resolve_config(Some(config_path.display().to_string())).unwrap(); + std::env::remove_var("LDK_API_KEY"); + std::env::remove_var("LDK_TLS_CERT_PATH"); + + assert_eq!(resolved.base_url, "127.0.0.1:4242"); + assert_eq!(resolved.api_key, "deadbeef"); + assert_eq!(resolved.tls_cert_pem, b"test-cert"); + + std::fs::remove_dir_all(temp_dir).unwrap(); + } + + #[test] + fn resolve_config_falls_back_to_default_grpc_address() { + let _lock = ENV_LOCK.lock().unwrap(); + + let temp_dir = std::env::temp_dir() + .join(format!("ldk-server-mcp-config-fallback-{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let cert_path = temp_dir.join("tls.crt"); + std::fs::write(&cert_path, b"test-cert").unwrap(); + + // No config file, no LDK_BASE_URL — should fall back to default + std::env::set_var("LDK_API_KEY", "deadbeef"); + std::env::set_var("LDK_TLS_CERT_PATH", &cert_path); + std::env::remove_var("LDK_BASE_URL"); + let resolved = + resolve_config(Some(temp_dir.join("nonexistent.toml").display().to_string())).unwrap(); + std::env::remove_var("LDK_API_KEY"); + std::env::remove_var("LDK_TLS_CERT_PATH"); + + assert_eq!(resolved.base_url, DEFAULT_GRPC_SERVICE_ADDRESS); + + std::fs::remove_dir_all(temp_dir).unwrap(); + } + + #[test] + fn resolve_config_uses_storage_dir_for_credentials() { + let _lock = ENV_LOCK.lock().unwrap(); + + let temp_dir = + std::env::temp_dir().join(format!("ldk-server-mcp-storage-dir-{}", std::process::id())); + std::fs::create_dir_all(temp_dir.join("regtest")).unwrap(); + + let config_path = temp_dir.join("config.toml"); + let custom_storage = temp_dir.join("custom-storage"); + std::fs::create_dir_all(custom_storage.join("regtest")).unwrap(); + + let cert_path = custom_storage.join("tls.crt"); + std::fs::write(&cert_path, b"storage-cert").unwrap(); + std::fs::write(custom_storage.join("regtest").join("api_key"), [0xAB, 0xCD]).unwrap(); + + std::fs::write( + &config_path, + format!( + r#" + [node] + network = "regtest" + + [storage.disk] + dir_path = "{}" + "#, + custom_storage.display() + ), + ) + .unwrap(); + + std::env::remove_var("LDK_API_KEY"); + std::env::remove_var("LDK_TLS_CERT_PATH"); + std::env::remove_var("LDK_BASE_URL"); + let resolved = resolve_config(Some(config_path.display().to_string())).unwrap(); + + assert_eq!(resolved.base_url, DEFAULT_GRPC_SERVICE_ADDRESS); + assert_eq!(resolved.api_key, "abcd"); + assert_eq!(resolved.tls_cert_pem, b"storage-cert"); + + std::fs::remove_dir_all(temp_dir).unwrap(); + } +} diff --git a/ldk-server-mcp/src/main.rs b/ldk-server-mcp/src/main.rs new file mode 100644 index 00000000..25f95b7f --- /dev/null +++ b/ldk-server-mcp/src/main.rs @@ -0,0 +1,161 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +mod config; +mod mcp; +mod protocol; +mod tools; + +use ldk_server_client::client::LdkServerClient; +use ldk_server_client::ldk_server_grpc::api::GetNodeInfoRequest; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::mcp::InitializeResult; +use crate::protocol::{ + JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse, INVALID_PARAMS, METHOD_NOT_FOUND, + PARSE_ERROR, +}; +use crate::tools::build_tool_registry; + +#[tokio::main] +async fn main() { + let mut config_path = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => { + config_path = args.next(); + if config_path.is_none() { + eprintln!("Error: --config requires a path argument"); + std::process::exit(1); + } + }, + other => { + eprintln!("Unknown argument: {other}"); + std::process::exit(1); + }, + } + } + + let cfg = match config::resolve_config(config_path) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + }, + }; + + let client = match LdkServerClient::new(cfg.base_url, cfg.api_key, &cfg.tls_cert_pem) { + Ok(c) => c, + Err(e) => { + eprintln!("Error: Failed to create client: {e}"); + std::process::exit(1); + }, + }; + + // Probe the server so misconfiguration surfaces on startup rather than on + // the first tool call. We warn instead of exiting so the MCP protocol loop + // still answers `initialize` and `tools/list` even when the server is + // temporarily unreachable. + if let Err(e) = client.get_node_info(GetNodeInfoRequest {}).await { + eprintln!("Warning: Failed to reach ldk-server on startup: {e}"); + } + + let registry = build_tool_registry(); + + eprintln!("ldk-server-mcp: ready, waiting for JSON-RPC requests on stdin"); + + let stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut reader = BufReader::new(stdin); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(_) => {}, + Err(e) => { + eprintln!("Error reading stdin: {e}"); + break; + }, + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let request: JsonRpcRequest = match serde_json::from_str(trimmed) { + Ok(r) => r, + Err(_) => { + let err = + JsonRpcErrorResponse::new(Value::Null, PARSE_ERROR, "Parse error".to_string()); + let resp = serde_json::to_string(&err).unwrap(); + let _ = stdout.write_all(resp.as_bytes()).await; + let _ = stdout.write_all(b"\n").await; + let _ = stdout.flush().await; + continue; + }, + }; + + // Notifications have no id — do not respond + if request.id.is_none() { + continue; + } + + let id = request.id.unwrap(); + + let response_str = match request.method.as_str() { + "initialize" => { + let result = InitializeResult::new(); + let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap()); + serde_json::to_string(&resp).unwrap() + }, + "tools/list" => { + let tools = registry.list_tools(); + let resp = JsonRpcResponse::new(id, serde_json::json!({ "tools": tools })); + serde_json::to_string(&resp).unwrap() + }, + "tools/call" => { + let params = request.params.unwrap_or(Value::Null); + match params.get("name").and_then(|v| v.as_str()) { + Some(tool_name) => { + let tool_args = + params.get("arguments").cloned().unwrap_or(serde_json::json!({})); + let result = registry.call_tool(&client, tool_name, tool_args).await; + let resp = JsonRpcResponse::new(id, serde_json::to_value(result).unwrap()); + serde_json::to_string(&resp).unwrap() + }, + None => { + let err = JsonRpcErrorResponse::new( + id, + INVALID_PARAMS, + "Missing required parameter: name".to_string(), + ); + serde_json::to_string(&err).unwrap() + }, + } + }, + _ => { + let err = JsonRpcErrorResponse::new( + id, + METHOD_NOT_FOUND, + format!("Method not found: {}", request.method), + ); + serde_json::to_string(&err).unwrap() + }, + }; + + let _ = stdout.write_all(response_str.as_bytes()).await; + let _ = stdout.write_all(b"\n").await; + let _ = stdout.flush().await; + } +} diff --git a/ldk-server-mcp/src/mcp.rs b/ldk-server-mcp/src/mcp.rs new file mode 100644 index 00000000..fb5fd786 --- /dev/null +++ b/ldk-server-mcp/src/mcp.rs @@ -0,0 +1,89 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use serde::Serialize; +use serde_json::Value; + +pub const PROTOCOL_VERSION: &str = "2025-11-25"; +pub const SERVER_NAME: &str = "ldk-server-mcp"; +pub const SERVER_VERSION: &str = "0.1.0"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: String, + pub capabilities: Capabilities, + pub server_info: ServerInfo, +} + +#[derive(Debug, Serialize)] +pub struct Capabilities { + pub tools: ToolsCapability, +} + +#[derive(Debug, Serialize)] +pub struct ToolsCapability {} + +#[derive(Debug, Serialize)] +pub struct ServerInfo { + pub name: String, + pub version: String, +} + +impl InitializeResult { + pub fn new() -> Self { + Self { + protocol_version: PROTOCOL_VERSION.to_string(), + capabilities: Capabilities { tools: ToolsCapability {} }, + server_info: ServerInfo { + name: SERVER_NAME.to_string(), + version: SERVER_VERSION.to_string(), + }, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResult { + pub content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +#[derive(Debug, Serialize)] +pub struct ToolContent { + #[serde(rename = "type")] + pub content_type: String, + pub text: String, +} + +impl ToolCallResult { + pub fn success(text: String) -> Self { + Self { + content: vec![ToolContent { content_type: "text".to_string(), text }], + is_error: None, + } + } + + pub fn error(text: String) -> Self { + Self { + content: vec![ToolContent { content_type: "text".to_string(), text }], + is_error: Some(true), + } + } +} diff --git a/ldk-server-mcp/src/protocol.rs b/ldk-server-mcp/src/protocol.rs new file mode 100644 index 00000000..d9d08e94 --- /dev/null +++ b/ldk-server-mcp/src/protocol.rs @@ -0,0 +1,100 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_server_client::error::{LdkServerError, LdkServerErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PARSE_ERROR: i64 = -32700; +pub const METHOD_NOT_FOUND: i64 = -32601; +pub const INVALID_PARAMS: i64 = -32602; +pub const INTERNAL_ERROR: i64 = -32603; + +/// Classified error produced by MCP tool handlers. The `code` is reused for JSON-RPC error +/// responses at the envelope level, and for categorising the error text that gets surfaced +/// through a `ToolCallResult` with `isError: true`. +#[derive(Debug)] +pub struct McpError { + pub code: i64, + pub message: String, +} + +impl McpError { + pub fn invalid_params(message: impl Into) -> Self { + Self { code: INVALID_PARAMS, message: message.into() } + } + + pub fn internal(message: impl Into) -> Self { + Self { code: INTERNAL_ERROR, message: message.into() } + } + + pub fn category(&self) -> &'static str { + match self.code { + INVALID_PARAMS => "Invalid params", + INTERNAL_ERROR => "Internal error", + _ => "Error", + } + } +} + +impl From for McpError { + fn from(e: LdkServerError) -> Self { + let code = match e.error_code { + LdkServerErrorCode::InvalidRequestError => INVALID_PARAMS, + LdkServerErrorCode::AuthError + | LdkServerErrorCode::LightningError + | LdkServerErrorCode::InternalServerError + | LdkServerErrorCode::InternalError => INTERNAL_ERROR, + }; + Self { code, message: e.message } + } +} + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + #[allow(dead_code)] + pub jsonrpc: String, + pub id: Option, + pub method: String, + pub params: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + pub result: Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcErrorResponse { + pub jsonrpc: String, + pub id: Value, + pub error: JsonRpcError, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl JsonRpcResponse { + pub fn new(id: Value, result: Value) -> Self { + Self { jsonrpc: "2.0".to_string(), id, result } + } +} + +impl JsonRpcErrorResponse { + pub fn new(id: Value, code: i64, message: String) -> Self { + Self { jsonrpc: "2.0".to_string(), id, error: JsonRpcError { code, message, data: None } } + } +} diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs new file mode 100644 index 00000000..3821ecdb --- /dev/null +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -0,0 +1,348 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use hex_conservative::DisplayHex; +use ldk_server_client::client::LdkServerClient; +use ldk_server_client::ldk_server_grpc::api::{ + Bolt11ClaimForHashRequest, Bolt11FailForHashRequest, Bolt11ReceiveForHashRequest, + Bolt11ReceiveRequest, Bolt11ReceiveVariableAmountViaJitChannelRequest, + Bolt11ReceiveViaJitChannelRequest, Bolt11SendRequest, Bolt12ReceiveRequest, Bolt12SendRequest, + CloseChannelRequest, ConnectPeerRequest, DecodeInvoiceRequest, DecodeOfferRequest, + DisconnectPeerRequest, ExportPathfindingScoresRequest, ForceCloseChannelRequest, + GetBalancesRequest, GetNodeInfoRequest, GetPaymentDetailsRequest, GraphGetChannelRequest, + GraphGetNodeRequest, GraphListChannelsRequest, GraphListNodesRequest, ListChannelsRequest, + ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, OnchainReceiveRequest, + OnchainSendRequest, OpenChannelRequest, SignMessageRequest, SpliceInRequest, SpliceOutRequest, + SpontaneousSendRequest, UnifiedSendRequest, UpdateChannelConfigRequest, VerifySignatureRequest, +}; +use ldk_server_client::DEFAULT_EXPIRY_SECS; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; + +use crate::protocol::McpError; + +fn parse_request(args: Value) -> Result { + serde_json::from_value(args).map_err(|e| McpError::invalid_params(e.to_string())) +} + +fn serialize_response(response: T) -> Result { + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) +} + +pub async fn handle_get_node_info( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client.get_node_info(GetNodeInfoRequest {}).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_get_balances( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client.get_balances(GetBalancesRequest {}).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_onchain_receive( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = + client.onchain_receive(OnchainReceiveRequest {}).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Result { + let request: OnchainSendRequest = parse_request(args)?; + let response = client.onchain_send(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt11_receive( + client: &LdkServerClient, args: Value, +) -> Result { + let mut request: Bolt11ReceiveRequest = parse_request(args)?; + if request.expiry_secs == 0 { + request.expiry_secs = DEFAULT_EXPIRY_SECS; + } + let response = client.bolt11_receive(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt11_receive_for_hash( + client: &LdkServerClient, args: Value, +) -> Result { + let mut request: Bolt11ReceiveForHashRequest = parse_request(args)?; + if request.expiry_secs == 0 { + request.expiry_secs = DEFAULT_EXPIRY_SECS; + } + let response = client.bolt11_receive_for_hash(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt11_claim_for_hash( + client: &LdkServerClient, args: Value, +) -> Result { + let request: Bolt11ClaimForHashRequest = parse_request(args)?; + let response = client.bolt11_claim_for_hash(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt11_fail_for_hash( + client: &LdkServerClient, args: Value, +) -> Result { + let request: Bolt11FailForHashRequest = parse_request(args)?; + let response = client.bolt11_fail_for_hash(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt11_receive_via_jit_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let mut request: Bolt11ReceiveViaJitChannelRequest = parse_request(args)?; + if request.expiry_secs == 0 { + request.expiry_secs = DEFAULT_EXPIRY_SECS; + } + let response = client.bolt11_receive_via_jit_channel(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt11_receive_variable_amount_via_jit_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let mut request: Bolt11ReceiveVariableAmountViaJitChannelRequest = parse_request(args)?; + if request.expiry_secs == 0 { + request.expiry_secs = DEFAULT_EXPIRY_SECS; + } + let response = client + .bolt11_receive_variable_amount_via_jit_channel(request) + .await + .map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt11_send(client: &LdkServerClient, args: Value) -> Result { + let request: Bolt11SendRequest = parse_request(args)?; + let response = client.bolt11_send(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt12_receive( + client: &LdkServerClient, args: Value, +) -> Result { + let request: Bolt12ReceiveRequest = parse_request(args)?; + let response = client.bolt12_receive(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_bolt12_send(client: &LdkServerClient, args: Value) -> Result { + let request: Bolt12SendRequest = parse_request(args)?; + let response = client.bolt12_send(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_spontaneous_send( + client: &LdkServerClient, args: Value, +) -> Result { + let request: SpontaneousSendRequest = parse_request(args)?; + let response = client.spontaneous_send(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_unified_send(client: &LdkServerClient, args: Value) -> Result { + let request: UnifiedSendRequest = parse_request(args)?; + let response = client.unified_send(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_open_channel(client: &LdkServerClient, args: Value) -> Result { + let request: OpenChannelRequest = parse_request(args)?; + let response = client.open_channel(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_splice_in(client: &LdkServerClient, args: Value) -> Result { + let request: SpliceInRequest = parse_request(args)?; + let response = client.splice_in(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_splice_out(client: &LdkServerClient, args: Value) -> Result { + let request: SpliceOutRequest = parse_request(args)?; + let response = client.splice_out(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_close_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let request: CloseChannelRequest = parse_request(args)?; + let response = client.close_channel(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_force_close_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let request: ForceCloseChannelRequest = parse_request(args)?; + let response = client.force_close_channel(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_list_channels( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client.list_channels(ListChannelsRequest {}).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_update_channel_config( + client: &LdkServerClient, args: Value, +) -> Result { + let request: UpdateChannelConfigRequest = parse_request(args)?; + let response = client.update_channel_config(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_list_payments( + client: &LdkServerClient, args: Value, +) -> Result { + let request: ListPaymentsRequest = parse_request(args)?; + let response = client.list_payments(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_get_payment_details( + client: &LdkServerClient, args: Value, +) -> Result { + let request: GetPaymentDetailsRequest = parse_request(args)?; + let response = client.get_payment_details(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_list_forwarded_payments( + client: &LdkServerClient, args: Value, +) -> Result { + let request: ListForwardedPaymentsRequest = parse_request(args)?; + let response = client.list_forwarded_payments(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_connect_peer(client: &LdkServerClient, args: Value) -> Result { + let request: ConnectPeerRequest = parse_request(args)?; + let response = client.connect_peer(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_disconnect_peer( + client: &LdkServerClient, args: Value, +) -> Result { + let request: DisconnectPeerRequest = parse_request(args)?; + let response = client.disconnect_peer(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_list_peers(client: &LdkServerClient, _args: Value) -> Result { + let response = client.list_peers(ListPeersRequest {}).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_decode_invoice( + client: &LdkServerClient, args: Value, +) -> Result { + let request: DecodeInvoiceRequest = parse_request(args)?; + let response = client.decode_invoice(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_decode_offer(client: &LdkServerClient, args: Value) -> Result { + let request: DecodeOfferRequest = parse_request(args)?; + let response = client.decode_offer(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +// `message` is a proto `bytes` field, which deserializes as a numeric array rather than a UTF-8 +// string, so we manually extract the string and turn it into bytes instead of delegating to the +// proto-typed deserializer. +pub async fn handle_sign_message(client: &LdkServerClient, args: Value) -> Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::invalid_params("Missing required parameter: message"))? + .to_string(); + let request = SignMessageRequest { message: message.into_bytes().into() }; + let response = client.sign_message(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_verify_signature( + client: &LdkServerClient, args: Value, +) -> Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::invalid_params("Missing required parameter: message"))? + .to_string(); + let signature = args + .get("signature") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::invalid_params("Missing required parameter: signature"))? + .to_string(); + let public_key = args + .get("public_key") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::invalid_params("Missing required parameter: public_key"))? + .to_string(); + let request = + VerifySignatureRequest { message: message.into_bytes().into(), signature, public_key }; + let response = client.verify_signature(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_export_pathfinding_scores( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client + .export_pathfinding_scores(ExportPathfindingScoresRequest {}) + .await + .map_err(McpError::from)?; + Ok(json!({ "pathfinding_scores": response.scores.to_lower_hex_string() })) +} + +pub async fn handle_graph_list_channels( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = + client.graph_list_channels(GraphListChannelsRequest {}).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_graph_get_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let request: GraphGetChannelRequest = parse_request(args)?; + let response = client.graph_get_channel(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_graph_list_nodes( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = + client.graph_list_nodes(GraphListNodesRequest {}).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_graph_get_node( + client: &LdkServerClient, args: Value, +) -> Result { + let request: GraphGetNodeRequest = parse_request(args)?; + let response = client.graph_get_node(request).await.map_err(McpError::from)?; + serialize_response(response) +} diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs new file mode 100644 index 00000000..f689d3d9 --- /dev/null +++ b/ldk-server-mcp/src/tools/mod.rs @@ -0,0 +1,312 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub mod handlers; +pub mod schema; + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; + +use ldk_server_client::client::LdkServerClient; +use serde_json::Value; + +use crate::mcp::{ToolCallResult, ToolDefinition}; +use crate::protocol::McpError; + +type ToolHandler = for<'a> fn( + &'a LdkServerClient, + Value, +) + -> Pin> + Send + 'a>>; + +pub struct ToolRegistry { + definitions: Vec, + handlers: HashMap<&'static str, ToolHandler>, +} + +struct ToolSpec { + name: &'static str, + description: &'static str, + input_schema: fn() -> Value, + handler: ToolHandler, +} + +fn tool_spec( + name: &'static str, description: &'static str, input_schema: fn() -> Value, + handler: ToolHandler, +) -> ToolSpec { + ToolSpec { name, description, input_schema, handler } +} + +impl ToolRegistry { + pub fn list_tools(&self) -> &[ToolDefinition] { + &self.definitions + } + + pub async fn call_tool( + &self, client: &LdkServerClient, name: &str, args: Value, + ) -> ToolCallResult { + let Some(handler) = self.handlers.get(name) else { + return ToolCallResult::error(format!("Unknown tool: {name}")); + }; + match handler(client, args).await { + Ok(value) => { + let text = serde_json::to_string(&value) + .unwrap_or_else(|e| format!("Failed to serialize response: {e}")); + ToolCallResult::success(text) + }, + Err(e) => ToolCallResult::error(format!("{}: {}", e.category(), e.message)), + } + } +} + +pub fn build_tool_registry() -> ToolRegistry { + let tools = vec![ + tool_spec( + "get_node_info", + "Retrieve node info including node_id, sync status, and best block", + schema::get_node_info_schema, + |client, args| Box::pin(handlers::handle_get_node_info(client, args)), + ), + tool_spec( + "get_balances", + "Retrieve an overview of all known balances (on-chain and Lightning)", + schema::get_balances_schema, + |client, args| Box::pin(handlers::handle_get_balances(client, args)), + ), + tool_spec( + "onchain_receive", + "Generate a new on-chain Bitcoin funding address", + schema::onchain_receive_schema, + |client, args| Box::pin(handlers::handle_onchain_receive(client, args)), + ), + tool_spec( + "onchain_send", + "Send an on-chain Bitcoin payment to an address", + schema::onchain_send_schema, + |client, args| Box::pin(handlers::handle_onchain_send(client, args)), + ), + tool_spec( + "bolt11_receive", + "Create a BOLT11 Lightning invoice to receive a payment", + schema::bolt11_receive_schema, + |client, args| Box::pin(handlers::handle_bolt11_receive(client, args)), + ), + tool_spec( + "bolt11_receive_for_hash", + "Create a BOLT11 Lightning invoice for a specific payment hash", + schema::bolt11_receive_for_hash_schema, + |client, args| Box::pin(handlers::handle_bolt11_receive_for_hash(client, args)), + ), + tool_spec( + "bolt11_claim_for_hash", + "Manually claim a BOLT11 payment for a specific payment hash", + schema::bolt11_claim_for_hash_schema, + |client, args| Box::pin(handlers::handle_bolt11_claim_for_hash(client, args)), + ), + tool_spec( + "bolt11_fail_for_hash", + "Manually fail a BOLT11 payment for a specific payment hash", + schema::bolt11_fail_for_hash_schema, + |client, args| Box::pin(handlers::handle_bolt11_fail_for_hash(client, args)), + ), + tool_spec( + "bolt11_receive_via_jit_channel", + "Create a BOLT11 Lightning invoice to receive via an LSPS2 JIT channel", + schema::bolt11_receive_via_jit_channel_schema, + |client, args| Box::pin(handlers::handle_bolt11_receive_via_jit_channel(client, args)), + ), + tool_spec( + "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, + |client, args| { + Box::pin(handlers::handle_bolt11_receive_variable_amount_via_jit_channel( + client, args, + )) + }, + ), + tool_spec( + "bolt11_send", + "Pay a BOLT11 Lightning invoice", + schema::bolt11_send_schema, + |client, args| Box::pin(handlers::handle_bolt11_send(client, args)), + ), + tool_spec( + "bolt12_receive", + "Create a BOLT12 offer for receiving Lightning payments", + schema::bolt12_receive_schema, + |client, args| Box::pin(handlers::handle_bolt12_receive(client, args)), + ), + tool_spec( + "bolt12_send", + "Pay a BOLT12 Lightning offer", + schema::bolt12_send_schema, + |client, args| Box::pin(handlers::handle_bolt12_send(client, args)), + ), + tool_spec( + "spontaneous_send", + "Send a spontaneous (keysend) payment to a Lightning node", + schema::spontaneous_send_schema, + |client, args| Box::pin(handlers::handle_spontaneous_send(client, args)), + ), + tool_spec( + "unified_send", + "Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name", + schema::unified_send_schema, + |client, args| Box::pin(handlers::handle_unified_send(client, args)), + ), + tool_spec( + "open_channel", + "Open a new Lightning channel with a remote node", + schema::open_channel_schema, + |client, args| Box::pin(handlers::handle_open_channel(client, args)), + ), + tool_spec( + "splice_in", + "Increase a channel's balance by splicing in on-chain funds", + schema::splice_in_schema, + |client, args| Box::pin(handlers::handle_splice_in(client, args)), + ), + tool_spec( + "splice_out", + "Decrease a channel's balance by splicing out to on-chain", + schema::splice_out_schema, + |client, args| Box::pin(handlers::handle_splice_out(client, args)), + ), + tool_spec( + "close_channel", + "Cooperatively close a Lightning channel", + schema::close_channel_schema, + |client, args| Box::pin(handlers::handle_close_channel(client, args)), + ), + tool_spec( + "force_close_channel", + "Force close a Lightning channel unilaterally", + schema::force_close_channel_schema, + |client, args| Box::pin(handlers::handle_force_close_channel(client, args)), + ), + tool_spec( + "list_channels", + "List all known Lightning channels", + schema::list_channels_schema, + |client, args| Box::pin(handlers::handle_list_channels(client, args)), + ), + tool_spec( + "update_channel_config", + "Update forwarding fees and CLTV delta for a channel", + schema::update_channel_config_schema, + |client, args| Box::pin(handlers::handle_update_channel_config(client, args)), + ), + tool_spec( + "list_payments", + "List all payments (supports pagination via page_token)", + schema::list_payments_schema, + |client, args| Box::pin(handlers::handle_list_payments(client, args)), + ), + tool_spec( + "get_payment_details", + "Get details of a specific payment by its ID", + schema::get_payment_details_schema, + |client, args| Box::pin(handlers::handle_get_payment_details(client, args)), + ), + tool_spec( + "list_forwarded_payments", + "List all forwarded payments (supports pagination via page_token)", + schema::list_forwarded_payments_schema, + |client, args| Box::pin(handlers::handle_list_forwarded_payments(client, args)), + ), + tool_spec( + "connect_peer", + "Connect to a Lightning peer without opening a channel", + schema::connect_peer_schema, + |client, args| Box::pin(handlers::handle_connect_peer(client, args)), + ), + tool_spec( + "disconnect_peer", + "Disconnect from a Lightning peer", + schema::disconnect_peer_schema, + |client, args| Box::pin(handlers::handle_disconnect_peer(client, args)), + ), + tool_spec( + "list_peers", + "List all known Lightning peers", + schema::list_peers_schema, + |client, args| Box::pin(handlers::handle_list_peers(client, args)), + ), + tool_spec( + "decode_invoice", + "Decode a BOLT11 invoice and return its parsed fields", + schema::decode_invoice_schema, + |client, args| Box::pin(handlers::handle_decode_invoice(client, args)), + ), + tool_spec( + "decode_offer", + "Decode a BOLT12 offer and return its parsed fields", + schema::decode_offer_schema, + |client, args| Box::pin(handlers::handle_decode_offer(client, args)), + ), + tool_spec( + "sign_message", + "Sign a message with the node's secret key", + schema::sign_message_schema, + |client, args| Box::pin(handlers::handle_sign_message(client, args)), + ), + tool_spec( + "verify_signature", + "Verify a signature against a message and public key", + schema::verify_signature_schema, + |client, args| Box::pin(handlers::handle_verify_signature(client, args)), + ), + tool_spec( + "export_pathfinding_scores", + "Export the pathfinding scores used by the Lightning router", + schema::export_pathfinding_scores_schema, + |client, args| Box::pin(handlers::handle_export_pathfinding_scores(client, args)), + ), + tool_spec( + "graph_list_channels", + "List all known short channel IDs in the network graph", + schema::graph_list_channels_schema, + |client, args| Box::pin(handlers::handle_graph_list_channels(client, args)), + ), + tool_spec( + "graph_get_channel", + "Get channel information from the network graph by short channel ID", + schema::graph_get_channel_schema, + |client, args| Box::pin(handlers::handle_graph_get_channel(client, args)), + ), + tool_spec( + "graph_list_nodes", + "List all known node IDs in the network graph", + schema::graph_list_nodes_schema, + |client, args| Box::pin(handlers::handle_graph_list_nodes(client, args)), + ), + tool_spec( + "graph_get_node", + "Get node information from the network graph by node ID", + schema::graph_get_node_schema, + |client, args| Box::pin(handlers::handle_graph_get_node(client, args)), + ), + ]; + + let mut definitions = Vec::with_capacity(tools.len()); + let mut handlers = HashMap::with_capacity(tools.len()); + for spec in tools { + definitions.push(ToolDefinition { + name: spec.name.to_string(), + description: spec.description.to_string(), + input_schema: (spec.input_schema)(), + }); + handlers.insert(spec.name, spec.handler); + } + + ToolRegistry { definitions, handlers } +} diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs new file mode 100644 index 00000000..b9c45144 --- /dev/null +++ b/ldk-server-mcp/src/tools/schema.rs @@ -0,0 +1,708 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use serde_json::{json, Value}; + +// Shared fragment: `Bolt11InvoiceDescription` oneof mirrors the prost-generated shape, +// i.e. {"kind": {"direct": "..."}} or {"kind": {"hash": "..."}}. +fn bolt11_invoice_description_schema() -> Value { + json!({ + "type": "object", + "description": "Invoice description (mutually exclusive direct text or SHA-256 hash of a longer description)", + "properties": { + "kind": { + "oneOf": [ + { + "type": "object", + "properties": { + "direct": { + "type": "string", + "description": "The description text to include directly in the invoice" + } + }, + "required": ["direct"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "hash": { + "type": "string", + "description": "SHA-256 hash of the description, hex-encoded" + } + }, + "required": ["hash"], + "additionalProperties": false + } + ] + } + } + }) +} + +// Shared fragment: `RouteParametersConfig` mirrors the proto shape. +fn route_parameters_config_schema() -> Value { + json!({ + "type": "object", + "description": "Routing and pathfinding constraints", + "properties": { + "max_total_routing_fee_msat": { + "type": "integer", + "description": "Maximum total routing fee in millisatoshis. Defaults to 1% of payment + 50 sats" + }, + "max_total_cltv_expiry_delta": { + "type": "integer", + "description": "Maximum total CLTV delta for the route (default: 1008)" + }, + "max_path_count": { + "type": "integer", + "description": "Maximum number of paths for MPP payments (default: 10)" + }, + "max_channel_saturation_power_of_half": { + "type": "integer", + "description": "Maximum channel capacity share as power of 1/2 (default: 2)" + } + } + }) +} + +// Shared fragment: `ChannelConfig` mirrors the proto shape. +fn channel_config_schema() -> Value { + json!({ + "type": "object", + "description": "Forwarding fee, CLTV delta, and dust-HTLC configuration for the channel", + "properties": { + "forwarding_fee_proportional_millionths": { + "type": "integer", + "description": "Fee in millionths of a satoshi charged per satoshi forwarded" + }, + "forwarding_fee_base_msat": { + "type": "integer", + "description": "Base fee in millisatoshis for forwarded payments" + }, + "cltv_expiry_delta": { + "type": "integer", + "description": "CLTV delta between incoming and outbound HTLCs" + }, + "force_close_avoidance_max_fee_satoshis": { + "type": "integer", + "description": "The maximum additional fee we are willing to pay to avoid waiting for the counterparty's to_self_delay to reclaim funds" + }, + "accept_underpaying_htlcs": { + "type": "boolean", + "description": "If set, allows the channel counterparty to skim an additional fee off inbound HTLCs" + }, + "max_dust_htlc_exposure": { + "type": "object", + "description": "Cap on total dust HTLC exposure. Provide exactly one variant.", + "oneOf": [ + { + "type": "object", + "properties": { + "fixed_limit_msat": { + "type": "integer", + "description": "Fixed exposure limit in millisatoshis" + } + }, + "required": ["fixed_limit_msat"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "fee_rate_multiplier": { + "type": "integer", + "description": "Multiplier on the on-chain sweep feerate" + } + }, + "required": ["fee_rate_multiplier"], + "additionalProperties": false + } + ] + } + } + }) +} + +fn page_token_schema() -> Value { + json!({ + "type": "object", + "description": "Pagination token from a previous response", + "properties": { + "token": { "type": "string" }, + "index": { "type": "integer" } + }, + "required": ["token", "index"] + }) +} + +pub fn get_node_info_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn get_balances_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn onchain_receive_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn onchain_send_schema() -> Value { + json!({ + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "The Bitcoin address to send coins to" + }, + "amount_sats": { + "type": "integer", + "description": "The amount in satoshis to send. Respects on-chain reserve for anchor channels" + }, + "send_all": { + "type": "boolean", + "description": "If true, send full balance (ignores amount_sats). Warning: will not retain on-chain reserves for anchor channels" + }, + "fee_rate_sat_per_vb": { + "type": "integer", + "description": "Fee rate in satoshis per virtual byte. If not set, a reasonable estimate will be used" + } + }, + "required": ["address"] + }) +} + +pub fn bolt11_receive_schema() -> Value { + json!({ + "type": "object", + "properties": { + "amount_msat": { + "type": "integer", + "description": "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" + }, + "description": bolt11_invoice_description_schema(), + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" + } + }, + "required": [] + }) +} + +pub fn bolt11_receive_for_hash_schema() -> Value { + json!({ + "type": "object", + "properties": { + "amount_msat": { + "type": "integer", + "description": "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" + }, + "description": bolt11_invoice_description_schema(), + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" + }, + "payment_hash": { + "type": "string", + "description": "The hex-encoded 32-byte payment hash to use for the invoice" + } + }, + "required": ["payment_hash"] + }) +} + +pub fn bolt11_claim_for_hash_schema() -> Value { + json!({ + "type": "object", + "properties": { + "payment_hash": { + "type": "string", + "description": "The hex-encoded 32-byte payment hash. If provided, verifies that the preimage matches" + }, + "claimable_amount_msat": { + "type": "integer", + "description": "The amount in millisatoshis that is claimable. If not provided, skips amount verification" + }, + "preimage": { + "type": "string", + "description": "The hex-encoded 32-byte payment preimage" + } + }, + "required": ["preimage"] + }) +} + +pub fn bolt11_fail_for_hash_schema() -> Value { + json!({ + "type": "object", + "properties": { + "payment_hash": { + "type": "string", + "description": "The hex-encoded 32-byte payment hash" + } + }, + "required": ["payment_hash"] + }) +} + +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": bolt11_invoice_description_schema(), + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" + }, + "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": bolt11_invoice_description_schema(), + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" + }, + "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", + "properties": { + "invoice": { + "type": "string", + "description": "A BOLT11 invoice string to pay" + }, + "amount_msat": { + "type": "integer", + "description": "Amount in millisatoshis. Required when paying a zero-amount invoice" + }, + "route_parameters": route_parameters_config_schema() + }, + "required": ["invoice"] + }) +} + +pub fn bolt12_receive_schema() -> Value { + json!({ + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description to attach to the offer" + }, + "amount_msat": { + "type": "integer", + "description": "Amount in millisatoshis. If unset, a variable-amount offer is returned" + }, + "expiry_secs": { + "type": "integer", + "description": "Offer expiry time in seconds" + }, + "quantity": { + "type": "integer", + "description": "Number of items requested. Can only be set for fixed-amount offers" + } + }, + "required": ["description"] + }) +} + +pub fn bolt12_send_schema() -> Value { + json!({ + "type": "object", + "properties": { + "offer": { + "type": "string", + "description": "A BOLT12 offer string to pay" + }, + "amount_msat": { + "type": "integer", + "description": "Amount in millisatoshis. Required when paying a zero-amount offer" + }, + "quantity": { + "type": "integer", + "description": "Number of items requested" + }, + "payer_note": { + "type": "string", + "description": "Note to include for the payee. Reflected back in the invoice" + }, + "route_parameters": route_parameters_config_schema() + }, + "required": ["offer"] + }) +} + +pub fn spontaneous_send_schema() -> Value { + json!({ + "type": "object", + "properties": { + "amount_msat": { + "type": "integer", + "description": "The amount in millisatoshis to send" + }, + "node_id": { + "type": "string", + "description": "The hex-encoded public key of the destination node" + }, + "route_parameters": route_parameters_config_schema() + }, + "required": ["amount_msat", "node_id"] + }) +} + +pub fn unified_send_schema() -> Value { + json!({ + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "A BIP 21 URI or BIP 353 Human-Readable Name to pay" + }, + "amount_msat": { + "type": "integer", + "description": "The amount in millisatoshis to send. Required for zero-amount or variable-amount URIs" + }, + "route_parameters": route_parameters_config_schema() + }, + "required": ["uri"] + }) +} + +pub fn open_channel_schema() -> Value { + json!({ + "type": "object", + "properties": { + "node_pubkey": { + "type": "string", + "description": "The hex-encoded public key of the node to open a channel with" + }, + "address": { + "type": "string", + "description": "Address of the remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" + }, + "channel_amount_sats": { + "type": "integer", + "description": "The amount in satoshis to commit to the channel" + }, + "push_to_counterparty_msat": { + "type": "integer", + "description": "Amount in millisatoshis to push to the remote side" + }, + "announce_channel": { + "type": "boolean", + "description": "Whether the channel should be public (default: false)" + }, + "disable_counterparty_reserve": { + "type": "boolean", + "description": "Allow the counterparty to spend all its channel balance. Cannot be set together with announce_channel" + }, + "channel_config": channel_config_schema() + }, + "required": ["node_pubkey", "address", "channel_amount_sats"] + }) +} + +pub fn splice_in_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_channel_id": { + "type": "string", + "description": "The local user_channel_id of the channel" + }, + "counterparty_node_id": { + "type": "string", + "description": "The hex-encoded public key of the channel's counterparty node" + }, + "splice_amount_sats": { + "type": "integer", + "description": "The amount in satoshis to splice into the channel" + } + }, + "required": ["user_channel_id", "counterparty_node_id", "splice_amount_sats"] + }) +} + +pub fn splice_out_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_channel_id": { + "type": "string", + "description": "The local user_channel_id of the channel" + }, + "counterparty_node_id": { + "type": "string", + "description": "The hex-encoded public key of the channel's counterparty node" + }, + "splice_amount_sats": { + "type": "integer", + "description": "The amount in satoshis to splice out of the channel" + }, + "address": { + "type": "string", + "description": "Bitcoin address for the spliced-out funds. If not set, uses the node's on-chain wallet" + } + }, + "required": ["user_channel_id", "counterparty_node_id", "splice_amount_sats"] + }) +} + +pub fn close_channel_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_channel_id": { + "type": "string", + "description": "The local user_channel_id of the channel" + }, + "counterparty_node_id": { + "type": "string", + "description": "The hex-encoded public key of the node to close the channel with" + } + }, + "required": ["user_channel_id", "counterparty_node_id"] + }) +} + +pub fn force_close_channel_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_channel_id": { + "type": "string", + "description": "The local user_channel_id of the channel" + }, + "counterparty_node_id": { + "type": "string", + "description": "The hex-encoded public key of the node to close the channel with" + }, + "force_close_reason": { + "type": "string", + "description": "The reason for force-closing the channel" + } + }, + "required": ["user_channel_id", "counterparty_node_id"] + }) +} + +pub fn list_channels_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn update_channel_config_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_channel_id": { + "type": "string", + "description": "The local user_channel_id of the channel" + }, + "counterparty_node_id": { + "type": "string", + "description": "The hex-encoded public key of the counterparty node" + }, + "channel_config": channel_config_schema() + }, + "required": ["user_channel_id", "counterparty_node_id"] + }) +} + +pub fn list_payments_schema() -> Value { + json!({ + "type": "object", + "properties": { + "page_token": page_token_schema() + }, + "required": [] + }) +} + +pub fn get_payment_details_schema() -> Value { + json!({ + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "description": "The payment ID in hex-encoded form" + } + }, + "required": ["payment_id"] + }) +} + +pub fn list_forwarded_payments_schema() -> Value { + json!({ + "type": "object", + "properties": { + "page_token": page_token_schema() + }, + "required": [] + }) +} + +pub fn connect_peer_schema() -> Value { + json!({ + "type": "object", + "properties": { + "node_pubkey": { + "type": "string", + "description": "The hex-encoded public key of the node to connect to" + }, + "address": { + "type": "string", + "description": "Address of the remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" + }, + "persist": { + "type": "boolean", + "description": "Whether to persist the connection for automatic reconnection on restart (default: false)" + } + }, + "required": ["node_pubkey", "address"] + }) +} + +pub fn disconnect_peer_schema() -> Value { + json!({ + "type": "object", + "properties": { + "node_pubkey": { + "type": "string", + "description": "The hex-encoded public key of the node to disconnect from" + } + }, + "required": ["node_pubkey"] + }) +} + +pub fn list_peers_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn decode_invoice_schema() -> Value { + json!({ + "type": "object", + "properties": { + "invoice": { + "type": "string", + "description": "The BOLT11 invoice string to decode" + } + }, + "required": ["invoice"] + }) +} + +pub fn decode_offer_schema() -> Value { + json!({ + "type": "object", + "properties": { + "offer": { + "type": "string", + "description": "The BOLT12 offer string to decode" + } + }, + "required": ["offer"] + }) +} + +pub fn sign_message_schema() -> Value { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message to sign (will be sent as UTF-8 bytes)" + } + }, + "required": ["message"] + }) +} + +pub fn verify_signature_schema() -> Value { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message that was signed (sent as UTF-8 bytes)" + }, + "signature": { + "type": "string", + "description": "The zbase32-encoded signature to verify" + }, + "public_key": { + "type": "string", + "description": "The hex-encoded public key of the signer" + } + }, + "required": ["message", "signature", "public_key"] + }) +} + +pub fn export_pathfinding_scores_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn graph_list_channels_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn graph_get_channel_schema() -> Value { + json!({ + "type": "object", + "properties": { + "short_channel_id": { + "type": "integer", + "description": "The short channel ID to look up" + } + }, + "required": ["short_channel_id"] + }) +} + +pub fn graph_list_nodes_schema() -> Value { + json!({ "type": "object", "properties": {}, "required": [] }) +} + +pub fn graph_get_node_schema() -> Value { + json!({ + "type": "object", + "properties": { + "node_id": { + "type": "string", + "description": "The hex-encoded node ID to look up" + } + }, + "required": ["node_id"] + }) +} diff --git a/ldk-server-mcp/tests/fixtures/test_cert.pem b/ldk-server-mcp/tests/fixtures/test_cert.pem new file mode 100644 index 00000000..3d23f614 --- /dev/null +++ b/ldk-server-mcp/tests/fixtures/test_cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUaIpIYZhk0rQjfg8F24i+TVYFQNgwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDIyNjEyNDkzMFoXDTM2MDIy +NDEyNDkzMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA16GHTRmriId5v9pUIFk61spibHPGDeSLXFHM4EAHH6TA +1tc3vTrgkxXy65Ru6TlvSY8RSXl7GqtdSdLFX3ehOl6EOqxrM4R6iWrNbJqbhsVP +T1ILWAdCObV66vRdus8UtdYs9RfTMrM9ghyKAKxrb/v6oU+UVCqngLIw2qZTM6Ne +eQtU/YU3l0BG3mc0ufWmsN8XSMJeJxfcFZLQPIk1/h6NmRmOcjATgiaSGCkKW4WX +p9K52Za9GinUaOqN87lM+SZX03wJSwatm0vBcLHc9Cc3BAx7Hsd/+Em9ywchSCto +5Ay5OjsdOhXkxGVBmlqWEaECQ4M9hYKT4a+e6wF+owIDAQABo1MwUTAdBgNVHQ4E +FgQU74Mhg80zO7Yl02H45GgJLV2Yio0wHwYDVR0jBBgwFoAU74Mhg80zO7Yl02H4 +5GgJLV2Yio0wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEANlja +ph1/yXgInYiPswpyO3K67ujq9Gdn+XkFbsMiJqrvj2WJATYClkFIrb1DQaZV3ff6 +QAsxcpgAiNmLjlZ9A9G/6QMyFcqgI9Hzpd9nN8c0b1nQDuE7gLozCR0H7WeS9TRW +fE3mQBRZxahW78og2UvD4NeElvuk/hCPB0teovAUCaqpTsDnEeAGV8LVjMWRVp8h +gES9A4VOObWwfEirWSU3Bn3HwkVTkRbnJvo/b+3KpvXRS81M3eZxnPdmGK0zP+lY +40KYABID1DxTqYwjJI2nDEhR6+2ppATw3PhkEQQi+zpP9Tqxque2VtpGDJcyPOl1 +LXIaAEULV0zCGunmMQ== +-----END CERTIFICATE----- diff --git a/ldk-server-mcp/tests/integration.rs b/ldk-server-mcp/tests/integration.rs new file mode 100644 index 00000000..98a2a1bf --- /dev/null +++ b/ldk-server-mcp/tests/integration.rs @@ -0,0 +1,440 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::io::{BufRead, BufReader, Write}; + +use serde_json::{json, Value}; + +const NUM_TOOLS: usize = 37; +const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ + "bolt11_claim_for_hash", + "bolt11_fail_for_hash", + "bolt11_receive", + "bolt11_receive_for_hash", + "bolt11_receive_variable_amount_via_jit_channel", + "bolt11_receive_via_jit_channel", + "bolt11_send", + "bolt12_receive", + "bolt12_send", + "close_channel", + "connect_peer", + "decode_invoice", + "decode_offer", + "disconnect_peer", + "export_pathfinding_scores", + "force_close_channel", + "get_balances", + "get_node_info", + "get_payment_details", + "graph_get_channel", + "graph_get_node", + "graph_list_channels", + "graph_list_nodes", + "list_channels", + "list_forwarded_payments", + "list_payments", + "list_peers", + "onchain_receive", + "onchain_send", + "open_channel", + "sign_message", + "splice_in", + "splice_out", + "spontaneous_send", + "unified_send", + "update_channel_config", + "verify_signature", +]; + +fn test_cert_path() -> String { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/test_cert.pem") + .to_str() + .unwrap() + .to_string() +} + +struct McpProcess { + child: std::process::Child, + stdin: std::process::ChildStdin, + reader: BufReader, +} + +impl McpProcess { + fn spawn() -> Self { + let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_ldk-server-mcp")) + .env("LDK_BASE_URL", "localhost:19999") + .env("LDK_API_KEY", "deadbeef") + .env("LDK_TLS_CERT_PATH", test_cert_path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn MCP process"); + + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let reader = BufReader::new(stdout); + + McpProcess { child, stdin, reader } + } + + fn send(&mut self, msg: &Value) { + let line = serde_json::to_string(msg).unwrap(); + writeln!(self.stdin, "{}", line).expect("Failed to write to stdin"); + self.stdin.flush().expect("Failed to flush stdin"); + } + + fn recv(&mut self) -> Value { + let mut line = String::new(); + self.reader.read_line(&mut line).expect("Failed to read from stdout"); + serde_json::from_str(line.trim()).expect("Failed to parse JSON response") + } +} + +impl Drop for McpProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn assert_unreachable_tool(tool_name: &str, arguments: Value) { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + } + })); + + 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_initialize() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.1"} + } + })); + + let resp = proc.recv(); + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 1); + assert_eq!(resp["result"]["protocolVersion"], "2025-11-25"); + assert!(resp["result"]["capabilities"]["tools"].is_object()); + assert_eq!(resp["result"]["serverInfo"]["name"], "ldk-server-mcp"); + assert_eq!(resp["result"]["serverInfo"]["version"], "0.1.0"); +} + +#[test] +fn test_tools_list() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + })); + + let resp = proc.recv(); + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 1); + + let tools = resp["result"]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), NUM_TOOLS, "Expected {NUM_TOOLS} tools, got {}", tools.len()); + let mut tool_names = tools + .iter() + .map(|tool| tool["name"].as_str().expect("Tool missing name").to_string()) + .collect::>(); + tool_names.sort(); + + let mut expected_tool_names = + EXPECTED_TOOLS.iter().map(|name| name.to_string()).collect::>(); + expected_tool_names.sort(); + assert_eq!(tool_names, expected_tool_names, "Tool names drifted from the expected API surface"); + + for tool in tools { + assert!(tool["name"].is_string(), "Tool missing name"); + assert!(tool["description"].is_string(), "Tool missing description"); + assert!(tool["inputSchema"].is_object(), "Tool missing inputSchema"); + } +} + +#[test] +fn test_tools_call_unknown_tool() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "nonexistent_tool", + "arguments": {} + } + })); + + 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.contains("Unknown tool"), "Expected 'Unknown tool' in error, got: {text}"); +} + +#[test] +fn test_tools_call_unreachable_server() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_node_info", + "arguments": {} + } + })); + + 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_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() { + assert_unreachable_tool( + "bolt11_receive_variable_amount_via_jit_channel", + json!({ "description": "test jit" }), + ); +} + +#[test] +fn test_bolt11_receive_for_hash_unreachable() { + assert_unreachable_tool( + "bolt11_receive_for_hash", + json!({ + "payment_hash": "00".repeat(32), + "description": "test hodl" + }), + ); +} + +#[test] +fn test_bolt11_claim_for_hash_unreachable() { + assert_unreachable_tool( + "bolt11_claim_for_hash", + json!({ + "payment_hash": "11".repeat(32), + "preimage": "22".repeat(32) + }), + ); +} + +#[test] +fn test_bolt11_fail_for_hash_unreachable() { + assert_unreachable_tool("bolt11_fail_for_hash", json!({ "payment_hash": "33".repeat(32) })); +} + +#[test] +fn test_unified_send_unreachable() { + assert_unreachable_tool("unified_send", json!({ "uri": "bitcoin:tb1qexample?amount=0.001" })); +} + +#[test] +fn test_list_peers_unreachable() { + assert_unreachable_tool("list_peers", json!({})); +} + +#[test] +fn test_decode_invoice_unreachable() { + assert_unreachable_tool("decode_invoice", json!({ "invoice": "lnbc1example" })); +} + +#[test] +fn test_decode_offer_unreachable() { + assert_unreachable_tool("decode_offer", json!({ "offer": "lno1example" })); +} + +#[test] +fn test_notification_no_response() { + let mut proc = McpProcess::spawn(); + + // Send a notification (no id) - should produce no response + proc.send(&json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + })); + + // Send a real request after the notification + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.1"} + } + })); + + // The first response we get should be for id 42, not for the notification + let resp = proc.recv(); + assert_eq!(resp["id"], 42); +} + +#[test] +fn test_graph_list_channels_unreachable() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "graph_list_channels", + "arguments": {} + } + })); + + 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_graph_get_channel_unreachable() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "graph_get_channel", + "arguments": {"short_channel_id": 12345} + } + })); + + 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_graph_list_nodes_unreachable() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "graph_list_nodes", + "arguments": {} + } + })); + + 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_graph_get_node_unreachable() { + let mut proc = McpProcess::spawn(); + + proc.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "graph_get_node", + "arguments": {"node_id": "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"} + } + })); + + 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_malformed_json() { + let mut proc = McpProcess::spawn(); + + // Send garbage + writeln!(proc.stdin, "this is not json").unwrap(); + proc.stdin.flush().unwrap(); + + let resp = proc.recv(); + assert_eq!(resp["jsonrpc"], "2.0"); + assert!(resp["error"].is_object()); + assert_eq!(resp["error"]["code"], -32700); + assert_eq!(resp["error"]["message"], "Parse error"); +}