modelcontextprotocol / modelcontextprotocol/php-sdk

[Streamable HTTP][Server] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs

Offen
#275 4 Kommentare 1 Reaktion 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

bug
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:

  1. Run an MCP server using the PHP SDK over Streamable HTTP.
  2. 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).
  3. Use a client that sends multiple requests concurrently within the same MCP session.
  4. Trigger 2-3 parallel tools/call requests (for example multiple get_record-style read calls).
  5. Observe that some calls succeed, while another response is effectively lost from the session-backed outgoing queue.
  6. 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:

  1. 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;
        }
    }
}
  1. 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));
}
  1. 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);
}
  1. The protocol saves the session in a finally block, meaning concurrent requests can each persist their own in-memory snapshot of the same session:
} finally {
    $session->save();
}
  1. 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

Beitragsleitfaden öffnen

Erste Schritte

  1. Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
  3. Forke das Repository und arbeite in einem Branch.
  4. Ö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

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.