diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c8beb0..d3ebfb4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 11d2a361..5794b634 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -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) | diff --git a/docs/run/sessions.md b/docs/run/sessions.md index 11cf15c1..ad410769 100644 --- a/docs/run/sessions.md +++ b/docs/run/sessions.md @@ -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 diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9bb28c81..11a0b676 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -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 * @@ -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 @@ -982,6 +1004,7 @@ public function build(): Server : null, requestStateCodec: $this->requestStateCodec(), sessionLock: $this->sessionLock, + sessionless: $this->sessionless, ); $modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions(); diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 61c11cc1..624cd44d 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -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; @@ -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. @@ -112,6 +122,8 @@ class Protocol /** * @param array>> $requestHandlers * @param array $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, @@ -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(); @@ -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. @@ -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; + } + try { $session->save(); } catch (\Throwable $e) { @@ -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(); @@ -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()); @@ -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; @@ -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) { @@ -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(), @@ -1142,6 +1192,10 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, return $session; } + if ($sessionless) { + return $this->createEphemeralSession(); + } + 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)); @@ -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. * diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index d07d565e..e5c0a162 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -75,6 +75,8 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra /** @var non-empty-list|null null names every handshake revision */ private ?array $handshakeVersions = null; + private bool $sessionless = false; + private ?string $immediateResponse = null; private ?int $immediateStatusCode = null; @@ -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. @@ -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()); } @@ -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()); } @@ -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; @@ -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); diff --git a/tests/Integration/Fixture/http.php b/tests/Integration/Fixture/http.php index fd0cbcda..a4b71aa0 100644 --- a/tests/Integration/Fixture/http.php +++ b/tests/Integration/Fixture/http.php @@ -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; @@ -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') diff --git a/tests/Integration/SessionlessHttpTest.php b/tests/Integration/SessionlessHttpTest.php new file mode 100644 index 00000000..452eae53 --- /dev/null +++ b/tests/Integration/SessionlessHttpTest.php @@ -0,0 +1,168 @@ +sessions = sys_get_temp_dir().'/mcp-integration-sessionless-'.getmypid(); + $this->port = ServerProcess::findFreePort(); + + @mkdir($this->sessions); + + $this->server = new Process( + [\PHP_BINARY, '-S', \sprintf('127.0.0.1:%d', $this->port), __DIR__.'/Fixture/http.php'], + env: [ + 'MCP_INTEGRATION_SESSIONS' => $this->sessions, + 'MCP_INTEGRATION_SESSIONLESS' => '1', + ], + ); + $this->server->start(); + + $deadline = microtime(true) + 5; + + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The fixture server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server?->stop(); + + foreach (glob($this->sessions.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->sessions); + } + + #[TestDox('a handshake-era client initializes, lists and calls tools without a session')] + public function testClientTalksWithoutASession(): void + { + $transport = new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)); + + $client = Client::builder() + ->setClientInfo('integration-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->setInitTimeout(self::TIMEOUT) + ->setRequestTimeout(self::TIMEOUT) + ->build(); + + $client->connect($transport); + + $this->assertSame(ProtocolVersion::V2025_11_25, $client->getProtocolVersion()); + $this->assertNull($transport->getSessionId()); + + $this->assertSame(['echo'], array_map(static fn ($tool): string => $tool->name, $client->listTools()->tools)); + + $result = $client->callTool('echo', ['text' => 'hello']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('hello', $result->content[0]->text); + + $client->disconnect(); + + $this->assertSame([], glob($this->sessions.'/*') ?: []); + } + + #[TestDox('initialize, tools/list and tools/call are answered without Mcp-Session-Id, GET and DELETE with 405')] + public function testWireCarriesNoSessionId(): void + { + $initialize = $this->request('POST', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'raw-client', 'version' => '1.0.0'], + ], + ]); + + $this->assertSame(200, $initialize['status']); + $this->assertArrayNotHasKey('mcp-session-id', $initialize['headers']); + + $list = $this->request('POST', ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list']); + + $this->assertSame(200, $list['status']); + $this->assertArrayNotHasKey('mcp-session-id', $list['headers']); + $this->assertSame('echo', $list['body']['result']['tools'][0]['name']); + + $call = $this->request('POST', [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => ['name' => 'echo', 'arguments' => ['text' => 'hello']], + ]); + + $this->assertSame(200, $call['status']); + $this->assertArrayNotHasKey('mcp-session-id', $call['headers']); + $this->assertSame('hello', $call['body']['result']['content'][0]['text']); + + $this->assertSame(405, $this->request('GET')['status']); + $this->assertSame(405, $this->request('DELETE')['status']); + } + + /** + * @param array|null $body + * + * @return array{status: int, headers: array>, body: array} + */ + private function request(string $method, ?array $body = null): array + { + $response = HttpClient::create()->request($method, \sprintf('http://127.0.0.1:%d/', $this->port), [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value, + ], + 'body' => null === $body ? '' : json_encode($body, \JSON_THROW_ON_ERROR), + 'timeout' => self::TIMEOUT, + ]); + + $payload = $response->getContent(false); + + return [ + 'status' => $response->getStatusCode(), + 'headers' => $response->getHeaders(false), + 'body' => '' === $payload ? [] : json_decode($payload, true, flags: \JSON_THROW_ON_ERROR), + ]; + } +} diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 24d169c6..fbb5a65d 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -102,6 +102,45 @@ public function testNotificationHandledByMultipleHandlers(): void ); } + #[TestDox('Without sessions, a transport other than Streamable HTTP still gets a session on initialize')] + public function testSessionlessKeepsTheSessionOfOtherTransportsOnInitialize(): void + { + $this->transport->expects($this->once())->method('setSessionId'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: new SessionManager(new InMemorySessionStore()), + sessionless: true, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}', + null, + ); + } + + #[TestDox('Without sessions, a transport other than Streamable HTTP still requires a session id after initialize')] + public function testSessionlessKeepsRequiringASessionIdOnOtherTransports(): void + { + $transport = new RecordingTransport(); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: new SessionManager(new InMemorySessionStore()), + sessionless: true, + ); + + $protocol->processInput($transport, '{"jsonrpc":"2.0","id":2,"method":"tools/list"}', null); + + $this->assertCount(1, $transport->sent); + $this->assertSame(400, $transport->sent[0]['context']['status_code']); + } + #[TestDox('An id-less error response from the client is logged and ignored, not stored under a collapsed session key')] public function testIdLessErrorResponseIsIgnored(): void { diff --git a/tests/Unit/Server/Transport/SessionlessHttpTest.php b/tests/Unit/Server/Transport/SessionlessHttpTest.php new file mode 100644 index 00000000..2f26620d --- /dev/null +++ b/tests/Unit/Server/Transport/SessionlessHttpTest.php @@ -0,0 +1,272 @@ +factory = new Psr17Factory(); + $this->sessions = sys_get_temp_dir().'/mcp-sessionless-'.bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + foreach (glob($this->sessions.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->sessions); + } + + #[TestDox('initialize is answered without a session id, and nothing is stored')] + public function testInitializeIsAnsweredWithoutASessionId(): void + { + $response = $this->send('POST', $this->initialize()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader(StreamableHttpTransport::SESSION_HEADER)); + $this->assertSame(ProtocolVersion::V2025_11_25->value, self::decode($response)['result']['protocolVersion']); + $this->assertSame([], $this->storedSessions()); + } + + #[TestDox('requests after initialize are served without a session id')] + public function testRequestsAreServedWithoutASessionId(): void + { + $this->send('POST', $this->initialize()); + + $list = $this->send('POST', $this->request('tools/list')); + $call = $this->send('POST', $this->request('tools/call', ['name' => 'echo', 'arguments' => ['text' => 'hello']])); + + $this->assertSame(200, $list->getStatusCode()); + $this->assertContains('echo', array_column(self::decode($list)['result']['tools'], 'name')); + $this->assertSame(200, $call->getStatusCode()); + $this->assertSame('hello', self::decode($call)['result']['content'][0]['text']); + $this->assertFalse($list->hasHeader(StreamableHttpTransport::SESSION_HEADER)); + $this->assertFalse($call->hasHeader(StreamableHttpTransport::SESSION_HEADER)); + $this->assertSame([], $this->storedSessions()); + } + + #[TestDox('a session id sent by the client is ignored instead of refused')] + public function testSessionIdOfTheClientIsIgnored(): void + { + $response = $this->send('POST', $this->request('tools/list'), [StreamableHttpTransport::SESSION_HEADER => 'f0e1d2c3-b4a5-4697-8879-6a5b4c3d2e1f']); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertArrayHasKey('tools', self::decode($response)['result']); + $this->assertFalse($response->hasHeader(StreamableHttpTransport::SESSION_HEADER)); + } + + #[TestDox('GET and DELETE are answered with 405, naming only POST as the method to use')] + public function testGetAndDeleteAreNotAllowed(): void + { + $get = $this->send('GET', headers: ['Accept' => 'text/event-stream']); + $delete = $this->send('DELETE', headers: [StreamableHttpTransport::SESSION_HEADER => 'f0e1d2c3-b4a5-4697-8879-6a5b4c3d2e1f']); + + $this->assertSame(405, $get->getStatusCode()); + $this->assertSame('POST, OPTIONS', $get->getHeaderLine('Allow')); + $this->assertSame(405, $delete->getStatusCode()); + $this->assertSame('POST, OPTIONS', $delete->getHeaderLine('Allow')); + } + + #[TestDox('notifications sent during a request reach the client on its stream, without a session id')] + public function testNotificationsDuringARequestAreStreamed(): void + { + $response = $this->send('POST', $this->request('tools/call', ['name' => 'notify', 'arguments' => []])); + + $this->assertSame('text/event-stream', $response->getHeaderLine('Content-Type')); + $this->assertFalse($response->hasHeader(StreamableHttpTransport::SESSION_HEADER)); + + $stream = $this->readStream($response); + + $this->assertSame(2, substr_count($stream, 'notifications\/progress')); + $this->assertStringContainsString('"text":"done"', $stream); + $this->assertSame([], $this->storedSessions()); + } + + #[TestDox('a request to the client fails right away instead of waiting for an answer that cannot arrive')] + public function testRequestToTheClientFailsRightAway(): void + { + $started = microtime(true); + $response = $this->send('POST', $this->request('tools/call', ['name' => 'ask', 'arguments' => []])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('without sessions', self::decode($response)['result']['content'][0]['text']); + $this->assertLessThan(5, microtime(true) - $started); + $this->assertSame([], $this->storedSessions()); + } + + #[TestDox('a request to the client after a notification fails right away too')] + public function testRequestToTheClientAfterANotificationFailsRightAway(): void + { + $started = microtime(true); + $response = $this->send('POST', $this->request('tools/call', ['name' => 'notify-then-ask', 'arguments' => []])); + $stream = $this->readStream($response); + + $this->assertStringContainsString('notifications\/progress', $stream); + $this->assertStringContainsString('Failed to send the message to the client.', $stream); + $this->assertLessThan(5, microtime(true) - $started); + $this->assertSame([], $this->storedSessions()); + } + + #[TestDox('the modern era is served as before')] + public function testModernEraIsServedAsBefore(): void + { + $body = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'server/discover', + 'params' => ['_meta' => [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]], + ], \JSON_THROW_ON_ERROR); + + $response = $this->send('POST', $body, [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([ProtocolVersion::V2026_07_28->value], self::decode($response)['result']['supportedVersions']); + } + + private function server(): Server + { + return Server::builder() + ->setServerInfo('sessionless', '1.0.0') + ->setSession(new FileSessionStore($this->sessions)) + ->withoutSessions() + ->addTool(static fn (string $text): string => $text, 'echo') + ->addTool(static function (RequestContext $context): string { + $context->getClientGateway()->notify(new ProgressNotification('p', 0.5)); + $context->getClientGateway()->notify(new ProgressNotification('p', 1.0)); + + return 'done'; + }, 'notify') + ->addTool(static function (RequestContext $context): string { + $answer = $context->getClientGateway()->request(new PingRequest(), 5); + + return $answer instanceof Error ? $answer->message : 'answered'; + }, 'ask') + ->addTool(static function (RequestContext $context): string { + $context->getClientGateway()->notify(new ProgressNotification('p', 0.5)); + $answer = $context->getClientGateway()->request(new PingRequest(), 5); + + return $answer instanceof Error ? $answer->message : 'answered'; + }, 'notify-then-ask') + ->build(); + } + + private function initialize(): string + { + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => ['elicitation' => new \stdClass()], + 'clientInfo' => ['name' => 'handshake-client', 'version' => '1.0.0'], + ], + ], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $params + */ + private function request(string $method, array $params = []): string + { + return json_encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => $method, 'params' => (object) $params], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $headers + */ + private function send(string $method, string $body = '', array $headers = []): ResponseInterface + { + $request = $this->factory->createServerRequest($method, 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->factory->createStream($body)); + + if ('POST' === $method && !str_contains($body, '"initialize"')) { + $request = $request->withHeader(StreamableHttpTransport::PROTOCOL_VERSION_HEADER, ProtocolVersion::V2025_11_25->value); + } + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + return $this->server()->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + } + + /** + * @return list + */ + private function storedSessions(): array + { + return glob($this->sessions.'/*') ?: []; + } + + /** + * Reads an SSE response; the stream calls ob_flush() itself, so the output is captured by a handler, not a plain buffer. + */ + private function readStream(ResponseInterface $response): string + { + $output = ''; + ob_start(static function (string $chunk) use (&$output): string { + $output .= $chunk; + + return ''; + }); + try { + $response->getBody()->getContents(); + } finally { + ob_end_flush(); + } + + return $output; + } + + /** + * @return array + */ + private static function decode(ResponseInterface $response): array + { + return json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR); + } +}