modelcontextprotocol / modelcontextprotocol/php-sdk

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

Ouverte
#467 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

bug P1 Server
Langage dominant
PHP
Étoiles
1.6k
Forks
173
Merge moyen
2 j 49 min
PR mergées (30 j)
23

Description

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.

Guide de contribution

Ouvrir le guide de contribution

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Piste de recherche

Commencez par lire StreamableHttpTransport.php autour de createJsonResponse() et Protocol.php autour de consumeOutgoingMessages() afin de comprendre comment les requêtes PHP-FPM concurrentes drainent la file d’attente de la session. Reproduisez des requêtes POST parallèles avec le comportement décrit du client MCP, puis vérifiez que chaque réponse HTTP est un objet JSON-RPC unique et que les autres réponses en attente ne sont ni perdues ni renvoyées dans la mauvaise réponse.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
php
Domaine
api, backend
Type d'issue
Bug
Difficulté
4/5
Temps estimé
3-5 jours
Activité
Active
Clarté
Plutôt claire
Accessibilité débutants
55/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.