Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ cargo fmt --all
2. Regenerate protos (see above)
3. Create handler in `ldk-server/src/api/` (follow existing patterns)
4. Add route in `ldk-server/src/service.rs`
5. Map the RPC to its required permission in `method_authorization` in `ldk-server/src/macaroons/authorization.rs`.
Unmapped methods return `UNIMPLEMENTED`, even for admin tokens.
5. Map the RPC to its required permission in `method_authorization` in
`ldk-server-grpc/src/permissions.rs` and update the test table in
`ldk-server/src/macaroons/authorization.rs`. Unmapped methods return `UNIMPLEMENTED`, even for
admin tokens, and are not listed by the MCP server.
6. Add CLI command in `ldk-server-cli/src/main.rs`
7. For a unary RPC, add the MCP tool in `ldk-server-mcp/src/tools/` and update the tool list test
in `ldk-server-mcp/tests/integration.rs`. Add a live test in `e2e-tests/tests/mcp.rs` if applicable.
Expand Down
14 changes: 14 additions & 0 deletions e2e-tests/tests/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ async fn test_mcp_macaroon_lifecycle_and_error_categories() {
assert!(listed["macaroons"].as_array().unwrap().iter().any(|key| key["id"] == id));
assert!(!listed.to_string().contains(secret));
let mut reader = McpHandle::start_with_macaroon(&server, secret);
let tools = reader.call(0, "tools/list", json!({}));
let mut tool_names: Vec<_> = tools["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect();
tool_names.sort();
assert_eq!(
tool_names,
["export_pathfinding_scores", "get_balances", "get_node_info", "get_permissions"]
);
let admin_tools = admin.call(4, "tools/list", json!({}));
assert!(admin_tools["result"]["tools"].as_array().unwrap().len() > tool_names.len());
let permissions =
reader.call(1, "tools/call", json!({"name": "get_permissions", "arguments": {}}));
let permissions = tool_result_json(&permissions);
Expand Down
106 changes: 106 additions & 0 deletions ldk-server-grpc/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@
// You may not use this file except in accordance with one or both of these
// licenses.

use crate::endpoints::{
BOLT11_CLAIM_FOR_ID_PATH, BOLT11_FAIL_FOR_ID_PATH, BOLT11_RECEIVE_FOR_HASH_PATH,
BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_FOR_HASH_PATH,
BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH,
BOLT11_RECEIVE_VIA_JIT_CHANNEL_FOR_HASH_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH,
BOLT11_SEND_PATH, BOLT11_SEND_UNDERPAYING_PATH, BOLT12_CREATE_PAYER_PROOF_PATH,
BOLT12_RECEIVE_PATH, BOLT12_RECEIVE_REFUND_PATH, BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH,
BUMP_CHANNEL_FUNDING_FEE_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, CREATE_MACAROON_PATH,
DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH,
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_CHANNEL_FORWARDING_STATS_PATH,
GET_FORWARDED_PAYMENT_DETAILS_PATH, GET_FORWARDED_PAYMENT_TRACKING_MODE_PATH,
GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GET_PERMISSIONS_PATH, GRAPH_GET_CHANNEL_PATH,
GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
LIST_CHANNEL_FORWARDING_STATS_PATH, LIST_CHANNEL_PAIR_FORWARDING_STATS_PATH,
LIST_FORWARDED_PAYMENTS_PATH, LIST_MACAROONS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
ONCHAIN_BUMP_FEE_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH,
REVOKE_MACAROON_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
VERIFY_SIGNATURE_PATH,
};

pub const ADMIN_PERMISSION: &str = "admin";
pub const NODE_READ_PERMISSION: &str = "node:read";
pub const ONCHAIN_RECEIVE_PERMISSION: &str = "onchain:receive";
Expand Down Expand Up @@ -117,3 +138,88 @@ impl std::str::FromStr for MacaroonPreset {
Self::ALL.into_iter().find(|preset| preset.name() == name).ok_or("Unknown macaroon preset")
}
}

/// How an RPC method is authorized.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MethodAuthorization {
/// The caller needs this permission, or `admin`.
Permission(&'static str),
/// Any authenticated caller may use the method.
AuthenticatedOnly,
/// The method is not known to the server and is rejected.
Unknown,
}

/// Returns how `method`, an RPC name such as [`GET_NODE_INFO_PATH`], is authorized.
///
/// This is the mapping the server enforces, so clients can use it to tell which RPCs a
/// macaroon's permissions allow.
pub fn method_authorization(method: &str) -> MethodAuthorization {
match method {
GET_NODE_INFO_PATH | GET_BALANCES_PATH | EXPORT_PATHFINDING_SCORES_PATH => {
MethodAuthorization::Permission(NODE_READ_PERMISSION)
},
ONCHAIN_RECEIVE_PATH => MethodAuthorization::Permission(ONCHAIN_RECEIVE_PERMISSION),
ONCHAIN_SEND_PATH | ONCHAIN_BUMP_FEE_PATH => {
MethodAuthorization::Permission(ONCHAIN_SEND_PERMISSION)
},
BOLT11_RECEIVE_PATH
| BOLT11_RECEIVE_FOR_HASH_PATH
| BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH
| BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH
| BOLT11_RECEIVE_VIA_JIT_CHANNEL_FOR_HASH_PATH
| BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_FOR_HASH_PATH
| BOLT12_RECEIVE_PATH
| BOLT12_RECEIVE_REFUND_PATH => MethodAuthorization::Permission(INVOICES_CREATE_PERMISSION),
BOLT11_CLAIM_FOR_ID_PATH | BOLT11_FAIL_FOR_ID_PATH => {
MethodAuthorization::Permission(PAYMENTS_CLAIM_PERMISSION)
},
BOLT11_SEND_PATH
| BOLT11_SEND_UNDERPAYING_PATH
| BOLT12_SEND_PATH
| BOLT12_SEND_REFUND_PATH
| SPONTANEOUS_SEND_PATH
| UNIFIED_SEND_PATH
| SPLICE_OUT_PATH => MethodAuthorization::Permission(PAYMENTS_SEND_PERMISSION),
GET_PAYMENT_DETAILS_PATH
| LIST_PAYMENTS_PATH
| LIST_FORWARDED_PAYMENTS_PATH
| GET_FORWARDED_PAYMENT_DETAILS_PATH
| GET_FORWARDED_PAYMENT_TRACKING_MODE_PATH
| GET_CHANNEL_FORWARDING_STATS_PATH
| LIST_CHANNEL_FORWARDING_STATS_PATH
| LIST_CHANNEL_PAIR_FORWARDING_STATS_PATH => {
MethodAuthorization::Permission(PAYMENTS_READ_PERMISSION)
},
LIST_CHANNELS_PATH => MethodAuthorization::Permission(CHANNELS_READ_PERMISSION),
OPEN_CHANNEL_PATH
| UPDATE_CHANNEL_CONFIG_PATH
| CLOSE_CHANNEL_PATH
| SPLICE_IN_PATH
| BUMP_CHANNEL_FUNDING_FEE_PATH => MethodAuthorization::Permission(CHANNELS_MANAGE_PERMISSION),
FORCE_CLOSE_CHANNEL_PATH => {
MethodAuthorization::Permission(CHANNELS_FORCE_CLOSE_PERMISSION)
},
LIST_PEERS_PATH => MethodAuthorization::Permission(PEERS_READ_PERMISSION),
CONNECT_PEER_PATH | DISCONNECT_PEER_PATH => {
MethodAuthorization::Permission(PEERS_MANAGE_PERMISSION)
},
SIGN_MESSAGE_PATH | BOLT12_CREATE_PAYER_PROOF_PATH => {
MethodAuthorization::Permission(MESSAGES_SIGN_PERMISSION)
},
VERIFY_SIGNATURE_PATH => MethodAuthorization::Permission(MESSAGES_VERIFY_PERMISSION),
GRAPH_LIST_CHANNELS_PATH
| GRAPH_GET_CHANNEL_PATH
| GRAPH_LIST_NODES_PATH
| GRAPH_GET_NODE_PATH => MethodAuthorization::Permission(GRAPH_READ_PERMISSION),
DECODE_INVOICE_PATH | DECODE_OFFER_PATH => {
MethodAuthorization::Permission(UTILITIES_READ_PERMISSION)
},
SUBSCRIBE_EVENTS_PATH => MethodAuthorization::Permission(EVENTS_READ_PERMISSION),
CREATE_MACAROON_PATH | LIST_MACAROONS_PATH | REVOKE_MACAROON_PATH => {
MethodAuthorization::Permission(MACAROONS_MANAGE_PERMISSION)
},
GET_PERMISSIONS_PATH => MethodAuthorization::AuthenticatedOnly,
_ => MethodAuthorization::Unknown,
}
}
4 changes: 4 additions & 0 deletions ldk-server-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Add to your Claude Code MCP settings (`.claude/settings.json`):

All unary LDK Server RPCs are exposed as MCP tools. Use `tools/list` to discover the current set.

On startup the server calls `get_permissions` and only lists the tools the configured macaroon is
allowed to call, so a restricted macaroon is not offered tools the server would deny. If the
permissions cannot be fetched, every tool is listed.

Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `metrics` are not exposed as tools.

The `create_macaroon` tool returns a private token that may be saved in chat history or tool logs.
Expand Down
23 changes: 14 additions & 9 deletions ldk-server-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ mod protocol;
mod tools;

use ldk_server_client::client::LdkServerClient;
use ldk_server_client::ldk_server_grpc::api::GetNodeInfoRequest;
use ldk_server_client::ldk_server_grpc::api::GetPermissionsRequest;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

Expand Down Expand Up @@ -60,16 +60,21 @@ 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 mut registry = build_tool_registry();

// Ask the server what this macaroon may do, and only list the tools it can call so the model
// is not offered tools that would be denied. This also surfaces misconfiguration on startup
// rather than on the first tool call. On failure we warn and list every tool instead of
// exiting, so the MCP protocol loop still answers `initialize` and `tools/list` even when the
// server is temporarily unreachable.
match client.get_permissions(GetPermissionsRequest {}).await {
Ok(response) => match response.macaroon {
Some(macaroon) => registry.retain_allowed_tools(&macaroon),
None => eprintln!("Warning: ldk-server returned no macaroon permissions"),
},
Err(e) => eprintln!("Warning: Failed to reach ldk-server on startup: {e}"),
}

let registry = build_tool_registry();

eprintln!("ldk-server-mcp: ready, waiting for JSON-RPC requests on stdin");

let stdin = tokio::io::stdin();
Expand Down
Loading
Loading