modelcontextprotocol / modelcontextprotocol/php-sdk
[Streamable HTTP][Server] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs
Dieses Issue hat noch niemand übernommen.
- Vorherrschende Sprache
- PHP
- Sterne
- 1.6k
- Forks
- 173
- Ø Merge
- 2 T. 49 Min.
- Gemergte PRs (30 T.)
- 23
Beschreibung
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.
Beitragsleitfaden
Erste Schritte
- Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
- Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
- Forke das Repository und arbeite in einem Branch.
- Öffne einen Pull Request, der die Issue-Nummer nennt.
Rechercherichtung
Beginne damit, Psr16SessionStore read/write, die save-Methode des Session-Objekts, queueOutgoing und den finally-Block des Protokolls nachzuverfolgen. Vergleiche parallele Requests, die outgoing_queue und den Status ausstehender Requests verändern, und bestimme anschließend ein nebenläufigkeitssicheres Design. Als erledigt gilt die Aufgabe, wenn parallele Requests für eine MCP-Session alle eingereihten Responses und den Status ausstehender Requests ohne veraltete oder unbekannte Message-IDs beibehalten.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Bewertung
- Tech-Stack
- php
- Bereich
- api, backend
- Issue-Typ
- Bug
- Schwierigkeit
- 5/5
- Geschätzter Aufwand
- Über eine Woche
- Aktivitätsstatus
- Ruhig
- Klarheit
- Größtenteils klar
- Anfängerfreundlichkeit
- 35/100