Skip to content

[Streamable HTTP][Server] Concurrent requests in the same session can overwrite queued responses and cause stale/unknown message IDs #275

@tebaly

Description

@tebaly

Describe the bug

Concurrent HTTP requests within the same MCP session can corrupt session-backed protocol state in the PHP SDK.

In Streamable HTTP mode, the server stores MCP protocol state (including outgoing messages and pending requests) inside a shared session payload. The SDK mutates that payload using a read -> modify -> write whole session pattern without any locking or atomic merge semantics.

When multiple requests are processed concurrently for the same Mcp-Session-Id (for example, several parallel tools/call requests from Cursor IDE), one request can overwrite session changes made by another request. In practice, this appears to cause lost outgoing responses / queue entries and leads the client to report stale or unknown message IDs, while the server has actually already produced the response.

This looks like an SDK-level concurrency bug rather than an application bug.

To Reproduce

Steps to reproduce the behavior:

  1. Run an MCP server using the PHP SDK over Streamable HTTP.
  2. Configure it with a shared session store (for example PSR-16 cache via Symfony MCP Bundle, but the same read/modify/write pattern appears to affect file storage too).
  3. Use a client that sends multiple requests concurrently within the same MCP session.
  4. Trigger 2-3 parallel tools/call requests (for example multiple get_record-style read calls).
  5. Observe that some calls succeed, while another response is effectively lost from the session-backed outgoing queue.
  6. On the client side, this can surface as:
    • hanging tool calls,
    • Received a response for an unknown message ID,
    • stale responses after reconnect.

Expected behavior

Concurrent requests for the same MCP session should not overwrite each other's protocol state.

At minimum, the SDK should guarantee safe mutation of session-backed MCP state (outgoing_queue, pending request/response maps, counters, etc.) when multiple HTTP requests for the same session are processed in parallel.

Possible valid fixes could include:

  • locking per MCP session during request processing,
  • atomic session mutation,
  • splitting queue state out of the monolithic session blob,
  • or another concurrency-safe design.

Logs

Client-side symptoms observed in Cursor IDE:

Ignoring stale response (unknown message ID): Received a response for an unknown message ID: {"jsonrpc":"2.0","id":10,"result":{...}}

We also observed cases where multiple parallel get_record calls were started, two completed successfully, and one response payload appeared only as a stale/unknown-message-id response on the client side.

Relevant SDK code showing the read/modify/write pattern:

  1. The PSR-16 session store performs plain get() / set() with no locking:
class Psr16SessionStore implements SessionStoreInterface
{
    public function read(Uuid $id): string|false
    {
        try {
            return $this->cache->get($this->getKey($id), false);
        } catch (\Throwable) {
            return false;
        }
    }

    public function write(Uuid $id, string $data): bool
    {
        try {
            return $this->cache->set($this->getKey($id), $data, $this->ttl);
        } catch (\Throwable) {
            return false;
        }
    }
}
  1. The session object saves the full session payload as one JSON blob:
public function save(): bool
{
    return $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR));
}
  1. Outgoing messages are appended by reading the queue from session state, mutating it in memory, and writing it back later:
private function queueOutgoing(Request|Notification|Response|Error $message, array $context, SessionInterface $session): void
{
    try {
        $encoded = json_encode($message, \JSON_THROW_ON_ERROR);
    } catch (\JsonException $e) {
        $this->logger->error('Failed to encode message to JSON.', [
            'exception' => $e,
        ]);

        return;
    }

    $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []);
    $queue[] = [
        'message' => $encoded,
        'context' => $context,
    ];
    $session->set(self::SESSION_OUTGOING_QUEUE, $queue);
}
  1. The protocol saves the session in a finally block, meaning concurrent requests can each persist their own in-memory snapshot of the same session:
} finally {
    $session->save();
}
  1. Pending request state is mutated the same way:
$pending = $session->get(self::SESSION_PENDING_REQUESTS, []);
$pending[$requestId] = [
    'request_id' => $requestId,
    'timeout' => $timeout,
    'timestamp' => time(),
];
$session->set(self::SESSION_PENDING_REQUESTS, $pending);

This combination strongly suggests a lost-update race condition when two or more requests mutate the same session concurrently.

Additional context

In our integration, this happens systematically when a client sends parallel MCP requests over HTTP within the same session.

A project-level workaround is to serialize all MCP HTTP requests per session with a lock such as mcp-session:{id} around the entire server->run($transport) call. However, that appears to be a mitigation for an SDK concurrency issue, not the ideal long-term fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions