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
- Point MCP Inspector at a
http_integrated Laravel MCP server running behind PHP-FPM with 2+ workers.
- Click "Connect". Inspector sends
initialize, then sends logging/setLevel (its default debug log-level preference) and notifications/initialized in quick succession.
- 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!
Describe the bug
Using
php-mcp/laravel'shttp_integratedStreamable HTTP transport behind traditional PHP-FPM (multiple independent worker processes, no shared event loop / no Octane/Swoole), a real MCP client that sendsnotifications/initializedand a follow-up request (e.g.logging/setLevel) as two separate near-simultaneous HTTP requests can hit an intermittent-32600: Client session not initializederror 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-Idconcurrently.Setup
php-mcp/server3.3.0,php-mcp/laravel(latest), Laravel 13http_integrated(Streamable HTTP over Laravel routing)mcp.session.driver = cache, backed by Laravel'sdatabasecache 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)To Reproduce
http_integratedLaravel MCP server running behind PHP-FPM with 2+ workers.initialize, then sendslogging/setLevel(its default debug log-level preference) andnotifications/initializedin quick succession.Actual behavior
Intermittently (reproduced on 2 of 3 consecutive connection attempts in our environment), the server logs show
logging/setLevelbeing received beforenotifications/initializedhas been fully processed, and the server rejects it:Note the received order in the log already has
logging/setLevelfirst — 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 writesinitialized: trueto the shared session store first. The client-visible symptom is that the whole Inspector connection attempt fails with a "Connection error" banner, even thoughinitializeitself 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
stdioor 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 thehttp_integratedLaravel 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
/mcpwith aCache::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)
http_integratedunder 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!