Repository navigation
[Server] Serve handshake-era clients without sessions - #645
Conversation
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
|
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 🙏 |
6fe6535 to
bca19b6
Compare
|
Went with your proposal: |
bca19b6 to
1478479
Compare
|
@chr-hertel could you take another look? |
| { | ||
| return json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR); | ||
| } | ||
| } |
There was a problem hiding this comment.
not sure we need that many tests
There was a problem hiding this comment.
🤖 Ruud's Claude here
Trimmed the duplicates: the supports*() checks share one code path and are covered by a single test now, and the raw-wire integration test is gone, as the unit tests cover the same responses.
| * | ||
| * It lives as long as its request and is never stored, so it cannot be saved. | ||
| */ | ||
| final class EphemeralSession extends Session |
There was a problem hiding this comment.
either remove final or create an interface ?
There was a problem hiding this comment.
🤖 Ruud's Claude here
Added EphemeralSessionInterface, which EphemeralSession implements and every check uses, so the class stays final.
| $capabilities = ($this->configuration->capabilities ?? new ServerCapabilities())->withoutListChanged(); | ||
|
|
||
| // Updates of a subscribed resource reach the client outside a request, which needs a session. | ||
| if ($session instanceof EphemeralSession) { |
There was a problem hiding this comment.
i dislike that its a class i'd prefer an interface here
There was a problem hiding this comment.
🤖 Ruud's Claude here
It checks against EphemeralSessionInterface now, like the other places.
| progress and log messages, still reach the client on that request's stream. Servers that need the rest call | ||
| `setSession()`. | ||
|
|
||
| The modern (`2026-07-28`) era has no sessions to begin with and is not affected, and neither is stdio, where the |
There was a problem hiding this comment.
indeed maybe this feature could even help us with migrating pre-(2026-07-28) servers. +1 to add this
1478479 to
42a953f
Compare
42a953f to
e247750
Compare
|
Thanks @ruudk - took this over to get it moving: rebased on |
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, and the in-memory fallback was lost between requests under PHP-FPM anyway. Sessions over Streamable HTTP are now turned on by Builder::setSession() only. Without it, initialize is answered without an Mcp-Session-Id, every later request runs on an EphemeralSession that is never stored, and GET and DELETE are answered with 405. What needs a session fails loud: requests to the client, the client capability checks, queued messages and saving the session throw a LogicException; input-required results, resource subscriptions and logging/setLevel are answered with -32603. The protocol version comes from the MCP-Protocol-Version header. Stdio and the 2026-07-28 era are not affected.
An uncaught LogicException from elicit() and the like reached the client as "Error while executing tool", with the hint only in the log.
Only later suspensions were refused, so a handler suspending on a request itself sent the client a request whose answer could not be matched.
Throwing turned the documented fallback of a handler into an error, although that fallback works: the client cannot be asked, and what it declared is not known.
logging/setLevel always failed while logging stayed advertised, which can fail the connect of clients that set a level on it, and the level stayed at warning.
e247750 to
e1c5a9f
Compare
|
Thank you! |
chr-hertel
left a comment
There was a problem hiding this comment.
Thanks @ruudk - let's hope for little regressions 🤞

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.Sessions over Streamable HTTP are now only turned on by
Builder::setSession(). Without it:initializeis answered withoutMcp-Session-Id, and a session id the client sends anyway is ignored;EphemeralSessionthat is never stored, with the protocol version taken from theMCP-Protocol-Versionheader;GETandDELETEare answered with405, which the spec allows for a server without a stream and for one that does not let clients end sessions.What needs a session fails loud: requests to the client (elicitation, sampling,
roots/list), thesupports*()client capability checks, messages sent outside a request and saving the session throw aLogicException; input-required results,resources/subscribe/unsubscribeandlogging/setLevelare answered with-32603, and thesubscriberesource capability is not advertised. Progress and other notifications sent during a request still reach the client. The TypeScript SDK serves handshake-era traffic the same way withsessionIdGenerator: undefined.This is a BC break for HTTP servers that relied on the in-memory fallback session, which was lost between requests under PHP-FPM or
php -Sanyway; they keep sessions withsetSession(new InMemorySessionStore()). stdio and the2026-07-28era are not affected.