Emit conformant JSON-RPC on denial and error paths - #6055
Merged
Conversation
jhrozek
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
aponcedeleonch,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
July 27, 2026 19:49
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6055 +/- ##
==========================================
- Coverage 72.29% 72.24% -0.06%
==========================================
Files 724 724
Lines 75330 75348 +18
==========================================
- Hits 54461 54432 -29
- Misses 16973 17034 +61
+ Partials 3896 3882 -14 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
10 tasks
JAORMX
approved these changes
Jul 27, 2026
11 tasks
This was referenced Jul 27, 2026
handleUnauthorized encoded a *jsonrpc2.Response with encoding/json
directly. That struct carries no json tags and no MarshalJSON, so
reflection emitted Go-capitalized keys and dropped the mandatory
"jsonrpc":"2.0". Worse, jsonrpc2.ID's only field is unexported, so it
rendered as {} unconditionally -- destroying the request id even when
valid, which left clients unable to correlate the denial at all.
Encode through jsonrpc2.EncodeMessage instead, which marshals via the
library's wireCombined struct and gets the lowercase tags, the version
tag, and id,omitempty. Omitting an absent id rather than emitting null
is deliberate: MCP types the error response as id?: string | number,
so null is not representable, and the transport spec says the body
"has no id".
Encoding now happens before any header is written, which removes a
double-write where a 403 header could be followed by an http.Error 500.
Renames a local that was assigning the incoming err parameter rather
than shadowing it.
Refs #5950
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
writeErrorResponse marshaled a *jsonrpc2.Response with encoding/json,
so like the denial path it emitted capitalized keys, no "jsonrpc":"2.0",
and an id that always serialized as {}. Its marshal-failure fallback was
not valid JSON-RPC either: a bare {"error": "Internal server error"}
with no envelope at all.
Encode through jsonrpc2.EncodeMessage, before writing the header, and
make the fallback a real JSON-RPC error body.
The protocol-violation path also hardcoded an empty id, so fixing the
encoder alone would still have left that response uncorrelatable. Recover
the real id from the parsed request on the context instead.
The typed jsonrpc2.ID parameter is kept deliberately: an any-typed id is
exactly how a caller reintroduces the {} bug by passing the struct.
Refs #5950
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both webhook middlewares carried the same defect as the authz paths:
sendErrorResponse encoded a *jsonrpc2.Response with encoding/json, so
the wire body used capitalized keys, omitted "jsonrpc":"2.0", and
serialized every request id as {}. Both also wrote the status header
before building the body.
Encode through jsonrpc2.EncodeMessage, before the header, in both.
Five test assertions pinned the broken shape and so kept it in place;
two of those were unchecked type assertions that would have panicked
rather than failed. One assertion was worse than useless: a
require.NotNil on errResp["ID"] under a comment claiming the string id
round-tripped. It never did -- the value was {}, and NotNil passes on a
non-nil empty map. Each package now has a test pinning the whole
envelope instead.
Test fixtures built ids as int and float64, types the parser never
produces, since ParsedMCPRequest.ID comes from a normalized
jsonrpc2.ID. They now use int64.
The error code stays as the HTTP status for now; correcting it is
client-visible and tracked separately.
Refs #5950
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment justified asserting id presence separately by saying JSONEq cannot distinguish an absent id from any other value. It can: JSONEq compares fully decoded values, so it enforces the exact key set. The separate assertion is worth keeping as self-documenting, just not for that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The raw client's response struct had no jsonrpc field at all, so a missing or wrong version tag was invisible to every e2e test -- the harness could not have asserted the thing #5950 is about even if a test wanted to. Combined with encoding/json matching "Error" to json:"error" case-insensitively, that is how the malformed envelope rode through the suite unnoticed: only the outer envelope was reflected, while the inner error object carries real json tags, so assertions on error.code kept passing. Surface the tag on RawResponse and assert it, along with the request id round-tripping, on the dual-era denial path. A comment there described the non-conformant wire shape as a known open problem and deferred the conformance assertion; it now makes that assertion instead. Because the dual-era suite needs live infrastructure, the new field also gets hermetic coverage in the httptest-based client test, which runs in every shard -- otherwise the detector added to catch this bug would itself be unverified before merge. Refs #5950 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
encoding/json falls back to case-insensitive field matching, so a body carrying "JSONRPC":"2.0" satisfied json:"jsonrpc" and passed the new version-tag assertion. That is a hole in exactly the place the assertion was added to guard, since #5950 was a capitalized-keys bug: the absent tag a reflection-marshaled response produces is caught, but an envelope hand-rolled with an untagged field would not have been, and this tree has five hand-built envelope builders. Read the tag from a raw map instead, which is case-sensitive, so every assertion on it is strict without each test opting in. Refs #5950 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jhrozek
force-pushed
the
fix-nonconformant-jsonrpc-denials
branch
from
July 28, 2026 06:39
246aa7c to
0e7fc58
Compare
This was referenced Jul 28, 2026
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Three HTTP denial paths each carry a private copy of the same block: encode a *jsonrpc2.Response via jsonrpc2.EncodeMessage before writing any header, fall back to a hardcoded conformant body on the unreachable encode failure, then set Content-Type, write status, write body. That block exists because of #5950 — encoding/json on a jsonrpc2 type reflects Go-capitalized keys, drops the mandatory "jsonrpc":"2.0" tag, and renders every id as {} — so the only thing preventing a fourth hand-rolled copy from reintroducing that bug has been a greppable convention. #6055's review rejected a map-building shared helper, because an any-typed id parameter is exactly how a caller reintroduces the {} id bug, but noted a narrower helper taking a jsonrpc2 message avoids the objection. Add exactly that: mcp.EncodeJSONRPCError and mcp.WriteJSONRPCError take a typed *jsonrpc2.Response, so no id can reach them untyped. Scope is deliberately three sites, not four. The fourth carrier of this block, pkg/authz/response_filter.go, is being rewritten by #6087 and again by #6088; collapsing it here would guarantee a conflict and duplicate that work, so it keeps its private copy for now and can adopt the helper once those land. Behavior is unchanged except the per-site fallback bodies, which were unreachable and now uniformly report -32603 rather than echoing a code whose integrity is unknown once encoding has failed. Each site keeps its own status and its own code via #6066's mcp.JSONRPCCodeForStatus. The 'never json.Marshal a jsonrpc2 type' convention is also recorded in .claude/rules/go-style.md. A lint gate was considered and deliberately not added: forbidigo matches call text and cannot see argument types, so it cannot distinguish marshaling a jsonrpc2 value from any other json.Marshal call, and wiring up gocritic/ruleguard for one rule is not worth the infrastructure. The helper plus the recorded rule is the enforcement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Three HTTP denial paths each carry a private copy of the same block: encode a *jsonrpc2.Response via jsonrpc2.EncodeMessage before writing any header, fall back to a hardcoded conformant body on the unreachable encode failure, then set Content-Type, write status, write body. That block exists because of #5950 — encoding/json on a jsonrpc2 type reflects Go-capitalized keys, drops the mandatory "jsonrpc":"2.0" tag, and renders every id as {} — so the only thing preventing a fourth hand-rolled copy from reintroducing that bug has been a greppable convention. #6055's review rejected a map-building shared helper, because an any-typed id parameter is exactly how a caller reintroduces the {} id bug, but noted a narrower helper taking a jsonrpc2 message avoids the objection. Add exactly that: mcp.EncodeJSONRPCError and mcp.WriteJSONRPCError take a typed *jsonrpc2.Response, so no id can reach them untyped. Scope is deliberately three sites, not four. The fourth carrier of this block, pkg/authz/response_filter.go, is being rewritten by #6087 and again by #6088; collapsing it here would guarantee a conflict and duplicate that work, so it keeps its private copy for now and can adopt the helper once those land. Behavior is unchanged except the per-site fallback bodies, which were unreachable and now uniformly report -32603 rather than echoing a code whose integrity is unknown once encoding has failed. Each site keeps its own status and its own code via #6066's mcp.JSONRPCCodeForStatus. The 'never json.Marshal a jsonrpc2 type' convention is also recorded in .claude/rules/go-style.md. A lint gate was considered and deliberately not added: forbidigo matches call text and cannot see argument types, so it cannot distinguish marshaling a jsonrpc2 value from any other json.Marshal call, and wiring up gocritic/ruleguard for one rule is not worth the infrastructure. The helper plus the recorded rule is the enforcement. Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Authorization denials, and three sibling error paths, emitted a body that is not valid JSON-RPC 2.0. All four built a
*jsonrpc2.Response(golang.org/x/exp/jsonrpc2) and serialized it withencoding/jsondirectly. That struct carries no json tags and noMarshalJSON, so reflection produced:{"Result":null,"Error":{"code":403,"message":"unknown MCP method: ... "},"ID":{}}Go-capitalized keys, no mandatory
"jsonrpc":"2.0", and — the part the issue under-reported — the request id destroyed on every response.jsonrpc2.ID's only field is unexported, soencoding/jsonrenders it{}unconditionally, even for a perfectly valid id. A client could not correlate a denial to the request that caused it. That is the more likely explanation for a Modern go-sdk client failing to surface these denials than the key casing alone.The bug survived e2e for a subtle reason worth recording: only the outer envelope was reflected. The inner error object is a
jsonrpc2.WireError, which does carry json tags, so assertions onerror.codekept passing while the envelope around them was malformed.What changed
jsonrpc2.EncodeMessageat all four sites. It marshals through the library'swireCombined, which has the correct lowercase tags, stamps the version tag unconditionally, and tagsidomitempty.handleUnauthorized(a 403 header followed, on encode failure, byhttp.Error(..., 500)— a second header plus garbage appended to the body), and fixes the webhook paths, which wrote the header first and swallowed the encode error, leaving a header with no body at all on failure.response_filter.go, which hardcoded an empty id, so fixing the encoder alone would have left that response uncorrelatable.require.NotNilonerrResp["ID"]under a comment claiming the string id round-tripped. It never did — the value was{}, andNotNilpasses on a non-nil empty map.jsonrpcfield. It now has one, read case-sensitively, becauseencoding/jsonfield matching falls back to case-insensitive and would have let"JSONRPC":"2.0"satisfy ajson:"jsonrpc"tag — a hole in exactly the place the guard was added.Fixes #5950
Type of change
Test plan
task test-e2e)task lint-fix)Unit tests: every touched package is green (
pkg/authz/...,pkg/webhook/...), re-verified after rebasing ontomain. Notetask testcould not be used locally — it panics on agotestfmtv2.5.0 bug ("BUG: Empty package name encountered") that reproduces on a clean tree, so the underlyinggo testinvocation was used instead. CI runs the real thing.Each new test was proven to be a real regression guard by reverting the production change and confirming it fails, then restoring it. Worth calling out for one case specifically: the miscased-key assertion in
mcp_raw_client_test.gofails before the case-sensitivity fix and passes after, whereas the absent-key case passes either way and is there to pin a different property.E2E: compile-verified only. The dual-era suite needs a built binary and live proxy/backend containers. Because of that, the new
RawResponse.JSONRPCfield also got hermetichttptestcoverage inTestRawClientSend, which runs in every shard — otherwise the detector added to catch this bug would itself have been unverified before merge.Linting not run locally; leaving it to CI.
API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Changes
pkg/authz/middleware.gohandleUnauthorized:EncodeMessage, encode before header, fix double-write and a clobberederrparameterpkg/authz/response_filter.gowriteErrorResponse:EncodeMessage; newrequestID()recovers the real id for the protocol-violation pathpkg/webhook/validating/middleware.gosendErrorResponse:EncodeMessage, encode before headerpkg/webhook/mutating/middleware.gopkg/authz/middleware_test.gopkg/authz/response_filter_test.gopkg/webhook/*/middleware_test.goint/float64→int64fixtures; one envelope test per packagetest/e2e/mcp_raw_client.gojsonrpconRawResponse, read case-sensitivelytest/e2e/mcp_raw_client_test.gotest/e2e/dual_era_functional_test.goDoes this introduce a user-facing change?
Yes. Denial and error responses from the authorization middleware, the response filter, and both webhook middlewares are now valid JSON-RPC 2.0: lowercase
jsonrpc/id/error, the version tag present, and the request id echoed back so clients can correlate. Clients that previously tolerated the malformed body via case-insensitive unmarshal are unaffected; spec-strict clients can now parse these responses at all.Special notes for reviewers
On absent ids: MCP deliberately overrides base JSON-RPC here, and this PR follows MCP. Base JSON-RPC 2.0 §5 says a Response
id"MUST be Null" when it cannot be determined. MCP cannot express that:schema/2025-11-25/schema.tstypes the error response asid?: RequestIdwhereRequestId = string | number, and both transport pages say the body "has noid" — including for the 403 case specifically. So an absent id is omitted, not emitted asnull.This is not theoretical. The reference TypeScript SDK enforces it:
JSONRPCErrorResponseSchema(packages/core/src/schemas.ts:168) is a.strict()object withid: RequestIdSchema.optional(), which admitsundefinedbut notnull— andJSONRPCMessageSchema.parse(), the throwing variant, gates every inbound message in the client transports. A response carrying"id":nullmakes that client throw inside its transport, before the application sees the error. Please read the two paragraphs above before filing this as a bug; one review pass already flagged it, applying base JSON-RPC rather than MCP.Note
id: 0still round-trips correctly.wireCombined.IDis aninterface{}, soomitemptytestsIsNil()rather than zero-ness — a hand-rolled map plusomitemptywould have silently dropped a zero id.Deliberately out of scope, each tracked:
writeErrorResponsewrites a bare JSON object into atext/event-streamwith nodata:prefix. Per the WHATWG grammar that parses as an unrecognized field and is silently discarded, so the client hangs to its own timeout. This PR makes that payload conformant, which means it now reads as correct while remaining unreadable by any SSE client — please don't take the green bytes there as evidence the path works. Fixing it needs a design decision, not a mechanical change.mainalready, by Omit absent JSON-RPC ids instead of emitting null #6068 ("Omit absent JSON-RPC ids instead of emitting null"), so nothing outstanding here. It covered three hand-built envelopes that emitted"id":null(session/jsonrpc_errors.go,ratelimit/middleware.go,mcp/classification_response.go) — the same rule as above, opposite behavior. Listed only because this branch is where the rule was established and verified against the SDK.500/413/422). Left untouched on purpose: client-visible, asserted acrosspkg/vmcp/server/*_test.goand documented indocs/arch/10-virtual-mcp-architecture.md.403stays regardless — the draft spec blesses allocating outside the reserved range.No shared helper, deliberately. The four sites keep their own encode/write blocks. A shared map-building helper was designed and rejected: the tree already has five hand-rolled envelope builders, a sixth absorbs none of them without an options struct, and an
any-typed id parameter is precisely how a caller reintroduces the{}bug at the one site holding a typedjsonrpc2.ID. The single rule that prevents a fifth occurrence is greppable instead: neverjson.Marshalajsonrpc2type. A narrower helper taking ajsonrpc2.Messagewould avoid that objection and is worth a follow-up, but it was not folded in here.A stale detail in the issue. #5950's repro uses
server/discover, which no longer reproduces — #5953 made it always-allowed (middleware.go), after the issue was filed. Tests usetasks/list, which is genuinely absent fromMCPMethodToFeatureOperationand which the map's own comment already names as denied by default.