Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Fix `InMemorySessionStore::exists()` reporting a session past its TTL as existing, while `read()` already treated it as gone.
* Fix `Psr16NotificationBus` subscribers missing the notifications published after the bus stayed quiet for longer than its TTL: the sequence counter no longer expires, and a subscriber whose cursor is ahead of a lost counter reads what the bus holds.
* Fix `Session::save()` wiping the other keys of a session that the store failed to read, e.g. when `Psr16SessionStore` hits a cache error: it now returns `false` instead of writing the changed keys alone.
* Add `Builder::withoutSessions()` to serve handshake-era clients over Streamable HTTP without sessions: `initialize` is answered without `Mcp-Session-Id`, every later request is served on its own, and `GET` and `DELETE` are answered with `405`, so a horizontally scaled server needs no shared session store or lock for them. Requests to the client (elicitation, sampling, roots) fail right away in this mode. Off by default; stdio and the `2026-07-28` era are not affected.
* Add `Psr6SessionStore` and `Psr6NotificationBus`, which work like `Psr16SessionStore` and `Psr16NotificationBus` over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool as `$cache`.

### Client
Expand Down
1 change: 1 addition & 0 deletions docs/run/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ $server = Server::builder()
| `setDiscoverer()` | discoverer | Replace the class that scans for attributes (the discovery `cache` is then not used) |
| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Configure session management, see [Sessions](sessions.md) |
| `setSessionLock()` | sessionLock | Serialize the concurrent requests of one session, see [Sessions](sessions.md#concurrent-requests) |
| `withoutSessions()` | - | Serve handshake-era clients over HTTP without sessions, see [Sessions](sessions.md#serving-without-sessions) |
| `setLogger()` | logger | Set PSR-3 logger |
| `setContainer()` | container | Set PSR-11 container |
| `setEventDispatcher()` | eventDispatcher | Set PSR-14 event dispatcher, see [Events](../advanced/events.md) |
Expand Down
31 changes: 31 additions & 0 deletions docs/run/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,37 @@ tens of thousands, use `Psr6SessionStore` or `Psr16SessionStore` with Redis or a
across hosts, expires sessions on its own and needs no garbage collection (see below). A network-mounted directory adds
latency and makes session availability depend on the shared filesystem.

## Serving Without Sessions

A session is optional in the handshake era: the specification says a server "MAY assign a session ID at
initialization time". `withoutSessions()` makes the server assign none, so the servers behind a load balancer
need no shared session store or lock to answer handshake-era clients:

```php
$server = Server::builder()
->withoutSessions()
->build();
```

Over Streamable HTTP, `initialize` is then answered without an `Mcp-Session-Id` header, and every later request
is served on a session of its own that is dropped once the request is answered. A `Mcp-Session-Id` the client
sends anyway is ignored. `GET` and `DELETE` are answered with HTTP `405`, as there is no stream to open and no
session to end.

What a session carries from one request to the next is gone:

- the client's capabilities and the log level it set with `logging/setLevel`;
- `resources/subscribe` subscriptions and other messages the server sends outside a request, like list changes;
- requests to the client: elicitation, sampling and `roots/list`. The client answers them in a POST of its own,
which cannot be matched to the request that waits for the answer, so the `ClientGateway` method gets an error
right away instead of waiting for its timeout.

Notifications sent while a request runs, like progress, still reach the client on that request's stream.
Servers that need the rest keep using sessions.

The modern (`2026-07-28`) era has no sessions to begin with and is not affected, and neither is stdio, where the
connection holds the one session.

## Garbage Collection Configuration

The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native
Expand Down
23 changes: 23 additions & 0 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ final class Builder

private int $inputRequiredTimeout = InputRequiredShim::DEFAULT_ROUND_TIMEOUT;

private bool $sessionless = false;

/**
* @see self::assemble() for why this is memoized
*
Expand Down Expand Up @@ -663,6 +665,26 @@ public function setSessionLock(SessionLockInterface $sessionLock): self
return $this;
}

/**
* Serve handshake-era clients over Streamable HTTP without sessions. Off by default.
*
* `initialize` is answered without an `Mcp-Session-Id`, every later request is served on
* a session of its own, and `GET` and `DELETE` are answered with `405`. No session store or
* lock has to be shared between the servers behind a load balancer for these clients.
*
* What a session carries from one request to the next is gone: the client's capabilities,
* the log level it set, its resource subscriptions, and the queue of messages the server
* sends outside a request. Requests to the client (elicitation, sampling, roots) fail right
* away, as its answer arrives in a POST of its own. Notifications sent during a request,
* such as progress, still reach the client. The modern era and stdio are not affected.
*/
public function withoutSessions(): self
{
$this->sessionless = true;

return $this;
}

/**
* @param string[] $scanDirs
* @param string[] $excludeDirs
Expand Down Expand Up @@ -982,6 +1004,7 @@ public function build(): Server
: null,
requestStateCodec: $this->requestStateCodec(),
sessionLock: $this->sessionLock,
sessionless: $this->sessionless,
);

$modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions();
Expand Down
70 changes: 70 additions & 0 deletions src/Server/Protocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
use Mcp\Server\Stateless\RequestStateCodec;
use Mcp\Server\Suspension\NotificationSuspension;
use Mcp\Server\Suspension\RequestSuspension;
use Mcp\Server\Transport\StreamableHttpTransport;
use Mcp\Server\Transport\TransportInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -70,6 +71,15 @@ class Protocol

public const SESSION_LOGGING_LEVEL = '_mcp.logging_level';

/** Session key marking a session made for one HTTP request of a server without sessions */
private const SESSION_EPHEMERAL = '_mcp.ephemeral';

/**
* The client answers a request in a POST of its own, which a server without sessions
* cannot match to the request that is waiting for the answer.
*/
private const SESSIONLESS_CLIENT_REQUEST_MESSAGE = 'Requests to the client need a session, and this server runs without sessions.';

/**
* Deliberately generic: unexpected throwables carry internal details such as file paths, class
* names and argument types, which must not be handed to the peer. The full exception is logged.
Expand Down Expand Up @@ -112,6 +122,8 @@ class Protocol
/**
* @param array<int, RequestHandlerInterface<ResultInterface|array<string, mixed>>> $requestHandlers
* @param array<int, NotificationHandlerInterface> $notificationHandlers
* @param bool $sessionless serve each handshake-era request over Streamable HTTP
* on a session of its own, without a `Mcp-Session-Id`
*/
public function __construct(
private readonly array $requestHandlers,
Expand All @@ -123,6 +135,7 @@ public function __construct(
private readonly ?InputRequiredShim $inputRequiredShim = null,
private readonly ?RequestStateCodec $requestStateCodec = null,
private readonly ?SessionLockInterface $sessionLock = null,
private readonly bool $sessionless = false,
) {
$this->awaitedRequestIds = new \WeakMap();
$this->fiberOutgoingMessages = new \WeakMap();
Expand All @@ -139,6 +152,10 @@ public function connect(TransportInterface $transport): void
{
$transport->onMessage($this->processInput(...));

if ($this->sessionless && $transport instanceof StreamableHttpTransport) {
$transport->setSessionless(true);
}

$transport->onSessionEnd($this->destroySession(...));

// The transport keeps these callbacks, so they reference it weakly to not keep it alive.
Expand Down Expand Up @@ -311,6 +328,11 @@ private function handleMessages(TransportInterface $transport, ?Uuid $sessionId,
}
}

// Stored only while one of its handlers is suspended, which ends it when it completes.
if ($this->isEphemeral($session)) {
return;
Comment on lines +332 to +333
}

try {
$session->save();
} catch (\Throwable $e) {
Expand Down Expand Up @@ -431,6 +453,12 @@ private function handleRequest(TransportInterface $transport, Request $request,

$result = $fiber->start();

// Answered right away instead of waiting out the timeout for an answer that cannot be matched.
while ($result instanceof RequestSuspension && $this->isEphemeral($session)) {
$this->logger->warning('Refused a request to the client on a server without sessions.', ['method' => $result->request::getMethod()]);
$result = $fiber->resume(Error::forInternalError(self::SESSIONLESS_CLIENT_REQUEST_MESSAGE));
}

if ($fiber->isSuspended()) {
$beforeSuspension = $session->all();

Expand Down Expand Up @@ -941,6 +969,10 @@ public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId, ?Transpo
$session = $this->sessionManager->createWithId($sessionId);

try {
if ($yieldedValue instanceof RequestSuspension && $this->isEphemeral($session)) {
throw new RuntimeException(self::SESSIONLESS_CLIENT_REQUEST_MESSAGE);
}

if ($yieldedValue instanceof RequestSuspension) {
$requestId = $this->registerRequest($yieldedValue->request, $yieldedValue->timeout, $session);
$outgoing = $yieldedValue->request->withId($requestId->toRfc4122());
Expand Down Expand Up @@ -1048,6 +1080,17 @@ private function dispatchResult(Response|Error $result, Request $request, Sessio
*/
private function saveResumedSession(SessionInterface $session): void
{
// An ephemeral session ends with the request it was made for.
if ($this->isEphemeral($session)) {
try {
$this->sessionManager->destroy($session->getId());
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Failed to destroy the session of a resumed request: %s', $e->getMessage()), ['exception' => $e]);
}

return;
}

$sessionId = $session->getId();
$locked = false;

Expand Down Expand Up @@ -1115,6 +1158,9 @@ private function hasInitializeRequest(array $messages): bool
*/
private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, array $messages): ?SessionInterface
{
// Without sessions, every request is served on its own: the transport ignores and emits no session id.
$sessionless = $this->sessionless && $transport instanceof StreamableHttpTransport;

if ($this->hasInitializeRequest($messages)) {
// Spec: An initialize request must not be part of a batch.
if (\count($messages) > 1) {
Expand All @@ -1132,6 +1178,10 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
return null;
}

if ($sessionless) {
return $this->createEphemeralSession();
}

$session = $this->sessionManager->create();
$this->logger->debug('Created new session for initialize', [
'session_id' => $session->getId()->toRfc4122(),
Expand All @@ -1142,6 +1192,10 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
return $session;
}

if ($sessionless) {
return $this->createEphemeralSession();
Comment on lines +1195 to +1196
}

if (!$sessionId) {
// Echo the id so a client probing with `server/discover` can correlate the refusal.
$error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.', self::findRequestId($messages));
Expand Down Expand Up @@ -1174,6 +1228,22 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId,
return $this->sessionManager->createWithId($sessionId);
}

/**
* A session for one request, never saved unless one of its handlers suspends.
*/
private function createEphemeralSession(): SessionInterface
{
$session = $this->sessionManager->create();
$session->set(self::SESSION_EPHEMERAL, true);

return $session;
}

private function isEphemeral(SessionInterface $session): bool
{
return $this->sessionless && true === $session->get(self::SESSION_EPHEMERAL);
}

/**
* The id of a single request, so a refusal of the whole input can be correlated with it.
*
Expand Down
26 changes: 23 additions & 3 deletions src/Server/Transport/StreamableHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra
/** @var non-empty-list<ProtocolVersion>|null null names every handshake revision */
private ?array $handshakeVersions = null;

private bool $sessionless = false;

private ?string $immediateResponse = null;
private ?int $immediateStatusCode = null;

Expand Down Expand Up @@ -176,6 +178,17 @@ public function setHandshakeVersions(array $versions): void
$this->handshakeVersions = $versions;
}

/**
* Serves the handshake era without sessions: no `Mcp-Session-Id` is read or
* sent, and `DELETE`, like `GET`, is answered with `405`.
*
* @internal called by {@see \Mcp\Server\Protocol::connect()} for a server built with {@see \Mcp\Server\Builder::withoutSessions()}
*/
public function setSessionless(bool $sessionless): void
{
$this->sessionless = $sessionless;
}

public function send(string $data, array $context): void
{
// An error with its own HTTP status rejects the whole POST and is its only answer.
Expand Down Expand Up @@ -277,7 +290,7 @@ protected function createJsonResponse(): ResponseInterface
->withBody($this->streamFactory->createStream(implode('', $events)));
}

if ($this->sessionId) {
if ($this->sessionId && !$this->sessionless) {
$response = $response->withHeader(self::SESSION_HEADER, $this->sessionId->toRfc4122());
}

Expand Down Expand Up @@ -371,7 +384,7 @@ protected function createStreamedResponse(): ResponseInterface
->withHeader('X-Accel-Buffering', 'no')
->withBody($stream);

if ($this->sessionId) {
if ($this->sessionId && !$this->sessionless) {
$response = $response->withHeader(self::SESSION_HEADER, $this->sessionId->toRfc4122());
}

Expand Down Expand Up @@ -425,7 +438,7 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re
->withBody($this->streamFactory->createStream($payload));

if (405 === $statusCode) {
$response = $response->withHeader('Allow', 'POST, DELETE, OPTIONS');
$response = $response->withHeader('Allow', $this->sessionless ? 'POST, OPTIONS' : 'POST, DELETE, OPTIONS');
}

return $response;
Expand Down Expand Up @@ -500,6 +513,13 @@ private function handleRequest(ServerRequestInterface $request): ResponseInterfa

private function handleHandshakeRequest(ServerRequestInterface $request, ?string $body): ResponseInterface
{
if ($this->sessionless) {
return match ($request->getMethod()) {
'POST' => $this->handlePostRequest($body ?? '', self::accessToken($request)),
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
};
}

$sessionIdHeaders = $request->getHeader(self::SESSION_HEADER);
if (\count($sessionIdHeaders) > 1) {
return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must not be repeated.'), 400);
Expand Down
6 changes: 5 additions & 1 deletion tests/Integration/Fixture/http.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/

// Server for HttpNegotiationTest under `php -S`, era picked by MCP_INTEGRATION_HANDSHAKE_ONLY / MCP_INTEGRATION_MODERN_ONLY.
// Server for HttpNegotiationTest and SessionlessHttpTest under `php -S`, era picked by MCP_INTEGRATION_HANDSHAKE_ONLY / MCP_INTEGRATION_MODERN_ONLY, sessions turned off by MCP_INTEGRATION_SESSIONLESS.

use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
Expand All @@ -31,6 +31,10 @@
$builder->withoutModernEra();
}

if ('' !== (string) getenv('MCP_INTEGRATION_SESSIONLESS')) {
$builder->withoutSessions();
}

$request = (new Psr17Factory())->createServerRequestFromGlobals();

$response = '' !== (string) getenv('MCP_INTEGRATION_MODERN_ONLY')
Expand Down
Loading
Loading