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

未关闭
#467 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
55/100
Issue 类型
缺陷
描述清晰度
基本清楚
活跃度
活跃
技术栈
php
领域
api, backend

调研方向

首先阅读 StreamableHttpTransport.php 中 createJsonResponse() 附近的代码,以及 Protocol.php 中 consumeOutgoingMessages() 附近的代码,以了解并发的 PHP-FPM 请求如何排空会话队列。使用所描述的 MCP 客户端行为重现并行 POST 请求,然后验证每个 HTTP 响应都是单个 JSON-RPC 对象,并且队列中的其他响应不会丢失或在错误的响应中返回。

由索引模型根据 Issue 内容生成。

描述

bug P1 Server
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.

主要语言
PHP
星标
1.6k
派生
173
平均合并
2 天 49 分钟
30 天内合并 PR
23

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

modelcontextprotocol/php-sdk 的其他 Issue

查看 modelcontextprotocol/php-sdk 的全部 Issue

相似的 Issue

更多 PHP Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。