modelcontextprotocol / modelcontextprotocol/php-sdk

StreamableHttpTransport returns a JSON array in the response body when parallel POST requests arrive concurrently (PHP-FPM)

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

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

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

説明

Summary

When a client sends multiple tool calls as parallel POST requests to a Streamable HTTP MCP server running under PHP-FPM,
StreamableHttpTransport::createJsonResponse() returns a JSON array of all queued responses in a single HTTP response body. This violates the MCP spec and causes spec-compliant clients to fail with a ValidationError.

Environment
  • mcp/sdk: v0.6.0 (latest)
  • symfony/mcp-bundle: v0.10.0
  • PHP: 8.4, running under PHP-FPM (multiple concurrent worker processes)
  • MCP client: Python mcp SDK (mcp/client/streamable_http.py), protocol version 2025-03-26
Steps to reproduce
  1. Start a Symfony MCP server using StreamableHttpTransport under PHP-FPM
  2. Connect an MCP client and initialise a session
  3. Have the client dispatch multiple (8 tools in my case) parallel tool calls in a single agent turn
  4. Observe the server response
What happens

PHP-FPM spins up 8 worker processes, one per request. All 8 workers process their requests concurrently and each calls queueOutgoing(), which appends to the shared cache-backed session queue. There is then a race on consumeOutgoingMessages():

  // Protocol.php
  public function consumeOutgoingMessages(Uuid $sessionId): array
  {
      $session = $this->sessionManager->createWithId($sessionId);
      $queue   = $session->get(self::SESSION_OUTGOING_QUEUE, []);
      $session->set(self::SESSION_OUTGOING_QUEUE, []);  // atomically clears the entire queue
      $session->save();
      return $queue;
  }

Whichever worker wins the race reads all 8 responses from the queue. That response then hits this branch in StreamableHttpTransport:

  // StreamableHttpTransport.php, line 151
  $responseBody = 1 === \count($messages)
      ? $messages[0]
      : '['.implode(',', $messages).']';  // ← returns a JSON array

One HTTP response body becomes:

  [                                                                                                                                                             {"jsonrpc":"2.0","id":2,"result":{...}},
    {"jsonrpc":"2.0","id":3,"result":{...}},
    {"jsonrpc":"2.0","id":4,"result":{...}},                                                                                                                    ...7 more entries...
  ]

The remaining 7 workers drain an empty queue and return 202 No Content.

The Python MCP client then calls:

  # mcp/client/streamable_http.py, line 385
  message = JSONRPCMessage.model_validate_json(content)

and crashes with:

  pydantic_core._pydantic_core.ValidationError: 4 validation errors for JSONRPCMessage
  JSONRPCRequest
    Input should be an object [type=model_type, input_value=[{'jsonrpc': '2.0', 'id': ...}], input_type=list]
  JSONRPCNotification
    Input should be an object [type=model_type, ...input_type=list]
  JSONRPCResponse
    Input should be an object [type=model_type, ...input_type=list]
  JSONRPCError
    Input should be an object [type=model_type, ...input_type=list]

The 7 requests that got 202 No Content receive no response at all, causing the agent to time out waiting for results.

What the MCP spec requires

The MCP Streamable HTTP spec is explicit on this point:

▎ "If the server responds directly in the HTTP response body, it MUST use Content-Type: application/json and the body MUST be a single JSON-RPC message
▎ object."

For parallel tool calls the spec defines two compliant patterns:

Option A — each POST returns its own single response synchronously:
Client Server

    |--- POST /mcp (id=2) ---->|---> 200 {"jsonrpc":"2.0","id":2,"result":{...}}
    |--- POST /mcp (id=3) ---->|---> 200 {"jsonrpc":"2.0","id":3,"result":{...}}
    |--- POST /mcp (id=4) ---->|---> 200 {"jsonrpc":"2.0","id":4,"result":{...}}

Option B — POSTs return 202, responses delivered over SSE GET stream:
Client Server

    |--- GET /mcp (SSE) ------>|   (persistent stream)
    |--- POST /mcp (id=2) ---->|---> 202 Accepted
    |--- POST /mcp (id=3) ---->|---> 202 Accepted
    |--- POST /mcp (id=4) ---->|---> 202 Accepted
    |<-- SSE data: id=2 -------|
    |<-- SSE data: id=3 -------|
    |<-- SSE data: id=4 -------|

Returning a JSON array is not a valid option under any revision of the spec.

Root cause

The design of the shared session outgoing queue assumes a single-process, sequential request model (PHP CLI / built-in server). Under PHP-FPM, multiple worker processes share the same session cache and race to drain the queue, so a worker can inadvertently collect and return responses that belong to other concurrent requests.

The array branch on line 151 of StreamableHttpTransport was written as a defensive fallback for a scenario that should never happen in single-process mode, but it surfaces as a reliable bug under any multi-process deployment.

Suggested fix

createJsonResponse() should never return more than one response. If the queue happens to contain multiple messages (due to a race), only the message matching the current request's ID should be returned; the rest should remain in the queue for the SSE GET stream to deliver.

A minimal change that prevents the spec violation:

  // StreamableHttpTransport.php
  protected function createJsonResponse(): ResponseInterface
  {
      $outgoingMessages = $this->getOutgoingMessages($this->sessionId);

      if (empty($outgoingMessages)) {
          return $this->responseFactory->createResponse(202)
              ->withHeader('Content-Type', 'application/json');
      }

      $messages = array_column($outgoingMessages, 'message');

      // NEVER return an array — the MCP spec requires a single JSON-RPC object
      // per HTTP response. Return only the first message; others will be delivered
      // via the SSE GET stream.
      $responseBody = $messages[0];

      // ...
  }

A more complete fix would make the queue per-request-ID rather than a shared FIFO, so each worker only ever sees its own response.

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

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

はじめの一歩

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

調査の方向性

まず、StreamableHttpTransport.php の createJsonResponse() 付近と Protocol.php の consumeOutgoingMessages() 付近を読み、並行する PHP-FPM リクエストがセッションキューをどのように排出するかを理解します。説明されている MCP クライアントの動作で並列 POST リクエストを再現し、その後、各 HTTP レスポンスが単一の JSON-RPC オブジェクトであること、またキューに入っている他のレスポンスが失われたり誤ったレスポンスで返されたりしないことを確認します。

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

評価

技術スタック
php
領域
api, backend
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
おおむね明確
初心者へのやさしさ
55/100

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

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