From bf0c5e721de100466cdf43f5a82a253b58a7cd4f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 14:37:16 +0200 Subject: [PATCH 01/21] Re-add Deserialize to proto request types Derive `serde::Deserialize` (alongside the existing `serde::Serialize`) and `#[serde(default)]` on all generated prost messages so that JSON payloads can be deserialized directly into the request types. This reinstates the deserializers that were dropped in 8af3189 so that clients like `ldk-server-mcp` can parse tool arguments straight into the typed proto structs instead of threading every field through `serde_json::Value`. Generated with the assistance of AI (Claude). --- ldk-server-grpc/build.rs | 3 +- ldk-server-grpc/src/api.rs | 227 ++++++++++++++++++++++------------ ldk-server-grpc/src/error.rs | 5 +- ldk-server-grpc/src/events.rs | 20 +-- ldk-server-grpc/src/types.rs | 154 +++++++++++++++-------- 5 files changed, 268 insertions(+), 141 deletions(-) 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)] From c9eb8f27e8cc25ec44fdcc7a6117e4206d27fb5e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Apr 2026 11:27:19 +0200 Subject: [PATCH 02/21] Add ldk-server-mcp to the workspace Move the MCP bridge into the ldk-server workspace and switch it to an in-tree client dependency so workspace builds and tests cover it directly. Co-Authored-By: HAL 9000 --- Cargo.lock | 11 + Cargo.toml | 2 +- ldk-server-mcp/CLAUDE.md | 61 ++ ldk-server-mcp/Cargo.toml | 11 + ldk-server-mcp/README.md | 171 ++++ ldk-server-mcp/src/config.rs | 198 +++++ ldk-server-mcp/src/main.rs | 140 ++++ ldk-server-mcp/src/mcp.rs | 89 +++ ldk-server-mcp/src/protocol.rs | 61 ++ ldk-server-mcp/src/tools/handlers.rs | 844 ++++++++++++++++++++ ldk-server-mcp/src/tools/mod.rs | 311 ++++++++ ldk-server-mcp/src/tools/schema.rs | 755 +++++++++++++++++ ldk-server-mcp/tests/fixtures/test_cert.pem | 19 + ldk-server-mcp/tests/integration.rs | 465 +++++++++++ 14 files changed, 3137 insertions(+), 1 deletion(-) create mode 100644 ldk-server-mcp/CLAUDE.md create mode 100644 ldk-server-mcp/Cargo.toml create mode 100644 ldk-server-mcp/README.md create mode 100644 ldk-server-mcp/src/config.rs create mode 100644 ldk-server-mcp/src/main.rs create mode 100644 ldk-server-mcp/src/mcp.rs create mode 100644 ldk-server-mcp/src/protocol.rs create mode 100644 ldk-server-mcp/src/tools/handlers.rs create mode 100644 ldk-server-mcp/src/tools/mod.rs create mode 100644 ldk-server-mcp/src/tools/schema.rs create mode 100644 ldk-server-mcp/tests/fixtures/test_cert.pem create mode 100644 ldk-server-mcp/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index 061ac6b6..06af7dfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1316,6 +1316,17 @@ dependencies = [ "tonic", ] +[[package]] +name = "ldk-server-mcp" +version = "0.1.0" +dependencies = [ + "ldk-server-client", + "serde", + "serde_json", + "tokio", + "toml", +] + [[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/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md new file mode 100644 index 00000000..9687a06c --- /dev/null +++ b/ldk-server-mcp/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md — ldk-server-mcp + +MCP (Model Context Protocol) server that exposes LDK Server operations as tools for AI agents. + +## Build / Test Commands + +```bash +cargo fmt --all +cargo check +cargo test +cargo clippy +``` + +## 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**: `2024-11-05` +- **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` + +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` diff --git a/ldk-server-mcp/Cargo.toml b/ldk-server-mcp/Cargo.toml new file mode 100644 index 00000000..df8fc51c --- /dev/null +++ b/ldk-server-mcp/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ldk-server-mcp" +version = "0.1.0" +edition = "2021" + +[dependencies] +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"] } +toml = { version = "0.8", default-features = false, features = ["parse"] } diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md new file mode 100644 index 00000000..e96d9ca5 --- /dev/null +++ b/ldk-server-mcp/README.md @@ -0,0 +1,171 @@ +# 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. + +## Building + +```bash +cargo build --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" +./target/release/ldk-server-mcp +``` + +Or using a config file: + +```bash +./target/release/ldk-server-mcp --config /path/to/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 + +The server exposes 37 unary LDK Server RPCs as MCP tools. + +Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `metrics` are not exposed as tools. + +### Node +| Tool | Description | +|------|-------------| +| `get_node_info` | Retrieve node info including node_id, sync status, and best block | +| `get_balances` | Retrieve an overview of all known balances (on-chain and Lightning) | + +### On-chain +| Tool | Description | +|------|-------------| +| `onchain_receive` | Generate a new on-chain Bitcoin funding address | +| `onchain_send` | Send an on-chain Bitcoin payment to an address | + +### Payments +| Tool | Description | +|------|-------------| +| `bolt11_receive` | Create a BOLT11 Lightning invoice to receive a payment | +| `bolt11_receive_for_hash` | Create a BOLT11 Lightning invoice for a specific payment hash | +| `bolt11_claim_for_hash` | Manually claim a BOLT11 payment for a specific payment hash | +| `bolt11_fail_for_hash` | Manually fail a BOLT11 payment for a specific payment hash | +| `bolt11_receive_via_jit_channel` | Create a BOLT11 Lightning invoice to receive via an LSPS2 JIT channel | +| `bolt11_receive_variable_amount_via_jit_channel` | Create a variable-amount BOLT11 Lightning invoice to receive via an LSPS2 JIT channel | +| `bolt11_send` | Pay a BOLT11 Lightning invoice | +| `bolt12_receive` | Create a BOLT12 offer for receiving Lightning payments | +| `bolt12_send` | Pay a BOLT12 Lightning offer | +| `spontaneous_send` | Send a spontaneous (keysend) payment to a Lightning node | +| `unified_send` | Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name | + +### Channels +| Tool | Description | +|------|-------------| +| `open_channel` | Open a new Lightning channel with a remote node | +| `close_channel` | Cooperatively close a Lightning channel | +| `force_close_channel` | Force close a Lightning channel unilaterally | +| `list_channels` | List all known Lightning channels | +| `update_channel_config` | Update forwarding fees and CLTV delta for a channel | +| `splice_in` | Increase a channel's balance by splicing in on-chain funds | +| `splice_out` | Decrease a channel's balance by splicing out to on-chain | + +### Payment History +| Tool | Description | +|------|-------------| +| `list_payments` | List all payments (supports pagination via page_token) | +| `get_payment_details` | Get details of a specific payment by its ID | +| `list_forwarded_payments` | List all forwarded payments (supports pagination via page_token) | + +### Peers +| Tool | Description | +|------|-------------| +| `connect_peer` | Connect to a Lightning peer without opening a channel | +| `disconnect_peer` | Disconnect from a Lightning peer | +| `list_peers` | List all known Lightning peers | + +### Utilities +| Tool | Description | +|------|-------------| +| `decode_invoice` | Decode a BOLT11 invoice and return its parsed fields | +| `decode_offer` | Decode a BOLT12 offer and return its parsed fields | +| `sign_message` | Sign a message with the node's secret key | +| `verify_signature` | Verify a signature against a message and public key | +| `export_pathfinding_scores` | Export the pathfinding scores used by the Lightning router | + +## MCP Protocol + +- **Protocol version**: `2024-11-05` +- **Transport**: stdio (one JSON-RPC 2.0 message per line) +- **Methods**: `initialize`, `tools/list`, `tools/call` + +## Testing + +```bash +cargo test +``` + +## 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..81e62ace --- /dev/null +++ b/ldk-server-mcp/src/config.rs @@ -0,0 +1,198 @@ +// 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::fmt::Write as _; +use std::path::PathBuf; + +use serde::Deserialize; + +const DEFAULT_CONFIG_FILE: &str = "config.toml"; +const DEFAULT_CERT_FILE: &str = "tls.crt"; +const API_KEY_FILE: &str = "api_key"; +const DEFAULT_GRPC_SERVICE_ADDRESS: &str = "127.0.0.1:3536"; + +fn get_default_data_dir() -> Option { + #[cfg(target_os = "macos")] + { + #[allow(deprecated)] + std::env::home_dir().map(|home| home.join("Library/Application Support/ldk-server")) + } + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA").ok().map(|appdata| PathBuf::from(appdata).join("ldk-server")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + #[allow(deprecated)] + std::env::home_dir().map(|home| home.join(".ldk-server")) + } +} + +fn get_default_config_path() -> Option { + get_default_data_dir().map(|dir| dir.join(DEFAULT_CONFIG_FILE)) +} + +fn get_default_cert_path() -> Option { + get_default_data_dir().map(|path| path.join(DEFAULT_CERT_FILE)) +} + +fn get_default_api_key_path(network: &str) -> Option { + get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE)) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(&mut encoded, "{byte:02x}"); + } + encoded +} + +#[derive(Debug, Deserialize)] +pub struct Config { + pub node: NodeConfig, + pub tls: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TlsConfig { + pub cert_path: Option, +} + +#[derive(Debug, Deserialize)] +pub struct NodeConfig { + #[serde(default = "default_grpc_service_address")] + pub grpc_service_address: String, + network: String, +} + +fn default_grpc_service_address() -> String { + DEFAULT_GRPC_SERVICE_ADDRESS.to_string() +} + +impl Config { + pub fn network(&self) -> Result { + match self.node.network.as_str() { + "bitcoin" | "mainnet" => Ok("bitcoin".to_string()), + "testnet" => Ok("testnet".to_string()), + "testnet4" => Ok("testnet4".to_string()), + "signet" => Ok("signet".to_string()), + "regtest" => Ok("regtest".to_string()), + other => Err(format!("Unsupported network: {other}")), + } + } +} + +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))?; + toml::from_str(&contents) + .map_err(|e| format!("Failed to parse config file '{}': {}", path.display(), e)) +} + +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 = std::env::var("LDK_BASE_URL").ok().or_else(|| { + config.as_ref().map(|c| c.node.grpc_service_address.clone()) + }).ok_or_else(|| { + "Base URL not provided. Set LDK_BASE_URL or ensure config file exists at ~/.ldk-server/config.toml".to_string() + })?; + + let api_key = std::env::var("LDK_API_KEY").ok().or_else(|| { + let network = + config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string()); + get_default_api_key_path(&network) + .and_then(|path| std::fs::read(&path).ok()) + .map(|bytes| hex_encode(&bytes)) + }).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 = std::env::var("LDK_TLS_CERT_PATH").ok().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(get_default_cert_path) + }).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, Config, DEFAULT_GRPC_SERVICE_ADDRESS}; + + #[test] + fn config_defaults_grpc_service_address() { + let config: Config = toml::from_str( + r#" + [node] + network = "regtest" + "#, + ) + .unwrap(); + + assert_eq!(config.node.grpc_service_address, DEFAULT_GRPC_SERVICE_ADDRESS); + } + + #[test] + fn resolve_config_uses_grpc_service_address_from_config() { + 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); + 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(); + } +} diff --git a/ldk-server-mcp/src/main.rs b/ldk-server-mcp/src/main.rs new file mode 100644 index 00000000..7dfec64c --- /dev/null +++ b/ldk-server-mcp/src/main.rs @@ -0,0 +1,140 @@ +// 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 serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::mcp::InitializeResult; +use crate::protocol::{ + JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse, 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); + }, + }; + + 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); + let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + 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() + }, + _ => { + 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..22aa8bf9 --- /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 = "2024-11-05"; +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..c14df7cd --- /dev/null +++ b/ldk-server-mcp/src/protocol.rs @@ -0,0 +1,61 @@ +// 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::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PARSE_ERROR: i64 = -32700; +pub const METHOD_NOT_FOUND: i64 = -32601; +#[allow(dead_code)] +pub const INVALID_PARAMS: i64 = -32602; +#[allow(dead_code)] +pub const INTERNAL_ERROR: i64 = -32603; + +#[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..ac24dba6 --- /dev/null +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -0,0 +1,844 @@ +// 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::fmt::Write as _; + +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::ldk_server_grpc::types::{ + bolt11_invoice_description, channel_config, Bolt11InvoiceDescription, ChannelConfig, PageToken, + RouteParametersConfig, +}; +use serde_json::{json, Value}; + +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; + +fn hex_encode(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(&mut encoded, "{byte:02x}"); + } + encoded +} + +fn parse_page_token(token_str: &str) -> Result { + let parts: Vec<&str> = token_str.split(':').collect(); + if parts.len() != 2 { + return Err("Page token must be in format 'token:index'".to_string()); + } + let index = parts[1].parse::().map_err(|_| "Invalid page token index".to_string())?; + Ok(PageToken { token: parts[0].to_string(), index }) +} + +fn format_page_token(pt: &PageToken) -> String { + format!("{}:{}", pt.token, pt.index) +} + +fn build_route_parameters(args: &Value) -> RouteParametersConfig { + RouteParametersConfig { + max_total_routing_fee_msat: args.get("max_total_routing_fee_msat").and_then(|v| v.as_u64()), + max_total_cltv_expiry_delta: args + .get("max_total_cltv_expiry_delta") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA), + max_path_count: args + .get("max_path_count") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_MAX_PATH_COUNT), + max_channel_saturation_power_of_half: args + .get("max_channel_saturation_power_of_half") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF), + } +} + +fn build_channel_config(args: &Value) -> Result, String> { + let forwarding_fee_proportional_millionths = args + .get("forwarding_fee_proportional_millionths") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let forwarding_fee_base_msat = + args.get("forwarding_fee_base_msat").and_then(|v| v.as_u64()).map(|v| v as u32); + let cltv_expiry_delta = + args.get("cltv_expiry_delta").and_then(|v| v.as_u64()).map(|v| v as u32); + let force_close_avoidance_max_fee_satoshis = + args.get("force_close_avoidance_max_fee_satoshis").and_then(|v| v.as_u64()); + let accept_underpaying_htlcs = args.get("accept_underpaying_htlcs").and_then(|v| v.as_bool()); + let max_dust_htlc_exposure = match ( + args.get("max_dust_htlc_exposure_fixed_limit_msat").and_then(|v| v.as_u64()), + args.get("max_dust_htlc_exposure_fee_rate_multiplier").and_then(|v| v.as_u64()), + ) { + (Some(_), Some(_)) => { + return Err( + "Only one of max_dust_htlc_exposure_fixed_limit_msat or max_dust_htlc_exposure_fee_rate_multiplier can be set" + .to_string(), + ) + }, + (Some(limit_msat), None) => { + Some(channel_config::MaxDustHtlcExposure::FixedLimitMsat(limit_msat)) + }, + (None, Some(multiplier)) => { + Some(channel_config::MaxDustHtlcExposure::FeeRateMultiplier(multiplier)) + }, + (None, None) => None, + }; + + if forwarding_fee_proportional_millionths.is_none() + && forwarding_fee_base_msat.is_none() + && cltv_expiry_delta.is_none() + && force_close_avoidance_max_fee_satoshis.is_none() + && accept_underpaying_htlcs.is_none() + && max_dust_htlc_exposure.is_none() + { + return Ok(None); + } + + Ok(Some(ChannelConfig { + forwarding_fee_proportional_millionths, + forwarding_fee_base_msat, + cltv_expiry_delta, + force_close_avoidance_max_fee_satoshis, + accept_underpaying_htlcs, + max_dust_htlc_exposure, + })) +} + +fn build_update_channel_config(args: &Value) -> Result { + Ok(build_channel_config(args)?.unwrap_or(ChannelConfig { + forwarding_fee_proportional_millionths: None, + forwarding_fee_base_msat: None, + cltv_expiry_delta: None, + force_close_avoidance_max_fee_satoshis: None, + accept_underpaying_htlcs: None, + max_dust_htlc_exposure: None, + })) +} + +fn build_bolt11_invoice_description( + args: &Value, +) -> Result, String> { + let description_str = args.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); + let description_hash = + args.get("description_hash").and_then(|v| v.as_str()).map(|s| s.to_string()); + + match (description_str, description_hash) { + (Some(desc), None) => Ok(Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Direct(desc)), + })), + (None, Some(hash)) => Ok(Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Hash(hash)), + })), + (Some(_), Some(_)) => { + Err("Only one of description or description_hash can be set".to_string()) + }, + (None, None) => Ok(None), + } +} + +pub async fn handle_get_node_info(client: &LdkServerClient, _args: Value) -> Result { + let response = + client.get_node_info(GetNodeInfoRequest {}).await.map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_get_balances(client: &LdkServerClient, _args: Value) -> Result { + let response = + client.get_balances(GetBalancesRequest {}).await.map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_onchain_receive( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = + client.onchain_receive(OnchainReceiveRequest {}).await.map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Result { + let address = args + .get("address") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: address")? + .to_string(); + let amount_sats = args.get("amount_sats").and_then(|v| v.as_u64()); + let send_all = args.get("send_all").and_then(|v| v.as_bool()); + let fee_rate_sat_per_vb = args.get("fee_rate_sat_per_vb").and_then(|v| v.as_u64()); + + let response = client + .onchain_send(OnchainSendRequest { address, amount_sats, send_all, fee_rate_sat_per_vb }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt11_receive(client: &LdkServerClient, args: Value) -> Result { + let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); + let invoice_description = build_bolt11_invoice_description(&args)?; + + let expiry_secs = args + .get("expiry_secs") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_EXPIRY_SECS); + + let response = client + .bolt11_receive(Bolt11ReceiveRequest { + description: invoice_description, + expiry_secs, + amount_msat, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt11_receive_for_hash( + client: &LdkServerClient, args: Value, +) -> Result { + let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); + let description = build_bolt11_invoice_description(&args)?; + let expiry_secs = args + .get("expiry_secs") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_EXPIRY_SECS); + let payment_hash = args + .get("payment_hash") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: payment_hash")? + .to_string(); + + let response = client + .bolt11_receive_for_hash(Bolt11ReceiveForHashRequest { + amount_msat, + description, + expiry_secs, + payment_hash, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt11_claim_for_hash( + client: &LdkServerClient, args: Value, +) -> Result { + let payment_hash = args.get("payment_hash").and_then(|v| v.as_str()).map(|s| s.to_string()); + let claimable_amount_msat = args.get("claimable_amount_msat").and_then(|v| v.as_u64()); + let preimage = args + .get("preimage") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: preimage")? + .to_string(); + + let response = client + .bolt11_claim_for_hash(Bolt11ClaimForHashRequest { + payment_hash, + claimable_amount_msat, + preimage, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt11_fail_for_hash( + client: &LdkServerClient, args: Value, +) -> Result { + let payment_hash = args + .get("payment_hash") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: payment_hash")? + .to_string(); + + let response = client + .bolt11_fail_for_hash(Bolt11FailForHashRequest { payment_hash }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt11_receive_via_jit_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let amount_msat = args + .get("amount_msat") + .and_then(|v| v.as_u64()) + .ok_or("Missing required parameter: amount_msat")?; + let description = build_bolt11_invoice_description(&args)?; + let expiry_secs = args + .get("expiry_secs") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_EXPIRY_SECS); + let max_total_lsp_fee_limit_msat = + args.get("max_total_lsp_fee_limit_msat").and_then(|v| v.as_u64()); + + let response = client + .bolt11_receive_via_jit_channel(Bolt11ReceiveViaJitChannelRequest { + amount_msat, + description, + expiry_secs, + max_total_lsp_fee_limit_msat, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt11_receive_variable_amount_via_jit_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let description = build_bolt11_invoice_description(&args)?; + let expiry_secs = args + .get("expiry_secs") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_EXPIRY_SECS); + let max_proportional_lsp_fee_limit_ppm_msat = + args.get("max_proportional_lsp_fee_limit_ppm_msat").and_then(|v| v.as_u64()); + + let response = client + .bolt11_receive_variable_amount_via_jit_channel( + Bolt11ReceiveVariableAmountViaJitChannelRequest { + description, + expiry_secs, + max_proportional_lsp_fee_limit_ppm_msat, + }, + ) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt11_send(client: &LdkServerClient, args: Value) -> Result { + let invoice = args + .get("invoice") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: invoice")? + .to_string(); + let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); + let route_parameters = build_route_parameters(&args); + + let response = client + .bolt11_send(Bolt11SendRequest { + invoice, + amount_msat, + route_parameters: Some(route_parameters), + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt12_receive(client: &LdkServerClient, args: Value) -> Result { + let description = args + .get("description") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: description")? + .to_string(); + let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); + let expiry_secs = args.get("expiry_secs").and_then(|v| v.as_u64()).map(|v| v as u32); + let quantity = args.get("quantity").and_then(|v| v.as_u64()); + + let response = client + .bolt12_receive(Bolt12ReceiveRequest { description, amount_msat, expiry_secs, quantity }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_bolt12_send(client: &LdkServerClient, args: Value) -> Result { + let offer = args + .get("offer") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: offer")? + .to_string(); + let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); + let quantity = args.get("quantity").and_then(|v| v.as_u64()); + let payer_note = args.get("payer_note").and_then(|v| v.as_str()).map(|s| s.to_string()); + let route_parameters = build_route_parameters(&args); + + let response = client + .bolt12_send(Bolt12SendRequest { + offer, + amount_msat, + quantity, + payer_note, + route_parameters: Some(route_parameters), + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_spontaneous_send( + client: &LdkServerClient, args: Value, +) -> Result { + let amount_msat = args + .get("amount_msat") + .and_then(|v| v.as_u64()) + .ok_or("Missing required parameter: amount_msat")?; + let node_id = args + .get("node_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: node_id")? + .to_string(); + let route_parameters = build_route_parameters(&args); + + let response = client + .spontaneous_send(SpontaneousSendRequest { + amount_msat, + node_id, + route_parameters: Some(route_parameters), + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_unified_send(client: &LdkServerClient, args: Value) -> Result { + let uri = args + .get("uri") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: uri")? + .to_string(); + let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); + let route_parameters = build_route_parameters(&args); + + let response = client + .unified_send(UnifiedSendRequest { + uri, + amount_msat, + route_parameters: Some(route_parameters), + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_open_channel(client: &LdkServerClient, args: Value) -> Result { + let node_pubkey = args + .get("node_pubkey") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: node_pubkey")? + .to_string(); + let address = args + .get("address") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: address")? + .to_string(); + let channel_amount_sats = args + .get("channel_amount_sats") + .and_then(|v| v.as_u64()) + .ok_or("Missing required parameter: channel_amount_sats")?; + let push_to_counterparty_msat = args.get("push_to_counterparty_msat").and_then(|v| v.as_u64()); + let announce_channel = args.get("announce_channel").and_then(|v| v.as_bool()).unwrap_or(false); + let disable_counterparty_reserve = + args.get("disable_counterparty_reserve").and_then(|v| v.as_bool()).unwrap_or(false); + let channel_config = build_channel_config(&args)?; + + let response = client + .open_channel(OpenChannelRequest { + node_pubkey, + address, + channel_amount_sats, + push_to_counterparty_msat, + channel_config, + announce_channel, + disable_counterparty_reserve, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_splice_in(client: &LdkServerClient, args: Value) -> Result { + let user_channel_id = args + .get("user_channel_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: user_channel_id")? + .to_string(); + let counterparty_node_id = args + .get("counterparty_node_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: counterparty_node_id")? + .to_string(); + let splice_amount_sats = args + .get("splice_amount_sats") + .and_then(|v| v.as_u64()) + .ok_or("Missing required parameter: splice_amount_sats")?; + + let response = client + .splice_in(SpliceInRequest { user_channel_id, counterparty_node_id, splice_amount_sats }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_splice_out(client: &LdkServerClient, args: Value) -> Result { + let user_channel_id = args + .get("user_channel_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: user_channel_id")? + .to_string(); + let counterparty_node_id = args + .get("counterparty_node_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: counterparty_node_id")? + .to_string(); + let splice_amount_sats = args + .get("splice_amount_sats") + .and_then(|v| v.as_u64()) + .ok_or("Missing required parameter: splice_amount_sats")?; + let address = args.get("address").and_then(|v| v.as_str()).map(|s| s.to_string()); + + let response = client + .splice_out(SpliceOutRequest { + user_channel_id, + counterparty_node_id, + address, + splice_amount_sats, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_close_channel(client: &LdkServerClient, args: Value) -> Result { + let user_channel_id = args + .get("user_channel_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: user_channel_id")? + .to_string(); + let counterparty_node_id = args + .get("counterparty_node_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: counterparty_node_id")? + .to_string(); + + let response = client + .close_channel(CloseChannelRequest { user_channel_id, counterparty_node_id }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_force_close_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let user_channel_id = args + .get("user_channel_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: user_channel_id")? + .to_string(); + let counterparty_node_id = args + .get("counterparty_node_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: counterparty_node_id")? + .to_string(); + let force_close_reason = + args.get("force_close_reason").and_then(|v| v.as_str()).map(|s| s.to_string()); + + let response = client + .force_close_channel(ForceCloseChannelRequest { + user_channel_id, + counterparty_node_id, + force_close_reason, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_list_channels(client: &LdkServerClient, _args: Value) -> Result { + let response = + client.list_channels(ListChannelsRequest {}).await.map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_update_channel_config( + client: &LdkServerClient, args: Value, +) -> Result { + let user_channel_id = args + .get("user_channel_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: user_channel_id")? + .to_string(); + let counterparty_node_id = args + .get("counterparty_node_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: counterparty_node_id")? + .to_string(); + + let channel_config = build_update_channel_config(&args)?; + + let response = client + .update_channel_config(UpdateChannelConfigRequest { + user_channel_id, + counterparty_node_id, + channel_config: Some(channel_config), + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_list_payments(client: &LdkServerClient, args: Value) -> Result { + let page_token = match args.get("page_token").and_then(|v| v.as_str()) { + Some(token_str) => Some(parse_page_token(token_str)?), + None => None, + }; + + let response = client + .list_payments(ListPaymentsRequest { page_token }) + .await + .map_err(|e| e.message.clone())?; + + let mut result = serde_json::to_value(&response) + .map_err(|e| format!("Failed to serialize response: {e}"))?; + + if let Some(ref npt) = response.next_page_token { + result + .as_object_mut() + .unwrap() + .insert("next_page_token".to_string(), json!(format_page_token(npt))); + } + + Ok(result) +} + +pub async fn handle_get_payment_details( + client: &LdkServerClient, args: Value, +) -> Result { + let payment_id = args + .get("payment_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: payment_id")? + .to_string(); + + let response = client + .get_payment_details(GetPaymentDetailsRequest { payment_id }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_list_forwarded_payments( + client: &LdkServerClient, args: Value, +) -> Result { + let page_token = match args.get("page_token").and_then(|v| v.as_str()) { + Some(token_str) => Some(parse_page_token(token_str)?), + None => None, + }; + + let response = client + .list_forwarded_payments(ListForwardedPaymentsRequest { page_token }) + .await + .map_err(|e| e.message.clone())?; + + let mut result = serde_json::to_value(&response) + .map_err(|e| format!("Failed to serialize response: {e}"))?; + + if let Some(ref npt) = response.next_page_token { + result + .as_object_mut() + .unwrap() + .insert("next_page_token".to_string(), json!(format_page_token(npt))); + } + + Ok(result) +} + +pub async fn handle_connect_peer(client: &LdkServerClient, args: Value) -> Result { + let node_pubkey = args + .get("node_pubkey") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: node_pubkey")? + .to_string(); + let address = args + .get("address") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: address")? + .to_string(); + let persist = args.get("persist").and_then(|v| v.as_bool()).unwrap_or(false); + + let response = client + .connect_peer(ConnectPeerRequest { node_pubkey, address, persist }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_disconnect_peer( + client: &LdkServerClient, args: Value, +) -> Result { + let node_pubkey = args + .get("node_pubkey") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: node_pubkey")? + .to_string(); + + let response = client + .disconnect_peer(DisconnectPeerRequest { node_pubkey }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_list_peers(client: &LdkServerClient, _args: Value) -> Result { + let response = client.list_peers(ListPeersRequest {}).await.map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_decode_invoice(client: &LdkServerClient, args: Value) -> Result { + let invoice = args + .get("invoice") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: invoice")? + .to_string(); + + let response = client + .decode_invoice(DecodeInvoiceRequest { invoice }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_decode_offer(client: &LdkServerClient, args: Value) -> Result { + let offer = args + .get("offer") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: offer")? + .to_string(); + + let response = + client.decode_offer(DecodeOfferRequest { offer }).await.map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_sign_message(client: &LdkServerClient, args: Value) -> Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: message")? + .to_string(); + + let response = client + .sign_message(SignMessageRequest { message: message.into_bytes().into() }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_verify_signature( + client: &LdkServerClient, args: Value, +) -> Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: message")? + .to_string(); + let signature = args + .get("signature") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: signature")? + .to_string(); + let public_key = args + .get("public_key") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: public_key")? + .to_string(); + + let response = client + .verify_signature(VerifySignatureRequest { + message: message.into_bytes().into(), + signature, + public_key, + }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_export_pathfinding_scores( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client + .export_pathfinding_scores(ExportPathfindingScoresRequest {}) + .await + .map_err(|e| e.message.clone())?; + let scores_hex = hex_encode(&response.scores); + Ok(json!({ "pathfinding_scores": scores_hex })) +} + +pub async fn handle_graph_list_channels( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client + .graph_list_channels(GraphListChannelsRequest {}) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_graph_get_channel( + client: &LdkServerClient, args: Value, +) -> Result { + let short_channel_id = args + .get("short_channel_id") + .and_then(|v| v.as_u64()) + .ok_or("Missing required parameter: short_channel_id")?; + + let response = client + .graph_get_channel(GraphGetChannelRequest { short_channel_id }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_graph_list_nodes( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = + client.graph_list_nodes(GraphListNodesRequest {}).await.map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} + +pub async fn handle_graph_get_node(client: &LdkServerClient, args: Value) -> Result { + let node_id = args + .get("node_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: node_id")? + .to_string(); + + let response = client + .graph_get_node(GraphGetNodeRequest { node_id }) + .await + .map_err(|e| e.message.clone())?; + serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +} diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs new file mode 100644 index 00000000..1fc2b5dc --- /dev/null +++ b/ldk-server-mcp/src/tools/mod.rs @@ -0,0 +1,311 @@ +// 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::future::Future; +use std::pin::Pin; + +use ldk_server_client::client::LdkServerClient; +use serde_json::Value; + +use crate::mcp::{ToolCallResult, ToolDefinition}; + +type ToolHandler = for<'a> fn( + &'a LdkServerClient, + Value, +) -> Pin> + Send + 'a>>; + +pub struct ToolRegistry { + tools: Vec<(ToolDefinition, 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) -> Vec<&ToolDefinition> { + self.tools.iter().map(|(def, _)| def).collect() + } + + pub async fn call_tool( + &self, client: &LdkServerClient, name: &str, args: Value, + ) -> ToolCallResult { + for (def, handler) in &self.tools { + if def.name == name { + return match handler(client, args).await { + Ok(value) => { + let text = serde_json::to_string_pretty(&value) + .unwrap_or_else(|e| format!("Failed to serialize response: {e}")); + ToolCallResult::success(text) + }, + Err(e) => ToolCallResult::error(e), + }; + } + } + ToolCallResult::error(format!("Unknown tool: {name}")) + } +} + +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)), + ), + ] + .into_iter() + .map(|spec| { + ( + ToolDefinition { + name: spec.name.to_string(), + description: spec.description.to_string(), + input_schema: (spec.input_schema)(), + }, + spec.handler, + ) + }) + .collect(); + + ToolRegistry { tools } +} diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs new file mode 100644 index 00000000..9905f65c --- /dev/null +++ b/ldk-server-mcp/src/tools/schema.rs @@ -0,0 +1,755 @@ +// 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}; + +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": { + "type": "string", + "description": "Description to attach to the invoice. Mutually exclusive with description_hash" + }, + "description_hash": { + "type": "string", + "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" + }, + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (default: 86400)" + } + }, + "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": { + "type": "string", + "description": "Description to attach to the invoice. Mutually exclusive with description_hash" + }, + "description_hash": { + "type": "string", + "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" + }, + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (default: 86400)" + }, + "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, it will be used to verify 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": { + "type": "string", + "description": "Description to attach to the invoice. Mutually exclusive with description_hash" + }, + "description_hash": { + "type": "string", + "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" + }, + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (default: 86400)" + }, + "max_total_lsp_fee_limit_msat": { + "type": "integer", + "description": "Optional upper bound for the total fee an LSP may deduct when opening the JIT channel" + } + }, + "required": ["amount_msat"] + }) +} + +pub fn bolt11_receive_variable_amount_via_jit_channel_schema() -> Value { + json!({ + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description to attach to the invoice. Mutually exclusive with description_hash" + }, + "description_hash": { + "type": "string", + "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" + }, + "expiry_secs": { + "type": "integer", + "description": "Invoice expiry time in seconds (default: 86400)" + }, + "max_proportional_lsp_fee_limit_ppm_msat": { + "type": "integer", + "description": "Optional upper bound for the proportional fee, in parts-per-million millisatoshis, that an LSP may deduct when opening the JIT channel" + } + }, + "required": [] + }) +} + +pub fn bolt11_send_schema() -> Value { + json!({ + "type": "object", + "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" + }, + "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)" + } + }, + "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" + }, + "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)" + } + }, + "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" + }, + "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)" + } + }, + "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" + }, + "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)" + } + }, + "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)" + }, + "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_fixed_limit_msat": { + "type": "integer", + "description": "Sets a fixed limit on the total dust exposure in millisatoshis. Mutually exclusive with max_dust_htlc_exposure_fee_rate_multiplier" + }, + "max_dust_htlc_exposure_fee_rate_multiplier": { + "type": "integer", + "description": "Sets a multiplier on the on-chain sweep feerate to determine the maximum allowed dust exposure. Mutually exclusive with max_dust_htlc_exposure_fixed_limit_msat" + }, + "disable_counterparty_reserve": { + "type": "boolean", + "description": "Allow the counterparty to spend all its channel balance. Cannot be set together with announce_channel" + } + }, + "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" + }, + "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_fixed_limit_msat": { + "type": "integer", + "description": "Sets a fixed limit on the total dust exposure in millisatoshis. Mutually exclusive with max_dust_htlc_exposure_fee_rate_multiplier" + }, + "max_dust_htlc_exposure_fee_rate_multiplier": { + "type": "integer", + "description": "Sets a multiplier on the on-chain sweep feerate to determine the maximum allowed dust exposure. Mutually exclusive with max_dust_htlc_exposure_fixed_limit_msat" + } + }, + "required": ["user_channel_id", "counterparty_node_id"] + }) +} + +pub fn list_payments_schema() -> Value { + json!({ + "type": "object", + "properties": { + "page_token": { + "type": "string", + "description": "Pagination token from a previous response (format: token:index)" + } + }, + "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": { + "type": "string", + "description": "Pagination token from a previous response (format: token:index)" + } + }, + "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" + } + }, + "required": ["message"] + }) +} + +pub fn verify_signature_schema() -> Value { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message that was signed" + }, + "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..62fac5d5 --- /dev/null +++ b/ldk-server-mcp/tests/integration.rs @@ -0,0 +1,465 @@ +// 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 std::process::{Command, Stdio}; + +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() +} + +fn cargo_bin_path() -> String { + let output = Command::new("cargo") + .args(["build", "--message-format=json"]) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .output() + .expect("Failed to build binary"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + for line in stdout.lines() { + if let Ok(msg) = serde_json::from_str::(line) { + if msg.get("reason").and_then(|r| r.as_str()) == Some("compiler-artifact") + && msg.get("target").and_then(|t| t.get("name")).and_then(|n| n.as_str()) + == Some("ldk-server-mcp") + && msg.get("executable").and_then(|e| e.as_str()).is_some() + { + return msg["executable"].as_str().unwrap().to_string(); + } + } + } + panic!("Could not find compiled binary path"); +} + +struct McpProcess { + child: std::process::Child, + stdin: std::process::ChildStdin, + reader: BufReader, +} + +impl McpProcess { + fn spawn() -> Self { + let bin = cargo_bin_path(); + let mut child = Command::new(&bin) + .env("LDK_BASE_URL", "localhost:19999") + .env("LDK_API_KEY", "deadbeef") + .env("LDK_TLS_CERT_PATH", test_cert_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(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": "2024-11-05", + "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"], "2024-11-05"); + 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": "2024-11-05", + "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"); +} From 74e47a1a8f9aebe0cc79b9346d3fc1b3b0fbe82e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Apr 2026 11:40:56 +0200 Subject: [PATCH 03/21] f - Add ldk-server-mcp to the workspace Support storage.disk.dir_path config and fall back to the default gRPC address when neither LDK_BASE_URL nor a config file is present, matching the behavior of ldk-server-cli. Co-Authored-By: HAL 9000 --- ldk-server-mcp/src/config.rs | 114 +++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 6 deletions(-) diff --git a/ldk-server-mcp/src/config.rs b/ldk-server-mcp/src/config.rs index 81e62ace..8cc7ed57 100644 --- a/ldk-server-mcp/src/config.rs +++ b/ldk-server-mcp/src/config.rs @@ -46,6 +46,14 @@ fn get_default_api_key_path(network: &str) -> Option { get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE)) } +fn api_key_path_for_storage_dir(storage_dir: &str, network: &str) -> PathBuf { + PathBuf::from(storage_dir).join(network).join(API_KEY_FILE) +} + +fn cert_path_for_storage_dir(storage_dir: &str) -> PathBuf { + PathBuf::from(storage_dir).join(DEFAULT_CERT_FILE) +} + fn hex_encode(bytes: &[u8]) -> String { let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { @@ -58,6 +66,17 @@ fn hex_encode(bytes: &[u8]) -> String { pub struct Config { pub node: NodeConfig, pub tls: Option, + pub storage: Option, +} + +#[derive(Debug, Deserialize)] +pub struct StorageConfig { + pub disk: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DiskConfig { + pub dir_path: Option, } #[derive(Debug, Deserialize)] @@ -109,17 +128,23 @@ pub fn resolve_config(config_path: Option) -> Result None, }; - let base_url = std::env::var("LDK_BASE_URL").ok().or_else(|| { - config.as_ref().map(|c| c.node.grpc_service_address.clone()) - }).ok_or_else(|| { - "Base URL not provided. Set LDK_BASE_URL or ensure config file exists at ~/.ldk-server/config.toml".to_string() - })?; + let storage_dir = + config.as_ref().and_then(|c| c.storage.as_ref()?.disk.as_ref()?.dir_path.as_deref()); + + let base_url = std::env::var("LDK_BASE_URL") + .ok() + .or_else(|| config.as_ref().map(|c| c.node.grpc_service_address.clone())) + .unwrap_or_else(default_grpc_service_address); let api_key = std::env::var("LDK_API_KEY").ok().or_else(|| { let network = config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string()); - get_default_api_key_path(&network) + 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| hex_encode(&bytes)) }).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() @@ -129,6 +154,7 @@ pub fn resolve_config(config_path: Option) -> Result) -> Result = Mutex::new(()); #[test] fn config_defaults_grpc_service_address() { @@ -160,6 +191,8 @@ mod tests { #[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(); @@ -185,6 +218,7 @@ mod tests { 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"); @@ -195,4 +229,72 @@ mod tests { 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(); + } } From 3febdfafe4e7e9c4538e3ea8ed7c1b4461ff2af7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Apr 2026 11:44:20 +0200 Subject: [PATCH 04/21] f - Add ldk-server-mcp to the workspace Fix clippy needless_borrows_for_generic_args in test. Co-Authored-By: HAL 9000 --- ldk-server-mcp/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldk-server-mcp/src/config.rs b/ldk-server-mcp/src/config.rs index 8cc7ed57..74ce1e02 100644 --- a/ldk-server-mcp/src/config.rs +++ b/ldk-server-mcp/src/config.rs @@ -269,7 +269,7 @@ mod tests { 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(custom_storage.join("regtest").join("api_key"), [0xAB, 0xCD]).unwrap(); std::fs::write( &config_path, From 1504096d5d393eff45c840773c70c2902746057b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Apr 2026 11:42:14 +0200 Subject: [PATCH 05/21] f - Add ldk-server-mcp to the workspace Move routing and invoice default constants into ldk-server-client so they are shared between ldk-server-cli and ldk-server-mcp. Co-Authored-By: HAL 9000 --- ldk-server-cli/src/main.rs | 12 ++++-------- ldk-server-client/src/lib.rs | 9 +++++++++ ldk-server-mcp/src/tools/handlers.rs | 9 ++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index f001d871..bf680233 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -49,6 +49,10 @@ use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken, RouteParametersConfig, }; +use ldk_server_client::{ + DEFAULT_EXPIRY_SECS, DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF, DEFAULT_MAX_PATH_COUNT, + DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, +}; use serde::Serialize; use serde_json::{json, Value}; use types::{ @@ -58,14 +62,6 @@ use types::{ mod config; mod types; -// Having these default values as constants in the Proto file and -// importing/reusing them here might be better, but Proto3 removed -// the ability to set default values. -const DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA: u32 = 1008; -const DEFAULT_MAX_PATH_COUNT: u32 = 10; -const DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF: u32 = 2; -const DEFAULT_EXPIRY_SECS: u32 = 86_400; - const DEFAULT_DIR: &str = if cfg!(target_os = "macos") { "~/Library/Application Support/ldk-server" } else if cfg!(target_os = "windows") { diff --git a/ldk-server-client/src/lib.rs b/ldk-server-client/src/lib.rs index 4959f127..df40d23a 100644 --- a/ldk-server-client/src/lib.rs +++ b/ldk-server-client/src/lib.rs @@ -20,3 +20,12 @@ 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-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index ac24dba6..112cc534 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -26,13 +26,12 @@ use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, channel_config, 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_json::{json, Value}; -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; - fn hex_encode(bytes: &[u8]) -> String { let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { From f61149b1effa5719d0c44e184c510bc04a5c250d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 11:50:44 +0200 Subject: [PATCH 06/21] f - Add ldk-server-mcp to the workspace Move the shared config loading and credential resolution logic into ldk-server-client so that ldk-server-cli and ldk-server-mcp both consume the same implementation instead of duplicating path handling, TOML parsing, and API-key hex-encoding in each binary. Generated with the assistance of AI (Claude). --- Cargo.lock | 4 +- ldk-server-cli/src/main.rs | 48 +---- ldk-server-client/Cargo.toml | 5 +- .../src/config.rs | 77 +++++++- ldk-server-client/src/lib.rs | 4 + ldk-server-mcp/Cargo.toml | 1 - ldk-server-mcp/src/config.rs | 165 ++---------------- 7 files changed, 112 insertions(+), 192 deletions(-) rename {ldk-server-cli => ldk-server-client}/src/config.rs (54%) diff --git a/Cargo.lock b/Cargo.lock index 06af7dfe..715467a9 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]] @@ -1324,7 +1327,6 @@ dependencies = [ "serde", "serde_json", "tokio", - "toml", ] [[package]] diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index bf680233..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, @@ -59,7 +58,6 @@ use types::{ Amount, CliListForwardedPaymentsResponse, CliListPaymentsResponse, CliPaginatedResponse, }; -mod config; mod types; const DEFAULT_DIR: &str = if cfg!(target_os = "macos") { @@ -566,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 df40d23a..ff67cd9e 100644 --- a/ldk-server-client/src/lib.rs +++ b/ldk-server-client/src/lib.rs @@ -15,6 +15,10 @@ /// 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; diff --git a/ldk-server-mcp/Cargo.toml b/ldk-server-mcp/Cargo.toml index df8fc51c..30f7aee3 100644 --- a/ldk-server-mcp/Cargo.toml +++ b/ldk-server-mcp/Cargo.toml @@ -8,4 +8,3 @@ 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"] } -toml = { version = "0.8", default-features = false, features = ["parse"] } diff --git a/ldk-server-mcp/src/config.rs b/ldk-server-mcp/src/config.rs index 74ce1e02..f3882a87 100644 --- a/ldk-server-mcp/src/config.rs +++ b/ldk-server-mcp/src/config.rs @@ -7,113 +7,11 @@ // You may not use this file except in accordance with one or both of these // licenses. -use std::fmt::Write as _; use std::path::PathBuf; -use serde::Deserialize; - -const DEFAULT_CONFIG_FILE: &str = "config.toml"; -const DEFAULT_CERT_FILE: &str = "tls.crt"; -const API_KEY_FILE: &str = "api_key"; -const DEFAULT_GRPC_SERVICE_ADDRESS: &str = "127.0.0.1:3536"; - -fn get_default_data_dir() -> Option { - #[cfg(target_os = "macos")] - { - #[allow(deprecated)] - std::env::home_dir().map(|home| home.join("Library/Application Support/ldk-server")) - } - #[cfg(target_os = "windows")] - { - std::env::var("APPDATA").ok().map(|appdata| PathBuf::from(appdata).join("ldk-server")) - } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - #[allow(deprecated)] - std::env::home_dir().map(|home| home.join(".ldk-server")) - } -} - -fn get_default_config_path() -> Option { - get_default_data_dir().map(|dir| dir.join(DEFAULT_CONFIG_FILE)) -} - -fn get_default_cert_path() -> Option { - get_default_data_dir().map(|path| path.join(DEFAULT_CERT_FILE)) -} - -fn get_default_api_key_path(network: &str) -> Option { - get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE)) -} - -fn api_key_path_for_storage_dir(storage_dir: &str, network: &str) -> PathBuf { - PathBuf::from(storage_dir).join(network).join(API_KEY_FILE) -} - -fn cert_path_for_storage_dir(storage_dir: &str) -> PathBuf { - PathBuf::from(storage_dir).join(DEFAULT_CERT_FILE) -} - -fn hex_encode(bytes: &[u8]) -> String { - let mut encoded = String::with_capacity(bytes.len() * 2); - for byte in bytes { - let _ = write!(&mut encoded, "{byte:02x}"); - } - encoded -} - -#[derive(Debug, Deserialize)] -pub struct Config { - pub node: NodeConfig, - pub tls: Option, - pub storage: Option, -} - -#[derive(Debug, Deserialize)] -pub struct StorageConfig { - pub disk: Option, -} - -#[derive(Debug, Deserialize)] -pub struct DiskConfig { - pub dir_path: Option, -} - -#[derive(Debug, Deserialize)] -pub struct TlsConfig { - pub cert_path: Option, -} - -#[derive(Debug, Deserialize)] -pub struct NodeConfig { - #[serde(default = "default_grpc_service_address")] - pub grpc_service_address: String, - network: String, -} - -fn default_grpc_service_address() -> String { - DEFAULT_GRPC_SERVICE_ADDRESS.to_string() -} - -impl Config { - pub fn network(&self) -> Result { - match self.node.network.as_str() { - "bitcoin" | "mainnet" => Ok("bitcoin".to_string()), - "testnet" => Ok("testnet".to_string()), - "testnet4" => Ok("testnet4".to_string()), - "signet" => Ok("signet".to_string()), - "regtest" => Ok("regtest".to_string()), - other => Err(format!("Unsupported network: {other}")), - } - } -} - -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))?; - toml::from_str(&contents) - .map_err(|e| format!("Failed to parse config file '{}': {}", path.display(), e)) -} +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, @@ -128,36 +26,19 @@ pub fn resolve_config(config_path: Option) -> Result None, }; - let storage_dir = - config.as_ref().and_then(|c| c.storage.as_ref()?.disk.as_ref()?.dir_path.as_deref()); - - let base_url = std::env::var("LDK_BASE_URL") - .ok() - .or_else(|| config.as_ref().map(|c| c.node.grpc_service_address.clone())) - .unwrap_or_else(default_grpc_service_address); - - let api_key = std::env::var("LDK_API_KEY").ok().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| hex_encode(&bytes)) - }).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 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 = std::env::var("LDK_TLS_CERT_PATH").ok().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) - }).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_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| { @@ -169,26 +50,14 @@ pub fn resolve_config(config_path: Option) -> Result = Mutex::new(()); - #[test] - fn config_defaults_grpc_service_address() { - let config: Config = toml::from_str( - r#" - [node] - network = "regtest" - "#, - ) - .unwrap(); - - assert_eq!(config.node.grpc_service_address, DEFAULT_GRPC_SERVICE_ADDRESS); - } - #[test] fn resolve_config_uses_grpc_service_address_from_config() { let _lock = ENV_LOCK.lock().unwrap(); From 5f432b2783201b1afdc687c2ab9dbd16fc5dd962 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 11:51:28 +0200 Subject: [PATCH 07/21] f - Add ldk-server-mcp to the workspace Use hex-conservative's DisplayHex implementation for encoding the pathfinding scores blob instead of a hand-rolled hex formatter so the crate matches the hex encoding already used elsewhere in the workspace. Generated with the assistance of AI (Claude). --- Cargo.lock | 1 + ldk-server-mcp/Cargo.toml | 1 + ldk-server-mcp/src/tools/handlers.rs | 13 ++----------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 715467a9..122f7a48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1323,6 +1323,7 @@ dependencies = [ name = "ldk-server-mcp" version = "0.1.0" dependencies = [ + "hex-conservative 0.2.1", "ldk-server-client", "serde", "serde_json", diff --git a/ldk-server-mcp/Cargo.toml b/ldk-server-mcp/Cargo.toml index 30f7aee3..3a3459ed 100644 --- a/ldk-server-mcp/Cargo.toml +++ b/ldk-server-mcp/Cargo.toml @@ -4,6 +4,7 @@ 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" diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index 112cc534..70557243 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -7,8 +7,7 @@ // You may not use this file except in accordance with one or both of these // licenses. -use std::fmt::Write as _; - +use hex_conservative::DisplayHex; use ldk_server_client::client::LdkServerClient; use ldk_server_client::ldk_server_grpc::api::{ Bolt11ClaimForHashRequest, Bolt11FailForHashRequest, Bolt11ReceiveForHashRequest, @@ -32,14 +31,6 @@ use ldk_server_client::{ }; use serde_json::{json, Value}; -fn hex_encode(bytes: &[u8]) -> String { - let mut encoded = String::with_capacity(bytes.len() * 2); - for byte in bytes { - let _ = write!(&mut encoded, "{byte:02x}"); - } - encoded -} - fn parse_page_token(token_str: &str) -> Result { let parts: Vec<&str> = token_str.split(':').collect(); if parts.len() != 2 { @@ -791,7 +782,7 @@ pub async fn handle_export_pathfinding_scores( .export_pathfinding_scores(ExportPathfindingScoresRequest {}) .await .map_err(|e| e.message.clone())?; - let scores_hex = hex_encode(&response.scores); + let scores_hex = response.scores.to_lower_hex_string(); Ok(json!({ "pathfinding_scores": scores_hex })) } From b65906432308dba0c61c2509badb933dc47ba138 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 11:52:34 +0200 Subject: [PATCH 08/21] f - Add ldk-server-mcp to the workspace Dispatch tool invocations through a HashMap keyed by tool name rather than scanning the full registry on every call, and emit compact JSON for tool responses instead of pretty-printing so the MCP bridge doesn't spend tokens on insignificant whitespace when piping results to an agent. Generated with the assistance of AI (Claude). --- ldk-server-mcp/src/tools/mod.rs | 57 ++++++++++++++++----------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs index 1fc2b5dc..bc280ecc 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -10,6 +10,7 @@ pub mod handlers; pub mod schema; +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; @@ -24,7 +25,8 @@ type ToolHandler = for<'a> fn( ) -> Pin> + Send + 'a>>; pub struct ToolRegistry { - tools: Vec<(ToolDefinition, ToolHandler)>, + definitions: Vec, + handlers: HashMap<&'static str, ToolHandler>, } struct ToolSpec { @@ -42,26 +44,24 @@ fn tool_spec( } impl ToolRegistry { - pub fn list_tools(&self) -> Vec<&ToolDefinition> { - self.tools.iter().map(|(def, _)| def).collect() + pub fn list_tools(&self) -> &[ToolDefinition] { + &self.definitions } pub async fn call_tool( &self, client: &LdkServerClient, name: &str, args: Value, ) -> ToolCallResult { - for (def, handler) in &self.tools { - if def.name == name { - return match handler(client, args).await { - Ok(value) => { - let text = serde_json::to_string_pretty(&value) - .unwrap_or_else(|e| format!("Failed to serialize response: {e}")); - ToolCallResult::success(text) - }, - Err(e) => ToolCallResult::error(e), - }; - } + 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(e), } - ToolCallResult::error(format!("Unknown tool: {name}")) } } @@ -293,19 +293,18 @@ pub fn build_tool_registry() -> ToolRegistry { schema::graph_get_node_schema, |client, args| Box::pin(handlers::handle_graph_get_node(client, args)), ), - ] - .into_iter() - .map(|spec| { - ( - ToolDefinition { - name: spec.name.to_string(), - description: spec.description.to_string(), - input_schema: (spec.input_schema)(), - }, - spec.handler, - ) - }) - .collect(); + ]; + + 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 { tools } + ToolRegistry { definitions, handlers } } From 6bfeab524a657e452b50034f7cea62bdd04496bc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 11:53:33 +0200 Subject: [PATCH 09/21] f - Add ldk-server-mcp to the workspace Probe the ldk-server with a GetNodeInfo call during startup so bad credentials or an unreachable base URL surface right away instead of only being discovered on the first tool call. The probe only logs a warning if it fails so the JSON-RPC loop still serves `initialize` and `tools/list` responses when the server is temporarily down. Generated with the assistance of AI (Claude). --- ldk-server-mcp/src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ldk-server-mcp/src/main.rs b/ldk-server-mcp/src/main.rs index 7dfec64c..8ffe8d6f 100644 --- a/ldk-server-mcp/src/main.rs +++ b/ldk-server-mcp/src/main.rs @@ -13,6 +13,7 @@ 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}; @@ -58,6 +59,14 @@ async fn main() { }, }; + // 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"); From db41f1f62d6691a48e34ea2302cae80a846c97ff Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 11:53:58 +0200 Subject: [PATCH 10/21] f - Add ldk-server-mcp to the workspace Advertise the `2025-11-25` MCP protocol revision so the bridge reports the current protocol version rather than the older `2024-11-05` spec. Generated with the assistance of AI (Claude). --- ldk-server-mcp/CLAUDE.md | 2 +- ldk-server-mcp/README.md | 2 +- ldk-server-mcp/src/mcp.rs | 2 +- ldk-server-mcp/tests/integration.rs | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md index 9687a06c..800ebbbe 100644 --- a/ldk-server-mcp/CLAUDE.md +++ b/ldk-server-mcp/CLAUDE.md @@ -27,7 +27,7 @@ src/ ## MCP Protocol -- **Version**: `2024-11-05` +- **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` diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md index e96d9ca5..8df16bab 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -151,7 +151,7 @@ Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `me ## MCP Protocol -- **Protocol version**: `2024-11-05` +- **Protocol version**: `2025-11-25` - **Transport**: stdio (one JSON-RPC 2.0 message per line) - **Methods**: `initialize`, `tools/list`, `tools/call` diff --git a/ldk-server-mcp/src/mcp.rs b/ldk-server-mcp/src/mcp.rs index 22aa8bf9..fb5fd786 100644 --- a/ldk-server-mcp/src/mcp.rs +++ b/ldk-server-mcp/src/mcp.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::Value; -pub const PROTOCOL_VERSION: &str = "2024-11-05"; +pub const PROTOCOL_VERSION: &str = "2025-11-25"; pub const SERVER_NAME: &str = "ldk-server-mcp"; pub const SERVER_VERSION: &str = "0.1.0"; diff --git a/ldk-server-mcp/tests/integration.rs b/ldk-server-mcp/tests/integration.rs index 62fac5d5..5adaffba 100644 --- a/ldk-server-mcp/tests/integration.rs +++ b/ldk-server-mcp/tests/integration.rs @@ -160,7 +160,7 @@ fn test_initialize() { "id": 1, "method": "initialize", "params": { - "protocolVersion": "2024-11-05", + "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "test", "version": "0.1"} } @@ -169,7 +169,7 @@ fn test_initialize() { let resp = proc.recv(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 1); - assert_eq!(resp["result"]["protocolVersion"], "2024-11-05"); + 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"); @@ -350,7 +350,7 @@ fn test_notification_no_response() { "id": 42, "method": "initialize", "params": { - "protocolVersion": "2024-11-05", + "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "test", "version": "0.1"} } From a2f895030cfac32c9a74540bf96dacc98c03090e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 11:57:51 +0200 Subject: [PATCH 11/21] f - Add ldk-server-mcp to the workspace Classify tool handler errors with a dedicated `McpError` type that maps `LdkServerError` variants onto JSON-RPC error codes (`INVALID_PARAMS` for invalid-request errors, `INTERNAL_ERROR` for auth, Lightning and internal failures) and reuse those codes at the dispatch boundary so a `tools/call` request missing its `name` parameter returns a real JSON-RPC error instead of being silently routed as an "unknown tool". Handlers now propagate `LdkServerError` through a `From` conversion rather than cloning the error message. Generated with the assistance of AI (Claude). --- ldk-server-mcp/src/main.rs | 26 +- ldk-server-mcp/src/protocol.rs | 43 ++- ldk-server-mcp/src/tools/handlers.rs | 420 +++++++++++++++------------ ldk-server-mcp/src/tools/mod.rs | 6 +- 4 files changed, 301 insertions(+), 194 deletions(-) diff --git a/ldk-server-mcp/src/main.rs b/ldk-server-mcp/src/main.rs index 8ffe8d6f..25f95b7f 100644 --- a/ldk-server-mcp/src/main.rs +++ b/ldk-server-mcp/src/main.rs @@ -19,7 +19,8 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use crate::mcp::InitializeResult; use crate::protocol::{ - JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse, METHOD_NOT_FOUND, PARSE_ERROR, + JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse, INVALID_PARAMS, METHOD_NOT_FOUND, + PARSE_ERROR, }; use crate::tools::build_tool_registry; @@ -125,12 +126,23 @@ async fn main() { }, "tools/call" => { let params = request.params.unwrap_or(Value::Null); - let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); - 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() + 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( diff --git a/ldk-server-mcp/src/protocol.rs b/ldk-server-mcp/src/protocol.rs index c14df7cd..d9d08e94 100644 --- a/ldk-server-mcp/src/protocol.rs +++ b/ldk-server-mcp/src/protocol.rs @@ -7,16 +7,55 @@ // 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; -#[allow(dead_code)] pub const INVALID_PARAMS: i64 = -32602; -#[allow(dead_code)] 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)] diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index 70557243..84835ccc 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -31,12 +31,16 @@ use ldk_server_client::{ }; use serde_json::{json, Value}; -fn parse_page_token(token_str: &str) -> Result { +use crate::protocol::McpError; + +fn parse_page_token(token_str: &str) -> Result { let parts: Vec<&str> = token_str.split(':').collect(); if parts.len() != 2 { - return Err("Page token must be in format 'token:index'".to_string()); + return Err(McpError::invalid_params("Page token must be in format 'token:index'")); } - let index = parts[1].parse::().map_err(|_| "Invalid page token index".to_string())?; + let index = parts[1] + .parse::() + .map_err(|_| McpError::invalid_params("Invalid page token index"))?; Ok(PageToken { token: parts[0].to_string(), index }) } @@ -65,7 +69,7 @@ fn build_route_parameters(args: &Value) -> RouteParametersConfig { } } -fn build_channel_config(args: &Value) -> Result, String> { +fn build_channel_config(args: &Value) -> Result, McpError> { let forwarding_fee_proportional_millionths = args .get("forwarding_fee_proportional_millionths") .and_then(|v| v.as_u64()) @@ -82,10 +86,9 @@ fn build_channel_config(args: &Value) -> Result, String> { args.get("max_dust_htlc_exposure_fee_rate_multiplier").and_then(|v| v.as_u64()), ) { (Some(_), Some(_)) => { - return Err( - "Only one of max_dust_htlc_exposure_fixed_limit_msat or max_dust_htlc_exposure_fee_rate_multiplier can be set" - .to_string(), - ) + return Err(McpError::invalid_params( + "Only one of max_dust_htlc_exposure_fixed_limit_msat or max_dust_htlc_exposure_fee_rate_multiplier can be set", + )) }, (Some(limit_msat), None) => { Some(channel_config::MaxDustHtlcExposure::FixedLimitMsat(limit_msat)) @@ -116,7 +119,7 @@ fn build_channel_config(args: &Value) -> Result, String> { })) } -fn build_update_channel_config(args: &Value) -> Result { +fn build_update_channel_config(args: &Value) -> Result { Ok(build_channel_config(args)?.unwrap_or(ChannelConfig { forwarding_fee_proportional_millionths: None, forwarding_fee_base_msat: None, @@ -129,7 +132,7 @@ fn build_update_channel_config(args: &Value) -> Result { fn build_bolt11_invoice_description( args: &Value, -) -> Result, String> { +) -> Result, McpError> { let description_str = args.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); let description_hash = args.get("description_hash").and_then(|v| v.as_str()).map(|s| s.to_string()); @@ -142,37 +145,42 @@ fn build_bolt11_invoice_description( kind: Some(bolt11_invoice_description::Kind::Hash(hash)), })), (Some(_), Some(_)) => { - Err("Only one of description or description_hash can be set".to_string()) + Err(McpError::invalid_params("Only one of description or description_hash can be set")) }, (None, None) => Ok(None), } } -pub async fn handle_get_node_info(client: &LdkServerClient, _args: Value) -> Result { - let response = - client.get_node_info(GetNodeInfoRequest {}).await.map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| 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)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_get_balances(client: &LdkServerClient, _args: Value) -> Result { - let response = - client.get_balances(GetBalancesRequest {}).await.map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +pub async fn handle_get_balances( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client.get_balances(GetBalancesRequest {}).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_onchain_receive( client: &LdkServerClient, _args: Value, -) -> Result { +) -> Result { let response = - client.onchain_receive(OnchainReceiveRequest {}).await.map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + client.onchain_receive(OnchainReceiveRequest {}).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Result { let address = args .get("address") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: address")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: address"))? .to_string(); let amount_sats = args.get("amount_sats").and_then(|v| v.as_u64()); let send_all = args.get("send_all").and_then(|v| v.as_bool()); @@ -181,11 +189,14 @@ pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Resul let response = client .onchain_send(OnchainSendRequest { address, amount_sats, send_all, fee_rate_sat_per_vb }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_bolt11_receive(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_bolt11_receive( + client: &LdkServerClient, args: Value, +) -> Result { let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); let invoice_description = build_bolt11_invoice_description(&args)?; @@ -202,13 +213,14 @@ pub async fn handle_bolt11_receive(client: &LdkServerClient, args: Value) -> Res amount_msat, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_bolt11_receive_for_hash( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); let description = build_bolt11_invoice_description(&args)?; let expiry_secs = args @@ -219,7 +231,7 @@ pub async fn handle_bolt11_receive_for_hash( let payment_hash = args .get("payment_hash") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: payment_hash")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: payment_hash"))? .to_string(); let response = client @@ -230,19 +242,20 @@ pub async fn handle_bolt11_receive_for_hash( payment_hash, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_bolt11_claim_for_hash( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let payment_hash = args.get("payment_hash").and_then(|v| v.as_str()).map(|s| s.to_string()); let claimable_amount_msat = args.get("claimable_amount_msat").and_then(|v| v.as_u64()); let preimage = args .get("preimage") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: preimage")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: preimage"))? .to_string(); let response = client @@ -252,33 +265,35 @@ pub async fn handle_bolt11_claim_for_hash( preimage, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_bolt11_fail_for_hash( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let payment_hash = args .get("payment_hash") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: payment_hash")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: payment_hash"))? .to_string(); let response = client .bolt11_fail_for_hash(Bolt11FailForHashRequest { payment_hash }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_bolt11_receive_via_jit_channel( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let amount_msat = args .get("amount_msat") .and_then(|v| v.as_u64()) - .ok_or("Missing required parameter: amount_msat")?; + .ok_or_else(|| McpError::invalid_params("Missing required parameter: amount_msat"))?; let description = build_bolt11_invoice_description(&args)?; let expiry_secs = args .get("expiry_secs") @@ -296,13 +311,14 @@ pub async fn handle_bolt11_receive_via_jit_channel( max_total_lsp_fee_limit_msat, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_bolt11_receive_variable_amount_via_jit_channel( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let description = build_bolt11_invoice_description(&args)?; let expiry_secs = args .get("expiry_secs") @@ -321,15 +337,16 @@ pub async fn handle_bolt11_receive_variable_amount_via_jit_channel( }, ) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_bolt11_send(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_bolt11_send(client: &LdkServerClient, args: Value) -> Result { let invoice = args .get("invoice") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: invoice")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: invoice"))? .to_string(); let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); let route_parameters = build_route_parameters(&args); @@ -341,15 +358,18 @@ pub async fn handle_bolt11_send(client: &LdkServerClient, args: Value) -> Result route_parameters: Some(route_parameters), }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_bolt12_receive(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_bolt12_receive( + client: &LdkServerClient, args: Value, +) -> Result { let description = args .get("description") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: description")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: description"))? .to_string(); let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); let expiry_secs = args.get("expiry_secs").and_then(|v| v.as_u64()).map(|v| v as u32); @@ -358,15 +378,16 @@ pub async fn handle_bolt12_receive(client: &LdkServerClient, args: Value) -> Res let response = client .bolt12_receive(Bolt12ReceiveRequest { description, amount_msat, expiry_secs, quantity }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_bolt12_send(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_bolt12_send(client: &LdkServerClient, args: Value) -> Result { let offer = args .get("offer") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: offer")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: offer"))? .to_string(); let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); let quantity = args.get("quantity").and_then(|v| v.as_u64()); @@ -382,21 +403,22 @@ pub async fn handle_bolt12_send(client: &LdkServerClient, args: Value) -> Result route_parameters: Some(route_parameters), }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_spontaneous_send( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let amount_msat = args .get("amount_msat") .and_then(|v| v.as_u64()) - .ok_or("Missing required parameter: amount_msat")?; + .ok_or_else(|| McpError::invalid_params("Missing required parameter: amount_msat"))?; let node_id = args .get("node_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: node_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_id"))? .to_string(); let route_parameters = build_route_parameters(&args); @@ -407,15 +429,16 @@ pub async fn handle_spontaneous_send( route_parameters: Some(route_parameters), }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_unified_send(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_unified_send(client: &LdkServerClient, args: Value) -> Result { let uri = args .get("uri") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: uri")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: uri"))? .to_string(); let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); let route_parameters = build_route_parameters(&args); @@ -427,25 +450,26 @@ pub async fn handle_unified_send(client: &LdkServerClient, args: Value) -> Resul route_parameters: Some(route_parameters), }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_open_channel(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_open_channel(client: &LdkServerClient, args: Value) -> Result { let node_pubkey = args .get("node_pubkey") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: node_pubkey")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_pubkey"))? .to_string(); let address = args .get("address") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: address")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: address"))? .to_string(); - let channel_amount_sats = args - .get("channel_amount_sats") - .and_then(|v| v.as_u64()) - .ok_or("Missing required parameter: channel_amount_sats")?; + let channel_amount_sats = + args.get("channel_amount_sats").and_then(|v| v.as_u64()).ok_or_else(|| { + McpError::invalid_params("Missing required parameter: channel_amount_sats") + })?; let push_to_counterparty_msat = args.get("push_to_counterparty_msat").and_then(|v| v.as_u64()); let announce_channel = args.get("announce_channel").and_then(|v| v.as_bool()).unwrap_or(false); let disable_counterparty_reserve = @@ -463,48 +487,54 @@ pub async fn handle_open_channel(client: &LdkServerClient, args: Value) -> Resul disable_counterparty_reserve, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_splice_in(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_splice_in(client: &LdkServerClient, args: Value) -> Result { let user_channel_id = args .get("user_channel_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: user_channel_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? .to_string(); let counterparty_node_id = args .get("counterparty_node_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: counterparty_node_id")? + .ok_or_else(|| { + McpError::invalid_params("Missing required parameter: counterparty_node_id") + })? .to_string(); - let splice_amount_sats = args - .get("splice_amount_sats") - .and_then(|v| v.as_u64()) - .ok_or("Missing required parameter: splice_amount_sats")?; + let splice_amount_sats = + args.get("splice_amount_sats").and_then(|v| v.as_u64()).ok_or_else(|| { + McpError::invalid_params("Missing required parameter: splice_amount_sats") + })?; let response = client .splice_in(SpliceInRequest { user_channel_id, counterparty_node_id, splice_amount_sats }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_splice_out(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_splice_out(client: &LdkServerClient, args: Value) -> Result { let user_channel_id = args .get("user_channel_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: user_channel_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? .to_string(); let counterparty_node_id = args .get("counterparty_node_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: counterparty_node_id")? + .ok_or_else(|| { + McpError::invalid_params("Missing required parameter: counterparty_node_id") + })? .to_string(); - let splice_amount_sats = args - .get("splice_amount_sats") - .and_then(|v| v.as_u64()) - .ok_or("Missing required parameter: splice_amount_sats")?; + let splice_amount_sats = + args.get("splice_amount_sats").and_then(|v| v.as_u64()).ok_or_else(|| { + McpError::invalid_params("Missing required parameter: splice_amount_sats") + })?; let address = args.get("address").and_then(|v| v.as_str()).map(|s| s.to_string()); let response = client @@ -515,41 +545,49 @@ pub async fn handle_splice_out(client: &LdkServerClient, args: Value) -> Result< splice_amount_sats, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_close_channel(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_close_channel( + client: &LdkServerClient, args: Value, +) -> Result { let user_channel_id = args .get("user_channel_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: user_channel_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? .to_string(); let counterparty_node_id = args .get("counterparty_node_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: counterparty_node_id")? + .ok_or_else(|| { + McpError::invalid_params("Missing required parameter: counterparty_node_id") + })? .to_string(); let response = client .close_channel(CloseChannelRequest { user_channel_id, counterparty_node_id }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_force_close_channel( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let user_channel_id = args .get("user_channel_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: user_channel_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? .to_string(); let counterparty_node_id = args .get("counterparty_node_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: counterparty_node_id")? + .ok_or_else(|| { + McpError::invalid_params("Missing required parameter: counterparty_node_id") + })? .to_string(); let force_close_reason = args.get("force_close_reason").and_then(|v| v.as_str()).map(|s| s.to_string()); @@ -561,28 +599,33 @@ pub async fn handle_force_close_channel( force_close_reason, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_list_channels(client: &LdkServerClient, _args: Value) -> Result { - let response = - client.list_channels(ListChannelsRequest {}).await.map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +pub async fn handle_list_channels( + client: &LdkServerClient, _args: Value, +) -> Result { + let response = client.list_channels(ListChannelsRequest {}).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_update_channel_config( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let user_channel_id = args .get("user_channel_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: user_channel_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? .to_string(); let counterparty_node_id = args .get("counterparty_node_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: counterparty_node_id")? + .ok_or_else(|| { + McpError::invalid_params("Missing required parameter: counterparty_node_id") + })? .to_string(); let channel_config = build_update_channel_config(&args)?; @@ -594,23 +637,24 @@ pub async fn handle_update_channel_config( channel_config: Some(channel_config), }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_list_payments(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_list_payments( + client: &LdkServerClient, args: Value, +) -> Result { let page_token = match args.get("page_token").and_then(|v| v.as_str()) { Some(token_str) => Some(parse_page_token(token_str)?), None => None, }; - let response = client - .list_payments(ListPaymentsRequest { page_token }) - .await - .map_err(|e| e.message.clone())?; + let response = + client.list_payments(ListPaymentsRequest { page_token }).await.map_err(McpError::from)?; let mut result = serde_json::to_value(&response) - .map_err(|e| format!("Failed to serialize response: {e}"))?; + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}")))?; if let Some(ref npt) = response.next_page_token { result @@ -624,23 +668,24 @@ pub async fn handle_list_payments(client: &LdkServerClient, args: Value) -> Resu pub async fn handle_get_payment_details( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let payment_id = args .get("payment_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: payment_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: payment_id"))? .to_string(); let response = client .get_payment_details(GetPaymentDetailsRequest { payment_id }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_list_forwarded_payments( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let page_token = match args.get("page_token").and_then(|v| v.as_str()) { Some(token_str) => Some(parse_page_token(token_str)?), None => None, @@ -649,10 +694,10 @@ pub async fn handle_list_forwarded_payments( let response = client .list_forwarded_payments(ListForwardedPaymentsRequest { page_token }) .await - .map_err(|e| e.message.clone())?; + .map_err(McpError::from)?; let mut result = serde_json::to_value(&response) - .map_err(|e| format!("Failed to serialize response: {e}"))?; + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}")))?; if let Some(ref npt) = response.next_page_token { result @@ -664,104 +709,110 @@ pub async fn handle_list_forwarded_payments( Ok(result) } -pub async fn handle_connect_peer(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_connect_peer(client: &LdkServerClient, args: Value) -> Result { let node_pubkey = args .get("node_pubkey") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: node_pubkey")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_pubkey"))? .to_string(); let address = args .get("address") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: address")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: address"))? .to_string(); let persist = args.get("persist").and_then(|v| v.as_bool()).unwrap_or(false); let response = client .connect_peer(ConnectPeerRequest { node_pubkey, address, persist }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_disconnect_peer( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let node_pubkey = args .get("node_pubkey") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: node_pubkey")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_pubkey"))? .to_string(); let response = client .disconnect_peer(DisconnectPeerRequest { node_pubkey }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_list_peers(client: &LdkServerClient, _args: Value) -> Result { - let response = client.list_peers(ListPeersRequest {}).await.map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +pub async fn handle_list_peers(client: &LdkServerClient, _args: Value) -> Result { + let response = client.list_peers(ListPeersRequest {}).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_decode_invoice(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_decode_invoice( + client: &LdkServerClient, args: Value, +) -> Result { let invoice = args .get("invoice") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: invoice")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: invoice"))? .to_string(); - let response = client - .decode_invoice(DecodeInvoiceRequest { invoice }) - .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + let response = + client.decode_invoice(DecodeInvoiceRequest { invoice }).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_decode_offer(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_decode_offer(client: &LdkServerClient, args: Value) -> Result { let offer = args .get("offer") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: offer")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: offer"))? .to_string(); let response = - client.decode_offer(DecodeOfferRequest { offer }).await.map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + client.decode_offer(DecodeOfferRequest { offer }).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_sign_message(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_sign_message(client: &LdkServerClient, args: Value) -> Result { let message = args .get("message") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: message")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: message"))? .to_string(); let response = client .sign_message(SignMessageRequest { message: message.into_bytes().into() }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_verify_signature( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let message = args .get("message") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: message")? + .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("Missing required parameter: signature")? + .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("Missing required parameter: public_key")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: public_key"))? .to_string(); let response = client @@ -771,64 +822,67 @@ pub async fn handle_verify_signature( public_key, }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_export_pathfinding_scores( client: &LdkServerClient, _args: Value, -) -> Result { +) -> Result { let response = client .export_pathfinding_scores(ExportPathfindingScoresRequest {}) .await - .map_err(|e| e.message.clone())?; + .map_err(McpError::from)?; let scores_hex = response.scores.to_lower_hex_string(); Ok(json!({ "pathfinding_scores": scores_hex })) } pub async fn handle_graph_list_channels( client: &LdkServerClient, _args: Value, -) -> Result { - let response = client - .graph_list_channels(GraphListChannelsRequest {}) - .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) +) -> Result { + let response = + client.graph_list_channels(GraphListChannelsRequest {}).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_graph_get_channel( client: &LdkServerClient, args: Value, -) -> Result { +) -> Result { let short_channel_id = args .get("short_channel_id") .and_then(|v| v.as_u64()) - .ok_or("Missing required parameter: short_channel_id")?; + .ok_or_else(|| McpError::invalid_params("Missing required parameter: short_channel_id"))?; let response = client .graph_get_channel(GraphGetChannelRequest { short_channel_id }) .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } pub async fn handle_graph_list_nodes( client: &LdkServerClient, _args: Value, -) -> Result { +) -> Result { let response = - client.graph_list_nodes(GraphListNodesRequest {}).await.map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + client.graph_list_nodes(GraphListNodesRequest {}).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } -pub async fn handle_graph_get_node(client: &LdkServerClient, args: Value) -> Result { +pub async fn handle_graph_get_node( + client: &LdkServerClient, args: Value, +) -> Result { let node_id = args .get("node_id") .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: node_id")? + .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_id"))? .to_string(); - let response = client - .graph_get_node(GraphGetNodeRequest { node_id }) - .await - .map_err(|e| e.message.clone())?; - serde_json::to_value(response).map_err(|e| format!("Failed to serialize response: {e}")) + let response = + client.graph_get_node(GraphGetNodeRequest { node_id }).await.map_err(McpError::from)?; + serde_json::to_value(response) + .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) } diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs index bc280ecc..f689d3d9 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -18,11 +18,13 @@ 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>>; +) + -> Pin> + Send + 'a>>; pub struct ToolRegistry { definitions: Vec, @@ -60,7 +62,7 @@ impl ToolRegistry { .unwrap_or_else(|e| format!("Failed to serialize response: {e}")); ToolCallResult::success(text) }, - Err(e) => ToolCallResult::error(e), + Err(e) => ToolCallResult::error(format!("{}: {}", e.category(), e.message)), } } } From 082745bc53bc98ecbb32aee65fd49a0532dcf847 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 14:37:22 +0200 Subject: [PATCH 12/21] f - Add ldk-server-mcp to the workspace Use the proto-generated `Deserialize` impls to parse each tool's arguments directly into the typed request struct, dropping the per-field `serde_json::Value` scaffolding and the handcrafted helpers for `Bolt11InvoiceDescription`, `ChannelConfig`, `RouteParametersConfig` and page tokens. `expiry_secs` still gets the MCP-side 24h default when omitted; `sign_message` and `verify_signature` keep a manual arg path since their proto `bytes` field cannot be deserialized from a JSON string. Tool JSON schemas are updated to describe the now-nested shapes for invoice descriptions, route parameters, channel configs, and page tokens. Generated with the assistance of AI (Claude). --- ldk-server-mcp/src/tools/handlers.rs | 776 ++++----------------------- ldk-server-mcp/src/tools/schema.rs | 357 ++++++------ 2 files changed, 273 insertions(+), 860 deletions(-) diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index 84835ccc..3821ecdb 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -21,150 +21,33 @@ use ldk_server_client::ldk_server_grpc::api::{ OnchainSendRequest, OpenChannelRequest, SignMessageRequest, SpliceInRequest, SpliceOutRequest, SpontaneousSendRequest, UnifiedSendRequest, UpdateChannelConfigRequest, VerifySignatureRequest, }; -use ldk_server_client::ldk_server_grpc::types::{ - bolt11_invoice_description, channel_config, 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 ldk_server_client::DEFAULT_EXPIRY_SECS; +use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; use crate::protocol::McpError; -fn parse_page_token(token_str: &str) -> Result { - let parts: Vec<&str> = token_str.split(':').collect(); - if parts.len() != 2 { - return Err(McpError::invalid_params("Page token must be in format 'token:index'")); - } - let index = parts[1] - .parse::() - .map_err(|_| McpError::invalid_params("Invalid page token index"))?; - Ok(PageToken { token: parts[0].to_string(), index }) -} - -fn format_page_token(pt: &PageToken) -> String { - format!("{}:{}", pt.token, pt.index) -} - -fn build_route_parameters(args: &Value) -> RouteParametersConfig { - RouteParametersConfig { - max_total_routing_fee_msat: args.get("max_total_routing_fee_msat").and_then(|v| v.as_u64()), - max_total_cltv_expiry_delta: args - .get("max_total_cltv_expiry_delta") - .and_then(|v| v.as_u64()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA), - max_path_count: args - .get("max_path_count") - .and_then(|v| v.as_u64()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_MAX_PATH_COUNT), - max_channel_saturation_power_of_half: args - .get("max_channel_saturation_power_of_half") - .and_then(|v| v.as_u64()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF), - } +fn parse_request(args: Value) -> Result { + serde_json::from_value(args).map_err(|e| McpError::invalid_params(e.to_string())) } -fn build_channel_config(args: &Value) -> Result, McpError> { - let forwarding_fee_proportional_millionths = args - .get("forwarding_fee_proportional_millionths") - .and_then(|v| v.as_u64()) - .map(|v| v as u32); - let forwarding_fee_base_msat = - args.get("forwarding_fee_base_msat").and_then(|v| v.as_u64()).map(|v| v as u32); - let cltv_expiry_delta = - args.get("cltv_expiry_delta").and_then(|v| v.as_u64()).map(|v| v as u32); - let force_close_avoidance_max_fee_satoshis = - args.get("force_close_avoidance_max_fee_satoshis").and_then(|v| v.as_u64()); - let accept_underpaying_htlcs = args.get("accept_underpaying_htlcs").and_then(|v| v.as_bool()); - let max_dust_htlc_exposure = match ( - args.get("max_dust_htlc_exposure_fixed_limit_msat").and_then(|v| v.as_u64()), - args.get("max_dust_htlc_exposure_fee_rate_multiplier").and_then(|v| v.as_u64()), - ) { - (Some(_), Some(_)) => { - return Err(McpError::invalid_params( - "Only one of max_dust_htlc_exposure_fixed_limit_msat or max_dust_htlc_exposure_fee_rate_multiplier can be set", - )) - }, - (Some(limit_msat), None) => { - Some(channel_config::MaxDustHtlcExposure::FixedLimitMsat(limit_msat)) - }, - (None, Some(multiplier)) => { - Some(channel_config::MaxDustHtlcExposure::FeeRateMultiplier(multiplier)) - }, - (None, None) => None, - }; - - if forwarding_fee_proportional_millionths.is_none() - && forwarding_fee_base_msat.is_none() - && cltv_expiry_delta.is_none() - && force_close_avoidance_max_fee_satoshis.is_none() - && accept_underpaying_htlcs.is_none() - && max_dust_htlc_exposure.is_none() - { - return Ok(None); - } - - Ok(Some(ChannelConfig { - forwarding_fee_proportional_millionths, - forwarding_fee_base_msat, - cltv_expiry_delta, - force_close_avoidance_max_fee_satoshis, - accept_underpaying_htlcs, - max_dust_htlc_exposure, - })) -} - -fn build_update_channel_config(args: &Value) -> Result { - Ok(build_channel_config(args)?.unwrap_or(ChannelConfig { - forwarding_fee_proportional_millionths: None, - forwarding_fee_base_msat: None, - cltv_expiry_delta: None, - force_close_avoidance_max_fee_satoshis: None, - accept_underpaying_htlcs: None, - max_dust_htlc_exposure: None, - })) -} - -fn build_bolt11_invoice_description( - args: &Value, -) -> Result, McpError> { - let description_str = args.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let description_hash = - args.get("description_hash").and_then(|v| v.as_str()).map(|s| s.to_string()); - - match (description_str, description_hash) { - (Some(desc), None) => Ok(Some(Bolt11InvoiceDescription { - kind: Some(bolt11_invoice_description::Kind::Direct(desc)), - })), - (None, Some(hash)) => Ok(Some(Bolt11InvoiceDescription { - kind: Some(bolt11_invoice_description::Kind::Hash(hash)), - })), - (Some(_), Some(_)) => { - Err(McpError::invalid_params("Only one of description or description_hash can be set")) - }, - (None, None) => Ok(None), - } +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)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + serialize_response(response) } pub async fn handle_onchain_receive( @@ -172,628 +55,230 @@ pub async fn handle_onchain_receive( ) -> Result { let response = client.onchain_receive(OnchainReceiveRequest {}).await.map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + serialize_response(response) } pub async fn handle_onchain_send(client: &LdkServerClient, args: Value) -> Result { - let address = args - .get("address") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: address"))? - .to_string(); - let amount_sats = args.get("amount_sats").and_then(|v| v.as_u64()); - let send_all = args.get("send_all").and_then(|v| v.as_bool()); - let fee_rate_sat_per_vb = args.get("fee_rate_sat_per_vb").and_then(|v| v.as_u64()); - - let response = client - .onchain_send(OnchainSendRequest { address, amount_sats, send_all, fee_rate_sat_per_vb }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); - let invoice_description = build_bolt11_invoice_description(&args)?; - - let expiry_secs = args - .get("expiry_secs") - .and_then(|v| v.as_u64()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_EXPIRY_SECS); - - let response = client - .bolt11_receive(Bolt11ReceiveRequest { - description: invoice_description, - expiry_secs, - amount_msat, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); - let description = build_bolt11_invoice_description(&args)?; - let expiry_secs = args - .get("expiry_secs") - .and_then(|v| v.as_u64()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_EXPIRY_SECS); - let payment_hash = args - .get("payment_hash") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: payment_hash"))? - .to_string(); - - let response = client - .bolt11_receive_for_hash(Bolt11ReceiveForHashRequest { - amount_msat, - description, - expiry_secs, - payment_hash, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 payment_hash = args.get("payment_hash").and_then(|v| v.as_str()).map(|s| s.to_string()); - let claimable_amount_msat = args.get("claimable_amount_msat").and_then(|v| v.as_u64()); - let preimage = args - .get("preimage") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: preimage"))? - .to_string(); - - let response = client - .bolt11_claim_for_hash(Bolt11ClaimForHashRequest { - payment_hash, - claimable_amount_msat, - preimage, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 payment_hash = args - .get("payment_hash") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: payment_hash"))? - .to_string(); - - let response = client - .bolt11_fail_for_hash(Bolt11FailForHashRequest { payment_hash }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 amount_msat = args - .get("amount_msat") - .and_then(|v| v.as_u64()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: amount_msat"))?; - let description = build_bolt11_invoice_description(&args)?; - let expiry_secs = args - .get("expiry_secs") - .and_then(|v| v.as_u64()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_EXPIRY_SECS); - let max_total_lsp_fee_limit_msat = - args.get("max_total_lsp_fee_limit_msat").and_then(|v| v.as_u64()); - - let response = client - .bolt11_receive_via_jit_channel(Bolt11ReceiveViaJitChannelRequest { - amount_msat, - description, - expiry_secs, - max_total_lsp_fee_limit_msat, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 description = build_bolt11_invoice_description(&args)?; - let expiry_secs = args - .get("expiry_secs") - .and_then(|v| v.as_u64()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_EXPIRY_SECS); - let max_proportional_lsp_fee_limit_ppm_msat = - args.get("max_proportional_lsp_fee_limit_ppm_msat").and_then(|v| v.as_u64()); - + let 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( - Bolt11ReceiveVariableAmountViaJitChannelRequest { - description, - expiry_secs, - max_proportional_lsp_fee_limit_ppm_msat, - }, - ) + .bolt11_receive_variable_amount_via_jit_channel(request) .await .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + serialize_response(response) } pub async fn handle_bolt11_send(client: &LdkServerClient, args: Value) -> Result { - let invoice = args - .get("invoice") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: invoice"))? - .to_string(); - let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); - let route_parameters = build_route_parameters(&args); - - let response = client - .bolt11_send(Bolt11SendRequest { - invoice, - amount_msat, - route_parameters: Some(route_parameters), - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 description = args - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: description"))? - .to_string(); - let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); - let expiry_secs = args.get("expiry_secs").and_then(|v| v.as_u64()).map(|v| v as u32); - let quantity = args.get("quantity").and_then(|v| v.as_u64()); - - let response = client - .bolt12_receive(Bolt12ReceiveRequest { description, amount_msat, expiry_secs, quantity }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 offer = args - .get("offer") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: offer"))? - .to_string(); - let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); - let quantity = args.get("quantity").and_then(|v| v.as_u64()); - let payer_note = args.get("payer_note").and_then(|v| v.as_str()).map(|s| s.to_string()); - let route_parameters = build_route_parameters(&args); - - let response = client - .bolt12_send(Bolt12SendRequest { - offer, - amount_msat, - quantity, - payer_note, - route_parameters: Some(route_parameters), - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 amount_msat = args - .get("amount_msat") - .and_then(|v| v.as_u64()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: amount_msat"))?; - let node_id = args - .get("node_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_id"))? - .to_string(); - let route_parameters = build_route_parameters(&args); - - let response = client - .spontaneous_send(SpontaneousSendRequest { - amount_msat, - node_id, - route_parameters: Some(route_parameters), - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 uri = args - .get("uri") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: uri"))? - .to_string(); - let amount_msat = args.get("amount_msat").and_then(|v| v.as_u64()); - let route_parameters = build_route_parameters(&args); - - let response = client - .unified_send(UnifiedSendRequest { - uri, - amount_msat, - route_parameters: Some(route_parameters), - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 node_pubkey = args - .get("node_pubkey") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_pubkey"))? - .to_string(); - let address = args - .get("address") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: address"))? - .to_string(); - let channel_amount_sats = - args.get("channel_amount_sats").and_then(|v| v.as_u64()).ok_or_else(|| { - McpError::invalid_params("Missing required parameter: channel_amount_sats") - })?; - let push_to_counterparty_msat = args.get("push_to_counterparty_msat").and_then(|v| v.as_u64()); - let announce_channel = args.get("announce_channel").and_then(|v| v.as_bool()).unwrap_or(false); - let disable_counterparty_reserve = - args.get("disable_counterparty_reserve").and_then(|v| v.as_bool()).unwrap_or(false); - let channel_config = build_channel_config(&args)?; - - let response = client - .open_channel(OpenChannelRequest { - node_pubkey, - address, - channel_amount_sats, - push_to_counterparty_msat, - channel_config, - announce_channel, - disable_counterparty_reserve, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 user_channel_id = args - .get("user_channel_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? - .to_string(); - let counterparty_node_id = args - .get("counterparty_node_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - McpError::invalid_params("Missing required parameter: counterparty_node_id") - })? - .to_string(); - let splice_amount_sats = - args.get("splice_amount_sats").and_then(|v| v.as_u64()).ok_or_else(|| { - McpError::invalid_params("Missing required parameter: splice_amount_sats") - })?; - - let response = client - .splice_in(SpliceInRequest { user_channel_id, counterparty_node_id, splice_amount_sats }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 user_channel_id = args - .get("user_channel_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? - .to_string(); - let counterparty_node_id = args - .get("counterparty_node_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - McpError::invalid_params("Missing required parameter: counterparty_node_id") - })? - .to_string(); - let splice_amount_sats = - args.get("splice_amount_sats").and_then(|v| v.as_u64()).ok_or_else(|| { - McpError::invalid_params("Missing required parameter: splice_amount_sats") - })?; - let address = args.get("address").and_then(|v| v.as_str()).map(|s| s.to_string()); - - let response = client - .splice_out(SpliceOutRequest { - user_channel_id, - counterparty_node_id, - address, - splice_amount_sats, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 user_channel_id = args - .get("user_channel_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? - .to_string(); - let counterparty_node_id = args - .get("counterparty_node_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - McpError::invalid_params("Missing required parameter: counterparty_node_id") - })? - .to_string(); - - let response = client - .close_channel(CloseChannelRequest { user_channel_id, counterparty_node_id }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 user_channel_id = args - .get("user_channel_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? - .to_string(); - let counterparty_node_id = args - .get("counterparty_node_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - McpError::invalid_params("Missing required parameter: counterparty_node_id") - })? - .to_string(); - let force_close_reason = - args.get("force_close_reason").and_then(|v| v.as_str()).map(|s| s.to_string()); - - let response = client - .force_close_channel(ForceCloseChannelRequest { - user_channel_id, - counterparty_node_id, - force_close_reason, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + serialize_response(response) } pub async fn handle_update_channel_config( client: &LdkServerClient, args: Value, ) -> Result { - let user_channel_id = args - .get("user_channel_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: user_channel_id"))? - .to_string(); - let counterparty_node_id = args - .get("counterparty_node_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - McpError::invalid_params("Missing required parameter: counterparty_node_id") - })? - .to_string(); - - let channel_config = build_update_channel_config(&args)?; - - let response = client - .update_channel_config(UpdateChannelConfigRequest { - user_channel_id, - counterparty_node_id, - channel_config: Some(channel_config), - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 page_token = match args.get("page_token").and_then(|v| v.as_str()) { - Some(token_str) => Some(parse_page_token(token_str)?), - None => None, - }; - - let response = - client.list_payments(ListPaymentsRequest { page_token }).await.map_err(McpError::from)?; - - let mut result = serde_json::to_value(&response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}")))?; - - if let Some(ref npt) = response.next_page_token { - result - .as_object_mut() - .unwrap() - .insert("next_page_token".to_string(), json!(format_page_token(npt))); - } - - Ok(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 payment_id = args - .get("payment_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: payment_id"))? - .to_string(); - - let response = client - .get_payment_details(GetPaymentDetailsRequest { payment_id }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 page_token = match args.get("page_token").and_then(|v| v.as_str()) { - Some(token_str) => Some(parse_page_token(token_str)?), - None => None, - }; - - let response = client - .list_forwarded_payments(ListForwardedPaymentsRequest { page_token }) - .await - .map_err(McpError::from)?; - - let mut result = serde_json::to_value(&response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}")))?; - - if let Some(ref npt) = response.next_page_token { - result - .as_object_mut() - .unwrap() - .insert("next_page_token".to_string(), json!(format_page_token(npt))); - } - - Ok(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 node_pubkey = args - .get("node_pubkey") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_pubkey"))? - .to_string(); - let address = args - .get("address") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: address"))? - .to_string(); - let persist = args.get("persist").and_then(|v| v.as_bool()).unwrap_or(false); - - let response = client - .connect_peer(ConnectPeerRequest { node_pubkey, address, persist }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 node_pubkey = args - .get("node_pubkey") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_pubkey"))? - .to_string(); - - let response = client - .disconnect_peer(DisconnectPeerRequest { node_pubkey }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + serialize_response(response) } pub async fn handle_decode_invoice( client: &LdkServerClient, args: Value, ) -> Result { - let invoice = args - .get("invoice") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: invoice"))? - .to_string(); - - let response = - client.decode_invoice(DecodeInvoiceRequest { invoice }).await.map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 offer = args - .get("offer") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: offer"))? - .to_string(); - - let response = - client.decode_offer(DecodeOfferRequest { offer }).await.map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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 response = client - .sign_message(SignMessageRequest { message: message.into_bytes().into() }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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( @@ -814,17 +299,10 @@ pub async fn handle_verify_signature( .and_then(|v| v.as_str()) .ok_or_else(|| McpError::invalid_params("Missing required parameter: public_key"))? .to_string(); - - let response = client - .verify_signature(VerifySignatureRequest { - message: message.into_bytes().into(), - signature, - public_key, - }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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( @@ -834,8 +312,7 @@ pub async fn handle_export_pathfinding_scores( .export_pathfinding_scores(ExportPathfindingScoresRequest {}) .await .map_err(McpError::from)?; - let scores_hex = response.scores.to_lower_hex_string(); - Ok(json!({ "pathfinding_scores": scores_hex })) + Ok(json!({ "pathfinding_scores": response.scores.to_lower_hex_string() })) } pub async fn handle_graph_list_channels( @@ -843,24 +320,15 @@ pub async fn handle_graph_list_channels( ) -> Result { let response = client.graph_list_channels(GraphListChannelsRequest {}).await.map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + serialize_response(response) } pub async fn handle_graph_get_channel( client: &LdkServerClient, args: Value, ) -> Result { - let short_channel_id = args - .get("short_channel_id") - .and_then(|v| v.as_u64()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: short_channel_id"))?; - - let response = client - .graph_get_channel(GraphGetChannelRequest { short_channel_id }) - .await - .map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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( @@ -868,21 +336,13 @@ pub async fn handle_graph_list_nodes( ) -> Result { let response = client.graph_list_nodes(GraphListNodesRequest {}).await.map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + serialize_response(response) } pub async fn handle_graph_get_node( client: &LdkServerClient, args: Value, ) -> Result { - let node_id = args - .get("node_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError::invalid_params("Missing required parameter: node_id"))? - .to_string(); - - let response = - client.graph_get_node(GraphGetNodeRequest { node_id }).await.map_err(McpError::from)?; - serde_json::to_value(response) - .map_err(|e| McpError::internal(format!("Failed to serialize response: {e}"))) + 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/schema.rs b/ldk-server-mcp/src/tools/schema.rs index 9905f65c..b9c45144 100644 --- a/ldk-server-mcp/src/tools/schema.rs +++ b/ldk-server-mcp/src/tools/schema.rs @@ -9,30 +9,151 @@ use serde_json::{json, Value}; -pub fn get_node_info_schema() -> 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", - "properties": {}, - "required": [] + "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 + } + ] + } + } }) } -pub fn get_balances_schema() -> Value { +// Shared fragment: `RouteParametersConfig` mirrors the proto shape. +fn route_parameters_config_schema() -> Value { json!({ "type": "object", - "properties": {}, - "required": [] + "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)" + } + } }) } -pub fn onchain_receive_schema() -> Value { +// Shared fragment: `ChannelConfig` mirrors the proto shape. +fn channel_config_schema() -> Value { json!({ "type": "object", - "properties": {}, - "required": [] + "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", @@ -66,17 +187,10 @@ pub fn bolt11_receive_schema() -> Value { "type": "integer", "description": "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" }, - "description": { - "type": "string", - "description": "Description to attach to the invoice. Mutually exclusive with description_hash" - }, - "description_hash": { - "type": "string", - "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" - }, + "description": bolt11_invoice_description_schema(), "expiry_secs": { "type": "integer", - "description": "Invoice expiry time in seconds (default: 86400)" + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" } }, "required": [] @@ -91,17 +205,10 @@ pub fn bolt11_receive_for_hash_schema() -> Value { "type": "integer", "description": "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" }, - "description": { - "type": "string", - "description": "Description to attach to the invoice. Mutually exclusive with description_hash" - }, - "description_hash": { - "type": "string", - "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" - }, + "description": bolt11_invoice_description_schema(), "expiry_secs": { "type": "integer", - "description": "Invoice expiry time in seconds (default: 86400)" + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" }, "payment_hash": { "type": "string", @@ -118,7 +225,7 @@ pub fn bolt11_claim_for_hash_schema() -> Value { "properties": { "payment_hash": { "type": "string", - "description": "The hex-encoded 32-byte payment hash. If provided, it will be used to verify that the preimage matches" + "description": "The hex-encoded 32-byte payment hash. If provided, verifies that the preimage matches" }, "claimable_amount_msat": { "type": "integer", @@ -154,17 +261,10 @@ pub fn bolt11_receive_via_jit_channel_schema() -> Value { "type": "integer", "description": "The amount in millisatoshis to request" }, - "description": { - "type": "string", - "description": "Description to attach to the invoice. Mutually exclusive with description_hash" - }, - "description_hash": { - "type": "string", - "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" - }, + "description": bolt11_invoice_description_schema(), "expiry_secs": { "type": "integer", - "description": "Invoice expiry time in seconds (default: 86400)" + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" }, "max_total_lsp_fee_limit_msat": { "type": "integer", @@ -179,17 +279,10 @@ pub fn bolt11_receive_variable_amount_via_jit_channel_schema() -> Value { json!({ "type": "object", "properties": { - "description": { - "type": "string", - "description": "Description to attach to the invoice. Mutually exclusive with description_hash" - }, - "description_hash": { - "type": "string", - "description": "SHA-256 hash of the description (hex). Use instead of description for longer text. Mutually exclusive with description" - }, + "description": bolt11_invoice_description_schema(), "expiry_secs": { "type": "integer", - "description": "Invoice expiry time in seconds (default: 86400)" + "description": "Invoice expiry time in seconds (defaults to 86400 if omitted or 0)" }, "max_proportional_lsp_fee_limit_ppm_msat": { "type": "integer", @@ -212,22 +305,7 @@ pub fn bolt11_send_schema() -> Value { "type": "integer", "description": "Amount in millisatoshis. Required when paying a zero-amount invoice" }, - "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)" - } + "route_parameters": route_parameters_config_schema() }, "required": ["invoice"] }) @@ -278,22 +356,7 @@ pub fn bolt12_send_schema() -> Value { "type": "string", "description": "Note to include for the payee. Reflected back in the invoice" }, - "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)" - } + "route_parameters": route_parameters_config_schema() }, "required": ["offer"] }) @@ -311,22 +374,7 @@ pub fn spontaneous_send_schema() -> Value { "type": "string", "description": "The hex-encoded public key of the destination node" }, - "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)" - } + "route_parameters": route_parameters_config_schema() }, "required": ["amount_msat", "node_id"] }) @@ -344,22 +392,7 @@ pub fn unified_send_schema() -> Value { "type": "integer", "description": "The amount in millisatoshis to send. Required for zero-amount or variable-amount URIs" }, - "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)" - } + "route_parameters": route_parameters_config_schema() }, "required": ["uri"] }) @@ -389,38 +422,11 @@ pub fn open_channel_schema() -> Value { "type": "boolean", "description": "Whether the channel should be public (default: false)" }, - "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_fixed_limit_msat": { - "type": "integer", - "description": "Sets a fixed limit on the total dust exposure in millisatoshis. Mutually exclusive with max_dust_htlc_exposure_fee_rate_multiplier" - }, - "max_dust_htlc_exposure_fee_rate_multiplier": { - "type": "integer", - "description": "Sets a multiplier on the on-chain sweep feerate to determine the maximum allowed dust exposure. Mutually exclusive with max_dust_htlc_exposure_fixed_limit_msat" - }, "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"] }) @@ -511,11 +517,7 @@ pub fn force_close_channel_schema() -> Value { } pub fn list_channels_schema() -> Value { - json!({ - "type": "object", - "properties": {}, - "required": [] - }) + json!({ "type": "object", "properties": {}, "required": [] }) } pub fn update_channel_config_schema() -> Value { @@ -530,34 +532,7 @@ pub fn update_channel_config_schema() -> Value { "type": "string", "description": "The hex-encoded public key of the counterparty node" }, - "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_fixed_limit_msat": { - "type": "integer", - "description": "Sets a fixed limit on the total dust exposure in millisatoshis. Mutually exclusive with max_dust_htlc_exposure_fee_rate_multiplier" - }, - "max_dust_htlc_exposure_fee_rate_multiplier": { - "type": "integer", - "description": "Sets a multiplier on the on-chain sweep feerate to determine the maximum allowed dust exposure. Mutually exclusive with max_dust_htlc_exposure_fixed_limit_msat" - } + "channel_config": channel_config_schema() }, "required": ["user_channel_id", "counterparty_node_id"] }) @@ -567,10 +542,7 @@ pub fn list_payments_schema() -> Value { json!({ "type": "object", "properties": { - "page_token": { - "type": "string", - "description": "Pagination token from a previous response (format: token:index)" - } + "page_token": page_token_schema() }, "required": [] }) @@ -593,10 +565,7 @@ pub fn list_forwarded_payments_schema() -> Value { json!({ "type": "object", "properties": { - "page_token": { - "type": "string", - "description": "Pagination token from a previous response (format: token:index)" - } + "page_token": page_token_schema() }, "required": [] }) @@ -637,11 +606,7 @@ pub fn disconnect_peer_schema() -> Value { } pub fn list_peers_schema() -> Value { - json!({ - "type": "object", - "properties": {}, - "required": [] - }) + json!({ "type": "object", "properties": {}, "required": [] }) } pub fn decode_invoice_schema() -> Value { @@ -676,7 +641,7 @@ pub fn sign_message_schema() -> Value { "properties": { "message": { "type": "string", - "description": "The message to sign" + "description": "The message to sign (will be sent as UTF-8 bytes)" } }, "required": ["message"] @@ -689,7 +654,7 @@ pub fn verify_signature_schema() -> Value { "properties": { "message": { "type": "string", - "description": "The message that was signed" + "description": "The message that was signed (sent as UTF-8 bytes)" }, "signature": { "type": "string", @@ -705,19 +670,11 @@ pub fn verify_signature_schema() -> Value { } pub fn export_pathfinding_scores_schema() -> Value { - json!({ - "type": "object", - "properties": {}, - "required": [] - }) + json!({ "type": "object", "properties": {}, "required": [] }) } pub fn graph_list_channels_schema() -> Value { - json!({ - "type": "object", - "properties": {}, - "required": [] - }) + json!({ "type": "object", "properties": {}, "required": [] }) } pub fn graph_get_channel_schema() -> Value { @@ -734,11 +691,7 @@ pub fn graph_get_channel_schema() -> Value { } pub fn graph_list_nodes_schema() -> Value { - json!({ - "type": "object", - "properties": {}, - "required": [] - }) + json!({ "type": "object", "properties": {}, "required": [] }) } pub fn graph_get_node_schema() -> Value { From 73f8c663e9e440dc69be2ce0c4aa4123f9ebf04b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 27 Apr 2026 15:38:49 +0200 Subject: [PATCH 13/21] f - Add ldk-server-mcp to the workspace --- ldk-server-mcp/src/tools/handlers.rs | 42 ++++++++++++---------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index 3821ecdb..165a2e63 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -22,7 +22,7 @@ use ldk_server_client::ldk_server_grpc::api::{ SpontaneousSendRequest, UnifiedSendRequest, UpdateChannelConfigRequest, VerifySignatureRequest, }; use ldk_server_client::DEFAULT_EXPIRY_SECS; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{json, Value}; use crate::protocol::McpError; @@ -267,38 +267,32 @@ pub async fn handle_decode_offer(client: &LdkServerClient, args: Value) -> Resul 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. +// The proto `message` field is `bytes`, whose Deserialize impl expects a numeric array, but MCP +// clients naturally pass a UTF-8 string. We deserialize into a local args struct first and then +// build the proto request from it. +#[derive(Deserialize)] +struct SignMessageArgs { + message: String, +} + 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 SignMessageArgs { message } = parse_request(args)?; let request = SignMessageRequest { message: message.into_bytes().into() }; let response = client.sign_message(request).await.map_err(McpError::from)?; serialize_response(response) } +#[derive(Deserialize)] +struct VerifySignatureArgs { + message: String, + signature: String, + public_key: String, +} + 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 VerifySignatureArgs { message, signature, public_key } = parse_request(args)?; let request = VerifySignatureRequest { message: message.into_bytes().into(), signature, public_key }; let response = client.verify_signature(request).await.map_err(McpError::from)?; From cfad53fc420b1c457297de5e41146f1bc9a5f04f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Apr 2026 11:29:09 +0200 Subject: [PATCH 14/21] Improve mcp test coverage in workspace Use the workspace-built MCP binary directly in tests so regressions are caught without requiring a live server and without recompiling from inside each test. Generated with the assistance of AI (Claude). --- ldk-server-mcp/tests/integration.rs | 33 ++++------------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/ldk-server-mcp/tests/integration.rs b/ldk-server-mcp/tests/integration.rs index 5adaffba..98a2a1bf 100644 --- a/ldk-server-mcp/tests/integration.rs +++ b/ldk-server-mcp/tests/integration.rs @@ -8,7 +8,6 @@ // licenses. use std::io::{BufRead, BufReader, Write}; -use std::process::{Command, Stdio}; use serde_json::{json, Value}; @@ -61,29 +60,6 @@ fn test_cert_path() -> String { .to_string() } -fn cargo_bin_path() -> String { - let output = Command::new("cargo") - .args(["build", "--message-format=json"]) - .stderr(Stdio::piped()) - .stdout(Stdio::piped()) - .output() - .expect("Failed to build binary"); - - let stdout = String::from_utf8(output.stdout).unwrap(); - for line in stdout.lines() { - if let Ok(msg) = serde_json::from_str::(line) { - if msg.get("reason").and_then(|r| r.as_str()) == Some("compiler-artifact") - && msg.get("target").and_then(|t| t.get("name")).and_then(|n| n.as_str()) - == Some("ldk-server-mcp") - && msg.get("executable").and_then(|e| e.as_str()).is_some() - { - return msg["executable"].as_str().unwrap().to_string(); - } - } - } - panic!("Could not find compiled binary path"); -} - struct McpProcess { child: std::process::Child, stdin: std::process::ChildStdin, @@ -92,14 +68,13 @@ struct McpProcess { impl McpProcess { fn spawn() -> Self { - let bin = cargo_bin_path(); - let mut child = Command::new(&bin) + 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(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) .spawn() .expect("Failed to spawn MCP process"); From 6758876afa3a01196e73d59c88afe064482c7f8f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Apr 2026 11:32:22 +0200 Subject: [PATCH 15/21] Add end-to-end sanity checks for ldk-server-mcp Build the MCP binary in the e2e harness and exercise its stdio protocol against a live ldk-server so basic MCP functionality is verified without involving an agent. Co-Authored-By: HAL 9000 --- e2e-tests/build.rs | 15 +++++++- e2e-tests/src/lib.rs | 66 +++++++++++++++++++++++++++++++- e2e-tests/tests/mcp.rs | 87 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 e2e-tests/tests/mcp.rs diff --git a/e2e-tests/build.rs b/e2e-tests/build.rs index b8841d3d..e2344890 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,7 @@ 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/tests"); + 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..4239c0c3 --- /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": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "e2e-test", "version": "0.1"} + }), + ); + assert_eq!(initialize["result"]["protocolVersion"], "2024-11-05"); + assert!(initialize["result"]["capabilities"]["tools"].is_object()); + + let tools = mcp.call(2, "tools/list", json!({})); + let tool_names = tools["result"]["tools"].as_array().unwrap(); + assert!(tool_names.iter().any(|tool| tool["name"] == "get_node_info")); + assert!(tool_names.iter().any(|tool| tool["name"] == "onchain_receive")); + assert!(tool_names.iter().any(|tool| tool["name"] == "decode_invoice")); +} + +#[tokio::test] +async fn test_mcp_live_tool_calls() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let mut mcp = McpHandle::start(&server); + + let node_info = mcp.call(1, "tools/call", json!({ + "name": "get_node_info", + "arguments": {} + })); + let node_info_text = node_info["result"]["content"][0]["text"].as_str().unwrap(); + let node_info_json: serde_json::Value = serde_json::from_str(node_info_text).unwrap(); + assert_eq!(node_info_json["node_id"], server.node_id()); + + let onchain_receive = mcp.call(2, "tools/call", json!({ + "name": "onchain_receive", + "arguments": {} + })); + let onchain_receive_text = onchain_receive["result"]["content"][0]["text"].as_str().unwrap(); + let onchain_receive_json: serde_json::Value = + serde_json::from_str(onchain_receive_text).unwrap(); + assert!(onchain_receive_json["address"].as_str().unwrap().starts_with("bcrt1")); + + let invoice = server + .client() + .bolt11_receive(Bolt11ReceiveRequest { + amount_msat: Some(50_000_000), + description: Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Direct("mcp decode".to_string())), + }), + expiry_secs: 3600, + }) + .await + .unwrap(); + + let decode_invoice = mcp.call(3, "tools/call", json!({ + "name": "decode_invoice", + "arguments": { "invoice": invoice.invoice } + })); + let decode_invoice_text = decode_invoice["result"]["content"][0]["text"].as_str().unwrap(); + let decode_invoice_json: serde_json::Value = + serde_json::from_str(decode_invoice_text).unwrap(); + assert_eq!(decode_invoice_json["destination"], server.node_id()); + assert_eq!(decode_invoice_json["description"], "mcp decode"); + assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64); +} From d94223ff2142c884a86cf20fefe4404c7e431b40 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Apr 2026 11:42:25 +0200 Subject: [PATCH 16/21] f - Add end-to-end sanity checks for ldk-server-mcp Drop the tests/ directory from rerun-if-changed since test changes don't affect the mcp binary. Co-Authored-By: HAL 9000 --- e2e-tests/build.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e-tests/build.rs b/e2e-tests/build.rs index e2344890..d21fb16c 100644 --- a/e2e-tests/build.rs +++ b/e2e-tests/build.rs @@ -56,6 +56,5 @@ fn main() { 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/tests"); println!("cargo:rerun-if-changed=../ldk-server-mcp/Cargo.toml"); } From 0f7b92e2b5c1ba9ac66fd0b9b5b3077b031dfaa9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 15:32:13 +0200 Subject: [PATCH 17/21] f - Add end-to-end sanity checks for ldk-server-mcp Advertise the bumped `2025-11-25` MCP protocol version in the e2e test's initialize call so its assertion lines up with the server, and pull in the `hex-conservative` dependency that `ldk-server-client` started transitively pulling through its shared config module. Generated with the assistance of AI (Claude). --- e2e-tests/Cargo.lock | 1 + e2e-tests/tests/mcp.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) 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/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 4239c0c3..6fe137e8 100644 --- a/e2e-tests/tests/mcp.rs +++ b/e2e-tests/tests/mcp.rs @@ -24,12 +24,12 @@ async fn test_mcp_initialize_and_list_tools() { 1, "initialize", json!({ - "protocolVersion": "2024-11-05", + "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "e2e-test", "version": "0.1"} }), ); - assert_eq!(initialize["result"]["protocolVersion"], "2024-11-05"); + assert_eq!(initialize["result"]["protocolVersion"], "2025-11-25"); assert!(initialize["result"]["capabilities"]["tools"].is_object()); let tools = mcp.call(2, "tools/list", json!({})); From df9f8d53b7d21ecb9d127bff3ab6b7f07c0052e5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Apr 2026 11:33:22 +0200 Subject: [PATCH 18/21] Add CI coverage for ldk-server-mcp Run MCP-specific formatting, clippy, and crate tests in a dedicated workflow and add a separate job that exercises the MCP e2e sanity suite on Ubuntu. Co-Authored-By: HAL 9000 --- .github/workflows/mcp.yml | 66 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/mcp.yml diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml new file mode 100644 index 00000000..5f344dcd --- /dev/null +++ b/.github/workflows/mcp.yml @@ -0,0 +1,66 @@ +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 rustfmt clippy + + - name: Check formatting + run: cargo fmt --all -- --check + + - 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 + + mcp-e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Enable caching for bitcoind + id: cache-bitcoind + uses: actions/cache@v5 + with: + path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-${{ runner.os }}-${{ runner.arch }} + + - name: Download bitcoind + if: steps.cache-bitcoind.outputs.cache-hit != 'true' + run: | + source ./scripts/download_bitcoind.sh + mkdir -p bin + mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + + - name: Set bitcoind environment variable + run: echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + + - name: Run MCP end-to-end tests + run: cargo test --manifest-path e2e-tests/Cargo.toml mcp --verbose --color=always -- --test-threads=4 --nocapture + env: + RUST_BACKTRACE: 1 + BITCOIND_SKIP_DOWNLOAD: 1 From 4ea7519e00e18b9fab2290046dfb1d234c1033d9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Apr 2026 11:42:34 +0200 Subject: [PATCH 19/21] f - Add CI coverage for ldk-server-mcp Remove redundant formatting check (already covered by the main CI workflow) and drop the MCP e2e job (already covered by the e2e-tests workflow). Co-Authored-By: HAL 9000 --- .github/workflows/mcp.yml | 35 +---------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml index 5f344dcd..46a7f5d6 100644 --- a/.github/workflows/mcp.yml +++ b/.github/workflows/mcp.yml @@ -21,10 +21,7 @@ jobs: 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 rustfmt clippy - - - name: Check formatting - run: cargo fmt --all -- --check + rustup component add clippy - name: Check MCP crate builds run: cargo check -p ldk-server-mcp @@ -34,33 +31,3 @@ jobs: - name: Run MCP crate clippy run: cargo clippy -p ldk-server-mcp --all-targets -- -D warnings - - mcp-e2e: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Enable caching for bitcoind - id: cache-bitcoind - uses: actions/cache@v5 - with: - path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} - key: bitcoind-${{ runner.os }}-${{ runner.arch }} - - - name: Download bitcoind - if: steps.cache-bitcoind.outputs.cache-hit != 'true' - run: | - source ./scripts/download_bitcoind.sh - mkdir -p bin - mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} - - - name: Set bitcoind environment variable - run: echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - - - name: Run MCP end-to-end tests - run: cargo test --manifest-path e2e-tests/Cargo.toml mcp --verbose --color=always -- --test-threads=4 --nocapture - env: - RUST_BACKTRACE: 1 - BITCOIND_SKIP_DOWNLOAD: 1 From f199a4c78221e9f66cee28483353a42e1f94c7e3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Apr 2026 11:34:37 +0200 Subject: [PATCH 20/21] Document ldk-server-mcp in the workspace Describe the MCP bridge as a first-class workspace member and document how to build, test, and sanity-check it alongside the rest of ldk-server. Co-Authored-By: HAL 9000 --- README.md | 20 ++++++++++++++++++++ ldk-server-mcp/CLAUDE.md | 14 ++++++++++++-- ldk-server-mcp/README.md | 16 ++++++++++++---- 3 files changed, 44 insertions(+), 6 deletions(-) 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/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md index 800ebbbe..af791b91 100644 --- a/ldk-server-mcp/CLAUDE.md +++ b/ldk-server-mcp/CLAUDE.md @@ -2,13 +2,18 @@ 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 -cargo clippy +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 @@ -41,6 +46,9 @@ The server reads configuration in this precedence order (highest first): 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] @@ -59,3 +67,5 @@ When a new endpoint is added to `ldk-server-client`: 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/README.md b/ldk-server-mcp/README.md index 8df16bab..4f4973e5 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -2,10 +2,12 @@ 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 --release +cargo build -p ldk-server-mcp --release ``` ## Configuration @@ -35,15 +37,18 @@ cert_path = "/path/to/tls.crt" export LDK_BASE_URL="localhost:3000" export LDK_API_KEY="your_hex_encoded_api_key" export LDK_TLS_CERT_PATH="/path/to/tls.crt" -./target/release/ldk-server-mcp +cargo run -p ldk-server-mcp --release ``` Or using a config file: ```bash -./target/release/ldk-server-mcp --config /path/to/config.toml +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`): @@ -158,7 +163,10 @@ Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `me ## Testing ```bash -cargo test +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 From 9d74698ca78717205b8abffe6622f4c8d58f56e5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Apr 2026 11:42:49 +0200 Subject: [PATCH 21/21] f - Document ldk-server-mcp in the workspace Replace exhaustive tool listing with a pointer to tools/list to avoid the maintenance burden of keeping the README in sync with every new RPC. Co-Authored-By: HAL 9000 --- ldk-server-mcp/README.md | 63 +--------------------------------------- 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md index 4f4973e5..e5dc7fb5 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -89,71 +89,10 @@ Add to your Claude Code MCP settings (`.claude/settings.json`): ## Available Tools -The server exposes 37 unary LDK Server RPCs as MCP 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. -### Node -| Tool | Description | -|------|-------------| -| `get_node_info` | Retrieve node info including node_id, sync status, and best block | -| `get_balances` | Retrieve an overview of all known balances (on-chain and Lightning) | - -### On-chain -| Tool | Description | -|------|-------------| -| `onchain_receive` | Generate a new on-chain Bitcoin funding address | -| `onchain_send` | Send an on-chain Bitcoin payment to an address | - -### Payments -| Tool | Description | -|------|-------------| -| `bolt11_receive` | Create a BOLT11 Lightning invoice to receive a payment | -| `bolt11_receive_for_hash` | Create a BOLT11 Lightning invoice for a specific payment hash | -| `bolt11_claim_for_hash` | Manually claim a BOLT11 payment for a specific payment hash | -| `bolt11_fail_for_hash` | Manually fail a BOLT11 payment for a specific payment hash | -| `bolt11_receive_via_jit_channel` | Create a BOLT11 Lightning invoice to receive via an LSPS2 JIT channel | -| `bolt11_receive_variable_amount_via_jit_channel` | Create a variable-amount BOLT11 Lightning invoice to receive via an LSPS2 JIT channel | -| `bolt11_send` | Pay a BOLT11 Lightning invoice | -| `bolt12_receive` | Create a BOLT12 offer for receiving Lightning payments | -| `bolt12_send` | Pay a BOLT12 Lightning offer | -| `spontaneous_send` | Send a spontaneous (keysend) payment to a Lightning node | -| `unified_send` | Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name | - -### Channels -| Tool | Description | -|------|-------------| -| `open_channel` | Open a new Lightning channel with a remote node | -| `close_channel` | Cooperatively close a Lightning channel | -| `force_close_channel` | Force close a Lightning channel unilaterally | -| `list_channels` | List all known Lightning channels | -| `update_channel_config` | Update forwarding fees and CLTV delta for a channel | -| `splice_in` | Increase a channel's balance by splicing in on-chain funds | -| `splice_out` | Decrease a channel's balance by splicing out to on-chain | - -### Payment History -| Tool | Description | -|------|-------------| -| `list_payments` | List all payments (supports pagination via page_token) | -| `get_payment_details` | Get details of a specific payment by its ID | -| `list_forwarded_payments` | List all forwarded payments (supports pagination via page_token) | - -### Peers -| Tool | Description | -|------|-------------| -| `connect_peer` | Connect to a Lightning peer without opening a channel | -| `disconnect_peer` | Disconnect from a Lightning peer | -| `list_peers` | List all known Lightning peers | - -### Utilities -| Tool | Description | -|------|-------------| -| `decode_invoice` | Decode a BOLT11 invoice and return its parsed fields | -| `decode_offer` | Decode a BOLT12 offer and return its parsed fields | -| `sign_message` | Sign a message with the node's secret key | -| `verify_signature` | Verify a signature against a message and public key | -| `export_pathfinding_scores` | Export the pathfinding scores used by the Lightning router | - ## MCP Protocol - **Protocol version**: `2025-11-25`