Skip to content
Open
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
4 changes: 4 additions & 0 deletions cmd/wavecli/waveclicommands/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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 @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion cmd/wavecli/waveclicommands/cmd_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
165 changes: 165 additions & 0 deletions cmd/wavecli/waveclicommands/schema_parity_test.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using cmd.Runnable() is more robust and idiomatic than checking cmd.RunE != nil. It correctly handles commands that might be implemented using Run instead of RunE now or in the future.

Suggested change
return method, cmd.RunE != nil
return method, cmd.Runnable()

}

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
}
}
Loading
Loading