Skip to content

[Server] Serve handshake-era clients without sessions - #645

Open
ruudk wants to merge 1 commit into
modelcontextprotocol:mainfrom
ruudk:sessionless-handshake-era
Open

ruudk wants to merge 1 commit into
modelcontextprotocol:mainfrom
ruudk:sessionless-handshake-era

Conversation

@ruudk

@ruudk ruudk commented Oct 31, 2026

Copy link
Copy Markdown

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 initialize and 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:

  • initialize is answered without Mcp-Session-Id, and a session id the client sends anyway is ignored;
  • every request is served on a session of its own that is dropped once it is answered;
  • GET and DELETE are answered with 405, 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 with sessionIdGenerator: undefined.

The option is off by default, so existing servers behave exactly as before. The only API additions are Builder::withoutSessions(), a trailing optional $sessionless argument on Protocol::__construct() and an @internal StreamableHttpTransport::setSessionless(). stdio and the 2026-07-28 era are not affected.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread src/Server/Protocol.php
Comment on lines +332 to +333
if ($this->isEphemeral($session)) {
return;
Comment thread src/Server/Protocol.php
Comment on lines +1195 to +1196
if ($sessionless) {
return $this->createEphemeralSession();
@chr-hertel

Copy link
Copy Markdown
Member

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:

  1. at first i thought "we should call it disableSession()
  2. session usually is configured explicitly anyways
  3. session is deprecated
  4. and we are pre v1.0

should we maybe remove the fallback session mechanism and session only gets activated is setSession() is called? so even disableSession() would not be needed

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):

  • Elicitation (elicit(), elicitUrl())
  • Sampling (sample())
  • Roots (listRoots())
  • InputRequiredResult on the handshake leg
  • Resource subscriptions
  • Log level (logging/setLevel)
  • Queued messages (Protocol::sendRequest(), sendNotification())
  • Client capability checks (supportsElicitation() etc.)
  • Negotiated protocol version (RequestContext::getProtocolVersion())
  • Handler data kept between requests ($context->getSession()->set(...))

We'd need some tests around that as well 🙏

@chr-hertel chr-hertel added the Server Issues & PRs related to the Server component label Nov 3, 2026

This branch has not been deployed

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

Labels

Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Server] Serve handshake-era clients without a session

3 participants