Skip to content

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

Description

@gregordhimahi

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many users, highly requested featureServerIssues & PRs related to the Server componentbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions