Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions cmd/wavecli/waveclicommands/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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
Expand Down
4 changes: 4 additions & 0 deletions cmd/wavecli/waveclicommands/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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
Expand Down
112 changes: 77 additions & 35 deletions cmd/wavecli/waveclicommands/mcp_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
darioAnongba marked this conversation as resolved.

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
})
Expand Down Expand Up @@ -284,6 +267,65 @@ 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) {

offchain, err := parseDirectionField(args.Direction)
Comment thread
darioAnongba marked this conversation as resolved.
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 args.SendIntentID == "" {
Comment thread
darioAnongba marked this conversation as resolved.
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.
Expand Down
137 changes: 137 additions & 0 deletions cmd/wavecli/waveclicommands/mcp_wallet_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,149 @@
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 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
Expand Down
1 change: 1 addition & 0 deletions cmd/wavecli/waveclicommands/swap_removal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
7 changes: 7 additions & 0 deletions docs/daemon_cli_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading