Intermittent "Client session not initialized" race under PHP-FPM (multi-process) when a client pipelines requests right after initialize · Issue #83 · php-mcp/server · GitHub
Skip to content

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

Open

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!

Activity

  1. couto-elucubracion commented on Jul 13, 2026

    @couto-elucubracion
    Author

    Follow-up with the numbers I mentioned I'd share, plus a finding that (I think) rules out the most obvious explanation.

    App-level mitigation tried: a Laravel middleware wrapping Cache::lock("mcp-session:{sessionId}", 10)->block(5, ...) around any request carrying an Mcp-Session-Id header, forcing same-session requests to serialize before either one reaches the MCP controller.

    Measured, not assumed — a script opens initialize, grabs the Mcp-Session-Id, then fires logging/setLevel and notifications/initialized as two genuinely concurrent curl processes against a real PHP-FPM pool (this is exactly what MCP Inspector does):

    • Without the middleware: 29/30 failed (96.7%).
    • With the middleware: 29/50 failed (58%), consistent across two independent runs.

    So the lock helps, but 58% is still far from acceptable.

    Confirmed the lock is actually serializing correctly by adding temporary logging: on a failing attempt, notifications/initialized acquires the lock, runs next() to completion (the full php-mcp request cycle, presumably including the session write), and releases the lock — all of this before logging/setLevel even starts processing. And logging/setLevel still gets rejected with -32600. So this isn't (only) about which PHP-FPM worker happens to pick up which request first — the ordering at the Laravel-request level is exactly right, and it still fails.

    Testing the "pending write" hypothesis — the working theory was that the session write from notifications/initialized might not be flushed yet when logging/setLevel reads the session (php-mcp/server uses a ReactPHP/evenement event loop internally, which is a bit of an odd fit for PHP-FPM's synchronous per-request lifecycle, so a write pending on an event-loop tick that doesn't get scheduled before the HTTP response returns seemed plausible). Tried a short retry-on--32600 inside the same lock, a few attempts before giving up:

    • 3 attempts, 50ms apart (100ms total budget): 26/50 failed (52%) — barely different from no retry at all.
    • Suspecting the budget just wasn't generous enough, tried 10 attempts, 200ms apart (up to 2s total budget): 12/20 failed (60%) — no improvement, if anything flat. If this were a delayed write, 20x more time to wait should have pushed the failure rate toward zero. It didn't.

    The actual test: reproduced a failure, and — before touching/deleting the session — queried the real row for that session id directly in the underlying cache table. The stored payload already contained "initialized":true (plus the rest of the expected session state), immediately after the error response had already been sent. The write was not pending; it had already landed correctly. Also checked whether MySQL transaction isolation (REPEATABLE-READ, InnoDB's default) could produce a stale snapshot across the two requests — but confirmed autocommit=1 here, so each query commits individually; there's no multi-statement transaction spanning the gap that would explain it that way either.

    So the working theory in the issue body (an async write not yet visible) doesn't hold up under direct inspection — the data is correct in the store by the time of read, yet the read that mattered still saw it as uninitialized. That looks like a genuine stale read against already-correct data, not a delayed write, which means neither an app-level lock around the request nor a retry-with-backoff can fix it — there's nothing left to wait for. I haven't been able to pin down the exact mechanism from the outside (would need a closer look at the read path within the package itself, Protocol/SessionManager/Session::retrieve(), than I can safely do from application code), so I'm leaving that part as an open question rather than a closed diagnosis — happy to help dig further or share the repro script if useful.

    Reverted the retry experiment on our side (no measurable benefit above the lock alone across three independent runs — 52%/58%/60% show no real trend). Keeping just the session-lock middleware as a partial, honestly-labeled mitigation while this is open upstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions