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

オープン
#275 コメント 4 件 リアクション 1 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
5/5
見積もり時間
1週間以上
初心者へのやさしさ
35/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
静か
技術スタック
php
領域
api, backend

調査の方向性

まず、Psr16SessionStore read/write、セッションオブジェクトのsaveメソッド、queueOutgoing、プロトコルのfinallyブロックを追跡します。outgoing_queueと保留中のリクエスト状態を変更する並行リクエストを比較し、その後、並行性に安全な設計を決定します。完了の条件は、1つのMCPセッションに対する並列リクエストが、古いまたは不明なメッセージIDなしに、キューに入れられたすべてのレスポンスと保留中の状態を保持することです。

索引モデルが issue の本文から書いたものです。

説明

bug

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.

主要言語
PHP
スター
1.6k
フォーク
173
平均マージ
2日 49分
マージ済み PR(30日)
23

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

modelcontextprotocol/php-sdk のほかの issue

modelcontextprotocol/php-sdk の issue をすべて見る

似ている issue

PHP の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。