From 3d76173fb9472b8647cc6df867c24cf58657bba7 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 21 Jul 2026 17:10:43 +0200 Subject: [PATCH] wavecli: reconcile schema with live surfaces --- cmd/wavecli/waveclicommands/AGENTS.md | 4 + cmd/wavecli/waveclicommands/CLAUDE.md | 4 + cmd/wavecli/waveclicommands/cmd_schema.go | 3 +- .../waveclicommands/schema_parity_test.go | 165 ++++++++++++++ .../waveclicommands/schema_registry.go | 209 +++++++++++++++--- .../schema_registry_ark_observable.go | 10 + docs/daemon_cli_guide.md | 6 + 7 files changed, 375 insertions(+), 26 deletions(-) create mode 100644 cmd/wavecli/waveclicommands/schema_parity_test.go diff --git a/cmd/wavecli/waveclicommands/AGENTS.md b/cmd/wavecli/waveclicommands/AGENTS.md index 2ee80fe6e..2dc7f36ab 100644 --- a/cmd/wavecli/waveclicommands/AGENTS.md +++ b/cmd/wavecli/waveclicommands/AGENTS.md @@ -107,6 +107,10 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave truth for `schema` and MCP tool definitions. Built from the `walletAdmin`/`walletPayment`/`walletQuery`/`arkBase`/`arkVTXO`/ `arkSend`/`arkObservable` sub-registries. +- `schema_parity_test.go` walks the real cobra tree and an in-memory real MCP + server. Every visible local flag on the curated wallet/ark surface must + match the registry name and type, and `MCPTool` must match the exact live + tool set. - `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. diff --git a/cmd/wavecli/waveclicommands/CLAUDE.md b/cmd/wavecli/waveclicommands/CLAUDE.md index 2ee80fe6e..2dc7f36ab 100644 --- a/cmd/wavecli/waveclicommands/CLAUDE.md +++ b/cmd/wavecli/waveclicommands/CLAUDE.md @@ -107,6 +107,10 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/cmd/wave truth for `schema` and MCP tool definitions. Built from the `walletAdmin`/`walletPayment`/`walletQuery`/`arkBase`/`arkVTXO`/ `arkSend`/`arkObservable` sub-registries. +- `schema_parity_test.go` walks the real cobra tree and an in-memory real MCP + server. Every visible local flag on the curated wallet/ark surface must + match the registry name and type, and `MCPTool` must match the exact live + tool set. - `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. diff --git a/cmd/wavecli/waveclicommands/cmd_schema.go b/cmd/wavecli/waveclicommands/cmd_schema.go index b7e457beb..27adb27de 100644 --- a/cmd/wavecli/waveclicommands/cmd_schema.go +++ b/cmd/wavecli/waveclicommands/cmd_schema.go @@ -16,7 +16,8 @@ func newSchemaCmd() *cobra.Command { Long: "Returns the full method signature for a " + "CLI command as machine-readable JSON: " + "params, types, required fields, enum " + - "values, request/response types. Use " + + "values, request/response types, side effects, " + + "and output-schema id/version. Use " + "--all to dump every method.", Args: cobra.MaximumNArgs(1), RunE: schemaRun, diff --git a/cmd/wavecli/waveclicommands/schema_parity_test.go b/cmd/wavecli/waveclicommands/schema_parity_test.go new file mode 100644 index 000000000..b4eef4e33 --- /dev/null +++ b/cmd/wavecli/waveclicommands/schema_parity_test.go @@ -0,0 +1,165 @@ +package waveclicommands + +import ( + "context" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" +) + +func TestSchemaRegistryMatchesCobraTree(t *testing.T) { + t.Parallel() + + root := newRootCmd(true) + registry := methodRegistry() + byMethod := make(map[string]schemaMethod, len(registry)) + + for _, method := range registry { + require.NotContains(t, byMethod, method.Method) + byMethod[method.Method] = method + require.NotEmpty(t, method.OutputSchemaID) + require.Equal(t, uint32(1), method.OutputSchemaVersion) + + if method.MCPOnly { + continue + } + + cmd, _, err := root.Find(strings.Split(method.Method, ".")) + require.NoError(t, err, method.Method) + require.Equal( + t, schemaFlagTypes(method), commandFlagTypes(cmd), + method.Method, + ) + } + + walkCommands(root, func(cmd *cobra.Command) { + method, covered := coveredSchemaMethod(cmd) + if !covered { + return + } + + require.Contains( + t, byMethod, method, "covered cobra command %q "+ + "lacks schema", cmd.CommandPath(), + ) + }) +} + +func TestSchemaRegistryMatchesMCPTools(t *testing.T) { + t.Parallel() + + ctx := context.Background() + 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: "schema-parity", + 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) + + actual := make(map[string]bool, len(tools.Tools)) + for _, tool := range tools.Tools { + actual[tool.Name] = true + } + + expected := make(map[string]bool) + for _, method := range methodRegistry() { + if method.MCPTool { + expected[method.Method] = true + } + } + + require.Equal(t, expected, actual) +} + +func schemaFlagTypes(method schemaMethod) map[string]string { + flags := make(map[string]string) + for _, param := range method.Params { + if param.Positional { + continue + } + + flags[param.Name] = schemaParamFlagType(param) + } + + return flags +} + +func schemaParamFlagType(param schemaParam) string { + if param.FlagType != "" { + return param.FlagType + } + + switch param.Type { + case "enum": + return "string" + + case "string[]": + return "stringSlice" + + case "int64[]": + return "int64Slice" + + case "map[string]string": + return "stringToString" + + default: + return param.Type + } +} + +func commandFlagTypes(cmd *cobra.Command) map[string]string { + flags := make(map[string]string) + cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Hidden || flag.Name == "help" { + return + } + + flags[flag.Name] = flag.Value.Type() + }) + + return flags +} + +func walkCommands(cmd *cobra.Command, visit func(*cobra.Command)) { + visit(cmd) + for _, child := range cmd.Commands() { + walkCommands(child, visit) + } +} + +func coveredSchemaMethod(cmd *cobra.Command) (string, bool) { + path := strings.TrimPrefix(cmd.CommandPath(), "wavecli ") + method := strings.ReplaceAll(path, " ", ".") + + if strings.HasPrefix(path, "ark ") { + return method, cmd.RunE != nil + } + + switch method { + case "create", "unlock", "send", "recv", "activity", "balance", + "exit", "wallet-sweep", "getinfo", "activity.inspect", + "exit.status", "exit.summary", "exit.plan": + return method, true + + default: + return "", false + } +} diff --git a/cmd/wavecli/waveclicommands/schema_registry.go b/cmd/wavecli/waveclicommands/schema_registry.go index 3fded35de..fcb2f5634 100644 --- a/cmd/wavecli/waveclicommands/schema_registry.go +++ b/cmd/wavecli/waveclicommands/schema_registry.go @@ -1,5 +1,7 @@ package waveclicommands +import "strings" + // schemaParam describes a single parameter for a CLI command / RPC // method. type schemaParam struct { @@ -9,6 +11,11 @@ type schemaParam struct { // Type is the parameter type (string, int64, bool, enum, etc.). Type string `json:"type"` + // FlagType records the exact pflag type when Type intentionally uses a + // friendlier schema spelling, for example repeatable StringArray + // values. + FlagType string `json:"flag_type,omitempty"` + // Description explains what the parameter does. Description string `json:"description,omitempty"` @@ -17,6 +24,9 @@ type schemaParam struct { // Values lists valid values for enum-typed parameters. Values []string `json:"values,omitempty"` + + // Positional marks command arguments that are not cobra flags. + Positional bool `json:"positional,omitempty"` } // schemaMethod describes a single CLI command / RPC method. @@ -55,6 +65,18 @@ type schemaMethod struct { // are intentionally CLI-only because they handle secret // material). MCPTool bool `json:"mcp_tool,omitempty"` + + // MCPOnly marks tools that have no equivalent cobra command. + MCPOnly bool `json:"mcp_only,omitempty"` + + // SideEffect reports whether invoking the method can change wallet or + // daemon state. It is always emitted so agents need not infer defaults. + SideEffect bool `json:"side_effect"` + + // OutputSchemaID and OutputSchemaVersion identify the additive output + // contract for this command without wrapping the proto JSON response. + OutputSchemaID string `json:"output_schema_id"` + OutputSchemaVersion uint32 `json:"output_schema_version"` } // methodRegistry returns the full schema for all wavecli commands. @@ -71,9 +93,32 @@ func methodRegistry() []schemaMethod { out = append(out, arkSendMethodRegistry()...) out = append(out, arkObservableMethodRegistry()...) + for i := range out { + out[i].OutputSchemaID = "wavecli." + + strings.ReplaceAll(out[i].Method, ".", "-") + ".output" + out[i].OutputSchemaVersion = 1 + out[i].SideEffect = schemaMethodHasSideEffect(out[i].Method) + } + return out } +// schemaMethodHasSideEffect keeps the coarse safety label explicit and small. +// Preview-only and query methods are false; methods that allocate, queue, +// dispatch, unlock, or broadcast are true. +func schemaMethodHasSideEffect(method string) bool { + switch method { + case "create", "unlock", "send", "recv", "exit", "wallet-sweep", + "ark.sweep", "ark.board", "ark.rounds.join", + "ark.oor.newaddress", "ark.oor.receive", "ark.vtxos.refresh", + "ark.vtxos.leave", "ark.send.inround", "ark.send.oor": + return true + + default: + return false + } +} + // listOutputParams returns the standard agent-CLI output-shape // modifier params (--fields, --ndjson) for list-shaped commands. The // helper keeps every list entry's schema description in sync with @@ -126,6 +171,26 @@ func walletAdminMethodRegistry() []schemaMethod { "in the JSON response on " + "stdout (default: stderr only)", }, + { + Name: "recover", + Type: "bool", + Description: "recover from an " + + "existing " + + "mnemonic", + }, + { + Name: "mnemonic-file", + Type: "string", + Description: "path to an existing " + + "24-word aezeed mnemonic", + }, + { + Name: "recovery-window", + Type: "uint32", + Description: "key indexes to scan " + + "per " + + "recovery family", + }, }, RequestType: "CreateRequest", ResponseType: "CreateResponse", @@ -153,6 +218,28 @@ func walletAdminMethodRegistry() []schemaMethod { RequestType: "GetInfoRequest", ResponseType: "GetInfoResponse", JSONInput: true, + MCPTool: true, + }, + { + Method: "daemon.balance", + Description: "Display the raw daemon balance " + + "breakdown", + Params: nil, + RequestType: "GetBalanceRequest", + ResponseType: "GetBalanceResponse", + JSONInput: false, + MCPTool: true, + MCPOnly: true, + }, + { + Method: "ark.oor.newaddress", + Description: "Generate a new boarding address", + Params: nil, + RequestType: "NewAddressRequest", + ResponseType: "NewAddressResponse", + JSONInput: false, + MCPTool: true, + MCPOnly: true, }, } } @@ -165,6 +252,14 @@ func walletPaymentMethodRegistry() []schemaMethod { Method: "send", Description: "Send a payment (offchain or onchain)", Params: []schemaParam{ + { + Name: "destination", + Type: "string", + Description: "invoice or on-chain " + + "address", + Required: true, + Positional: true, + }, { Name: "offchain", Type: "bool", @@ -220,6 +315,26 @@ func walletPaymentMethodRegistry() []schemaMethod { Type: "bool", Description: "alias for force", }, + { + Name: "no-wait", + Type: "bool", + Description: "return after dispatch " + + "instead " + + "of waiting for settlement", + }, + { + Name: "wait-timeout", + Type: "duration", + Description: "maximum settlement " + + "wait; " + + "zero waits indefinitely", + }, + { + Name: "wait-poll-interval", + Type: "duration", + Description: "settlement status poll " + + "interval", + }, }, RequestType: "PrepareSendRequest", ResponseType: "SendResponse", @@ -272,7 +387,8 @@ func walletPaymentMethodRegistry() []schemaMethod { // walletQueryMethodRegistry returns the wallet query verbs (activity, // balance, exit, exit.status, activity.inspect). -func walletQueryMethodRegistry() []schemaMethod { +func walletQueryMethodRegistry() []schemaMethod { //nolint:funlen + return []schemaMethod{ { Method: "activity", @@ -295,9 +411,9 @@ func walletQueryMethodRegistry() []schemaMethod { "daemon default", }, { - Name: "offset", - Type: "uint32", - Description: "pagination offset", + Name: "cursor", + Type: "string", + Description: "activity page token", }, { Name: "format", @@ -342,6 +458,19 @@ func walletQueryMethodRegistry() []schemaMethod { "preview and exit 10 without " + "dispatching", }, + { + Name: "onchain-address", + Type: "string", + Description: "cooperative leave " + + "destination", + }, + { + Name: "force-unroll-ack", + Type: "string", + Description: "exact acknowledgement " + + "for " + + "unilateral unroll", + }, }, RequestType: "ExitRequest", ResponseType: "ExitResponse", @@ -360,6 +489,13 @@ func walletQueryMethodRegistry() []schemaMethod { Description: "VTXO outpoint " + "(txid:vout)", }, + { + Name: "detailed", + Type: "bool", + Description: "include tree, CSV, and " + + "fee " + + "progress", + }, }, RequestType: "ExitStatusRequest", ResponseType: "ExitStatusResponse", @@ -384,6 +520,7 @@ func walletQueryMethodRegistry() []schemaMethod { { Name: "outpoint", Type: "string[]", + FlagType: "stringArray", Required: true, Description: "VTXO outpoint " + "(txid:vout); repeatable", @@ -392,7 +529,6 @@ func walletQueryMethodRegistry() []schemaMethod { RequestType: "GetExitPlanRequest", ResponseType: "GetExitPlanResponse", JSONInput: false, - MCPTool: true, }, { Method: "wallet-sweep", @@ -429,7 +565,6 @@ func walletQueryMethodRegistry() []schemaMethod { RequestType: "SweepWalletRequest", ResponseType: "SweepWalletResponse", JSONInput: false, - MCPTool: true, }, { Method: "activity.inspect", @@ -441,6 +576,7 @@ func walletQueryMethodRegistry() []schemaMethod { Type: "string", Required: true, Description: "WalletEntry id", + Positional: true, }, { Name: "ledger-limit", @@ -487,19 +623,19 @@ func arkBaseMethodRegistry() []schemaMethod { "sweep and track confirmation", }, { - Name: "fee_rate_sat_per_vbyte", + Name: "fee-rate-sat-per-vbyte", Type: "int64", Description: "fee rate override; " + "zero estimates by target", }, { - Name: "conf_target", + Name: "conf-target", Type: "uint32", Description: "confirmation target; " + "zero uses default", }, { - Name: "sweep_address", + Name: "sweep-address", Type: "string", Description: "optional destination; " + "empty uses wallet address", @@ -522,13 +658,13 @@ func arkBaseMethodRegistry() []schemaMethod { "external_resolved, or failed", }, { - Name: "page_size", + Name: "page-size", Type: "uint32", Description: "maximum sweeps to " + "return; zero uses default", }, { - Name: "page_token", + Name: "page-token", Type: "string", Description: "token from a previous " + "sweep list response", @@ -596,6 +732,7 @@ func arkBaseMethodRegistry() []schemaMethod { RequestType: "NewReceiveScriptRequest", ResponseType: "NewReceiveScriptResponse", JSONInput: true, + MCPTool: true, }, } } @@ -634,6 +771,7 @@ func arkVTXOMethodRegistry() []schemaMethod { RequestType: "ListVTXOsRequest", ResponseType: "ListVTXOsResponse", JSONInput: true, + MCPTool: true, }, { Method: "ark.vtxos.refresh", @@ -661,6 +799,11 @@ func arkVTXOMethodRegistry() []schemaMethod { Description: "skip the interactive " + "fee confirmation", }, + { + Name: "dry_run", + Type: "bool", + Description: "preview without queuing", + }, { Name: "no_join", Type: "bool", @@ -672,6 +815,7 @@ func arkVTXOMethodRegistry() []schemaMethod { ResponseType: "RefreshVTXOsResponse", DryRun: true, JSONInput: true, + MCPTool: true, }, { Method: "ark.vtxos.leave", @@ -715,6 +859,11 @@ func arkVTXOMethodRegistry() []schemaMethod { Description: "skip --all " + "interactive confirmation", }, + { + Name: "dry_run", + Type: "bool", + Description: "preview without queuing", + }, { Name: "no_join", Type: "bool", @@ -726,6 +875,7 @@ func arkVTXOMethodRegistry() []schemaMethod { ResponseType: "LeaveVTXOsResponse", DryRun: true, JSONInput: true, + MCPTool: true, }, } } @@ -741,21 +891,33 @@ func arkSendMethodRegistry() []schemaMethod { { Name: "to", Type: "string[]", - Required: true, Description: "recipient address(es)", }, + { + Name: "pubkey", + Type: "string[]", + Description: "recipient x-only " + + "public key(s)", + }, { Name: "amount", Type: "int64[]", Required: true, Description: "amount(s) in sats " + - "(one per --to)", + "(one per recipient)", + }, + { + Name: "dry_run", + Type: "bool", + Description: "validate without " + + "submitting", }, }, RequestType: "SendVTXORequest", ResponseType: "SendVTXOResponse", DryRun: true, JSONInput: true, + MCPTool: true, }, { Method: "ark.send.oor", @@ -765,24 +927,14 @@ func arkSendMethodRegistry() []schemaMethod { Name: "to", Type: "string", Description: "recipient address " + - "(exactly one of to, pubkey, " + - "or pk_script)", + "(exactly one of to or pubkey)", }, { Name: "pubkey", Type: "string", Description: "recipient 32-byte " + "x-only pubkey hex (exactly " + - "one of to, pubkey, or " + - "pk_script)", - }, - { - Name: "pk_script", - Type: "string", - Description: "recipient raw " + - "pk_script hex (exactly one " + - "of to, pubkey, or " + - "pk_script)", + "one of to or pubkey)", }, { Name: "amount", @@ -796,11 +948,18 @@ func arkSendMethodRegistry() []schemaMethod { Description: "stable caller intent " + "key for retry-safe OOR sends", }, + { + Name: "dry_run", + Type: "bool", + Description: "validate without " + + "initiating", + }, }, RequestType: "SendOORRequest", ResponseType: "SendOORResponse", DryRun: true, JSONInput: true, + MCPTool: true, }, } } diff --git a/cmd/wavecli/waveclicommands/schema_registry_ark_observable.go b/cmd/wavecli/waveclicommands/schema_registry_ark_observable.go index 147897963..72b038807 100644 --- a/cmd/wavecli/waveclicommands/schema_registry_ark_observable.go +++ b/cmd/wavecli/waveclicommands/schema_registry_ark_observable.go @@ -49,6 +49,16 @@ func arkObservableMutateMethodRegistry() []schemaMethod { ResponseType: "BoardResponse", JSONInput: true, }, + { + Method: "ark.rounds.join", + Description: "Commit queued intents and join the " + + "next " + + "round", + Params: nil, + RequestType: "JoinNextRoundRequest", + ResponseType: "JoinNextRoundResponse", + JSONInput: false, + }, } } diff --git a/docs/daemon_cli_guide.md b/docs/daemon_cli_guide.md index 2c22ac5f6..97eb5f509 100644 --- a/docs/daemon_cli_guide.md +++ b/docs/daemon_cli_guide.md @@ -608,6 +608,12 @@ wavecli schema ark.vtxos.list wavecli schema --all ``` +Every schema entry includes a stable `output_schema_id` and +`output_schema_version`, plus a `side_effect` boolean and whether the same +method is exposed through MCP. The unit suite walks the real cobra command +tree and the live MCP tool list, so a declared flag or tool cannot silently +drift from the executable surface. + ### `mcp serve` (wavewalletrpc) Start an MCP (Model Context Protocol) server on stdio for AI agent