diff --git a/cmd/wavecli/waveclicommands/AGENTS.md b/cmd/wavecli/waveclicommands/AGENTS.md index 2ee80fe6e..5baa9c261 100644 --- a/cmd/wavecli/waveclicommands/AGENTS.md +++ b/cmd/wavecli/waveclicommands/AGENTS.md @@ -106,7 +106,8 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave machine-readable schema for all CLI commands; shared source of truth for `schema` and MCP tool definitions. Built from the `walletAdmin`/`walletPayment`/`walletQuery`/`arkBase`/`arkVTXO`/ - `arkSend`/`arkObservable` sub-registries. + `arkSend`/`arkObservable` sub-registries. MCP-only methods use + `mcp_only`; tools whose arguments differ from the CLI use `mcp_params`. - `buildMCPServer()` — constructs the MCP server and registers every exposed RPC as a typed tool; split from `mcpServe` (which owns the daemon dial and stdio transport) so the tool surface is testable. @@ -145,6 +146,10 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave - JSON output (`stdout`) and diagnostic output (`stderr`) are kept on separate streams so shell pipelines can consume the JSON body while a human reading the terminal sees informative warnings. +- MCP payment sends are two-phase: `send.prepare` validates and returns a + short-lived, single-use `send_intent_id`; `send` accepts only that id and + consumes the exact prepared intent. Never combine prepare and dispatch in + one MCP tool call. - `exit` defaults to a cooperative leave; it only starts a unilateral on-chain unroll when `--force-unroll-ack` matches the literal string `I_KNOW_WHAT_I_AM_DOING`, and that flag is mutually exclusive with diff --git a/cmd/wavecli/waveclicommands/CLAUDE.md b/cmd/wavecli/waveclicommands/CLAUDE.md index 2ee80fe6e..5baa9c261 100644 --- a/cmd/wavecli/waveclicommands/CLAUDE.md +++ b/cmd/wavecli/waveclicommands/CLAUDE.md @@ -106,7 +106,8 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave machine-readable schema for all CLI commands; shared source of truth for `schema` and MCP tool definitions. Built from the `walletAdmin`/`walletPayment`/`walletQuery`/`arkBase`/`arkVTXO`/ - `arkSend`/`arkObservable` sub-registries. + `arkSend`/`arkObservable` sub-registries. MCP-only methods use + `mcp_only`; tools whose arguments differ from the CLI use `mcp_params`. - `buildMCPServer()` — constructs the MCP server and registers every exposed RPC as a typed tool; split from `mcpServe` (which owns the daemon dial and stdio transport) so the tool surface is testable. @@ -145,6 +146,10 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave - JSON output (`stdout`) and diagnostic output (`stderr`) are kept on separate streams so shell pipelines can consume the JSON body while a human reading the terminal sees informative warnings. +- MCP payment sends are two-phase: `send.prepare` validates and returns a + short-lived, single-use `send_intent_id`; `send` accepts only that id and + consumes the exact prepared intent. Never combine prepare and dispatch in + one MCP tool call. - `exit` defaults to a cooperative leave; it only starts a unilateral on-chain unroll when `--force-unroll-ack` matches the literal string `I_KNOW_WHAT_I_AM_DOING`, and that flag is mutually exclusive with diff --git a/cmd/wavecli/waveclicommands/mcp_wallet.go b/cmd/wavecli/waveclicommands/mcp_wallet.go index 202a21a7c..0cc560ee2 100644 --- a/cmd/wavecli/waveclicommands/mcp_wallet.go +++ b/cmd/wavecli/waveclicommands/mcp_wallet.go @@ -136,45 +136,28 @@ func registerMCPWalletQueryTools(s *mcp.Server, func registerMCPWalletMutateTools(s *mcp.Server, client wavewalletrpc.WalletServiceClient) { - type sendArgs struct { - Destination string `json:"destination" jsonschema:"BOLT-11 invoice (offchain) or onchain address; the direction field picks which"` //nolint:ll - Direction string `json:"direction,omitempty" jsonschema:"'offchain' (default) for invoice, 'onchain' for cooperative leave"` //nolint:ll - AmtSat uint64 `json:"amt_sat,omitempty" jsonschema:"amount in satoshis (required for onchain unless sweep_all)"` //nolint:ll - MaxFeeSat uint64 `json:"max_fee_sat,omitempty" jsonschema:"max fee in satoshis; zero uses daemon defaults"` //nolint:ll - Note string `json:"note,omitempty" jsonschema:"caller-supplied label persisted with the entry"` //nolint:ll - SweepAll bool `json:"sweep_all,omitempty" jsonschema:"onchain only: drain every live VTXO"` //nolint:ll - } + mcp.AddTool(s, &mcp.Tool{ + Name: "send.prepare", + Description: "Validate and preview a payment without " + + "moving funds. Returns a short-lived, single-use " + + "send_intent_id for the send tool.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, + args mcpSendPrepareArgs) (*mcp.CallToolResult, any, error) { + + r, err := prepareMCPWalletSend(ctx, client, args) + + return r, nil, err + }) + mcp.AddTool(s, &mcp.Tool{ Name: "send", - Description: "Send a payment (offchain Lightning invoice " + - "by default; onchain cooperative leave with " + - "direction='onchain')", - }, func(ctx context.Context, _ *mcp.CallToolRequest, args sendArgs) ( + Description: "Dispatch exactly one previously prepared " + + "payment. Consumes the short-lived, single-use " + + "send_intent_id and may move funds.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, args mcpSendArgs) ( *mcp.CallToolResult, any, error) { - offchain, err := parseDirectionField(args.Direction) - if err != nil { - return nil, nil, err - } - req, err := buildWalletPrepareSendRequest( - args.Destination, offchain, args.AmtSat, args.MaxFeeSat, - args.Note, args.SweepAll, - ) - if err != nil { - return nil, nil, err - } - prepareResp, err := client.PrepareSend(ctx, req) - if err != nil { - return nil, nil, mapWalletRPCError(err) - } - resp, err := client.Send(ctx, &wavewalletrpc.SendRequest{ - SendIntentId: prepareResp.GetSendIntentId(), - }) - if err != nil { - return nil, nil, mapWalletRPCError(err) - } - - r, err := mcpResult(resp) + r, err := dispatchMCPWalletSend(ctx, client, args) return r, nil, err }) @@ -284,6 +267,73 @@ func registerMCPWalletMutateTools(s *mcp.Server, }) } +type mcpSendPrepareArgs struct { + Destination string `json:"destination" jsonschema:"BOLT-11 invoice (offchain) or onchain address; direction selects the rail"` //nolint:ll + Direction string `json:"direction,omitempty" jsonschema:"'offchain' (default) for invoice, 'onchain' for cooperative leave"` //nolint:ll + AmtSat uint64 `json:"amt_sat,omitempty" jsonschema:"amount in satoshis (required for onchain unless sweep_all)"` //nolint:ll + MaxFeeSat uint64 `json:"max_fee_sat,omitempty" jsonschema:"max fee in satoshis; zero uses daemon defaults"` //nolint:ll + Note string `json:"note,omitempty" jsonschema:"caller-supplied label persisted with the entry"` //nolint:ll + SweepAll bool `json:"sweep_all,omitempty" jsonschema:"onchain only: drain every live VTXO"` //nolint:ll +} + +type mcpSendArgs struct { + SendIntentID string `json:"send_intent_id" jsonschema:"short-lived, single-use id returned by send.prepare"` //nolint:ll +} + +// prepareMCPWalletSend validates and quotes a send without dispatching it. +func prepareMCPWalletSend(ctx context.Context, + client wavewalletrpc.WalletServiceClient, + args mcpSendPrepareArgs) (*mcp.CallToolResult, error) { + + if client == nil { + return nil, errWalletRPCDisabled + } + + offchain, err := parseDirectionField(args.Direction) + if err != nil { + return nil, err + } + req, err := buildWalletPrepareSendRequest( + args.Destination, offchain, args.AmtSat, args.MaxFeeSat, + args.Note, args.SweepAll, + ) + if err != nil { + return nil, err + } + + resp, err := client.PrepareSend(ctx, req) + if err != nil { + return nil, mapWalletRPCError(err) + } + + return mcpResult(resp) +} + +// dispatchMCPWalletSend consumes a prepared intent without reconstructing or +// silently changing any payment parameter. +func dispatchMCPWalletSend(ctx context.Context, + client wavewalletrpc.WalletServiceClient, + args mcpSendArgs) (*mcp.CallToolResult, error) { + + if client == nil { + return nil, errWalletRPCDisabled + } + + if args.SendIntentID == "" { + return nil, fmt.Errorf("send_intent_id is required; call " + + "send.prepare first") + } + + resp, err := client.Send(ctx, &wavewalletrpc.SendRequest{ + SendIntentId: args.SendIntentID, + }) + if err != nil { + return nil, mapWalletRPCError(err) + } + + return mcpResult(resp) +} + // buildWalletActivityRequest translates MCP activityArgs into a ListRequest, // applying the same activity filter parsing the CLI uses so the two // surfaces stay in lockstep. diff --git a/cmd/wavecli/waveclicommands/mcp_wallet_test.go b/cmd/wavecli/waveclicommands/mcp_wallet_test.go index b40891033..44f3684f0 100644 --- a/cmd/wavecli/waveclicommands/mcp_wallet_test.go +++ b/cmd/wavecli/waveclicommands/mcp_wallet_test.go @@ -1,12 +1,184 @@ package waveclicommands import ( + "context" + "encoding/json" "testing" "github.com/lightninglabs/wavelength/rpc/wavewalletrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" + "google.golang.org/grpc" ) +type recordingWalletServiceClient struct { + wavewalletrpc.WalletServiceClient + + prepareReqs []*wavewalletrpc.PrepareSendRequest + sendReqs []*wavewalletrpc.SendRequest +} + +func (c *recordingWalletServiceClient) PrepareSend(_ context.Context, + req *wavewalletrpc.PrepareSendRequest, _ ...grpc.CallOption) ( + *wavewalletrpc.PrepareSendResponse, error) { + + c.prepareReqs = append(c.prepareReqs, req) + + return &wavewalletrpc.PrepareSendResponse{ + SendIntentId: "intent-123", + AmountSat: 50_000, + ExpectedFeeSat: 123, + FeeKnown: true, + }, nil +} + +func (c *recordingWalletServiceClient) Send(_ context.Context, + req *wavewalletrpc.SendRequest, _ ...grpc.CallOption) ( + *wavewalletrpc.SendResponse, error) { + + c.sendReqs = append(c.sendReqs, req) + + return &wavewalletrpc.SendResponse{}, nil +} + +func TestPrepareMCPWalletSendDoesNotDispatch(t *testing.T) { + t.Parallel() + + client := &recordingWalletServiceClient{} + result, err := prepareMCPWalletSend( + t.Context(), client, mcpSendPrepareArgs{ + Destination: "lnbcrt100u1pwlqxyz", + MaxFeeSat: 250, + Note: "coffee", + }, + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, client.prepareReqs, 1) + require.Empty(t, client.sendReqs) + require.Equal( + t, "lnbcrt100u1pwlqxyz", client.prepareReqs[0].GetInvoice(), + ) + require.Equal(t, uint64(250), client.prepareReqs[0].GetMaxFeeSat()) + + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + var preview map[string]any + require.NoError(t, json.Unmarshal([]byte(text.Text), &preview)) + require.Equal(t, "intent-123", preview["send_intent_id"]) +} + +func TestDispatchMCPWalletSendConsumesExactIntent(t *testing.T) { + t.Parallel() + + client := &recordingWalletServiceClient{} + result, err := dispatchMCPWalletSend( + t.Context(), client, mcpSendArgs{ + SendIntentID: "intent-123", + }, + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Empty(t, client.prepareReqs) + require.Len(t, client.sendReqs, 1) + require.Equal( + t, "intent-123", client.sendReqs[0].GetSendIntentId(), + ) +} + +func TestDispatchMCPWalletSendRequiresIntent(t *testing.T) { + t.Parallel() + + client := &recordingWalletServiceClient{} + result, err := dispatchMCPWalletSend( + t.Context(), client, mcpSendArgs{}, + ) + require.Nil(t, result) + require.ErrorContains(t, err, "send.prepare") + require.Empty(t, client.prepareReqs) + require.Empty(t, client.sendReqs) +} + +func TestMCPWalletSendRejectsNilClient(t *testing.T) { + t.Parallel() + + result, err := prepareMCPWalletSend( + t.Context(), nil, mcpSendPrepareArgs{ + Destination: "lnbcrt100u1pwlqxyz", + }, + ) + require.Nil(t, result) + require.ErrorIs(t, err, errWalletRPCDisabled) + + result, err = dispatchMCPWalletSend( + t.Context(), nil, mcpSendArgs{ + SendIntentID: "intent-123", + }, + ) + require.Nil(t, result) + require.ErrorIs(t, err, errWalletRPCDisabled) +} + +func TestMCPWalletSendSchemaRegistry(t *testing.T) { + t.Parallel() + + prepare := findSchemaMethod(t, "send.prepare") + require.True(t, prepare.MCPTool) + require.True(t, prepare.MCPOnly) + require.Equal(t, "destination", prepare.Params[0].Name) + + send := findSchemaMethod(t, "send") + require.True(t, send.MCPTool) + require.Len(t, send.MCPParams, 1) + require.Equal(t, "send_intent_id", send.MCPParams[0].Name) + require.True(t, send.MCPParams[0].Required) +} + +func TestMCPWalletSendToolsExposeTwoPhaseSchemas(t *testing.T) { + t.Parallel() + + ctx := t.Context() + server := buildMCPServer(nil, nil) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + serverSession, err := server.Connect(ctx, serverTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + + client := mcp.NewClient( + &mcp.Implementation{ + Name: "test", + Version: "0", + }, + nil, + ) + clientSession, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer clientSession.Close() + + tools, err := clientSession.ListTools(ctx, nil) + require.NoError(t, err) + + var prepareSchema, sendSchema []byte + for _, tool := range tools.Tools { + schema, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + + switch tool.Name { + case "send.prepare": + prepareSchema = schema + + case "send": + sendSchema = schema + } + } + + require.Contains(t, string(prepareSchema), `"destination"`) + require.NotContains(t, string(prepareSchema), `"send_intent_id"`) + require.Contains(t, string(sendSchema), `"send_intent_id"`) + require.NotContains(t, string(sendSchema), `"destination"`) +} + // TestParseDirectionFieldDefaultsToOffchain confirms an agent that // omits the direction string lands on the safe invoice path. The CLI // flag layer enforces the same default via resolveOffchainFlag — the diff --git a/cmd/wavecli/waveclicommands/schema_registry.go b/cmd/wavecli/waveclicommands/schema_registry.go index 3fded35de..fdffe840f 100644 --- a/cmd/wavecli/waveclicommands/schema_registry.go +++ b/cmd/wavecli/waveclicommands/schema_registry.go @@ -30,6 +30,10 @@ type schemaMethod struct { // Params lists the accepted parameters. Params []schemaParam `json:"params"` + // MCPParams lists the MCP tool parameters when they differ from the CLI + // command. An omitted value means the MCP tool uses Params. + MCPParams []schemaParam `json:"mcp_params,omitempty"` + // RequestType is the proto request message name. RequestType string `json:"request_type"` @@ -55,6 +59,10 @@ type schemaMethod struct { // are intentionally CLI-only because they handle secret // material). MCPTool bool `json:"mcp_tool,omitempty"` + + // MCPOnly indicates that Method is discoverable only through MCP and + // has no corresponding Cobra command. + MCPOnly bool `json:"mcp_only,omitempty"` } // methodRegistry returns the full schema for all wavecli commands. @@ -227,6 +235,65 @@ func walletPaymentMethodRegistry() []schemaMethod { DryRun: true, JSONInput: false, MCPTool: true, + MCPParams: []schemaParam{ + { + Name: "send_intent_id", + Type: "string", + Description: "single-use id " + + "returned by send.prepare", + Required: true, + }, + }, + }, + { + Method: "send.prepare", + Description: "Validate and preview a payment without " + + "moving funds", + Params: []schemaParam{ + { + Name: "destination", + Type: "string", + Description: "invoice or on-chain " + + "address", + Required: true, + }, + { + Name: "direction", + Type: "enum", + Description: "payment rail; defaults " + + "to offchain", + Values: []string{ + "offchain", + "onchain", + }, + }, + { + Name: "amt_sat", + Type: "uint64", + Description: "amount in satoshis", + }, + { + Name: "max_fee_sat", + Type: "uint64", + Description: "maximum fee in satoshis", + }, + { + Name: "note", + Type: "string", + Description: "caller-supplied label", + }, + { + Name: "sweep_all", + Type: "bool", + Description: "onchain only: drain " + + "every live VTXO", + }, + }, + RequestType: "PrepareSendRequest", + ResponseType: "PrepareSendResponse", + JSONInput: false, + MCPTool: true, + MCPOnly: true, }, { Method: "recv", diff --git a/cmd/wavecli/waveclicommands/swap_removal_test.go b/cmd/wavecli/waveclicommands/swap_removal_test.go index 713ba8650..bf7adb7c1 100644 --- a/cmd/wavecli/waveclicommands/swap_removal_test.go +++ b/cmd/wavecli/waveclicommands/swap_removal_test.go @@ -120,6 +120,7 @@ func TestMCPServerAdvertisesNoSwapTools(t *testing.T) { // introspection, so a regression that dropped either while removing // swap fails here rather than shipping green. require.Contains(t, names, "send") + require.Contains(t, names, "send.prepare") require.Contains(t, names, "balance") require.Contains(t, names, "getinfo") } diff --git a/docs/daemon_cli_guide.md b/docs/daemon_cli_guide.md index 2c22ac5f6..4a7264aa7 100644 --- a/docs/daemon_cli_guide.md +++ b/docs/daemon_cli_guide.md @@ -623,6 +623,13 @@ transiting the protocol. Use the CLI directly for wallet operations. `ark.oor.receive` is exposed because it only allocates a fresh wallet-derived receive target and does not reveal seed material. +MCP sends are intentionally two-phase. First call `send.prepare` with the +destination, rail, amount, fee limit, and note. It validates the payment and +returns the exact preview plus a short-lived, single-use `send_intent_id` +without moving funds. After inspecting that preview, call `send` with only +that `send_intent_id`. The second call consumes the prepared intent and may +move funds; it cannot silently replace the reviewed payment parameters. + ## Regtest Quickstart A complete end-to-end workflow on regtest using the default (no