Skip to content

Emit conformant JSON-RPC on denial and error paths - #6055

Merged
JAORMX merged 6 commits into
mainfrom
fix-nonconformant-jsonrpc-denials
Jul 28, 2026
Merged

Emit conformant JSON-RPC on denial and error paths#6055
JAORMX merged 6 commits into
mainfrom
fix-nonconformant-jsonrpc-denials

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 with encoding/json directly. That struct carries no json tags and no MarshalJSON, 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, so encoding/json renders 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 on error.code kept passing while the envelope around them was malformed.

What changed

  • Encode via jsonrpc2.EncodeMessage at all four sites. It marshals through the library's wireCombined, which has the correct lowercase tags, stamps the version tag unconditionally, and tags id omitempty.
  • Encode before writing any header. This removes a live double-write in handleUnauthorized (a 403 header followed, on encode failure, by http.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.
  • Restore the request id on the protocol-violation path in response_filter.go, which hardcoded an empty id, so fixing the encoder alone would have left that response uncorrelatable.
  • Five test assertions pinned the broken shape and so held it in place; two were unchecked type assertions that would have panicked rather than failed. One 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.
  • The e2e harness could not detect this bug at all: its response struct had no jsonrpc field. It now has one, read case-sensitively, because encoding/json field matching falls back to case-insensitive and would have let "JSONRPC":"2.0" satisfy a json:"jsonrpc" tag — a hole in exactly the place the guard was added.

Fixes #5950

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Unit tests: every touched package is green (pkg/authz/..., pkg/webhook/...), re-verified after rebasing onto main. Note task test could not be used locally — it panics on a gotestfmt v2.5.0 bug ("BUG: Empty package name encountered") that reproduces on a clean tree, so the underlying go test invocation 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.go fails 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.JSONRPC field also got hermetic httptest coverage in TestRawClientSend, 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

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

File Change
pkg/authz/middleware.go handleUnauthorized: EncodeMessage, encode before header, fix double-write and a clobbered err parameter
pkg/authz/response_filter.go writeErrorResponse: EncodeMessage; new requestID() recovers the real id for the protocol-violation path
pkg/webhook/validating/middleware.go sendErrorResponse: EncodeMessage, encode before header
pkg/webhook/mutating/middleware.go same (10 call sites)
pkg/authz/middleware_test.go New envelope test through the real parsing+authz chain
pkg/authz/response_filter_test.go Envelope assertions on the existing disguised-frame test
pkg/webhook/*/middleware_test.go Fix 5 assertions pinning the old shape; de-vacuum the id assertion; int/float64int64 fixtures; one envelope test per package
test/e2e/mcp_raw_client.go Surface jsonrpc on RawResponse, read case-sensitively
test/e2e/mcp_raw_client_test.go Hermetic coverage incl. a miscased-key guard
test/e2e/dual_era_functional_test.go Replace a comment deferring conformance with the assertion it deferred

Does 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.ts types the error response as id?: RequestId where RequestId = string | number, and both transport pages say the body "has no id" — including for the 403 case specifically. So an absent id is omitted, not emitted as null.

This is not theoretical. The reference TypeScript SDK enforces it: JSONRPCErrorResponseSchema (packages/core/src/schemas.ts:168) is a .strict() object with id: RequestIdSchema.optional(), which admits undefined but not null — and JSONRPCMessageSchema.parse(), the throwing variant, gates every inbound message in the client transports. A response carrying "id":null makes 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: 0 still round-trips correctly. wireCombined.ID is an interface{}, so omitempty tests IsNil() rather than zero-ness — a hand-rolled map plus omitempty would have silently dropped a zero id.

Deliberately out of scope, each tracked:

  • Filter-failure errors on the SSE path are silently discarded #6037 — On the SSE path, writeErrorResponse writes a bare JSON object into a text/event-stream with no data: 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.
  • Error responses emit "id":null, which the TS SDK client rejects #6038Resolved on main already, 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.
  • Clean up JSON-RPC error codes and leaked error text on denial paths #6039 — HTTP statuses used as JSON-RPC error codes (500/413/422). Left untouched on purpose: client-visible, asserted across pkg/vmcp/server/*_test.go and documented in docs/arch/10-virtual-mcp-architecture.md. 403 stays 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 typed jsonrpc2.ID. The single rule that prevents a fifth occurrence is greppable instead: never json.Marshal a jsonrpc2 type. A narrower helper taking a jsonrpc2.Message would 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 use tasks/list, which is genuinely absent from MCPMethodToFeatureOperation and which the map's own comment already names as denied by default.

@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 51.42857% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.24%. Comparing base (ca4eda4) to head (0e7fc58).

Files with missing lines Patch % Lines
pkg/authz/response_filter.go 50.00% 4 Missing and 3 partials ⚠️
pkg/authz/middleware.go 42.85% 2 Missing and 2 partials ⚠️
pkg/webhook/mutating/middleware.go 57.14% 2 Missing and 1 partial ⚠️
pkg/webhook/validating/middleware.go 57.14% 2 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

jhrozek and others added 6 commits July 28, 2026 08:38
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
jhrozek force-pushed the fix-nonconformant-jsonrpc-denials branch from 246aa7c to 0e7fc58 Compare July 28, 2026 06:39
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 28, 2026
@JAORMX
JAORMX merged commit 9b0d83a into main Jul 28, 2026
48 checks passed
@JAORMX
JAORMX deleted the fix-nonconformant-jsonrpc-denials branch July 28, 2026 07:13
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authz-denied responses emit non-conformant JSON-RPC (capitalized keys, no jsonrpc field)

2 participants