Skip to content

[Server] Answer a JSON POST with only its own responses - #508

Open
vbcherepanov wants to merge 1 commit into
modelcontextprotocol:mainfrom
vbcherepanov:fix/467-json-response-own-messages
Open

vbcherepanov wants to merge 1 commit into
modelcontextprotocol:mainfrom
vbcherepanov:fix/467-json-response-own-messages

Conversation

@vbcherepanov

Copy link
Copy Markdown

Fixes #467.

With PHP-FPM, requests of the same session run in parallel and put their responses into the same session queue. createJsonResponse() took the whole queue, so one request got all responses as a JSON array and the others got 202 with nothing.

Now the transport collects the ids from its own POST body and takes only those responses. The rest stays in the queue. Batches are still answered with an array, single messages with one object, and an invalid message without a usable id still gets its error.

Protocol::consumeOutgoingMessages() gets the ids as an optional second argument. Without it nothing changes, so the SSE path and StdioTransport work as before.

The reproducer is testConcurrentPostsSharingASessionEachReceiveTheirOwnResponse: the second POST is handled completely between the first one queueing its response and reading the queue. It fails on main.

Not part of this PR:

@vbcherepanov
vbcherepanov force-pushed the fix/467-json-response-own-messages branch from e0fced5 to 64f02a0 Compare September 15, 2026 10:19
@michalcharvat

Copy link
Copy Markdown

Independent confirmation on v0.6.0 (Apache + mod_php), driving a real MCP server rather than a unit test: 20 concurrent tools/list + resources/list pairs, each on a freshly initialized session, both requests released from a thread barrier.

build pairs correct cross-delivery response lost (empty 202) HTTP 500
v0.6.0 40 27 11 2 0
v0.6.0 + this PR 80 70 0 6 4
v0.6.0 + this PR + #500 60 59 0 1 0

This PR removes the cross-delivery completely — 0 in 140 pairs after it, against 11 in 40 before. Both shapes described in #467 are gone: the JSON array carrying another request's response, and the 202 the other request got instead of its own.

Two notes from the same runs:

  1. The HTTP 500s that surface once cross-delivery is gone are JsonException: Control character error from Session::readData() on a half-written session file — [Server] Concurrent requests corrupt the session file in FileSessionStore, crashing Session::readData() with JsonException #498, fixed by [Server] Stop concurrent writes from corrupting session files #500. With both PRs applied they disappear. This PR does not cause them, it just stops masking them.
  2. One lost response survives both (1 in 60 pairs), which is the lost update of [Streamable HTTP][Server] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs #275 that this PR correctly says it does not address.

Real-client impact, for the record: Claude Code issues tools/list and resources/list concurrently on every connect, so a plain connection hits this. On a hit it cancels the orphaned request after 30 s with notifications/cancelled … "Request timed out" and silently ends up with no resources.

The patch was applied by hand onto v0.6.0 (the files have moved since), so line numbers differ but the logic is unchanged.

@michalcharvat

Copy link
Copy Markdown

Correction to my numbers above, and a data point on v0.8.1.

The residual-loss figure in my comment — "one lost response survives both (1 in 60 pairs)" — came from too small a sample. Larger runs put it at 6 in 180 pairs on v0.6.0 with both PRs applied, so roughly 1 in 30. That residual is still the lost update of #275 and still outside this PR's scope, so nothing here changes; I am correcting it because "1 in 60" understates how often a real client ends up waiting on a response that was silently dropped.

The cross-delivery result did not move, and it is the one I would stand behind: 0 in 180 pairs.

v0.8.1 still has the bug. Same harness against stock v0.8.1: 65 % of pairs correct, versus 67 % on stock v0.6.0. So the v0.8.0 refactor did not incidentally fix this and the PR is still needed against current releases — worth saying explicitly, because the files have moved enough that four hunks had to be finished by hand when porting it there.

base pairs correct cross-delivery response lost
stock v0.6.0 40 67 % 11 2
v0.6.0 + 508 + 500 180 97 % 0 6
stock v0.8.1 60 65 % many
v0.8.1 + 508 + 500 180 91 % 0 17

One caveat on that 17, since it would be easy to misread as "v0.8.1 loses more than v0.6.0": those runs shared a busy machine and a single run contributed 5 of them. I would not claim a difference between the two bases without re-measuring on an idle box. The zero cross-delivery is the robust part of both patched rows.

Same setup as before — Apache + mod_php, 20 concurrent tools/list + resources/list pairs per run, each pair on a freshly initialized session, both requests released from a thread barrier.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StreamableHttpTransport returns a JSON array in the response body when parallel POST requests arrive concurrently (PHP-FPM)

2 participants