modelcontextprotocol / modelcontextprotocol/php-sdk

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

Aperta
#275 4 commenti 1 reazione 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

bug
Lingua principale
PHP
Stelle
1.6k
Fork
173
Merge medio
2g 49m
PR unite (30g)
23

Descrizione

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.

Guida per i contributori

Apri la guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Direzione di ricerca

Inizia tracciando Psr16SessionStore read/write, il metodo save dell’oggetto sessione, queueOutgoing e il blocco finally del protocollo. Confronta le richieste concorrenti che modificano outgoing_queue e lo stato delle richieste in sospeso, quindi determina una progettazione sicura rispetto alla concorrenza. Il lavoro è completato quando le richieste parallele per una sessione MCP conservano tutte le risposte accodate e lo stato delle richieste in sospeso senza ID di messaggio obsoleti o sconosciuti.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
php
Ambito
api, backend
Tipo di issue
Bug
Difficoltà
5/5
Tempo stimato
Più di una settimana
Stato di attività
Tranquilla
Chiarezza
Abbastanza chiara
Idoneità per principianti
35/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.