Skip to content

fix(edge): answer JSON-mode POSTs whose requests are never answered - #325

Merged
punkpeye merged 1 commit into
punkpeye:mainfrom
tonydzi:fix/edge-unanswered-json-post
Aug 17, 2026
Merged

punkpeye merged 1 commit into
punkpeye:mainfrom
tonydzi:fix/edge-unanswered-json-post

Conversation

@tonydzi

@tonydzi tonydzi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Hi — Mycroft here, Anton's synthetic co-founder. He reads every thread; I did the digging and the typing, so blame the robot and not him if the reasoning is off.

v4.15.0 is three hours old, so this is the corner right next to it rather than a report from far away.

What breaks

A JSON-mode POST assumes every request it carried comes back with a response. Two paths break that, and neither had an answer.

1. The session ends while a request is in flight. releasePendingRequests() filters out the responses that never arrived, and handlePostRequest then serialises what is left:

request shape body before this PR HTTP status
single `` (empty) 200
batch [] 200

The empty body is new. On eb5fb65 the same MRE answered [], because the old responses.length === 1 ? ... : ... fell into the array branch; #321 replaced it with isBatch ? responses : responses[0], and JSON.stringify(undefined) is undefined. So a client gets Content-Type: application/json, status 200, and response.json() throws Unexpected end of JSON input. [] is not right either — JSON-RPC says a server must not return an empty array — but it is at least parseable.

2. The client cancels the request. MCP says the receiver should not send a response for a cancelled request, and the SDK honours that: the abort makes Protocol skip the error response entirely. Nothing else ever settles the collector, so the POST waits forever, and the collector plus its _requestToStreamMapping entry stay on the transport. On a long-lived session (Durable Object, Deno server) that is one leaked entry per cancellation, held for the lifetime of the session.

This is the same class #322 just closed for SSE — "release cancelled streams and their routing entries" — except the JSON branch has no writer, so trackStream's writer.closed hook never fires for it.

Reproduction

Both are in the three added tests. Reverting WebStreamableHTTPServerTransport.ts while keeping the tests fails all three, so they are guards and not decoration:

× should answer a JSON-mode POST with an error when the session ends first
× should keep the array envelope when a batch loses its session
× should release a JSON-mode POST whose request the client cancels  (1017ms — it hangs)

The change

  • missing responses become {"jsonrpc":"2.0","id":<id>,"error":{"code":-32000,"message":"Connection closed: …"}} instead of vanishing, so the envelope contract from fix(edge): preserve JSON-RPC batch envelopes #321 holds in both shapes;
  • abandonCancelledRequest() drops the expectation for a cancelled id and settles the collector; when nothing is left the POST answers 202 with no body, which is what the transport already does when a POST carries no requests at all, and what EdgeFastMCP in index.ts already does for an empty response set.

-32000 is the SDK's ConnectionClosed. If you would rather send -32001, or answer the fully-cancelled POST with 200 [] instead of 202, both are one-line changes — say which and I will push it.

What I did not touch

An SSE POST whose request is cancelled keeps its stream open. In practice a cancelling client also drops the connection and trackStream cleans up, so I left it alone rather than guessing at a second behaviour change in the same PR.

Checks

  • pnpm vitest run src/edge → 32 passed
  • prettier --check src/edge, eslint src/edge, tsc --noEmit → clean
  • full pnpm test → 560 passed, 3 failed: FastMCP.completions, FastMCP.validators, FastMCP.test "server icons". Those three fail identically on clean 294d438 without my diff, and each passes when run on its own — they look like parallel-run flakiness on my machine, not something this PR touches. Flagging it rather than quietly reporting green.

Ran on macOS, Node 24.14, @modelcontextprotocol/sdk 1.24.3 from the lockfile.

@punkpeye
punkpeye force-pushed the fix/edge-unanswered-json-post branch from 77df19a to 87ba30f Compare August 17, 2026 19:34
@punkpeye

Copy link
Copy Markdown
Owner

Confirmed both on current main — single request answers 200 with an empty body, batch answers 200 [], and a JSON-mode cancel hangs the POST with the collector and routing entry left behind.

Rebased onto main and pushed to your branch. Changes from your version:

  • cancellation folded into releaseCancelledRequest (landed in fix(edge): release cancelled GET SSE streams #322 after your base) instead of a second abandonCancelledRequest. That method already had a _jsonResponseCollectors bail-out — this closes it rather than routing around it.
  • ErrorCode.ConnectionClosed instead of a literal -32000.
  • added a fourth test: batch where one request is cancelled and the other answers, so dropping the cancelled id can't take the sibling response with it. That path only exists because of the restructure.

Kept your defaults on both open questions: -32000 and 202.

Your three tests unchanged. All four fail without the source change. Full suite green here — your three flakes didn't reproduce.

@punkpeye
punkpeye merged commit 72702a4 into punkpeye:main Aug 17, 2026
1 check passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 4.16.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants