-
Notifications
You must be signed in to change notification settings - Fork 126
[Streamable HTTP][Server] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs #275
Description
Describe the bug
Concurrent HTTP requests within the same MCP session can corrupt session-backed protocol state in the PHP SDK.
In Streamable HTTP mode, the server stores MCP protocol state (including outgoing messages and pending requests) inside a shared session payload. The SDK mutates that payload using a read -> modify -> write whole session pattern without any locking or atomic merge semantics.
When multiple requests are processed concurrently for the same Mcp-Session-Id (for example, several parallel tools/call requests 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:
- Run an MCP server using the PHP SDK over
Streamable HTTP. - Configure it with a shared session store (for example PSR-16 cache via Symfony MCP Bundle, but the same read/modify/write pattern appears to affect file storage too).
- Use a client that sends multiple requests concurrently within the same MCP session.
- Trigger 2-3 parallel
tools/callrequests (for example multipleget_record-style read calls). - Observe that some calls succeed, while another response is effectively lost from the session-backed outgoing queue.
- On the client side, this can surface as:
- hanging tool calls,
Received a response for an unknown message ID,- stale responses after reconnect.
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:
- locking per MCP session during request processing,
- atomic session mutation,
- splitting queue state out of the monolithic session blob,
- or another concurrency-safe design.
Logs
Client-side symptoms observed in Cursor IDE:
Ignoring stale response (unknown message ID): Received a response for an unknown message ID: {"jsonrpc":"2.0","id":10,"result":{...}}
We also observed cases where multiple parallel get_record calls 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:
- The PSR-16 session store performs plain
get()/set()with no locking:
class Psr16SessionStore implements SessionStoreInterface
{
public function read(Uuid $id): string|false
{
try {
return $this->cache->get($this->getKey($id), false);
} catch (\Throwable) {
return false;
}
}
public function write(Uuid $id, string $data): bool
{
try {
return $this->cache->set($this->getKey($id), $data, $this->ttl);
} catch (\Throwable) {
return false;
}
}
}- The session object saves the full session payload as one JSON blob:
public function save(): bool
{
return $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR));
}- Outgoing messages are appended by reading the queue from session state, mutating it in memory, and writing it back later:
private function queueOutgoing(Request|Notification|Response|Error $message, array $context, SessionInterface $session): void
{
try {
$encoded = json_encode($message, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->logger->error('Failed to encode message to JSON.', [
'exception' => $e,
]);
return;
}
$queue = $session->get(self::SESSION_OUTGOING_QUEUE, []);
$queue[] = [
'message' => $encoded,
'context' => $context,
];
$session->set(self::SESSION_OUTGOING_QUEUE, $queue);
}- The protocol saves the session in a
finallyblock, meaning concurrent requests can each persist their own in-memory snapshot of the same session:
} finally {
$session->save();
}- Pending request state is mutated the same way:
$pending = $session->get(self::SESSION_PENDING_REQUESTS, []);
$pending[$requestId] = [
'request_id' => $requestId,
'timeout' => $timeout,
'timestamp' => time(),
];
$session->set(self::SESSION_PENDING_REQUESTS, $pending);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 entire server->run($transport) call. However, that appears to be a mitigation for an SDK concurrency issue, not the ideal long-term fix.