Repository navigation
[Server][Streamable HTTP] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs #275
Description
Activity
Same issue exists in
FileSessionStore.Here is explanation from AI agent:
The bug is in how
Mcp\Server\Protocoluses any session store, not inFileSessionStoreitself. Here is the exact sequence:Session::readData()reads the entire JSON blob from the store on the firstget()call and caches it in memory for the rest of thatSessioninstance's lifetime. Each HTTP request gets its ownSessioninstance, so two concurrent requests hold two divergent in-memory snapshots.Protocol::processInput()dispatches the request, mutates the in-memory session (including appending to_mcp.outgoing_queueviaProtocol::queueOutgoing()), then calls$session->save()exactly once.save()serializes the whole blob and writes it back — whichever request writes last clobbers everything the other request wrote, including the other's queued response.Protocol::consumeOutgoingMessages()then creates yet another freshSessioninstance, reads the blob again, and atomically sets the queue to[]and saves. So a request can easily read another request's queued response (and deliver it as its own HTTP response — that is theReceived a response for an unknown message IDerror), or read an empty queue because the other request already consumed it (and return nothing — that is the timeout).
Any
SessionStoreInterfaceimplementation — file, KV, Redis, whatever — exhibits the same race. The store is opaque: it sees onlyread(Uuid)/write(Uuid, string)and cannot atomically merge two concurrent blob writes without knowing the schema. The only places to fix it are inside the SDK itself, or outside it by never letting two requests on the same session id run in parallel (a controller-level lock).As I understand it, this isn't a session storage issue. It's a problem with the SDK that describes how MCP uses session storage. There's no atomic data storage, so data gets corrupted. There's no goal in merging data; the goal is not to store this data together, but to store them in separate files.
Good
guillaume-sainthillier commented
on Sep 25, 2026 ContributorMore actionsAnother data point, and a proposal for the part that #508 and #500 leave open.
Setup: mcp/sdk v0.8.1, symfony/mcp-bundle v0.14.0, api-platform/mcp 4.4.1, handshake era, JSON responses,
FileSessionStore. The client is an n8n AI Agent (LangChain), which runs tool calls in parallel on one session. A lost response surfaces asMCP error -32001: Request timed out.Server Parallel tools/callon one sessionResult FrankenPHP, worker mode (production) 10 1 answered 202with an empty bodyPHP_CLI_SERVER_WORKERS=8 php -S3 × 10 1 × 202, 3 ×500(the500s are #498)Same, with a per-session lock around the controller 50 50 × 200The workaround decorates
mcp.server.<name>.controllerand wrapshandle()inLockFactory::createLock('mcp-session-'.$sessionId, ttl: 60)->acquire(true)from symfony/lock.initializehas no session id yet, so it skips the lock.Proposal: answer the POST directly, without the queue. #508 filters the queue by request id when it is read. That stops one request from receiving another's response, but it can't bring back a response that a concurrent
save()already overwrote. That is the residual loss measured on #508, about 1 pair in 30.A response to a request carried by the current POST could go back through the transport the same way the
null === $sessionpath already sends it (Protocol::sendResponse()), andcreateJsonResponse()would return it. The session queue would remain for what can't be answered inline: server-initiated requests and notifications, SSE streams, and handlers suspended in a fiber. Responses would then no longer depend on session writes at all, and #508's id filter would still apply to whatever goes through the queue.That alone doesn't cover the other session keys (pending requests, client info). Those would still need an opt-in per-session lock, for example
flockinFileSessionStoreand a symfony/lock adapter for the PSR-16 store.@chr-hertel Would you take this as a PR? Should it build on #508 or replace its filtering? I'm happy to write it, with a deterministic test that interleaves two requests on one store.
Thanks for reporting and looking into this - i just set up a similar to test script like @michalcharvat in #508 (at least i assume) and i would bring in #500 and #508 now, but we really have a gap still and i'd be curious to see you approach here @guillaume-sainthillier, yes - thanks already!
this is what i was using: https://github.com/chr-hertel/mcp-concurrency-test
(purely generated)
edit: changed my mind while reviewing #508 - merged #500 but would prefer your proposal over #508 @guillaume-sainthillier - if you're still up for that :)
- addedServerIssues & PRs related to the Server componentIssues & PRs related to the Server componentP1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested feature
on Oct 5, 2026 - changed the title
[-][Streamable HTTP][Server] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs[/-][+][Server][Streamable HTTP] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs[/+]on Oct 5, 2026 Agreed, answering the POST directly is the better fix for the response loss, and #508 can't close the residual case on its own.
I can take the other part guillaume mentioned and that #500 doesn't cover: an opt-in per-session lock, so pending requests and client info aren't lost when two requests save the same session. Rough plan: flock in FileSessionStore, and a small adapter for symfony/lock behind the PSR-16 store. Off by default.
@guillaume-sainthillier does that stay clear of what you're writing?
@chr-hertel if that works for you, I'll open it as a separate PR.For #508: if the id filter is still useful for what stays in the queue, I'll rebase it on top of guillaume's change and add a TTL so unclaimed responses don't pile up in the session. Otherwise I'll close it.
guillaume-sainthillier commented
on Oct 6, 2026 ContributorMore actionsThanks @vbcherepanov — yes, that's clear of #535. It doesn't touch the stores or locking, and lists the per-session lock as a follow-up, so it's yours.
One thing to know for the lock: #535 makes
consumeOutgoingMessages()skip the save when the queue is empty, so a JSON POST now writes the session once (indoProcessInput()) instead of twice.On #508: with #535, responses never enter the queue; only server-initiated requests and notifications do, and those aren't matched by id. So there's nothing left for the id filter to take, and no unclaimed responses to expire. I'd close it, but that's your and @chr-hertel's call.
- added a commit that references this issue
on Oct 8, 2026 - added a commit that references this issue
on Oct 9, 2026
Describe the bug
Concurrent HTTP requests within the same MCP session can corrupt session-backed protocol state in the PHP SDK.
In
Streamable HTTPmode, the server stores MCP protocol state (including outgoing messages and pending requests) inside a shared session payload. The SDK mutates that payload using aread -> modify -> write whole sessionpattern without any locking or atomic merge semantics.When multiple requests are processed concurrently for the same
Mcp-Session-Id(for example, several paralleltools/callrequests from Cursor IDE), one request can overwrite session changes made by another request. In practice, this appears to cause lost outgoing responses / queue entries and leads the client to report stale or unknown message IDs, while the server has actually already produced the response.This looks like an SDK-level concurrency bug rather than an application bug.
To Reproduce
Steps to reproduce the behavior:
Streamable HTTP.tools/callrequests (for example multipleget_record-style read calls).Received a response for an unknown message ID,Expected behavior
Concurrent requests for the same MCP session should not overwrite each other's protocol state.
At minimum, the SDK should guarantee safe mutation of session-backed MCP state (
outgoing_queue, pending request/response maps, counters, etc.) when multiple HTTP requests for the same session are processed in parallel.Possible valid fixes could include:
Logs
Client-side symptoms observed in Cursor IDE:
We also observed cases where multiple parallel
get_recordcalls were started, two completed successfully, and one response payload appeared only as a stale/unknown-message-id response on the client side.Relevant SDK code showing the read/modify/write pattern:
get()/set()with no locking:finallyblock, meaning concurrent requests can each persist their own in-memory snapshot of the same session:This combination strongly suggests a lost-update race condition when two or more requests mutate the same session concurrently.
Additional context
In our integration, this happens systematically when a client sends parallel MCP requests over HTTP within the same session.
A project-level workaround is to serialize all MCP HTTP requests per session with a lock such as
mcp-session:{id}around the entireserver->run($transport)call. However, that appears to be a mitigation for an SDK concurrency issue, not the ideal long-term fix.