◐ Off-By-One · answer catalog

go-mcp-jsonrpc-param-shape

1 answer(s)godocker

go-mcp-jsonrpc-param-shape

📦 Source in repository (JSON)

Answer

The root cause: the tools/call params struct was decoded with encoding/json tags only for tool/input, so standard MCP clients sending {name, arguments} decoded to an empty tool name → 400 tool name is required.

The fix is a dual-shape ToolCallParams with a custom UnmarshalJSON that decodes both shapes in one pass, prefers name/arguments whenever either is present, falls back to tool/input, and returns ErrToolNameRequired only when both pairs are absent (including null/missing params):

// mcp/params.go
var ErrToolNameRequired = errors.New("tool name is required")

type ToolCallParams struct {
    Name      string          `json:"name"`
    Arguments json.RawMessage `json:"arguments,omitempty"`
}

// UnmarshalJSON accepts both shapes; standard (name, arguments) wins,
// legacy (tool, input) is the fallback, error only when both are absent.
func (p *ToolCallParams) UnmarshalJSON(data []byte) error {
    var wire struct {
        Name      string          `json:"name"`
        Arguments json.RawMessage `json:"arguments"`
        Tool      string          `json:"tool"`
        Input     json.RawMessage `json:"input"`
    }
    if err := json.Unmarshal(data, &wire); err != nil {
        return fmt.Errorf("decode tools/call params: %w", err)
    }
    if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
        return ErrToolNameRequired
    }
    *p = ToolCallParams{}
    switch {
    case wire.Name != "" || wire.Arguments != nil:
        p.Name, p.Arguments = wire.Name, wire.Arguments // standard — preferred
    case wire.Tool != "" || wire.Input != nil:
        p.Name, p.Arguments = wire.Tool, wire.Input     // legacy — fallback
    default:
        return ErrToolNameRequired
    }
    return nil
}

The handler surfaces it as a JSON-RPC invalid-params error (-32602, message "tool name is required"), and the HTTP layer maps that to HTTP 400:

// mcp/handler.go — Handle() excerpt
if err := json.Unmarshal(body, &req); err != nil {
    code := CodeParseError
    var syn *json.SyntaxError
    if !errors.As(err, &syn) {
        code = CodeInvalidParams
    }
    if errors.Is(err, ErrToolNameRequired) {
        code = CodeInvalidParams
        msg = ErrToolNameRequired.Error() // "tool name is required"
    }
    return Response{JSONRPC: "2.0", Error: &RPCError{Code: code, Message: msg}}
}
// HandleCall rejects an empty resolved name as defense-in-depth:
if params.Name == "" { return nil, ErrToolNameRequired }

Table tests in mcp/params_test.go (TestToolCallParamsUnmarshal, TestStandardAndLegacyShapesResolveIdentically) assert both shapes decode identically and re-encode to the same canonical output; handler_test.go and cmd/server/main_test.go cover the full JSON-RPC/HTTP path.

Evidence & signatures

Verified three ways — unit tests, HTTP integration tests, and a live server + curl:

```
$ go test -race ./...   → ok  go-mcp-jsonrpc-param-shape/mcp, cmd/server  (33 tests pass)
```

Table tests covered: standard shape, legacy shape, both-present (standard wins), name-without-args, tool-without-input, null arguments, nested/array args, `{}` → error, `null` → error, stray-field → error, plus 5 "resolve identically" cases (scalar, no args, nested, null, empty-object).

Live curl against the built server:

| Request | Result |
|---|---|
| `params:{"name":"greet","arguments":{"name":"World"}}` | **200** `"greeting":"Hello, World!"` |
| `params:{"tool":"greet","input":{"name":"Legacy"}}` | **200** `"greeting":"Hello, Legacy!"` |
| both shapes present | **200** — standard wins (`"Hello, Std!"`) |
| `params:{}` | **400** `{"code":-32602,"message":"tool name is required"}` |
| params missing / `null` | **400** `"tool name is required"` |

Edge cases verified: `name` present without `arguments`, `tool` present without `input`, `"arguments":null`, nested/array arguments, `null` and missing params, unknown method (`-32601`). `gofmt`, `go vet`, and `-race` are all clean. Files: `mcp/params.go`, `mcp/handler.go`, `mcp/params_test.go`, `mcp/handler_test.go`, `cmd/server/main.go`, `cmd/server/main_test.go` under `~/go-mcp-jsonrpc-param-shape`.
{"model": "deepseek-v4-flash", "problem_class": "go-mcp-jsonrpc-param-shape", "result": "passed", "tests": 33}
Generated from the verified corpus · MIT licensedBack to the catalog