go-sdk/mcp/shared_test.go
José M. Requena Plens 3f43bcf012
mcp: add HasParams so middleware can tell whether params are present (#1269)
This adds `mcp.HasParams(req Request) bool`, so a receiving middleware
can ask whether the message it was handed carried a `params` member.

The member is optional for several methods: `serverMethodInfos` sets
`missingParamsOK` for `server/discover`, `ping`, the four list methods
and the `cancelled`, `initialized` and `roots/list_changed`
notifications, so `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` is a
complete request. For one of those, `unmarshalParams` returns a nil
`Params`, `newRequest` leaves `ServerRequest.Params` at the zero value
of `P`, and `GetParams` hands the middleware a non-nil interface holding
a typed nil pointer. Comparing that against nil says params are present,
and the first method called on it dereferences: `GetMeta` is promoted
from the embedded `Meta`. `Params` already declares `isNil` for
precisely this case, and it is unexported, so from outside the package
the accessor named after the question is the one thing that cannot be
reached.

`HasParams` is a function over `Request` rather than an exported method
on `Params` for two reasons. A middleware holds a `Request`, and this
way the caller never has to hold the value that panics. More
importantly, `isNil` cannot answer the question for every `Params`
value, so exporting it would ship a predicate that is itself unsafe: a
params type declared the documented way for a custom method, `struct{
mcp.ParamsBase }`, promotes `isNil` through a field selector, and that
dereferences the nil outer pointer before the method body runs. The
predicate therefore has to be computed from outside the value, and
`reflect.Value.IsNil` is what works for all of them.

Three judgement calls, stated so nobody has to find them.

First, `getRequestMeta` now goes through `HasParams`. That is a second
fix rather than a refactor: `AddReceivingCustomMethod` registers with
`missingParamsOK` too, so a client omitting `params` for a custom method
made `ServerRequest.ProtocolVersion`, `ClientInfo` and
`ClientCapabilities` panic in the promoted `isNil`. They now return
`""`, `nil` and `nil`. I could have left it out to keep the diff to one
function, but then the SDK would export a predicate that says to ask
before touching params while three of its own accessors still panicked
on the same value. If you would rather see it as a separate commit, say
so and I will split it.

Second, I did not add the predicate to the `Request` interface.
`Request` is sealed by `isRequest`, so a method would have been a
compatible change and would read slightly better at the call site, but a
free function leaves the interface alone and serves every `Request` just
as well.

Third, this reflects once per call of `getRequestMeta`. It is one
`reflect.ValueOf` on a value that is already an interface, on a path
where `newResult` already reflects per request.

Tests, both in `mcp/shared_test.go`.
`TestServerRequest_PerRequestAccessors_MissingParams` is the one that
fails before the change and passes after: on `main` it panics inside
`getRequestMeta` with `invalid memory address or nil pointer
dereference` from `(*customParams).isNil`, and here it asserts the three
accessors return zero values. `TestHasParams` covers the exported
behaviour: it builds exactly what `handleReceive` hands the middleware
chain for `tools/list`, `prompts/list`, `resources/list` and
`notifications/initialized` with no `params` member, asserts that
`GetParams` still returns a non-nil interface so the case under test is
still the typed-nil one, asserts `HasParams` reports false, and asserts
it reports true when params are present.

Behaviour an existing user would see change: only the three accessors
above, and only for a custom-method params type built on `ParamsBase`
whose `params` member was omitted, where a panic becomes a zero value.
Nothing about the wire format changes, no exported signature moves, and
no interface gains a method. A middleware that still writes
`req.GetParams() != nil` and then dereferences still panics; this gives
a caller the way to ask, it does not rescue a caller that does not.

Also here: one sentence on the doc comments of both
`AddReceivingMiddleware` methods saying a received message need not
carry params, since that is where a middleware author is reading, and
`examples/server/middleware/main.go` now logs `has_params` with
`mcp.HasParams` instead of the comparison that cannot work. The
example's printed output is unchanged, because the SDK client always
sends a `params` member for the methods it drives.

`go test ./...` and `gofmt -l .` are clean.

One thing to say plainly, since it is yours to decide and not mine: this
adds an exported symbol. `HasParams` is a new name in the public API of
`mcp`, and everything else here is doc comments, an example and the
three accessors. If you would rather not grow the surface for this, the
smaller answer is to keep the `getRequestMeta` fix and the doc-comment
sentence and drop the exported function, and a middleware author then
has the guarantee written down where they are reading even though they
still cannot ask the question in code. I prefer the function because the
guarantee alone leaves every middleware writing the same three lines of
`reflect`, and the one that does not write them panics on an ordinary
`tools/list`, but I will cut it to whichever shape you want.


Fixes #1261.

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
2026-09-14 18:45:48 +02:00

660 lines
20 KiB
Go

// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package mcp
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
func TestValidateRequestMeta(t *testing.T) {
tests := []struct {
name string
method string
params any
wantUsesNew bool
wantLogLevel LoggingLevel
wantErrContains string
}{
{
name: "no params: old protocol",
method: methodListTools,
params: nil,
wantUsesNew: false,
},
{
name: "no _meta: old protocol",
method: methodCallTool,
params: map[string]any{"name": "x"},
wantUsesNew: false,
},
{
name: "_meta without protocolVersion: old protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{"otherKey": "v"},
"name": "x",
},
wantUsesNew: false,
},
{
name: "new protocol with all required fields",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: true,
},
{
name: "new protocol missing clientInfo",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: true,
},
{
name: "new protocol invalid clientInfo",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: "not an object",
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: false,
wantErrContains: MetaKeyClientInfo,
},
{
name: "new protocol missing clientCapabilities",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
},
"name": "x",
},
wantUsesNew: false,
wantErrContains: MetaKeyClientCapabilities,
},
{
name: "malformed _meta is ignored",
method: methodCallTool,
params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`),
wantUsesNew: false,
},
{
name: "supported pre-2026-07-28 version: old protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20251125,
},
"name": "x",
},
wantUsesNew: false,
},
{
name: "empty protocolVersion declares nothing: old protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: "",
},
"name": "x",
},
wantUsesNew: false,
},
{
// An unsupported version sorting below 2026-07-28 is still a
// new-protocol request, so that the caller can answer it with
// CodeUnsupportedProtocolVersion.
name: "unsupported version: new protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: "1900-01-01",
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: true,
},
{
// The triple is defined by a revision the SDK does not know, so an
// unsupported version is reported without validating the rest of it.
name: "unsupported version missing clientCapabilities: new protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: "1900-01-01",
},
"name": "x",
},
wantUsesNew: true,
},
{
name: "new protocol with logLevel",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
MetaKeyClientCapabilities: map[string]any{},
MetaKeyLogLevel: "warning",
},
"name": "x",
},
wantUsesNew: true,
wantLogLevel: "warning",
},
{
name: "new protocol without logLevel",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: true,
wantLogLevel: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var raw json.RawMessage
switch p := tc.params.(type) {
case json.RawMessage:
raw = p
default:
raw = mustMarshal(tc.params)
}
id, err := jsonrpc.MakeID("test")
if err != nil {
t.Fatal(err)
}
req := &jsonrpc.Request{Method: tc.method, Params: raw, ID: id}
vmeta, err := validateRequestMeta(req)
usesNew := vmeta != nil && vmeta.usesNewProtocol
if usesNew != tc.wantUsesNew {
t.Errorf("usesNewProtocol = %v, want %v", usesNew, tc.wantUsesNew)
}
if vmeta != nil && vmeta.logLevel != tc.wantLogLevel {
t.Errorf("logLevel = %q, want %q", vmeta.logLevel, tc.wantLogLevel)
}
if tc.wantErrContains == "" {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErrContains)
}
var jerr *jsonrpc.Error
if !errors.As(err, &jerr) {
t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err)
}
if jerr.Code != jsonrpc.CodeInvalidParams {
t.Errorf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidParams)
}
if !strings.Contains(jerr.Message, tc.wantErrContains) {
t.Errorf("error message %q does not contain %q", jerr.Message, tc.wantErrContains)
}
})
}
}
func TestServerRequest_PerRequestAccessors(t *testing.T) {
// A request carrying the new-protocol _meta fields populates the
// accessors with values from _meta.
caps := &ClientCapabilities{Sampling: &SamplingCapabilities{}}
info := &Implementation{Name: "c", Version: "1"}
params := &CallToolParamsRaw{
Meta: Meta{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: info,
MetaKeyClientCapabilities: caps,
},
Name: "x",
}
req := &ServerRequest[*CallToolParamsRaw]{Params: params}
if got := req.ProtocolVersion(); got != protocolVersion20260728 {
t.Errorf("ProtocolVersion = %q, want %q", got, protocolVersion20260728)
}
if got := req.ClientInfo(); got == nil || got.Name != "c" {
t.Errorf("ClientInfo = %+v, want Name=c", got)
}
if got := req.ClientCapabilities(); got == nil || got.Sampling == nil {
t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", got)
}
}
func TestServerRequest_PerRequestAccessors_FromJSON(t *testing.T) {
// Values arriving over the wire are JSON maps; the accessors should
// re-decode them into typed Go values.
raw := json.RawMessage(`{
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "wire-client", "version": "9"},
"io.modelcontextprotocol/clientCapabilities": {"sampling": {}}
},
"name": "tool"
}`)
var params CallToolParamsRaw
if err := json.Unmarshal(raw, &params); err != nil {
t.Fatal(err)
}
req := &ServerRequest[*CallToolParamsRaw]{Params: &params}
if got, want := req.ProtocolVersion(), protocolVersion20260728; got != want {
t.Errorf("ProtocolVersion = %q, want %q", got, want)
}
gotInfo := req.ClientInfo()
wantInfo := &Implementation{Name: "wire-client", Version: "9"}
if diff := cmp.Diff(wantInfo, gotInfo); diff != "" {
t.Errorf("ClientInfo mismatch (-want +got):\n%s", diff)
}
gotCaps := req.ClientCapabilities()
if gotCaps == nil || gotCaps.Sampling == nil {
t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", gotCaps)
}
}
func TestServerRequest_PerRequestAccessors_FallbackToInitializeParams(t *testing.T) {
// With no _meta on the request, accessors must fall back to the
// session's InitializeParams (the old-protocol path).
ss := &ServerSession{}
ss.state.InitializeParams = &InitializeParams{
ProtocolVersion: protocolVersion20251125,
ClientInfo: &Implementation{Name: "old", Version: "0"},
Capabilities: &ClientCapabilities{Elicitation: &ElicitationCapabilities{}},
}
req := &ServerRequest[*CallToolParamsRaw]{
Session: ss,
Params: &CallToolParamsRaw{Name: "x"},
}
if got, want := req.ProtocolVersion(), protocolVersion20251125; got != want {
t.Errorf("ProtocolVersion fallback = %q, want %q", got, want)
}
if got := req.ClientInfo(); got == nil || got.Name != "old" {
t.Errorf("ClientInfo fallback = %+v, want Name=old", got)
}
if got := req.ClientCapabilities(); got == nil || got.Elicitation == nil {
t.Errorf("ClientCapabilities fallback = %+v, want non-nil Elicitation", got)
}
}
func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) {
// With no _meta and no session, accessors return zero values.
req := &ServerRequest[*CallToolParamsRaw]{
Params: &CallToolParamsRaw{Name: "x"},
}
if got := req.ProtocolVersion(); got != "" {
t.Errorf("ProtocolVersion = %q, want empty", got)
}
if got := req.ClientInfo(); got != nil {
t.Errorf("ClientInfo = %+v, want nil", got)
}
if got := req.ClientCapabilities(); got != nil {
t.Errorf("ClientCapabilities = %+v, want nil", got)
}
}
func TestHasParams(t *testing.T) {
// {"jsonrpc":"2.0","id":1,"method":"tools/list"} is a complete request:
// serverMethodInfos marks these methods missingParamsOK. Build what
// handleReceive hands the middleware chain for one.
for _, method := range []string{
methodListTools,
methodListPrompts,
methodListResources,
notificationInitialized,
} {
t.Run(method, func(t *testing.T) {
info := serverMethodInfos[method]
params, err := info.unmarshalParams(nil)
if err != nil {
t.Fatal(err)
}
req := info.newRequest(&ServerSession{}, params, nil)
if req.GetParams() == nil {
t.Fatal("GetParams returned a nil interface, so this test no longer covers the typed-nil case")
}
if HasParams(req) {
t.Error("HasParams = true for a request whose params member was omitted")
}
})
}
t.Run("params present", func(t *testing.T) {
info := serverMethodInfos[methodListTools]
params, err := info.unmarshalParams(json.RawMessage(`{"cursor":"c"}`))
if err != nil {
t.Fatal(err)
}
req := info.newRequest(&ServerSession{}, params, nil)
if !HasParams(req) {
t.Error("HasParams = false for a request carrying params")
}
})
}
func TestServerRequest_PerRequestAccessors_MissingParams(t *testing.T) {
// Custom methods also allow the params member to be omitted, and their
// params types embed ParamsBase, whose promoted isNil dereferences the
// typed-nil outer pointer.
type customParams struct{ ParamsBase }
req := &ServerRequest[*customParams]{}
if got := req.ProtocolVersion(); got != "" {
t.Errorf("ProtocolVersion = %q, want empty", got)
}
if got := req.ClientInfo(); got != nil {
t.Errorf("ClientInfo = %+v, want nil", got)
}
if got := req.ClientCapabilities(); got != nil {
t.Errorf("ClientCapabilities = %+v, want nil", got)
}
}
func TestImplementationDescriptionJSON(t *testing.T) {
impl := &Implementation{
Name: "greeter",
Title: "Greeter",
Description: "Example server for greeting tools",
Version: "v1.0.0",
}
got, err := json.Marshal(impl)
if err != nil {
t.Fatal(err)
}
want := `{"name":"greeter","title":"Greeter","description":"Example server for greeting tools","version":"v1.0.0"}`
if string(got) != want {
t.Fatalf("Implementation JSON = %s, want %s", got, want)
}
var roundTrip Implementation
if err := json.Unmarshal(got, &roundTrip); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(impl, &roundTrip); diff != "" {
t.Fatalf("Implementation round trip mismatch (-want +got):\n%s", diff)
}
got, err = json.Marshal(&Implementation{Name: "greeter", Version: "v1.0.0"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(got), "description") {
t.Fatalf("empty description should be omitted, got %s", got)
}
}
// TODO(v0.3.0): rewrite this test.
// func TestToolValidate(t *testing.T) {
// // Check that the tool returned from NewServerTool properly validates its input schema.
// type req struct {
// I int
// B bool
// S string `json:",omitempty"`
// P *int `json:",omitempty"`
// }
// dummyHandler := func(context.Context, *CallToolRequest, req) (*CallToolResultFor[any], error) {
// return nil, nil
// }
// st, err := newServerTool(&Tool{Name: "test", Description: "test"}, dummyHandler)
// if err != nil {
// t.Fatal(err)
// }
// for _, tt := range []struct {
// desc string
// args map[string]any
// want string // error should contain this string; empty for success
// }{
// {
// "both required",
// map[string]any{"I": 1, "B": true},
// "",
// },
// {
// "optional",
// map[string]any{"I": 1, "B": true, "S": "foo"},
// "",
// },
// {
// "wrong type",
// map[string]any{"I": 1.5, "B": true},
// "cannot unmarshal",
// },
// {
// "extra property",
// map[string]any{"I": 1, "B": true, "C": 2},
// "unknown field",
// },
// {
// "value for pointer",
// map[string]any{"I": 1, "B": true, "P": 3},
// "",
// },
// {
// "null for pointer",
// map[string]any{"I": 1, "B": true, "P": nil},
// "",
// },
// } {
// t.Run(tt.desc, func(t *testing.T) {
// raw, err := json.Marshal(tt.args)
// if err != nil {
// t.Fatal(err)
// }
// _, err = st.handler(context.Background(), &ServerRequest[*CallToolParamsFor[json.RawMessage]]{
// Params: &CallToolParamsFor[json.RawMessage]{Arguments: json.RawMessage(raw)},
// })
// if err == nil && tt.want != "" {
// t.Error("got success, wanted failure")
// }
// if err != nil {
// if tt.want == "" {
// t.Fatalf("failed with:\n%s\nwanted success", err)
// }
// if !strings.Contains(err.Error(), tt.want) {
// t.Fatalf("got:\n%s\nwanted to contain %q", err, tt.want)
// }
// }
// })
// }
// }
// TestNilParamsHandling tests that nil parameters don't cause panic in unmarshalParams.
// This addresses a vulnerability where missing or null parameters could crash the server.
// func TestNilParamsHandling(t *testing.T) {
// // Define test types for clarity
// type TestArgs struct {
// Name string `json:"name"`
// Value int `json:"value"`
// }
// // Simple test handler
// testHandler := func(ctx context.Context, req *ServerRequest[**GetPromptParams]) (*GetPromptResult, error) {
// result := "processed: " + req.Params.Arguments.Name
// return &CallToolResultFor[string]{StructuredContent: result}, nil
// }
// methodInfo := newServerMethodInfo(testHandler, missingParamsOK)
// // Helper function to test that unmarshalParams doesn't panic and handles nil gracefully
// mustNotPanic := func(t *testing.T, rawMsg json.RawMessage, expectNil bool) Params {
// t.Helper()
// defer func() {
// if r := recover(); r != nil {
// t.Fatalf("unmarshalParams panicked: %v", r)
// }
// }()
// params, err := methodInfo.unmarshalParams(rawMsg)
// if err != nil {
// t.Fatalf("unmarshalParams failed: %v", err)
// }
// if expectNil {
// if params != nil {
// t.Fatalf("Expected nil params, got %v", params)
// }
// return params
// }
// if params == nil {
// t.Fatal("unmarshalParams returned unexpected nil")
// }
// // Verify the result can be used safely
// typedParams := params.(TestParams)
// _ = typedParams.Name
// _ = typedParams.Arguments.Name
// _ = typedParams.Arguments.Value
// return params
// }
// // Test different nil parameter scenarios - with missingParamsOK flag, nil/null should return nil
// t.Run("missing_params", func(t *testing.T) {
// mustNotPanic(t, nil, true) // Expect nil with missingParamsOK flag
// })
// t.Run("explicit_null", func(t *testing.T) {
// mustNotPanic(t, json.RawMessage(`null`), true) // Expect nil with missingParamsOK flag
// })
// t.Run("empty_object", func(t *testing.T) {
// mustNotPanic(t, json.RawMessage(`{}`), false) // Empty object should create valid params
// })
// t.Run("valid_params", func(t *testing.T) {
// rawMsg := json.RawMessage(`{"name":"test","arguments":{"name":"hello","value":42}}`)
// params := mustNotPanic(t, rawMsg, false)
// // For valid params, also verify the values are parsed correctly
// typedParams := params.(TestParams)
// if typedParams.Name != "test" {
// t.Errorf("Expected name 'test', got %q", typedParams.Name)
// }
// if typedParams.Arguments.Name != "hello" {
// t.Errorf("Expected argument name 'hello', got %q", typedParams.Arguments.Name)
// }
// if typedParams.Arguments.Value != 42 {
// t.Errorf("Expected argument value 42, got %d", typedParams.Arguments.Value)
// }
// })
// }
// TestNilParamsEdgeCases tests edge cases to ensure we don't over-fix
// func TestNilParamsEdgeCases(t *testing.T) {
// type TestArgs struct {
// Name string `json:"name"`
// Value int `json:"value"`
// }
// type TestParams = *CallToolParamsFor[TestArgs]
// testHandler := func(context.Context, *ServerRequest[TestParams]) (*CallToolResultFor[string], error) {
// return &CallToolResultFor[string]{StructuredContent: "test"}, nil
// }
// methodInfo := newServerMethodInfo(testHandler, missingParamsOK)
// // These should fail normally, not be treated as nil params
// invalidCases := []json.RawMessage{
// json.RawMessage(""), // empty string - should error
// json.RawMessage("[]"), // array - should error
// json.RawMessage(`"null"`), // string "null" - should error
// json.RawMessage("0"), // number - should error
// json.RawMessage("false"), // boolean - should error
// }
// for i, rawMsg := range invalidCases {
// t.Run(fmt.Sprintf("invalid_case_%d", i), func(t *testing.T) {
// params, err := methodInfo.unmarshalParams(rawMsg)
// if err == nil && params == nil {
// t.Error("Should not return nil params without error")
// }
// })
// }
// // Test that methods without missingParamsOK flag properly reject nil params
// t.Run("reject_when_params_required", func(t *testing.T) {
// methodInfoStrict := newServerMethodInfo(testHandler, 0) // No missingParamsOK flag
// testCases := []struct {
// name string
// params json.RawMessage
// }{
// {"nil_params", nil},
// {"null_params", json.RawMessage(`null`)},
// }
// for _, tc := range testCases {
// t.Run(tc.name, func(t *testing.T) {
// _, err := methodInfoStrict.unmarshalParams(tc.params)
// if err == nil {
// t.Error("Expected error for required params, got nil")
// }
// if !strings.Contains(err.Error(), "missing required \"params\"") {
// t.Errorf("Expected 'missing required params' error, got: %v", err)
// }
// })
// }
// })
// }
// TestUnmarshalParamsInvalidParamsCode is a regression test for
// https://github.com/modelcontextprotocol/go-sdk/issues/976#issuecomment-4829124838.
// Malformed params for a registered method must surface as a JSON-RPC error
// whose code is CodeInvalidParams (-32602) rather than the zero-value 0.
func TestUnmarshalParamsInvalidParamsCode(t *testing.T) {
info, ok := serverMethodInfos[methodPing]
if !ok {
t.Fatalf("no methodInfo for %q", methodPing)
}
// Array where a struct is expected.
_, err := info.unmarshalParams(json.RawMessage(`["a","b"]`))
if err == nil {
t.Fatal("unmarshalParams returned nil error for malformed params")
}
var jerr *jsonrpc.Error
if !errors.As(err, &jerr) {
t.Fatalf("expected *jsonrpc.Error in chain, got %T: %v", err, err)
}
if jerr.Code != jsonrpc.CodeInvalidParams {
t.Errorf("error code = %d, want %d (CodeInvalidParams)", jerr.Code, jsonrpc.CodeInvalidParams)
}
if !strings.Contains(err.Error(), "unmarshaling") {
t.Errorf("error message = %q, want it to mention unmarshaling", err.Error())
}
}