Repository navigation
[Server] Fix lost responses on concurrent requests of the same session (Streamable HTTP) - #535
Conversation
|
nevermind changelog conflicts. they will happen all the time, and i can easily solve them while merging. just put the PR to ready for review when you think you're done :) |
There was a problem hiding this comment.
🟢 Approval recommended
The response race is addressed without changing other transports, with focused deterministic coverage for both interleavings.
0 open findings
What changed in this PR
Moves Streamable HTTP responses out of shared session queues, preventing concurrent requests from losing or consuming each other’s responses.
Changes:
- Adds inline-response transport behavior and protocol routing.
- Preserves queued notifications and SSE delivery.
- Adds deterministic concurrency and streamed-batch tests.
| File | Description |
|---|---|
CHANGELOG.md |
Documents the concurrency fix. |
src/Server/Protocol.php |
Sends eligible responses inline and avoids unnecessary saves. |
src/Server/Transport/InlineResponseTransportInterface.php |
Defines inline-response transport capability. |
src/Server/Transport/StreamableHttpTransport.php |
Collects inline responses for JSON and SSE output. |
tests/Unit/Server/Transport/Fixture/InterleavingSessionStore.php |
Simulates deterministic concurrent session writes. |
tests/Unit/Server/Transport/StreamableHttpTransportTest.php |
Tests concurrent POSTs and streamed batches. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@guillaume-sainthillier Please rebase and have a look if the test added in PR #545 is valuable to add here - thanks already! |
…n (Streamable HTTP) Fixes modelcontextprotocol#275 and modelcontextprotocol#467. A response to a POST went through the session's outgoing queue, which concurrent requests of the same session read and write back whole. One request could then answer with another's response, and the other with an empty 202. StreamableHttpTransport now implements InlineResponseTransportInterface: Protocol hands it the responses through send() and the transport answers the POST with them. The queue keeps server-initiated requests and notifications.
Ported from modelcontextprotocol#545: one worker polls the session for a client's answer while another stores it. Saving the session on every poll used to overwrite that answer. The store fixture moves to Session/Fixture and gains a hook after the next read. Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
5b0fc7a to
0cc2dd3
Compare
|
Rebased on main and added the test from #545, adapted to share the store fixture. |
Fixes #275, fixes #467. Implements the approach discussed in #275 (comment), replacing the filtering of #508.
Symptom
On Streamable HTTP (handshake era, JSON responses), concurrent requests on one session lose responses. A POST answers
202with an empty body instead of its JSON-RPC response, or answers with another request's response, sometimes inside an array. The client waits until it times out (TypeScript SDK:MCP error -32001: Request timed out). Parallel tool calls from LLM agents (n8n / LangChain) and Claude Code's concurrenttools/listandresources/liston connect run into it.Reproduction
Any server with several workers and a shared session store. Initialize a session, then send N POSTs at once with the same
Mcp-Session-Id. Measured with https://github.com/chr-hertel/mcp-concurrency-test (file store, loopback):tools/callphp -S, 8 workerstools/callphp -S, 8 workerstools/list+resources/listIn production on FrankenPHP worker mode (mcp/sdk 0.8.1 through symfony/mcp-bundle), 1 call in 10 got a
202.Root cause
A response was not returned by the POST that carried its request. It went through the session:
Protocol::sendResponse()queued it in_mcp.outgoing_queue(Protocol.php#L476-L482, #L503-L508) in the session data loaded at the start of the request.doProcessInput()saved that whole session back (#L223).createJsonResponse()reloaded the session and took the whole queue (StreamableHttpTransport.php#L224-L234, Protocol.php#L516-L524).With two requests A and B on one session:
202.202.Filtering the queue by request id (#508) fixes the second case but not the first.
Fix
StreamableHttpTransportimplements a new marker interface,InlineResponseTransportInterface. For such a transport,Protocol::sendResponse()hands every response toTransportInterface::send(), with the session id in thesession_idcontext key, instead of queueing it. This is the path session-less errors already took.consumeOutgoingMessages()only saves when it took something. Saving an unchanged session on every POST could only overwrite what a concurrent request had saved in the meantime. The same save ran on every poll of an SSE stream waiting for a client's answer (sampling, elicitation, roots), and could overwrite that answer when another worker stored it: the rare hangs seen in the multi-worker client tests ([Server] Stop overwriting client responses while polling for them #545, closed in favor of this PR).Tests
StreamableHttpTransportTest::testConcurrentPostsOfOneSessionEachGetTheirOwnResponseruns twotools/callPOSTs through realServerandStreamableHttpTransportinstances that share one store. The fixtureInterleavingSessionStoreruns B from inside A's session save, so the interleaving is deterministic and needs no real concurrency. There are two cases:Each POST must answer
200with its own id and the session header. Both cases fail onmain, where A answers202.testStreamedBatchCarriesInlineResponses: a batch whose tool call suspends to send progress still streams thepingresponse.ProtocolSessionRaceTest, ported from [Server] Stop overwriting client responses while polling for them #545: a client's answer stored by one worker while another polls the session for it is not overwritten. It fails without theconsumeOutgoingMessages()change.Unit and integration suites pass, PHPStan (level 8) and php-cs-fixer are clean.
Backward compatibility
InlineResponseTransportInterface.StdioTransport,InMemoryTransportand custom transports keep the queue. Stdio would work unchanged on the inline path too; making it the default for every transport could be a follow-up.StreamableHttpTransport: no signature changes.createJsonResponse()andcreateStreamedResponse()keep their protected signatures.Protocol::consumeOutgoingMessages()returns the same thing. It just skips a save that would not change anything.Not in this PR
flockforFileSessionStore, symfony/lock for the PSR-16 store). symfony/mcp-bundle would then need an option to wire it. Happy to follow up if that direction suits you.mcp.server.<name>.controllerwrappinghandle()in symfony/lock'sLockFactory::createLock('mcp-session-'.$sessionId). With this PR, that lock is no longer needed for responses.