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
- Start a Symfony MCP server using StreamableHttpTransport under PHP-FPM
- Connect an MCP client and initialise a session
- Have the client dispatch multiple (8 tools in my case) parallel tool calls in a single agent turn
- 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.
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
Steps to reproduce
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():
Whichever worker wins the race reads all 8 responses from the queue. That response then hits this branch in StreamableHttpTransport:
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:
and crashes with:
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
Option B — POSTs return 202, responses delivered over SSE GET stream:
Client Server
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:
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.