Repository navigation
Conversation
A session is optional in the handshake era, but the SDK always created one on initialize and refused every later request without it. So a horizontally scaled server had to share a session store and lock only to serve those clients. Builder::withoutSessions() answers initialize without an Mcp-Session-Id, serves every later request on a session of its own, and answers GET and DELETE with 405. Requests to the client fail right away, as their answer cannot be matched without a session. Off by default; stdio and the 2026-07-28 era are not affected.
There was a problem hiding this comment.
🟡 Changes recommended
Sessionless requests can leave persisted ephemeral sessions unreachable until expiry and expose the wrong protocol version to handlers.
2 open issues
What changed in this PR
Adds opt-in sessionless handling for handshake-era Streamable HTTP clients, allowing horizontally scaled servers to serve them without shared session storage.
Changes:
- Adds
Builder::withoutSessions()and request-local session handling, while preserving default behavior. - Omits session headers, rejects GET and DELETE, and fails client-directed requests immediately.
- Documents the trade-offs and adds unit and integration coverage.
| File | Description |
|---|---|
tests/Unit/Server/Transport/SessionlessHttpTest.php |
Tests sessionless HTTP requests, streaming, and client-request rejection. |
tests/Unit/Server/ProtocolTest.php |
Checks that other transports retain session requirements. |
tests/Integration/SessionlessHttpTest.php |
Tests sessionless client communication over HTTP. |
tests/Integration/Fixture/http.php |
Enables sessionless mode in the shared fixture. |
src/Server/Transport/StreamableHttpTransport.php |
Suppresses session headers and rejects unsupported methods. |
src/Server/Protocol.php |
Adds ephemeral sessions and client-request guards. |
src/Server/Builder.php |
Exposes and wires withoutSessions(). |
docs/run/sessions.md |
Explains sessionless configuration and limitations. |
docs/run/server-builder.md |
Lists the new builder option. |
CHANGELOG.md |
Records sessionless HTTP support. |
Review effort: Balanced
| if ($this->isEphemeral($session)) { | ||
| return; |
| if ($sessionless) { | ||
| return $this->createEphemeralSession(); |
|
Hi @ruudk, on the one hand i'm a bit scared about some side-effects here, but i think this is worth going into, so thanks for bringing this up. Some thoughts:
should we maybe remove the fallback session mechanism and session only gets activated is cc @soyuka opinion on that call? one layer of rework is missing tho: a lot of feature don't work without session in handshake era and would need error handling (fail loud):
We'd need some tests around that as well 🙏 |

Closes #644.
A session is optional in the handshake era: a server "MAY assign a session ID at initialization time". The SDK always creates one on
initializeand refuses every later request without it, so a horizontally scaled server has to share a session store and lock only to serve handshake-era clients.Builder::withoutSessions()turns that off for Streamable HTTP:initializeis answered withoutMcp-Session-Id, and a session id the client sends anyway is ignored;GETandDELETEare answered with405, which the spec allows for a server without a stream and for one that does not let clients end sessions.Requests to the client (elicitation, sampling,
roots/list) fail right away in this mode, since the client answers them in a POST of its own that cannot be matched without a session. Progress and other notifications sent during a request still reach the client. The TypeScript SDK serves handshake-era traffic the same way withsessionIdGenerator: undefined.The option is off by default, so existing servers behave exactly as before. The only API additions are
Builder::withoutSessions(), a trailing optional$sessionlessargument onProtocol::__construct()and an@internalStreamableHttpTransport::setSessionless(). stdio and the2026-07-28era are not affected.