Skip to content

Intermittent "Client session not initialized" race under PHP-FPM (multi-process) when a client pipelines requests right after initialize #83

Description

@couto-elucubracion

Describe the bug

Using php-mcp/laravel's http_integrated Streamable HTTP transport behind traditional PHP-FPM (multiple independent worker processes, no shared event loop / no Octane/Swoole), a real MCP client that sends notifications/initialized and a follow-up request (e.g. logging/setLevel) as two separate near-simultaneous HTTP requests can hit an intermittent -32600: Client session not initialized error on the second request — even though the session is genuinely active and was never deleted.

This is different from #39 (client reusing a stale/deleted session id). Here the session is still alive; the failure is a read/write ordering race across two different PHP-FPM worker processes handling the same Mcp-Session-Id concurrently.

Setup

  • php-mcp/server 3.3.0, php-mcp/laravel (latest), Laravel 13
  • Transport: http_integrated (Streamable HTTP over Laravel routing)
  • Session driver: mcp.session.driver = cache, backed by Laravel's database cache store (so session state genuinely is shared across processes via a DB-backed store — this isn't the "array driver, no sharing at all" failure mode)
  • Server: PHP-FPM with several worker processes (traditional multi-process model, not a persistent single-process event loop)
  • Client: MCP Inspector v0.22.0, connecting over Streamable HTTP

To Reproduce

  1. Point MCP Inspector at a http_integrated Laravel MCP server running behind PHP-FPM with 2+ workers.
  2. Click "Connect". Inspector sends initialize, then sends logging/setLevel (its default debug log-level preference) and notifications/initialized in quick succession.
  3. Repeat the connect a handful of times.

Actual behavior

Intermittently (reproduced on 2 of 3 consecutive connection attempts in our environment), the server logs show logging/setLevel being received before notifications/initialized has been fully processed, and the server rejects it:

[debug] Message received. {"sessionId":"...","message":{"...":{"jsonrpc":"2.0","id":1,"method":"logging/setLevel","params":{"level":"debug"}}}}
[debug] Message received. {"sessionId":"...","message":{"...":{"jsonrpc":"2.0","method":"notifications/initialized"}}}
[debug] MCP Processor caught McpServerException {"method":"logging/setLevel","code":-32600,"message":"Client session not initialized.","data":null}
[debug] Response sent. {"sessionId":"...","payload":{"...":{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Client session not initialized."}}}}

Note the received order in the log already has logging/setLevel first — but even when the two client-side sends happen to be issued in the "right" order, PHP-FPM may dispatch them to two different worker processes with no guaranteed commit order for whichever one writes initialized: true to the shared session store first. The client-visible symptom is that the whole Inspector connection attempt fails with a "Connection error" banner, even though initialize itself succeeded and the session was never deleted.

Expected behavior

A session that is genuinely still alive (not deleted, not expired) shouldn't intermittently reject a request as "not initialized" depending on which worker process happens to service it. Retrying the connection (a fresh session) usually succeeds, which points at this being a race rather than a deterministic client/server protocol mismatch.

Why this is specific to multi-process deployments

Under stdio or a single-process event-loop transport, there's no possibility of two messages for the same session landing on two independent processes with independent views of session state — everything is serialized through one process/one event loop. Under PHP-FPM (the deployment model the http_integrated Laravel transport is explicitly built for), that guarantee doesn't hold: each HTTP request can be handled by any available worker, and a compare-then-write on session state (initialized: true) isn't necessarily atomic/visible to a concurrently-running sibling request against the same session id.

What we tried on our end (app-level, not a fix for the package)

We experimented with wrapping our own middleware around /mcp with a Cache::lock("mcp-session:{sessionId}", ...) to force same-session requests to serialize rather than race — happy to share results/numbers if useful, but this obviously can't reach inside the package's own session read/write path, so it's a partial mitigation at best from our side.

Possible directions (not prescriptive, just what came to mind)

  • An atomic compare-and-set (or a lock acquired inside the package itself around session state transitions) when moving a session from "not yet initialized" to "initialized", so a concurrent request for the same session either waits briefly or gets a consistent view.
  • Or, if this is considered client behavior to fix (client should serialize its own sends for the same session over HTTP transports rather than pipeline them) — a note in the docs about this assumption for http_integrated under traditional multi-process PHP deployments (PHP-FPM without Octane/Swoole) would help others avoid the same debugging trip we just took.

Happy to provide a minimal repro repository or more log excerpts if helpful. Thanks for the package!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions