go-mcp-jsonrpc-param-shape
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.
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}