diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index f463c6b70..9e39a9e99 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -49,6 +49,13 @@ Options listed below were added and will be removed in the 1.11.0 version of the tells a client nothing and is indistinguishable from an unset code. See issue #1367. +- `nointernalerrorcode` added. If set to `1`, an error returned by a request + handler that carries no JSON-RPC error code is sent with code `0`, restoring + the previous behavior. The default behavior was changed to send it with + `-32603` (Internal error): `0` tells the peer nothing and is + indistinguishable from an unset code. The error's message is unchanged. + See issue #1394. + - `fatalnewprotocolerrors` added. If set to `1`, a streamable HTTP client permanently fails its connection when an independent `2026-07-28` request fails, restoring the previous behavior. By default, the failure is returned diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 2129e370b..c99528274 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -48,6 +48,13 @@ Options listed below were added and will be removed in the 1.11.0 version of the tells a client nothing and is indistinguishable from an unset code. See issue #1367. +- `nointernalerrorcode` added. If set to `1`, an error returned by a request + handler that carries no JSON-RPC error code is sent with code `0`, restoring + the previous behavior. The default behavior was changed to send it with + `-32603` (Internal error): `0` tells the peer nothing and is + indistinguishable from an unset code. The error's message is unchanged. + See issue #1394. + - `fatalnewprotocolerrors` added. If set to `1`, a streamable HTTP client permanently fails its connection when an independent `2026-07-28` request fails, restoring the previous behavior. By default, the failure is returned diff --git a/mcp/shared.go b/mcp/shared.go index 8cc117643..dcc69b482 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -42,6 +42,17 @@ import ( // The option will be removed in a future version of the SDK. var nowrapinvalidparams = mcpgodebug.Value("nowrapinvalidparams") +// nointernalerrorcode is a compatibility parameter that restores the previous +// behavior of [handleReceive]. When unset (the default), an error returned by +// a request handler that carries no JSON-RPC error code is sent with code +// -32603 (Internal error) rather than the zero-value code 0, which tells the +// peer nothing. The error's message is unchanged. See: +// https://github.com/modelcontextprotocol/go-sdk/issues/1394. +// +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in a future version of the SDK. +var nointernalerrorcode = mcpgodebug.Value("nointernalerrorcode") + const ( // latestProtocolVersion is the latest protocol version that this version of // the SDK supports. @@ -212,11 +223,30 @@ func handleReceive[S Session](ctx context.Context, session S, jreq *jsonrpc.Requ // mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol. res, err := mh(ctx, jreq.Method, req) if err != nil { - return nil, err + return nil, withInternalErrorCode(err) } return res, nil } +// withInternalErrorCode gives an error that carries no JSON-RPC error code the +// code -32603 (Internal error), so that it does not reach the peer as code 0. +// Errors that already carry a code are returned unchanged, as is +// [jsonrpc2.ErrNotHandled], which the connection turns into "method not found". +func withInternalErrorCode(err error) error { + var wireErr *jsonrpc.Error + if nointernalerrorcode == "1" || errors.As(err, &wireErr) || errors.Is(err, jsonrpc2.ErrNotHandled) { + return err + } + return &internalError{err} +} + +// internalError reports err with the code of [jsonrpc2.ErrInternal]. Its +// message is err's, and err stays in its chain for [errors.Is] and [errors.As]. +type internalError struct{ err error } + +func (e *internalError) Error() string { return e.err.Error() } +func (e *internalError) Unwrap() []error { return []error{e.err, jsonrpc2.ErrInternal} } + // checkRequest checks the given request against the provided method info, to // ensure it is a valid MCP request. // diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 45667689b..e9dc19066 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -5,8 +5,10 @@ package mcp import ( + "context" "encoding/json" "errors" + "fmt" "strings" "testing" @@ -658,3 +660,82 @@ func TestUnmarshalParamsInvalidParamsCode(t *testing.T) { t.Errorf("error message = %q, want it to mention unmarshaling", err.Error()) } } + +// TestHandlerErrorWithoutCode asserts that an error returned by a request +// handler without a JSON-RPC error code reaches the peer as -32603 (Internal +// error) with its message unchanged, while an error that carries a code keeps +// it. Both directions use the same path: a server's prompt handler and a +// client's sampling handler. See +// https://github.com/modelcontextprotocol/go-sdk/issues/1394. +func TestHandlerErrorWithoutCode(t *testing.T) { + coded := &jsonrpc.Error{Code: -32002, Message: "prompt is gone"} + for _, tc := range []struct { + name string + err error + wantCode int64 + }{ + {"plain", errors.New("prompt failed"), jsonrpc.CodeInternalError}, + {"wrapped plain", fmt.Errorf("rendering: %w", errors.New("prompt failed")), jsonrpc.CodeInternalError}, + {"coded", coded, -32002}, + {"wrapped coded", fmt.Errorf("lookup: %w", coded), -32002}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + server := NewServer(&Implementation{Name: "server", Version: "v0.0.1"}, nil) + server.AddPrompt(&Prompt{Name: "p"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) { + return nil, tc.err + }) + client := NewClient(&Implementation{Name: "client", Version: "v0.0.1"}, &ClientOptions{ + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, tc.err + }, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + // Sampling outside a request needs a protocol version before 2026-07-28. + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + check := func(who string, err error) { + t.Helper() + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("%s: error %v carries no JSON-RPC error", who, err) + } + if rpcErr.Code != tc.wantCode { + t.Errorf("%s: code = %d, want %d", who, rpcErr.Code, tc.wantCode) + } + if !strings.Contains(rpcErr.Message, tc.err.Error()) { + t.Errorf("%s: message = %q, want it to contain %q", who, rpcErr.Message, tc.err.Error()) + } + } + _, err = cs.GetPrompt(ctx, &GetPromptParams{Name: "p"}) + check("server prompt handler", err) + _, err = ss.CreateMessage(ctx, &CreateMessageParams{Messages: []*SamplingMessage{{Role: "user", Content: &TextContent{Text: "hi"}}}}) + check("client sampling handler", err) + + // MCPGODEBUG=nointernalerrorcode=1 restores the old code, 0, for errors without one. + defer func(old string) { nointernalerrorcode = old }(nointernalerrorcode) + nointernalerrorcode = "1" + _, err = cs.GetPrompt(ctx, &GetPromptParams{Name: "p"}) + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("old behavior: error %v carries no JSON-RPC error", err) + } + want := tc.wantCode + if want == jsonrpc.CodeInternalError { + want = 0 + } + if rpcErr.Code != want { + t.Errorf("old behavior: code = %d, want %d", rpcErr.Code, want) + } + }) + } +}