Skip to content

Omit absent JSON-RPC ids instead of emitting null - #6068

Merged
ChrisJBurns merged 7 commits into
mainfrom
fix-6038-omit-absent-jsonrpc-id
Jul 28, 2026
Merged

Omit absent JSON-RPC ids instead of emitting null#6068
ChrisJBurns merged 7 commits into
mainfrom
fix-6038-omit-absent-jsonrpc-id

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Seven hand-built JSON-RPC error envelopes dropped the raw request id straight into a map[string]any, so an absent id marshaled to "id":null. MCP does not allow that, and the reference client enforces it.

  • The rule. MCP narrows base JSON-RPC 2.0 for error responses: schema/2025-11-25 types them id?: RequestId where RequestId = string | number. null is not in the union, and the key is optional only on the error response — it stays required on JSONRPCResultResponse. Base JSON-RPC 2.0 §5 says the id "MUST be Null" when it cannot be determined; MCP deliberately overrides that, because its schema cannot express null. Both transport pages say the body "has no id", once specifically for the 403 Origin-check case. So an absent id is encoded by omitting the key.
  • Why it bites. The reference TypeScript SDK's JSONRPCErrorResponseSchema is a .strict() object with id: RequestIdSchema.optional() — which admits undefined but not null — and it gates every inbound message through parse(), the throwing variant, in all five client transports. An "id":null body makes a conformant client throw inside its transport before the application ever sees the error. For session-not-found that is especially costly: clients use code -32001 to trigger automatic session recovery, so they lose recovery, not merely an error message.
  • One reachable path, confirmed. rateLimitHandler gates only on the method being tools/call and has no notification guard, so a rate-limited tools/call notification reached writeRateLimited with a nil id and emitted "id":null at HTTP 429. This was verified end-to-end through the real ParsingMiddleware. The issue did not identify this site as reachable; it surfaced during review.
  • A shared predicate, because a nil check is not enough. session.HasJSONRPCID is the single definition of an absent id. Two sites receive the id as raw bytes threaded from the transparent proxy, where both json.RawMessage("null") and json.RawMessage(nil) are non-nil interfaces that marshal to null. A zero id still round-trips — the predicate tests nil-ness, not zero-ness.
  • Two sites deliberately do not use it. The origin 403 has no incoming id at all (rejected on the header before any body is parsed), so the key is simply deleted. writeSSEErrorEvent holds a typed jsonrpc2.ID and uses id.IsValid(): passing that struct through the any-typed predicate would report it present and emit "id":{}, which is neither absent nor a valid RequestId. HasJSONRPCID now documents that.
  • Tests that could not see the bug. Three tests decoded the id into a tagged struct and asserted Nil, which holds whether the key is absent or null. One of them passed with the bug deliberately reintroduced. All now decode into a map and assert key absence. Where an id is present, assertions check verbatim echo against the wire bytes, since a map decode coerces a numeric id to float64 and would drop the guarantee pinned in Transparent proxy: session-not-found 404 does not echo the request JSON-RPC id #5945.

Fixes #6038

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)

Every touched package is green, including under -race: pkg/transport/session, pkg/mcp, pkg/ratelimit, pkg/vmcp/server, pkg/transport/middleware/origin, pkg/transport/proxy/streamable, pkg/transport/proxy/transparent.

task test could not be scoped — the Taskfile's test target runs the whole suite over go list ./... with no package filter — so the underlying go test -count=1 invocation was used per package. CI runs the real thing. Linting was not run locally; leaving it to CI.

Every new and flipped assertion was proven to be a real regression guard by mutation: the production change was reverted or inverted, the test confirmed to fail, then restored. This was not ceremony — it caught two tests that passed with the bug present (TestParsingMiddlewareRejectsBatch, and the pre-existing origin test), and confirmed that swapping test fixtures from float64 to int64 had not silently rendered the zero-id rows inert.

Manual testing: the reachable rate-limit path was driven end-to-end through the real ParsingMiddleware with a tools/call notification, confirming 429 {"error":{...},"id":null,"jsonrpc":"2.0"} before the fix and the same body without the id key after.

E2E: compile-verified only (go vet ./test/e2e/...). The batch-rejection assertion in stdio_proxy_over_streamable_http_mcp_server_test.go was updated but needs a built binary and live containers to run.

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/transport/session/jsonrpc_errors.go New HasJSONRPCID; NotFoundBody omits the key; doc rewritten (#6031 had asserted null was required)
pkg/mcp/classification_response.go classificationErrorBody: conditional id; fallback literal
pkg/mcp/tool_filter.go filteredToolCallErrorBody: same
pkg/mcp/batch.go Doc comments only — both said "the JSON-RPC id is null"
pkg/ratelimit/middleware.go rateLimitedBody: conditional id; fmt.Sprintf fallback
pkg/vmcp/server/modern_envelope.go writeModernError / writeModernDenied: conditional id; writeModernResult deliberately unchanged, with a comment saying why
pkg/transport/middleware/origin/origin.go writeForbidden: "id": nil deleted outright; fallback literal and doc
pkg/transport/proxy/streamable/streamable_proxy.go writeSSEErrorEvent: gates on id.IsValid()
pkg/transport/proxy/transparent/transparent_proxy.go Comment sync — the requestID doc still claimed JSON-RPC requires a null id

Does this introduce a user-facing change?

Yes. Error responses from session-not-found, classification failures, batch rejection, filtered tool calls, rate limiting, the vMCP Modern path, the origin 403, and SSE error events now omit the id key when there is no id to echo, instead of sending "id":null. Spec-strict MCP clients can parse these responses at all rather than throwing in their transport layer; in particular, -32001-driven automatic session recovery starts working for them. Clients that tolerated the non-conformant body are unaffected — a present id is still echoed verbatim, including id: 0.

Special notes for reviewers

On the two halves of the vMCP/rate-limit commit. These have different exposure and the commit message says so. pkg/ratelimit fixes a path a real client can hit. pkg/vmcp/server is defense-in-depth on a path that is currently unreachable: every call site of writeModernError/writeModernDenied sits downstream of dispatchModern's parsed.ID == nil guard, which answers 202 before any error envelope is built, and vMCP ids arrive from jsonrpc2.ID.Raw() as nil | int64 | string, never raw bytes. The writers take id any, so guarding them is still right — but no client was receiving a null id from that path, and the PR should not be read as claiming otherwise.

Scope is wider than the issue. #6038 named three sites; there are seven. The extra four are pkg/mcp/tool_filter.go, pkg/transport/middleware/origin/origin.go, pkg/vmcp/server/modern_envelope.go, and writeSSEErrorEvent. The origin one is arguably the most clear-cut of all of them: it hardcoded "id": nil, and its 403 is the case the transport spec names explicitly.

Deliberately untouched:

No shared envelope builder, deliberately. Only the id predicate is shared. PR #6055 designed and rejected a full envelope helper; that reasoning still holds — the maps genuinely differ (rate-limit carries data, vMCP sets Cache-Control, statuses vary). Sharing just the predicate is what the two raw-bytes sites actually needed.

Where I would want the most scrutiny: whether session is the right home for HasJSONRPCID. It was chosen because that package already owns this envelope shape and is a near-leaf every affected package already imports, so it adds no new dependency edges. But a generic JSON-RPC predicate living in a package named session is a naming wart, and a small jsonrpcerr package would read better at the cost of one new import path.

Generated with Claude Code

jhrozek and others added 4 commits July 27, 2026 22:27
MCP narrows base JSON-RPC 2.0 for error responses: schema/2025-11-25
types them as `id?: RequestId` where RequestId = string | number, so
null is not representable and an absent id must be encoded by omitting
the key. Base JSON-RPC 2.0 section 5 says the id "MUST be Null" when it
cannot be determined; MCP deliberately overrides that.

This is enforced, not advisory. The reference TypeScript SDK gates every
inbound message through JSONRPCErrorResponseSchema, a .strict() object
with `id: RequestIdSchema.optional()`, using the throwing parse(). An
"id":null body therefore makes a conformant client throw inside its
transport before the application sees the error. For session-not-found
that is especially costly: clients use code -32001 to trigger automatic
session recovery, so they lose recovery, not merely an error message.

Add session.HasJSONRPCID as the single definition of an absent id. A
plain nil check is insufficient because the transparent proxy threads
the incoming id through as raw bytes: json.RawMessage(nil) and a
RawMessage holding literal null are both non-nil interfaces that
marshal to null. A zero id still round-trips - the predicate tests
nil-ness, not zero-ness.

Part of #6038.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apply the rule established in the previous commit to the three
hand-built error envelopes in pkg/mcp: classification errors, batch
rejections, and filtered tool calls. Each dropped the raw request id
straight into the map, so an absent id marshaled to "id":null and made
a spec-strict client throw inside its transport.

classificationErrorBody is the second site reached with raw bytes -- the
transparent proxy threads a json.RawMessage through it -- so it uses
session.HasJSONRPCID rather than a nil check.

Two tests decoded the id into a tagged struct and asserted Nil, which
holds whether the key is absent or null, so they could not observe this
bug at all; one of them passed with the bug deliberately reintroduced.
Both now decode into a map and assert key absence. Where an id is
present, the tests assert verbatim echo against the wire bytes, since a
map decode coerces a numeric id to float64 and would drop the guarantee
pinned in #5945.

Part of #6038.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two sites, with different exposure - worth distinguishing.

pkg/ratelimit is a reachable bug. rateLimitHandler gates only on the
method being tools/call and has no notification guard, so a well-formed
tools/call notification that trips the limiter reaches writeRateLimited
with a nil id and emits "id":null at HTTP 429 - precisely the payload
that makes a spec-strict client throw inside its transport. A new
handler-level test drives the middleware with a notification and pins
the key's absence; the pre-existing coverage only exercised the body
builder, so it could not observe this.

pkg/vmcp/server is defense-in-depth. Tracing every call site of
writeModernError and writeModernDenied shows all of them downstream of
dispatchModern's `parsed.ID == nil` guard, which answers 202 before any
error envelope is built, and vMCP ids arrive from jsonrpc2.ID.Raw() as
nil, int64 or string - never raw bytes. So the predicate is always true
there today. The writers take `id any`, so guarding them is still right,
but no client was receiving a null id from this path.

writeModernResult deliberately keeps emitting id unconditionally: MCP
requires it on a result response, so omitting would be as wrong as null.
A test pins that, since the obvious future refactor is to make it match
its siblings.

Part of #6038.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last two hand-built envelopes, neither of which needs the shared
predicate.

The origin middleware's 403 hardcoded "id": nil, so the key is simply
deleted: the request is rejected on its Origin header before any
JSON-RPC body is parsed, so there is no id to echo. This is the case
the MCP transport spec names explicitly when it says the body "has no
id" - it calls out the Origin check by name.

writeSSEErrorEvent holds a typed jsonrpc2.ID, so it gates on
id.IsValid() rather than HasJSONRPCID. That is not merely stylistic:
the shared predicate takes `any`, and a jsonrpc2.ID zero value is a
non-nil interface holding an empty struct, so it would report the id
present and emit "id":{} - neither absent nor a valid RequestId.
HasJSONRPCID now documents that.

The origin test decoded the id into a tagged struct and asserted Nil,
so it could not observe this bug at all; it now decodes into a map and
pins the exact body. The new SSE test asserts the data: framing as well
as the payload, since a bare JSON object in a text/event-stream is
silently discarded by the client (#6037, a neighbouring writer).

Part of #6038.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 72.20%. Comparing base (a099fa0) to head (e2eef35).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pkg/mcp/tool_filter.go 66.66% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #6068   +/-   ##
=======================================
  Coverage   72.20%   72.20%           
=======================================
  Files         721      721           
  Lines       75062    75097   +35     
=======================================
+ Hits        54198    54225   +27     
- Misses      17003    17006    +3     
- Partials     3861     3866    +5     

☔ 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.

@ChrisJBurns ChrisJBurns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-Agent Consensus Review

Agents consulted: mcp-protocol-reviewer, golang-arch-reviewer, test-coverage-reviewer, code-quality-completeness

Consensus Summary

# Finding Consensus Severity Action
F1 Missing call-site comment: id.IsValid() vs HasJSONRPCID in writeSSEErrorEvent 8/10 LOW Suggestion
F2 notAcceptableBody deferred fabricated id has no inline TODO 7/10 LOW Suggestion

Overall

This is a well-motivated, correctly implemented protocol compliance fix. All seven sites now conditionally omit the "id" key using HasJSONRPCID, the predicate correctly handles every wire-realistic id type (Go nil, json.RawMessage(nil), json.RawMessage("null"), json.RawMessage("0"), string, int, and float64 — including zero), and the test suite expansion uses the right map-decode technique to distinguish key absence from present-null throughout.

Two low-severity suggestions follow. Neither is blocking. The call-site comment on writeSSEErrorEvent's id.IsValid() guard (F1) is a future-maintainer safety net: without it, a refactor that "standardizes" that call to session.HasJSONRPCID would silently regress to "id":{} for zero-value IDs — worse than the original bug. The notAcceptableBody TODO (F2) makes the known deferred issue grep-findable without relying solely on the PR description.

The PR's own notes are unusually thorough. The documented deferrals (pkg/authz/handleUnauthorized, the fabricated "server-error" id) are reasonable and correctly scoped. The zero-id preservation is tested explicitly and held by multiple test rows. writeModernResult correctly continues to emit id unconditionally — the result-response schema requires it, unlike the error path — and the new comment pinning that distinction will prevent the obvious future refactor from going wrong.

Additional Findings (not in diff)

[F2 — LOW] notAcceptableBody at pkg/vmcp/server/server.go emits a fabricated "id":"server-error" string. This is correctly deferred (it is a different class of violation from this PR's scope), but the source has no inline TODO comment tracking it. Adding a // TODO: fabricated "server-error" id is non-conforming; fix in separate PR at the notAcceptableBody declaration would make the deferred state grep-findable.


Generated with Claude Code

Comment thread pkg/transport/proxy/streamable/streamable_proxy.go
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jul 27, 2026
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jul 28, 2026
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jul 28, 2026
@ChrisJBurns
ChrisJBurns merged commit 57e681f into main Jul 28, 2026
125 of 128 checks passed
@ChrisJBurns
ChrisJBurns deleted the fix-6038-omit-absent-jsonrpc-id branch July 28, 2026 01:31
jhrozek added a commit that referenced this pull request Jul 28, 2026
External review of the two preceding commits found no correctness
regressions but surfaced a comment that would have led a future
contributor to loosen a security guard, and a test hole that let a real
regression pass the whole suite.

The comment: objectCarriesResult was documented as reporting a non-null
"result" field. RawMessage's UnmarshalJSON runs even for a JSON null, so
probe.Result holds the 4 bytes "null" whenever the key exists at all and
an explicit null counts as carrying a result. That is the fail-closed
direction and is deliberate, but anyone adding a null check to match the
old wording would have widened the smuggling surface. Say what the code
does and why.

The test hole: every StructuralRoundTrip row asserts byte-identity for a
body needing no filtering, and every bypass test used LF bodies, so
nothing exercised a filtered event on a CRLF stream. Hardcoding the
replacement line's terminator as "\n" passed the entire suite.
TestResponseFilteringWriter_SSE_FilteredEventTerminators covers that and
the unterminated trailing event, which also pins the deliberate choice
not to fabricate a terminator.

Also: carriesResult now has direct coverage, including the null case
above and the two deliberate false results (an undecodable prefix, and a
nested array we do not recurse into) so neither gets "fixed" into a
regression. Three verb-free fmt.Errorf calls become errors.New, the
duplicated result probe is extracted, and the cedar-authorizer and
request boilerplate repeated across ten SSE tests moves into helpers
that assert the parsed request actually reached the context.

Deliberately unchanged: the JSON-RPC error code stays 500 (#6039), and
an absent id stays omitted rather than null, matching #6068.

Refs #6037

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jhrozek added a commit that referenced this pull request Jul 28, 2026
External review of the two preceding commits found no correctness
regressions but surfaced a comment that would have led a future
contributor to loosen a security guard, and a test hole that let a real
regression pass the whole suite.

The comment: objectCarriesResult was documented as reporting a non-null
"result" field. RawMessage's UnmarshalJSON runs even for a JSON null, so
probe.Result holds the 4 bytes "null" whenever the key exists at all and
an explicit null counts as carrying a result. That is the fail-closed
direction and is deliberate, but anyone adding a null check to match the
old wording would have widened the smuggling surface. Say what the code
does and why.

The test hole: every StructuralRoundTrip row asserts byte-identity for a
body needing no filtering, and every bypass test used LF bodies, so
nothing exercised a filtered event on a CRLF stream. Hardcoding the
replacement line's terminator as "\n" passed the entire suite.
TestResponseFilteringWriter_SSE_FilteredEventTerminators covers that and
the unterminated trailing event, which also pins the deliberate choice
not to fabricate a terminator.

Also: carriesResult now has direct coverage, including the null case
above and the two deliberate false results (an undecodable prefix, and a
nested array we do not recurse into) so neither gets "fixed" into a
regression. Three verb-free fmt.Errorf calls become errors.New, the
duplicated result probe is extracted, and the cedar-authorizer and
request boilerplate repeated across ten SSE tests moves into helpers
that assert the parsed request actually reached the context.

Deliberately unchanged: the JSON-RPC error code stays 500 (#6039), and
an absent id stays omitted rather than null, matching #6068.

Refs #6037

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jhrozek added a commit that referenced this pull request Jul 28, 2026
* Parse SSE responses per event, not per line

The response filter processed a text/event-stream body line by line, but
SSE semantics are per event: within one event all "data:" field values
concatenate, and a blank line is structural. Processing lines meant we
parsed the body differently than the client does, and every such
disagreement is a way to smuggle an unfiltered list past the filter
(#5257). Five were reachable:

- A payload split across two "data:" lines. Each half failed to decode
  on its own, so both passed through and the client reassembled the
  full list.
- Two JSON-RPC messages as two "data:" fields of one event. Assembling
  produced two concatenated values, which json.Unmarshal rejects as
  trailing data, so carriesResult missed it.
- Mixed line terminators. SSE permits CR, LF and CRLF interchangeably
  within one stream; sniffing one convention for the whole body left
  entire events buried inside what we treated as a single line.
- A leading UTF-8 BOM. Clients strip it per the WHATWG decode algorithm
  before parsing lines, so our "data:" prefix match failed where theirs
  succeeded.
- A Response carrying both error and result. filterListResponse
  returned early on any error, and the result went out untouched. The
  reference client's schema strips the stray error and accepts the
  result.

Scan (line, terminator) pairs and group them into events, assemble each
event's data payload, then decode and filter once. Every line is
re-emitted with the terminator it was scanned with, so the body is
reproduced structurally and the old separator reconciliation — which
wrote back at most one separator however many it dropped, collapsing
multi-event bodies — is deleted rather than patched. A frame that fails
closed now carries a correlatable JSON-RPC error instead of an empty
data buffer, which per WHATWG is never dispatched and left the client
hanging on its own timeout.

At the SSE framing layer, every payload a strict client can parse and
correlate as this request's Response is now assembled and decoded
before filtering. The residual pass-through paths carry only payloads
no strict parser accepts: a split inside a string literal leaves a raw
control character in a JSON string, which the client's own parser
rejects. So the gap is closed by construction rather than by luck. The
per-method result-shape fallbacks in filterToolsResponse and
filterFindToolResponse are a separate matter, untouched here.

Refs #6037

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Tighten SSE filter comments and close test gaps

External review of the two preceding commits found no correctness
regressions but surfaced a comment that would have led a future
contributor to loosen a security guard, and a test hole that let a real
regression pass the whole suite.

The comment: objectCarriesResult was documented as reporting a non-null
"result" field. RawMessage's UnmarshalJSON runs even for a JSON null, so
probe.Result holds the 4 bytes "null" whenever the key exists at all and
an explicit null counts as carrying a result. That is the fail-closed
direction and is deliberate, but anyone adding a null check to match the
old wording would have widened the smuggling surface. Say what the code
does and why.

The test hole: every StructuralRoundTrip row asserts byte-identity for a
body needing no filtering, and every bypass test used LF bodies, so
nothing exercised a filtered event on a CRLF stream. Hardcoding the
replacement line's terminator as "\n" passed the entire suite.
TestResponseFilteringWriter_SSE_FilteredEventTerminators covers that and
the unterminated trailing event, which also pins the deliberate choice
not to fabricate a terminator.

Also: carriesResult now has direct coverage, including the null case
above and the two deliberate false results (an undecodable prefix, and a
nested array we do not recurse into) so neither gets "fixed" into a
regression. Three verb-free fmt.Errorf calls become errors.New, the
duplicated result probe is extracted, and the cedar-authorizer and
request boilerplate repeated across ten SSE tests moves into helpers
that assert the parsed request actually reached the context.

Deliberately unchanged: the JSON-RPC error code stays 500 (#6039), and
an absent id stays omitted rather than null, matching #6068.

Refs #6037

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close two SSE filter bypasses found in review

Both are regressions the per-event rewrite introduced, found by review
on #6088 and reproduced before fixing.

Assembling the event's data values left leading whitespace on the
payload. Go's JSON scanner treats only SP, TAB, CR and LF as
whitespace, but unicode.IsSpace also covers U+000B, U+000C, U+0085,
U+00A0, U+1680 and U+3000 — and the go-sdk client trims its data buffer
with exactly bytes.TrimSpace. So a payload led by any of those six was
rejected by both DecodeMessage and carriesResult while the client
parsed it happily, and the event fell through to the undecodable branch
with the unfiltered list intact. Trim on assembly instead, which aligns
us with the client's own normalization: all six now filter correctly
rather than merely failing closed.

The second is a coverage regression rather than a new hole. Processing
per event means one malformed data value makes the whole event skip
filtering, where the old per-line loop filtered each line
independently. A result-bearing value sitting after a malformed one in
the same event therefore passed through raw. Probe each data value on
its own before conceding the event needs no filtering. This only ever
turns a pass-through into a fail-closed envelope, never the reverse, so
it cannot reintroduce the split-payload bypass the rewrite closed.

Tests for both, plus the two the review asked for: a strict
WHATWG-grammar SSE parser so the error-path assertions test delivery
rather than bytes — conformant payloads that no client dispatches are
the whole of #6037 — and the fail-closed invariant for the rest of the
stream after a mid-stream failure, which nothing pinned before.

That last test asserts an authorized tool survives the second event,
not just that the denied one is absent. Absence alone would also hold
if the loop truncated the stream, which is the regression the test
exists to catch; verified against that mutation specifically.

Refs #6037

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Match the client's SSE line model, not the spec's

Panel review on #6088 found the rewrite parses less than the client
that consumes these streams, and where the two disagree an upstream can
put a list through the gap. go-sdk's scanEvents — used by ToolHive's own
vMCP and mcpcompat stacks — reads lines with ReadBytes('\n'), so a lone
CR is not a terminator, and it applies TrimSpace to each data value
rather than to the assembled buffer. The scanner here did the opposite
on both counts, following the WHATWG grammar instead.

A bare CR inside a data payload therefore split a line for us and not
for the client: our second piece lost its "data:" prefix, the assembled
payload was truncated, nothing decoded, and the body went out verbatim
with the list intact. main filtered that body, so this was a regression
against it. Separately, one of the six whitespace bytes Go's JSON
scanner rejects but unicode.IsSpace accepts, sitting at an interior
value boundary, survived our single whole-buffer trim while the client
stripped it per value. main leaked that one too.

Scan on LF only and trim each value before joining. That parses a
superset of what we saw before, so it can only decode and filter more,
never less. A browser EventSource does treat a lone CR as a terminator
and will mis-frame a pass-through body carrying one — but identically to
how it would mis-frame the backend's own bytes, since the output stays
byte-preserving, so the worst case is an unparseable payload rather than
a disclosure.

Under LF-only scanning a CRLF line's CR lives in the line text, not the
terminator, so replacing a data line would have silently downgraded it
to bare LF. The CR is re-attached when a line is replaced.

The test oracle had the same bug as the code: it reimplemented WHATWG
line splitting, so the suite agreed with the implementation by
construction and could not have caught any of this. It now models the
go-sdk client, which is the reading that decides whether a payload
reaches a caller.

Also from the review: the fail-closed envelope inherited the offending
event's name, and go-sdk skips events not named "message", so on a named
event the #6037 hang survived the fix that was meant to end it. And an
upstream error carrying an explicit "result":null lost its real code to
our generic envelope; a null result cannot carry a list, so that check
now exempts it.

Three comments asserted things about go-sdk that reading it disproves,
including the one that made the trim granularity look safe. Corrected.

Refs #6037

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error responses emit "id":null, which the TS SDK client rejects

2 participants