From f8776c500178183ab24f10c29047c79c44980dc6 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 18 Sep 2026 06:37:25 +0800 Subject: [PATCH] internal/mcp: implement 2026-07-28 error code allocation policy (sa-70) The MCP 2026-07-28 revision partitions the JSON-RPC server-error range: -32000..-32019 stays implementation-defined (grandfathered), while -32020..-32099 is reserved for the MCP specification. The draft codes were renumbered (HeaderMismatch -32001 -> -32020, MissingRequiredClientCapability -32003 -> -32021, UnsupportedProtocolVersion -32004 -> -32022) and resource not found changed from -32002 to -32602 (Invalid Params). ggcode's MCP client previously had no awareness of the reserved range or the renumbering: a server answering -32020/-32021/-32022 surfaced as an opaque "JSON-RPC error -32020: ..." with no spec context, the initialize path did not recognize UnsupportedProtocolVersionError, and resources/read failures did not normalize the legacy/new not-found reporting. - new internal/mcp/errorcodes.go: reserved-range constants (plus grandfathered legacy aliases), classification helpers (isMCPReservedErrorCode / isUnsupportedProtocolVersionCode / isResourceNotFoundCode), decorateSpecError wrapping known spec codes with name+hint while keeping *Error reachable via errors.As, and annotateResourceReadError normalizing -32002 / -32602 not-found reporting for the agent. - client.go: sendRequest decorates spec-defined errors; Initialize renders an actionable UnsupportedProtocolVersion diagnostic listing supported versions (both -32022 and legacy -32004); ReadResource annotates not-found failures. Tests: unit coverage of the range/classification helpers plus end-to-end httptest pins for the decorated HeaderMismatch, the version-rejection diagnostic, and resource-not-found normalization. Full internal/mcp suite passes (goolm); darwin/arm64, linux/amd64, linux/arm64 build. Co-Authored-By: ggcode --- internal/mcp/client.go | 17 ++- internal/mcp/errorcodes.go | 209 +++++++++++++++++++++++++++ internal/mcp/errorcodes_test.go | 241 ++++++++++++++++++++++++++++++++ 3 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 internal/mcp/errorcodes.go create mode 100644 internal/mcp/errorcodes_test.go diff --git a/internal/mcp/client.go b/internal/mcp/client.go index e82da3f8d..eaad94966 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -371,6 +371,13 @@ func (c *Client) Initialize(ctx context.Context) (*InitializeResult, error) { } var result InitializeResult if err := c.sendRequest(ctx, "initialize", params, &result); err != nil { + // MCP 2026-07-28: recognize the spec's UnsupportedProtocolVersionError + // (renumbered -32022; draft -32004) and render an actionable + // diagnostic instead of an opaque JSON-RPC error. + if je, ok := jsonRPCErrorOf(err); ok && isUnsupportedProtocolVersionCode(je.Code) { + debug.Log("mcp-client", "server=%s initialize rejected with UnsupportedProtocolVersion: %v", c.name, err) + return nil, unsupportedProtocolVersionError(c.name, err) + } return nil, fmt.Errorf("mcp[%s]: initialize: %w", c.name, err) } @@ -564,7 +571,10 @@ func (c *Client) ReadResource(ctx context.Context, uri string) (*ReadResourceRes params := ReadResourceParams{URI: uri} var result ReadResourceResult if err := c.callWithMRTR(ctx, "resources/read", ¶ms, &result); err != nil { - return nil, fmt.Errorf("mcp[%s]: resources/read: %w", c.name, err) + // sa-70: normalize the 2026-07-28 resource-not-found change + // (legacy -32002 vs new -32602 Invalid Params) into a message the + // agent can act on. + return nil, fmt.Errorf("mcp[%s]: resources/read: %w", c.name, annotateResourceReadError(err, uri)) } c.storeListingsCache(cacheResourceRead, uri, result, []CacheableResult{result.CacheableResult}) return &result, nil @@ -825,7 +835,10 @@ func (c *Client) sendRequest(ctx context.Context, method string, params interfac } if resp.IsError() { - return resp.Error + // sa-70: annotate spec-defined error codes (reserved range + // -32020..-32099 and grandfathered legacy numbering) with their + // spec names so the agent sees WHY, not just a bare JSON-RPC code. + return decorateSpecError(resp.Error) } if result != nil && resp.Result != nil { diff --git a/internal/mcp/errorcodes.go b/internal/mcp/errorcodes.go new file mode 100644 index 000000000..6b8828fdd --- /dev/null +++ b/internal/mcp/errorcodes.go @@ -0,0 +1,209 @@ +package mcp + +// MCP 2026-07-28 error-code allocation policy (sa-70). +// +// Sources: +// - https://modelcontextprotocol.io/specification/2026-07-28/changelog +// - https://modelcontextprotocol.io/specification/2026-07-28/basic +// +// The 2026-07-28 revision partitions the JSON-RPC server-error range: +// -32000..-32019 stays implementation-defined (existing SDK usage is +// grandfathered); -32020..-32099 is reserved for the MCP specification, +// with codes defined exclusively by the spec and recorded in the schema. +// Codes introduced in the draft were renumbered accordingly: +// HeaderMismatch -32001 -> -32020, MissingRequiredClientCapability +// -32003 -> -32021, UnsupportedProtocolVersion -32004 -> -32022. +// The same revision changed the resource-not-found error code from the +// implementation-defined -32002 to -32602 (Invalid Params) to align with +// the JSON-RPC specification. +// +// GGCODE GAP THIS FILE CLOSES: the MCP client previously had no awareness +// of the reserved range or the renumbering. A server answering with +// -32020/-32021/-32022 surfaced as an opaque "JSON-RPC error -32020: ..." +// with no spec context; the initialize path did not recognize the spec's +// UnsupportedProtocolVersionError as a distinct, actionable condition; and +// resources/read failures did not normalize the legacy -32002 / new +// -32602 not-found reporting for the agent. + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +// JSON-RPC standard code MCP servers overload for resource not found as of +// 2026-07-28 (see isResourceNotFoundCode). +const ErrCodeInvalidParams = -32602 + +// MCP 2026-07-28 spec-reserved server error codes (range -32020..-32099). +const ( + // ErrCodeHeaderMismatch: Streamable HTTP server rejected the required + // standard MCP request headers (Mcp-Method / Mcp-Name, SEP-2243) - + // typically an MCP-aware proxy or routing middleware stripped or + // rewrote them. + ErrCodeHeaderMismatch = -32020 + + // ErrCodeMissingRequiredClientCapability: the request requires a + // client capability the client did not advertise. + ErrCodeMissingRequiredClientCapability = -32021 + + // ErrCodeUnsupportedProtocolVersion (UnsupportedProtocolVersionError, + // SEP-2575): the protocol version the request carries is not supported + // by the server. + ErrCodeUnsupportedProtocolVersion = -32022 +) + +// Pre-2026-07-28 draft numbering for the same conditions, published in the +// transport prose before the allocation policy renumbered them. Kept for +// interoperability with servers still using the grandfathered values; they +// sit in the implementation-defined -32000..-32019 zone and are NOT part +// of the reserved range. +const ( + legacyErrCodeHeaderMismatch = -32001 + legacyErrCodeResourceNotFound = -32002 + legacyErrCodeMissingRequiredClientCapability = -32003 + legacyErrCodeUnsupportedProtocolVersion = -32004 +) + +// isMCPReservedErrorCode reports whether code falls in the server-error +// sub-range the MCP 2026-07-28 specification reserves for itself +// (-32020..-32099). Codes outside it - including the grandfathered +// -32000..-32019 zone - remain implementation-defined. +func isMCPReservedErrorCode(code int) bool { + return code >= -32099 && code <= -32020 +} + +// isUnsupportedProtocolVersionCode reports whether code is the spec's +// UnsupportedProtocolVersionError in either the renumbered (-32022) or the +// pre-policy draft (-32004) numbering. +func isUnsupportedProtocolVersionCode(code int) bool { + return code == ErrCodeUnsupportedProtocolVersion || + code == legacyErrCodeUnsupportedProtocolVersion +} + +// isResourceNotFoundCode reports whether code can carry a resource-not-found +// condition: the legacy implementation-defined -32002, or -32602, which the +// 2026-07-28 spec adopted for not-found ("Change resource not found error +// code from -32002 to -32602 (Invalid Params)"). -32602 is ambiguous on its +// own - callers combine it with method context. +func isResourceNotFoundCode(code int) bool { + return code == legacyErrCodeResourceNotFound || code == ErrCodeInvalidParams +} + +// specErrorCodeHint returns the spec-defined name plus an actionable hint +// for known spec error codes (reserved range, or the grandfathered legacy +// numbering of the same conditions). Unknown codes return empty strings. +func specErrorCodeHint(code int) (name, hint string) { + switch code { + case ErrCodeHeaderMismatch, legacyErrCodeHeaderMismatch: + return "HeaderMismatch", + "the server rejected the required standard MCP request headers " + + "(Mcp-Method / Mcp-Name); check MCP-aware proxies or routing middleware" + case ErrCodeMissingRequiredClientCapability, legacyErrCodeMissingRequiredClientCapability: + return "MissingRequiredClientCapability", + "the request needs a client capability ggcode did not advertise; " + + "enable the corresponding ggcode feature before retrying" + case ErrCodeUnsupportedProtocolVersion, legacyErrCodeUnsupportedProtocolVersion: + return "UnsupportedProtocolVersion", + "the protocol version ggcode sent is not supported by this server" + default: + if isMCPReservedErrorCode(code) { + return "ReservedErrorCode", + "defined by the MCP specification (reserved range -32020..-32099)" + } + return "", "" + } +} + +// specAnnotatedError decorates a server JSON-RPC error whose code is +// spec-defined with the code's spec name and an actionable hint. It +// unwraps to the original *Error so errors.As keeps working for callers +// that classify by code. +type specAnnotatedError struct { + err *Error + name string + hint string +} + +func (e *specAnnotatedError) Error() string { + return fmt.Sprintf("%s [MCP spec: %s - %s]", e.err.Error(), e.name, e.hint) +} + +func (e *specAnnotatedError) Unwrap() error { return e.err } + +// decorateSpecError wraps a JSON-RPC error carrying a known spec-defined +// code (reserved range, or grandfathered legacy numbering) with spec +// context. Other errors pass through unchanged; the original *Error stays +// reachable via errors.As for programmatic classification. +func decorateSpecError(err error) error { + if err == nil { + return nil + } + je, ok := jsonRPCErrorOf(err) + if !ok { + return err + } + var already *specAnnotatedError + if errors.As(err, &already) { + return err // idempotent + } + name, hint := specErrorCodeHint(je.Code) + if name == "" { + return err + } + return &specAnnotatedError{err: je, name: name, hint: hint} +} + +// jsonRPCErrorOf extracts the JSON-RPC *Error from a wrapped error chain. +func jsonRPCErrorOf(err error) (*Error, bool) { + var je *Error + if errors.As(err, &je) { + return je, true + } + return nil, false +} + +// knownProtocolVersionList returns the sorted protocol versions this +// client accepts, for diagnostics. +func knownProtocolVersionList() []string { + vs := make([]string, 0, len(knownMCPProtocolVersions)) + for v := range knownMCPProtocolVersions { + vs = append(vs, v) + } + sort.Strings(vs) + return vs +} + +// unsupportedProtocolVersionError renders the initialize-time diagnostic +// for the spec's UnsupportedProtocolVersionError in either numbering. +// err must already carry the JSON-RPC error (it is included verbatim). +func unsupportedProtocolVersionError(server string, err error) error { + return fmt.Errorf( + "mcp[%s]: server rejected the protocol version (%s): it does not speak any version "+ + "this client supports (%s; latest %s). MCP 2026-07-28 servers advertise versions via "+ + "server/discover and are stateless; check whether a newer ggcode release supports "+ + "this server, or pin the server to a supported version.", + server, err.Error(), strings.Join(knownProtocolVersionList(), ", "), latestMCPProtocolVersion) +} + +// annotateResourceReadError normalizes resources/read failures for the +// 2026-07-28 resource-not-found change: the legacy -32002 code is +// unambiguous, while the new -32602 (Invalid Params) may be either a +// genuine parameter problem or a not-found, so the message says so. +func annotateResourceReadError(err error, uri string) error { + je, ok := jsonRPCErrorOf(err) + if !ok { + return err + } + switch { + case je.Code == legacyErrCodeResourceNotFound: + return fmt.Errorf("resource not found: %s (legacy code %d; 2026-07-28 servers report this as -32602): %w", + uri, je.Code, err) + case je.Code == ErrCodeInvalidParams: + return fmt.Errorf("resources/read rejected for %s with -32602 (Invalid Params); under MCP 2026-07-28 "+ + "this code also reports a missing resource, so verify the URI exists: %w", uri, err) + default: + return err + } +} diff --git a/internal/mcp/errorcodes_test.go b/internal/mcp/errorcodes_test.go new file mode 100644 index 000000000..0feb9fa8f --- /dev/null +++ b/internal/mcp/errorcodes_test.go @@ -0,0 +1,241 @@ +package mcp + +// Tests for the MCP 2026-07-28 error-code allocation policy (sa-70). +// +// Spec: https://modelcontextprotocol.io/specification/2026-07-28/changelog +// - -32020..-32099 is reserved for the MCP specification +// (-32000..-32019 stays implementation-defined / grandfathered). +// - HeaderMismatch -32001 -> -32020, MissingRequiredClientCapability +// -32003 -> -32021, UnsupportedProtocolVersion -32004 -> -32022. +// - Resource not found changes from -32002 to -32602 (Invalid Params). +// +// Pinned behavior: reserved-range errors carry spec context in their +// message, the original *Error stays reachable via errors.As for +// programmatic classification, initialize recognizes the spec's +// UnsupportedProtocolVersionError in both numberings, and resources/read +// failures normalize the legacy/new not-found reporting. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/topcheer/ggcode/internal/config" +) + +func TestIsMCPReservedErrorCode(t *testing.T) { + cases := []struct { + code int + want bool + }{ + {-32602, false}, // JSON-RPC standard + {-32000, false}, // grandfathered implementation-defined zone + {-32019, false}, // zone boundary + {-32001, false}, // legacy draft numbering: implementation-defined zone + {-32020, true}, // reserved range start + {-32022, true}, // UnsupportedProtocolVersion + {-32042, true}, // URL elicitation required + {-32099, true}, // reserved range end + {-32100, false}, // below range + } + for _, tc := range cases { + if got := isMCPReservedErrorCode(tc.code); got != tc.want { + t.Errorf("isMCPReservedErrorCode(%d) = %v, want %v", tc.code, got, tc.want) + } + } +} + +func TestErrorClassificationHelpers(t *testing.T) { + if !isUnsupportedProtocolVersionCode(-32022) || !isUnsupportedProtocolVersionCode(-32004) { + t.Error("both renumbered -32022 and draft -32004 must be recognized as UnsupportedProtocolVersion") + } + if isUnsupportedProtocolVersionCode(-32001) { + t.Error("-32001 is HeaderMismatch, not UnsupportedProtocolVersion") + } + if !isResourceNotFoundCode(-32002) || !isResourceNotFoundCode(-32602) { + t.Error("both legacy -32002 and new -32602 must be recognized as not-found carriers") + } + if isResourceNotFoundCode(-32001) { + t.Error("-32001 must not classify as not-found") + } +} + +func TestDecorateSpecError(t *testing.T) { + // Reserved-range code gets spec context and stays errors.As-able. + orig := &Error{Code: -32020, Message: "missing Mcp-Method header"} + err := decorateSpecError(fmt.Errorf("mcp[srv]: initialize: %w", orig)) + msg := err.Error() + if !strings.Contains(msg, "HeaderMismatch") || !strings.Contains(msg, "-32020") { + t.Fatalf("decorated message should name the spec code, got: %s", msg) + } + var je *Error + if !errors.As(err, &je) || je.Code != -32020 { + t.Fatalf("original *Error must stay reachable via errors.As, got %+v", je) + } + if decorateSpecError(err) != err { + t.Error("decorateSpecError must be idempotent") + } + // Implementation-defined zone passes through untouched. + plain := fmt.Errorf("boom: %w", &Error{Code: -32002, Message: "not found"}) + if got := decorateSpecError(plain); got != plain { + t.Errorf("implementation-defined zone must pass through, got: %v", got) + } + if decorateSpecError(nil) != nil { + t.Error("nil must stay nil") + } +} + +func TestInitializeUnsupportedProtocolVersionDiagnostic(t *testing.T) { + err := fmt.Errorf("mcp[srv]: initialize: %w", + decorateSpecError(&Error{Code: -32022, Message: "unsupported protocol version"})) + msg := unsupportedProtocolVersionError("srv", err).Error() + for _, want := range []string{"UnsupportedProtocolVersion", latestMCPProtocolVersion, "server/discover"} { + if !strings.Contains(msg, want) { + t.Errorf("diagnostic missing %q: %s", want, msg) + } + } + legacy := fmt.Errorf("mcp[srv]: initialize: %w", &Error{Code: -32004, Message: "bad version"}) + if je, ok := jsonRPCErrorOf(legacy); !ok || !isUnsupportedProtocolVersionCode(je.Code) { + t.Error("legacy -32004 must classify as UnsupportedProtocolVersion") + } +} + +func TestAnnotateResourceReadError(t *testing.T) { + legacy := fmt.Errorf("mcp[srv]: resources/read: %w", &Error{Code: -32002, Message: "Resource not found"}) + got := annotateResourceReadError(legacy, "file:///x") + if !strings.Contains(got.Error(), "resource not found") || !strings.Contains(got.Error(), "file:///x") { + t.Fatalf("legacy -32002 should normalize to a not-found message, got: %v", got) + } + var je *Error + if !errors.As(got, &je) || je.Code != -32002 { + t.Fatal("original *Error must stay unwrappable after annotation") + } + + newCode := fmt.Errorf("mcp[srv]: resources/read: %w", &Error{Code: -32602, Message: "Invalid params"}) + got2 := annotateResourceReadError(newCode, "file:///y") + if !strings.Contains(got2.Error(), "2026-07-28") { + t.Fatalf("new -32602 should mention the 2026-07-28 overload, got: %v", got2) + } + + other := fmt.Errorf("mcp[srv]: resources/read: %w", &Error{Code: -32601, Message: "method not found"}) + if got3 := annotateResourceReadError(other, "file:///z"); got3 != other { + t.Errorf("unrelated codes must pass through unchanged, got: %v", got3) + } +} + +// errServer starts an httptest MCP server answering every request with the +// given JSON-RPC error object (notifications get 202). +func errServer(t *testing.T, code int, message string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + var req struct { + ID json.RawMessage `json:"id"` + } + if json.Unmarshal(body, &req) != nil || req.ID == nil { + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]any{ + "code": code, + "message": message, + }, + } + _ = json.NewEncoder(w).Encode(resp) + })) +} + +// TestHeaderMismatchDecoratedEndToEnd pins the sendRequest wiring: a server +// answering -32020 (HeaderMismatch) yields an error that carries the spec +// annotation AND stays programmatically classifiable via errors.As. +func TestHeaderMismatchDecoratedEndToEnd(t *testing.T) { + srv := errServer(t, ErrCodeHeaderMismatch, "missing Mcp-Method header") + defer srv.Close() + + client := NewClientFromConfig(config.MCPServerConfig{Name: "sa70", Type: "http", URL: srv.URL}) + if err := client.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var result struct{} + err := client.sendRequest(ctx, "tools/call", map[string]any{"name": "x"}, &result) + if err == nil { + t.Fatal("expected HeaderMismatch error") + } + if !strings.Contains(err.Error(), "HeaderMismatch") { + t.Fatalf("error should carry the spec annotation, got: %v", err) + } + var je *Error + if !errors.As(err, &je) || je.Code != ErrCodeHeaderMismatch { + t.Fatalf("JSON-RPC *Error must stay reachable, got %+v", je) + } +} + +// TestConnectUnsupportedProtocolVersionDiagnostic pins the initialize +// wiring: a server rejecting the handshake with the spec's renumbered +// -32022 gets the actionable version diagnostic, and the legacy -32004 +// numbering is recognized too. +func TestConnectUnsupportedProtocolVersionDiagnostic(t *testing.T) { + for _, code := range []int{ErrCodeUnsupportedProtocolVersion, legacyErrCodeUnsupportedProtocolVersion} { + srv := errServer(t, code, "unsupported protocol version") + client := NewClientFromConfig(config.MCPServerConfig{Name: "sa70b", Type: "http", URL: srv.URL}) + if err := client.Start(context.Background()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _, err := client.Initialize(ctx) + cancel() + _ = client.Close() + srv.Close() + if err == nil { + t.Fatalf("code %d: expected initialize failure", code) + } + if !strings.Contains(err.Error(), "server rejected the protocol version") || + !strings.Contains(err.Error(), "server/discover") { + t.Fatalf("code %d: want actionable version diagnostic, got: %v", code, err) + } + } +} + +// TestReadResourceNotFoundEndToEnd pins the ReadResource wiring: the +// legacy -32002 surfaces as a "resource not found" message and the new +// -32602 (Invalid Params) carries the 2026-07-28 ambiguity note. +func TestReadResourceNotFoundEndToEnd(t *testing.T) { + srv := errServer(t, legacyErrCodeResourceNotFound, "Resource not found: file:///gone") + defer srv.Close() + + client := NewClientFromConfig(config.MCPServerConfig{Name: "sa70c", Type: "http", URL: srv.URL}) + if err := client.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + client.setNegotiatedState(latestMCPProtocolVersion, ServerCaps{Resources: &ResourcesCapability{}}) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := client.ReadResource(ctx, "file:///gone") + if err == nil { + t.Fatal("expected resource-read failure") + } + if !strings.Contains(err.Error(), "resource not found") { + t.Fatalf("legacy -32002 should surface as resource not found, got: %v", err) + } +}