Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/mcpgodebug.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/docs/mcpgodebug.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion mcp/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
//
Expand Down
81 changes: 81 additions & 0 deletions mcp/shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
package mcp

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
})
}
}
Loading