Summary
The authorization-denial path emits a response body that is not valid JSON-RPC 2.0: the top-level keys are Go-capitalized (Result/Error/ID) and the mandatory "jsonrpc":"2.0" field is absent. A spec-conformant (case-sensitive) MCP client cannot parse error/id, and the missing jsonrpc field is itself a violation.
Evidence (actual wire bytes)
POST /mcp with a server/discover call to an authz-enabled proxy (HTTP 403):
{"Result":null,"Error":{"code":403,"message":"unknown MCP method: server/discover (not configured for authorization)"},"ID":{}}
Root cause
pkg/authz/middleware.go handleUnauthorized (~L127-151) builds a *jsonrpc2.Response (golang.org/x/exp/jsonrpc2) and does json.NewEncoder(w).Encode(errorResponse) directly on the struct. In the pinned version of that package, Response{Result, Error, ID} has no json tags and no custom MarshalJSON, so encoding/json reflection produces capitalized keys and omits jsonrpc. Every other JSON-RPC response path in the codebase (pkg/mcp/classification_response.go, pkg/transport/session/jsonrpc_errors.go) instead hand-builds a map[string]any with lowercase keys and an explicit "jsonrpc":"2.0" — this denial path is the lone inconsistency.
Impact
- Conformant clients (case-sensitive JSON parsers, including the MCP go-sdk) cannot read the denial's
error/id.
- Likely interacts with a Modern (2026-07-28) go-sdk client's behavior: on a
server/discover 403 it appears to fall back / downgrade rather than surface the denial, plausibly because it cannot parse this malformed error as a JSON-RPC error. (Observed while building dual-era e2e tests; not root-caused to this specifically, flagging the interaction.)
- ToolHive's own hand-rolled test client only tolerates it via
encoding/json's case-insensitive unmarshal.
Suggested fix
Emit a conformant envelope on this path like the others do — hand-build the lowercase map[string]any with "jsonrpc":"2.0", "id", "error":{"code","message"} (or route through a shared conformant encoder), rather than json.Encode-ing the jsonrpc2.Response struct.
Context
Found while building dual-era stateless proxy e2e tests (#5837). Low-frequency path but client-facing and spec-relevant.
Summary
The authorization-denial path emits a response body that is not valid JSON-RPC 2.0: the top-level keys are Go-capitalized (
Result/Error/ID) and the mandatory"jsonrpc":"2.0"field is absent. A spec-conformant (case-sensitive) MCP client cannot parseerror/id, and the missingjsonrpcfield is itself a violation.Evidence (actual wire bytes)
POST /mcpwith aserver/discovercall to an authz-enabled proxy (HTTP 403):{"Result":null,"Error":{"code":403,"message":"unknown MCP method: server/discover (not configured for authorization)"},"ID":{}}Root cause
pkg/authz/middleware.gohandleUnauthorized(~L127-151) builds a*jsonrpc2.Response(golang.org/x/exp/jsonrpc2) and doesjson.NewEncoder(w).Encode(errorResponse)directly on the struct. In the pinned version of that package,Response{Result, Error, ID}has no json tags and no customMarshalJSON, soencoding/jsonreflection produces capitalized keys and omitsjsonrpc. Every other JSON-RPC response path in the codebase (pkg/mcp/classification_response.go,pkg/transport/session/jsonrpc_errors.go) instead hand-builds amap[string]anywith lowercase keys and an explicit"jsonrpc":"2.0"— this denial path is the lone inconsistency.Impact
error/id.server/discover403 it appears to fall back / downgrade rather than surface the denial, plausibly because it cannot parse this malformed error as a JSON-RPC error. (Observed while building dual-era e2e tests; not root-caused to this specifically, flagging the interaction.)encoding/json's case-insensitive unmarshal.Suggested fix
Emit a conformant envelope on this path like the others do — hand-build the lowercase
map[string]anywith"jsonrpc":"2.0","id","error":{"code","message"}(or route through a shared conformant encoder), rather thanjson.Encode-ing thejsonrpc2.Responsestruct.Context
Found while building dual-era stateless proxy e2e tests (#5837). Low-frequency path but client-facing and spec-relevant.