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
Contributor

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.

Sessions over Streamable HTTP are now only turned on by Builder::setSession(). Without it:

  • initialize is answered without Mcp-Session-Id, and a session id the client sends anyway is ignored;
  • every request runs on an EphemeralSession that is never stored, with the protocol version taken from the MCP-Protocol-Version header;
  • 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.

What needs a session fails loud: requests to the client (elicitation, sampling, roots/list), the supports*() client capability checks, messages sent outside a request and saving the session throw a LogicException; input-required results, resources/subscribe/unsubscribe and logging/setLevel are answered with -32603, and the subscribe resource 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 with sessionIdGenerator: 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 -S anyway; they keep sessions with setSession(new InMemorySessionStore()). 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 Resolved
Comment thread src/Server/Protocol.php Resolved
@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
@ruudk
ruudk force-pushed the sessionless-handshake-era branch from 6fe6535 to bca19b6 Compare November 4, 2026 08:42
@ruudk

ruudk commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Went with your proposal: withoutSessions() is gone, and sessions over HTTP are only active once setSession() is called (stdio keeps its in-memory one). Everything on your list now fails loud, with tests in SessionlessHttpTest: requests to the client and the supports*() checks throw a LogicException, input-required results, subscriptions and logging/setLevel get -32603, and the protocol version comes from the header.

@ruudk
ruudk force-pushed the sessionless-handshake-era branch from bca19b6 to 1478479 Compare November 4, 2026 08:46
@ruudk

ruudk commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@chr-hertel could you take another look?

@chr-hertel chr-hertel added the breaking change Breaking the Backwards Compatibility Promise label Nov 4, 2026
@chr-hertel chr-hertel added this to the 0.10.0 milestone Nov 4, 2026
{
return json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR);
}
}

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.

not sure we need that many tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 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.

Comment thread src/Server/Session/EphemeralSession.php Outdated
*
* It lives as long as its request and is never stored, so it cannot be saved.
*/
final class EphemeralSession extends Session

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.

either remove final or create an interface ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 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) {

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.

i dislike that its a class i'd prefer an interface here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Ruud's Claude here

It checks against EphemeralSessionInterface now, like the other places.

Comment thread docs/run/sessions.md
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

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.

indeed maybe this feature could even help us with migrating pre-(2026-07-28) servers. +1 to add this

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.
@ruudk
ruudk force-pushed the sessionless-handshake-era branch from 1478479 to 42a953f Compare November 6, 2026 10:50
@ruudk
ruudk requested a review from soyuka November 6, 2026 13:10

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

breaking change Breaking the Backwards Compatibility Promise 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

4 participants