diff --git a/CHANGELOG.md b/CHANGELOG.md index e70a3223..1782eb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is * [BC Break] Fix a `DELETE` of an unknown or ended session being answered `200` instead of the `404` the spec requires: `Protocol::destroySession()` and `BaseTransport::handleSessionEnd()` return `false` for such a session, as does the listener of `TransportInterface::onSessionEnd()`. A listener that returns nothing keeps the `200`. * [BC Break] `Builder::build()` and `Builder::buildStateless()` throw a `RuntimeException` when `setDiscovery()` is used without `symfony/finder` and without a custom discoverer, instead of logging a warning and discovering nothing +* [BC Break] Serve handshake-era clients over Streamable HTTP without sessions unless `Builder::setSession()` is called, instead of falling back to an `InMemorySessionStore` that PHP-FPM and `php -S` lose between requests: `initialize` is answered without `Mcp-Session-Id`, every later request is served on an `EphemeralSessionInterface` that is never stored, and `GET` and `DELETE` are answered with `405`, so a horizontally scaled server needs no shared session store or lock for them. What needs a session fails loud: requests to the client (elicitation, sampling, roots) and the client capability checks of `ClientGateway` throw a `LogicException`, as do `Protocol::sendRequest()`, `Protocol::sendNotification()` and saving the session; input-required results, `resources/subscribe`, `resources/unsubscribe` and `logging/setLevel` are answered with `-32603`, and `initialize` does not offer resource subscriptions. The protocol version comes from the `MCP-Protocol-Version` header. Call `setSession()` to keep sessions, e.g. `setSession(new InMemorySessionStore())` in a long-running process. Stdio and the `2026-07-28` era are not affected. * Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`). * Fix tools whose DocBlock uses a PHPStan/Psalm pseudo-type or a literal type (`non-empty-string`, `positive-int`, `'asc'|'desc'`, `array`, ...) rejecting valid arguments with `-32602`: parameters and array shape keys now get the JSON Schema type of their base type instead of `object`. * Fix `array` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`. diff --git a/docs/run/index.md b/docs/run/index.md index 1d1d048b..4348c4f0 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -34,8 +34,8 @@ The rest of this section: discovery, dependency injection, logging, pagination. * **[Framework integration](framework-integration.md)** — mounting the HTTP transport in a Symfony, Laravel, or Slim application, or running it standalone. -* **[Sessions](sessions.md)** — where per-client state lives, which matters as soon as - you serve HTTP from more than one process. +* **[Sessions](sessions.md)** — off over HTTP until you configure a store; what needs + them, and where per-client state lives once you serve HTTP from more than one process. * **[Serving both eras](protocol-eras.md)** — the same endpoint also answers protocol revision `2026-07-28`, which has no handshake and no sessions; what a modern request carries, and how each one is routed. diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 4eae36f0..fdc4d92e 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -347,7 +347,7 @@ $server = Server::builder() | `setHeaderValidator()` | headerValidation? | Toggle the SEP-2243 standard-header check on the modern leg of `build()` and on `buildStateless()` | | `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache?, namePatterns? | Configure attribute discovery | | `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) | +| `setSession()` | sessionStore?, sessionManager?, gcProbability?, gcDivisor? | Turn on sessions over HTTP and configure them, see [Sessions](sessions.md) | | `setSessionLock()` | sessionLock | Serialize the concurrent requests of one session, see [Sessions](sessions.md#concurrent-requests) | | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | diff --git a/docs/run/sessions.md b/docs/run/sessions.md index 11cf15c1..dbf81ba2 100644 --- a/docs/run/sessions.md +++ b/docs/run/sessions.md @@ -5,7 +5,9 @@ > [The 2026-07-28 lifecycle](../protocol-versions.md). One server serves both, so the configuration below still > applies to the handshake-era clients it answers. -Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: +Over Streamable HTTP, a server has sessions only once you configure them with `setSession()`; without it, it +serves handshake-era clients [without sessions](#serving-without-sessions). Over stdio, the connection holds the +one session in memory, with or without `setSession()`. Configure session storage and lifecycle: ```php use Mcp\Server; @@ -16,17 +18,17 @@ use Mcp\Server\Session\Psr6SessionStore; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Cache\Adapter\RedisAdapter; -// Override with file-based storage +// File-based storage $server = Server::builder() ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->build(); -// Override with in-memory storage and custom TTL +// In-memory storage with a custom TTL, for a single long-running process $server = Server::builder() ->setSession(new InMemorySessionStore(3600)) ->build(); -// Override with PSR-6 cache-based storage +// PSR-6 cache-based storage // Requires symfony/cache (or any other PSR-6 implementation) // composer require symfony/cache $redisAdapter = new RedisAdapter( @@ -42,7 +44,7 @@ $server = Server::builder() )) ->build(); -// Override with PSR-16 cache-based storage, which takes the same arguments +// PSR-16 cache-based storage, which takes the same arguments // Requires psr/simple-cache and a PSR-16 implementation $server = Server::builder() ->setSession(new Psr16SessionStore(new Psr16Cache($redisAdapter))) @@ -61,6 +63,41 @@ 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". A server built without `setSession()` assigns none, so the servers behind a load balancer +need no shared session store or lock to answer handshake-era clients. + +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; it implements +`EphemeralSessionInterface`, which a handler can check for. 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, and what needs it fails loud instead of silently +doing nothing: + +- Requests to the client: `elicit()`, `elicitUrl()`, `sample()` and `listRoots()` of the `ClientGateway` throw a + `LogicException` right away. The client answers them in a POST of its own, which cannot be matched to the request + that waits for the answer. A handler returning an `InputRequiredResult` is answered with an error for the same + reason. +- `resources/subscribe`, `resources/unsubscribe` and `logging/setLevel` are answered with a `-32603` error, and + `initialize` does not offer resource subscriptions. +- `Protocol::sendRequest()` and `Protocol::sendNotification()` throw a `LogicException`, as there is no stream to + deliver a message sent outside a request. +- The capabilities the client declared on `initialize` are not known to later requests, so `supportsElicitation()` + and the other checks of the `ClientGateway` throw a `LogicException`. +- What a handler sets on the session is gone on the next request, and `SessionInterface::save()` throws a + `LogicException`. + +`RequestContext::getProtocolVersion()` returns the revision the `MCP-Protocol-Version` header names, or +`2025-03-26` when the header is absent, as the specification asks. Notifications sent while a request runs, like +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 +connection holds the one session. + ## Garbage Collection Configuration The SDK periodically runs garbage collection to clean up expired sessions, similar to PHP's native @@ -205,7 +242,7 @@ right now and returns `false` otherwise; `release()` gives it back. **Available Session Stores:** -- `InMemorySessionStore`: Fast in-memory storage (default) +- `InMemorySessionStore`: Fast in-memory storage for a single long-running process - `FileSessionStore`: Persistent file-based storage - `Psr6SessionStore`: PSR-6 compliant cache-based storage - `Psr16SessionStore`: PSR-16 compliant cache-based storage diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index b6ab1c45..c9fdcd91 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -167,6 +167,29 @@ public function withoutListChanged(): self ); } + /** + * Returns a copy that does not let clients subscribe to resources. + * + * Resources announced only through the `subscribe` flag stay announced. + */ + public function withoutResourcesSubscribe(): self + { + return new self( + $this->tools, + $this->toolsListChanged, + $this->resources || $this->resourcesSubscribe, + false, + $this->resourcesListChanged, + $this->prompts, + $this->promptsListChanged, + $this->logging, + $this->completions, + $this->experimental, + $this->extensions, + $this->tasks, + ); + } + /** * @return array{ * logging?: \stdClass, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index db2b03c6..14039492 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -283,6 +283,8 @@ final class Builder private int $inputRequiredTimeout = InputRequiredShim::DEFAULT_ROUND_TIMEOUT; + private bool $sessions = false; + /** * @see self::assemble() for why this is memoized * @@ -647,7 +649,12 @@ public function setResourceSubscriptionManager(SubscriptionManagerInterface $sub } /** - * Configures the session layer. + * Turns on sessions for handshake-era clients over Streamable HTTP and configures them. + * + * Without it, these clients are served without sessions: `initialize` gets no `Mcp-Session-Id`, + * every later request runs on an {@see Session\EphemeralSession}, `GET` and `DELETE` get `405`, + * and what needs a session fails loud (see `docs/run/sessions.md`). Stdio keeps its one session + * in memory either way. * * @param int $gcProbability The numerator of the GC probability fraction (like PHP's session.gc_probability). Set to 0 to disable GC. * @param int $gcDivisor The denominator of the GC probability fraction (like PHP's session.gc_divisor). Probability = gcProbability/gcDivisor. @@ -658,6 +665,7 @@ public function setSession( int $gcProbability = 1, int $gcDivisor = 100, ): self { + $this->sessions = true; $this->sessionStore = $sessionStore; $this->sessionManager = $sessionManager; $this->gcProbability = $gcProbability; @@ -1003,6 +1011,7 @@ public function build(): Server : null, requestStateCodec: $this->requestStateCodec(), sessionLock: $this->sessionLock, + sessionless: !$this->sessions, ); $modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions(); diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 01ebfc5b..7652fd46 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -13,6 +13,7 @@ use Mcp\Exception\ClientException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\LogicException; use Mcp\Exception\RuntimeException; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\Content; @@ -39,6 +40,7 @@ use Mcp\Schema\Result\ListRootsResult; use Mcp\Schema\Tool; use Mcp\Schema\ToolChoice; +use Mcp\Server\Session\EphemeralSessionInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Suspension\NotificationSuspension; use Mcp\Server\Suspension\RequestSuspension; @@ -161,6 +163,7 @@ public function progress(float $progress, ?float $total = null, ?string $message * @return CreateSamplingMessageResult The sampling response * * @throws ClientException if the client request results in an error message + * @throws LogicException if the server runs without sessions * * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. * Integrate with an LLM provider's API directly instead. @@ -232,6 +235,7 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int * @return ElicitResult The elicitation response containing the user's action and any provided content * * @throws ClientException if the client request results in an error message + * @throws LogicException if the server runs without sessions */ public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120, ?string $key = null): ElicitResult { @@ -252,9 +256,12 @@ public function elicit(string $message, ElicitationSchema $requestedSchema, int * * @throws ClientException if the client request results in an error message * @throws InvalidArgumentException if the client did not declare url-mode elicitation + * @throws LogicException if the server runs without sessions */ public function elicitUrl(string $message, string $url, int $timeout = 120, ?string $key = null): ElicitResult { + $this->assertClientCanBeAsked(); + // URL mode only exists from 2025-11-25 on, and only for clients declaring it if (!$this->supportsElicitationUrl()) { throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.'); @@ -275,6 +282,7 @@ public function elicitUrl(string $message, string $url, int $timeout = 120, ?str * @return ListRootsResult The roots exposed by the client * * @throws ClientException if the client request results in an error message + * @throws LogicException if the server runs without sessions * * @deprecated since protocol revision 2026-07-28 (SEP-2577), earliest removal 2027-07-28. * Pass directories or files through tool arguments, resource URIs or server @@ -303,10 +311,12 @@ public function listRoots(int $timeout = 120): ListRootsResult * capabilities to determine if roots/list requests are supported. * * @return bool True if the client supports roots, false otherwise + * + * @throws LogicException if the server runs without sessions */ public function supportsRoots(): bool { - $capabilities = (array) $this->session->get('client_capabilities', []); + $capabilities = $this->clientCapabilities(); // MCP spec: capability presence indicates support (value is typically {} or []) return \array_key_exists('roots', $capabilities); @@ -320,10 +330,12 @@ public function supportsRoots(): bool * to determine if elicitation/create requests are supported. * * @return bool True if the client supports elicitation, false otherwise + * + * @throws LogicException if the server runs without sessions */ public function supportsElicitation(): bool { - $capabilities = (array) $this->session->get('client_capabilities', []); + $capabilities = $this->clientCapabilities(); // MCP spec: capability presence indicates support (value is typically {} or []) return \array_key_exists('elicitation', $capabilities); @@ -337,10 +349,12 @@ public function supportsElicitation(): bool * sampling/createMessage requests are supported. * * @return bool True if the client supports sampling, false otherwise + * + * @throws LogicException if the server runs without sessions */ public function supportsSampling(): bool { - $capabilities = (array) $this->session->get('client_capabilities', []); + $capabilities = $this->clientCapabilities(); // MCP spec: capability presence indicates support (value is typically {} or []) return \array_key_exists('sampling', $capabilities); @@ -355,6 +369,8 @@ public function supportsSampling(): bool * {@see self::sample()}. * * @return bool True if the client supports tool-enabled sampling, false otherwise + * + * @throws LogicException if the server runs without sessions */ public function supportsSamplingTools(): bool { @@ -368,6 +384,8 @@ public function supportsSamplingTools(): bool * only be sent when the client advertised `sampling.context`. * * @return bool True if the client supports sampling context, false otherwise + * + * @throws LogicException if the server runs without sessions */ public function supportsSamplingContext(): bool { @@ -381,6 +399,8 @@ public function supportsSamplingContext(): bool * that existed before URL elicitation — so url mode has to be named explicitly. * * @return bool True if the client supports url-mode elicitation, false otherwise + * + * @throws LogicException if the server runs without sessions */ public function supportsElicitationUrl(): bool { @@ -396,6 +416,8 @@ public function supportsElicitationUrl(): bool * to a text-only result otherwise. * * @return bool True if the client advertised the extension, false otherwise + * + * @throws LogicException if the server runs without sessions */ public function supportsExtension(ExtensionIdentifier|string $id): bool { @@ -410,7 +432,7 @@ public function supportsExtension(ExtensionIdentifier|string $id): bool */ private function hasSubCapability(string $capability, string $name): bool { - $capabilities = (array) $this->session->get('client_capabilities', []); + $capabilities = $this->clientCapabilities(); $declared = $capabilities[$capability] ?? null; if (\is_array($declared)) { @@ -450,6 +472,7 @@ private function sendElicitation(ElicitRequest $request, int $timeout, ?string $ * @return Response>|Error The client's response message * * @throws RuntimeException If Fiber support is not available + * @throws LogicException if the server runs without sessions * * @internal */ @@ -470,6 +493,8 @@ public function request(Request $request, int $timeout = 120): Response|Error */ private function suspend(Request $request, int $timeout, ?string $key = null): Response|Error { + $this->assertClientCanBeAsked(); + $response = \Fiber::suspend(new RequestSuspension($request, $this->session->getId()->toRfc4122(), $timeout, $key)); if (!$response instanceof Response && !$response instanceof Error) { @@ -478,4 +503,32 @@ private function suspend(Request $request, int $timeout, ?string $key = null): R return $response; } + + /** + * The capabilities the client declared on `initialize`, which only a session keeps for later requests. + * + * @return array + * + * @throws LogicException if the server runs without sessions + */ + private function clientCapabilities(): array + { + if ($this->session instanceof EphemeralSessionInterface) { + throw new LogicException('The client capabilities need a session; configure one with Builder::setSession().'); + } + + return (array) $this->session->get('client_capabilities', []); + } + + /** + * The client answers in a POST of its own, which a server without sessions cannot match to the request waiting for it. + * + * @throws LogicException if the server runs without sessions + */ + private function assertClientCanBeAsked(): void + { + if ($this->session instanceof EphemeralSessionInterface) { + throw new LogicException('Requests to the client need a session; configure one with Builder::setSession().'); + } + } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index 6f6b5de0..93ca1a3f 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -19,6 +19,7 @@ use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server\Configuration; +use Mcp\Server\Session\EphemeralSessionInterface; use Mcp\Server\Session\SessionInterface; /** @@ -55,6 +56,11 @@ public function handle(Request $request, SessionInterface $session): Response // the handshake era does not have. $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 EphemeralSessionInterface) { + $capabilities = $capabilities->withoutResourcesSubscribe(); + } + return new Response( $request->getId(), new InitializeResult( diff --git a/src/Server/InputRequiredShim.php b/src/Server/InputRequiredShim.php index 9dff786c..abce2805 100644 --- a/src/Server/InputRequiredShim.php +++ b/src/Server/InputRequiredShim.php @@ -19,6 +19,7 @@ use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\EphemeralSessionInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Stateless\InputContext; use Mcp\Server\Stateless\InputRequestCapabilities; @@ -96,6 +97,14 @@ public function fulfill( $round = 0; while (($ask = self::askOf($result)) instanceof InputRequiredResult) { + if ($session instanceof EphemeralSessionInterface) { + $this->logger->warning('A handler asked for input on a server without sessions, which cannot ask the client; the call was failed instead.', [ + 'method' => $request::getMethod(), + ]); + + return Error::forInternalError('Asking the client for input needs a session, and this server runs without sessions.', $request->getId()); + } + if (++$round > $this->maxRounds) { $this->logger->warning('A handler kept asking for input past the round limit; the call was failed instead.', [ 'method' => $request::getMethod(), diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 61c11cc1..720e6283 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -18,6 +18,7 @@ use Mcp\Event\ResponseEvent; use Mcp\Event\ServerRequestEvent; use Mcp\Exception\InvalidInputMessageException; +use Mcp\Exception\LogicException; use Mcp\Exception\RuntimeException; use Mcp\Exception\SessionLockException; use Mcp\Exception\SessionLockTimeoutException; @@ -29,9 +30,14 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Request\InitializeRequest; +use Mcp\Schema\Request\ResourceSubscribeRequest; +use Mcp\Schema\Request\ResourceUnsubscribeRequest; +use Mcp\Schema\Request\SetLogLevelRequest; use Mcp\Server\Authorization\AccessToken; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\EphemeralSession; +use Mcp\Server\Session\EphemeralSessionInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionLockInterface; use Mcp\Server\Session\SessionManagerInterface; @@ -39,6 +45,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 +77,13 @@ class Protocol public const SESSION_LOGGING_LEVEL = '_mcp.logging_level'; + /** Requests whose effect has to outlast them, which a server without sessions cannot keep. */ + private const SESSION_SCOPED_REQUESTS = [ + ResourceSubscribeRequest::class, + ResourceUnsubscribeRequest::class, + SetLogLevelRequest::class, + ]; + /** * 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 +126,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 +139,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 +156,10 @@ public function connect(TransportInterface $transport): void { $transport->onMessage($this->processInput(...)); + if ($this->isSessionless($transport)) { + $transport->setSessionless(true); + } + $transport->onSessionEnd($this->destroySession(...)); // The transport keeps these callbacks, so they reference it weakly to not keep it alive. @@ -146,7 +167,7 @@ public function connect(TransportInterface $transport): void $transport->setOutgoingMessagesProvider(fn (Uuid $sessionId): array => [ ...$this->takeFiberOutgoingMessages($transportRef->get()), - ...$this->consumeOutgoingMessages($sessionId), + ...($this->isSessionless($transportRef->get()) ? [] : $this->consumeOutgoingMessages($sessionId)), ]); $transport->setPendingRequestsProvider(fn (Uuid $sessionId, \Fiber $fiber): array => $this->getAwaitedPendingRequests($fiber, $sessionId)); @@ -311,6 +332,10 @@ private function handleMessages(TransportInterface $transport, ?Uuid $sessionId, } } + if ($session instanceof EphemeralSessionInterface) { + return; + } + try { $session->save(); } catch (\Throwable $e) { @@ -390,6 +415,13 @@ private function handleRequest(TransportInterface $transport, Request $request, $event = $this->dispatchEvent(new RequestEvent($request, $session)); $request = $event->getRequest(); + if ($session instanceof EphemeralSessionInterface && \in_array($request::class, self::SESSION_SCOPED_REQUESTS, true)) { + $error = Error::forInternalError(\sprintf('"%s" needs a session, and this server runs without sessions.', $request::getMethod()), $request->getId()); + $this->sendResponse($transport, $this->dispatchEvent(new ErrorEvent($error, $request, $session, null))->getError(), $session); + + return; + } + $handlerFound = false; foreach ($this->requestHandlers as $handler) { @@ -446,18 +478,20 @@ private function handleRequest(TransportInterface $transport, Request $request, // The transport resumes the fiber from what the session holds: it must // not get the fiber if the request the fiber awaits was not stored. A // later save must not store that request either, nobody would resume it. - try { - $saved = $session->save(); - } catch (\Throwable $e) { - $session->hydrate($beforeSuspension); + if (!$session instanceof EphemeralSessionInterface) { + try { + $saved = $session->save(); + } catch (\Throwable $e) { + $session->hydrate($beforeSuspension); - throw $e; - } + throw $e; + } - if (!$saved) { - $session->hydrate($beforeSuspension); + if (!$saved) { + $session->hydrate($beforeSuspension); - throw new RuntimeException('Failed to save the session of a suspended request.'); + throw new RuntimeException('Failed to save the session of a suspended request.'); + } } $suspended = true; @@ -547,6 +581,8 @@ private function handleNotification(Notification $notification, SessionInterface */ public function sendRequest(Request $request, int $timeout, SessionInterface $session): Uuid { + self::assertQueueable($session); + $requestId = $this->registerRequest($request, $timeout, $session); $this->queueOutgoing($request->withId($requestId->toRfc4122()), ['type' => 'request'], $session); @@ -589,6 +625,8 @@ private function registerRequest(Request $request, int $timeout, SessionInterfac */ public function sendNotification(Notification $notification, SessionInterface $session): void { + self::assertQueueable($session); + $this->logger->info('Queueing server notification to client', [ 'method' => $notification::getMethod(), ]); @@ -596,6 +634,16 @@ public function sendNotification(Notification $notification, SessionInterface $s $this->queueOutgoing($notification, ['type' => 'notification'], $session); } + /** + * @throws LogicException for the session of a server without sessions, which has no stream to deliver queued messages on + */ + private static function assertQueueable(SessionInterface $session): void + { + if ($session instanceof EphemeralSessionInterface) { + throw new LogicException('Messages sent outside a request need a session; configure one with Builder::setSession().'); + } + } + /** * Sends a response through the transport, on the exchange that carried its request. * @@ -922,6 +970,17 @@ public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId, ?Transpo ]); } + // The fiber holds the only copy of its session, which is never stored. + if ($this->isSessionless($transport)) { + if ($yieldedValue instanceof RequestSuspension) { + throw new LogicException('Requests to the client need a session; configure one with Builder::setSession().'); + } + + $this->queueFiberOutgoing($transport, $yieldedValue->notification); + + return null; + } + // The message the fiber suspended on must be stored, so this waits for the lock; when it // cannot be had, the exception makes the transport resume the fiber with an error. $this->sessionLock?->acquire($sessionId); @@ -1048,6 +1107,10 @@ private function dispatchResult(Response|Error $result, Request $request, Sessio */ private function saveResumedSession(SessionInterface $session): void { + if ($session instanceof EphemeralSessionInterface) { + return; + } + $sessionId = $session->getId(); $locked = false; @@ -1132,6 +1195,10 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, return null; } + if ($this->isSessionless($transport)) { + return $this->createEphemeralSession($transport); + } + $session = $this->sessionManager->create(); $this->logger->debug('Created new session for initialize', [ 'session_id' => $session->getId()->toRfc4122(), @@ -1142,6 +1209,10 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, return $session; } + if ($this->isSessionless($transport)) { + return $this->createEphemeralSession($transport); + } + 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 +1245,27 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, return $this->sessionManager->createWithId($sessionId); } + /** + * Without sessions, every handshake-era request over Streamable HTTP is served on a session of its own. + * + * @param TransportInterface|null $transport + * + * @phpstan-assert-if-true StreamableHttpTransport $transport + */ + private function isSessionless(?TransportInterface $transport): bool + { + return $this->sessionless && $transport instanceof StreamableHttpTransport; + } + + private function createEphemeralSession(StreamableHttpTransport $transport): EphemeralSession + { + $session = new EphemeralSession(); + // The revision the client negotiated on its `initialize`, as no session kept it. + $session->set('protocol_version', $transport->getHeaderProtocolVersion()->value); + + return $session; + } + /** * The id of a single request, so a refusal of the whole input can be correlated with it. * diff --git a/src/Server/Session/EphemeralSession.php b/src/Server/Session/EphemeralSession.php new file mode 100644 index 00000000..e1e745ff --- /dev/null +++ b/src/Server/Session/EphemeralSession.php @@ -0,0 +1,35 @@ +|null null names every handshake revision */ private ?array $handshakeVersions = null; + private bool $sessionless = false; + + private ProtocolVersion $headerProtocolVersion = ProtocolVersion::DEFAULT_HEADER_VERSION; + private ?string $immediateResponse = null; private ?int $immediateStatusCode = null; @@ -176,6 +180,27 @@ 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 without {@see \Mcp\Server\Builder::setSession()} + */ + public function setSessionless(bool $sessionless): void + { + $this->sessionless = $sessionless; + } + + /** + * The revision the `Mcp-Protocol-Version` header of the handshake-era request names, or the one the spec assumes without it. + * + * @internal read by {@see \Mcp\Server\Protocol} for a server built without {@see \Mcp\Server\Builder::setSession()} + */ + public function getHeaderProtocolVersion(): ProtocolVersion + { + return $this->headerProtocolVersion; + } + 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 +302,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 +396,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 +450,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 +525,16 @@ private function handleRequest(ServerRequestInterface $request): ResponseInterfa private function handleHandshakeRequest(ServerRequestInterface $request, ?string $body): ResponseInterface { + // Already validated by ProtocolVersionMiddleware. + $this->headerProtocolVersion = ProtocolVersion::tryFrom($request->getHeaderLine(self::PROTOCOL_VERSION_HEADER)) ?? ProtocolVersion::DEFAULT_HEADER_VERSION; + + 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..4871f390 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; @@ -23,14 +23,17 @@ $builder = Server::builder() ->setServerInfo('integration-server', '1.0.0') ->setInstructions('Be brief.') - // `php -S` keeps nothing between requests. - ->setSession(new FileSessionStore((string) getenv('MCP_INTEGRATION_SESSIONS'))) ->addTool(static fn (string $text): string => $text, name: 'echo', description: 'Echoes the text back.'); if ('' !== (string) getenv('MCP_INTEGRATION_HANDSHAKE_ONLY')) { $builder->withoutModernEra(); } +if ('' === (string) getenv('MCP_INTEGRATION_SESSIONLESS')) { + // `php -S` keeps nothing between requests. + $builder->setSession(new FileSessionStore((string) getenv('MCP_INTEGRATION_SESSIONS'))); +} + $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..bb816470 --- /dev/null +++ b/tests/Integration/SessionlessHttpTest.php @@ -0,0 +1,88 @@ +port = ServerProcess::findFreePort(); + + $this->server = new Process( + [\PHP_BINARY, '-S', \sprintf('127.0.0.1:%d', $this->port), __DIR__.'/Fixture/http.php'], + env: ['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 + { + ServerProcess::stop($this->server); + } + + #[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(); + } +} diff --git a/tests/Unit/Schema/ServerCapabilitiesTest.php b/tests/Unit/Schema/ServerCapabilitiesTest.php index 2105e3e3..b311359c 100644 --- a/tests/Unit/Schema/ServerCapabilitiesTest.php +++ b/tests/Unit/Schema/ServerCapabilitiesTest.php @@ -453,4 +453,25 @@ public function testWithoutListChangedKeepsKindsAnnouncedOnlyThroughListChanged( 'tools' => new \stdClass(), ], $capabilities->jsonSerialize()); } + + public function testWithoutResourcesSubscribeKeepsResourcesAnnouncedOnlyThroughSubscribe(): void + { + $capabilities = (new ServerCapabilities( + resources: false, + resourcesSubscribe: true, + resourcesListChanged: true, + logging: true, + ))->withoutResourcesSubscribe(); + + $this->assertTrue($capabilities->resources); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->logging); + $this->assertEquals([ + 'logging' => new \stdClass(), + 'prompts' => new \stdClass(), + 'resources' => (object) ['listChanged' => true], + 'tools' => new \stdClass(), + ], $capabilities->jsonSerialize()); + } } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 24d169c6..19c507d7 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -17,6 +17,7 @@ use Mcp\Event\RequestEvent; use Mcp\Event\ResponseEvent; use Mcp\Event\ServerRequestEvent; +use Mcp\Exception\LogicException; use Mcp\Exception\RuntimeException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Enum\LoggingLevel; @@ -32,6 +33,7 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; use Mcp\Server\RequestContext; +use Mcp\Server\Session\EphemeralSession; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Psr16SessionStore; use Mcp\Server\Session\Session; @@ -102,6 +104,79 @@ 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('A notification cannot be queued on the session of a server without sessions, which no stream would deliver')] + public function testNotificationCannotBeQueuedWithoutASession(): void + { + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: new SessionManager(new InMemorySessionStore()), + sessionless: true, + ); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Messages sent outside a request need a session; configure one with Builder::setSession().'); + + $protocol->sendNotification(new ProgressNotification('p', 0.5), new EphemeralSession()); + } + + #[TestDox('A request cannot be queued on the session of a server without sessions, whose answer could not be matched')] + public function testRequestCannotBeQueuedWithoutASession(): void + { + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: new SessionManager(new InMemorySessionStore()), + sessionless: true, + ); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Messages sent outside a request need a session; configure one with Builder::setSession().'); + + $protocol->sendRequest(new PingRequest(), 10, new EphemeralSession()); + } + #[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/Session/EphemeralSessionTest.php b/tests/Unit/Server/Session/EphemeralSessionTest.php new file mode 100644 index 00000000..c50cc105 --- /dev/null +++ b/tests/Unit/Server/Session/EphemeralSessionTest.php @@ -0,0 +1,48 @@ +set('cart.items', 2); + + $this->assertSame(2, $session->get('cart.items')); + $this->assertSame(['cart' => ['items' => 2]], $session->all()); + } + + #[TestDox('every instance gets an id of its own')] + public function testGetsAnIdOfItsOwn(): void + { + $this->assertFalse((new EphemeralSession())->getId()->equals((new EphemeralSession())->getId())); + } + + #[TestDox('saving fails loud instead of dropping what was meant to outlast the request')] + public function testSaveFailsLoud(): void + { + $session = new EphemeralSession(); + $session->set('cart.items', 2); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Nothing in a session outlives its request without a session store; configure one with Builder::setSession().'); + + $session->save(); + } +} diff --git a/tests/Unit/Server/Transport/DualEraRoutingTest.php b/tests/Unit/Server/Transport/DualEraRoutingTest.php index 82d2bc8f..edfe1316 100644 --- a/tests/Unit/Server/Transport/DualEraRoutingTest.php +++ b/tests/Unit/Server/Transport/DualEraRoutingTest.php @@ -13,6 +13,7 @@ use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Server; +use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Stateless\RequestMeta; use Mcp\Server\Transport\StreamableHttpTransport; use Nyholm\Psr7\Factory\Psr17Factory; @@ -217,6 +218,7 @@ private function server(bool $modern = true): Server { $builder = Server::builder() ->setServerInfo('dual-era-server', '1.0.0') + ->setSession(new InMemorySessionStore()) ->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument'); if (!$modern) { diff --git a/tests/Unit/Server/Transport/SessionlessHttpTest.php b/tests/Unit/Server/Transport/SessionlessHttpTest.php new file mode 100644 index 00000000..253a3a84 --- /dev/null +++ b/tests/Unit/Server/Transport/SessionlessHttpTest.php @@ -0,0 +1,399 @@ +factory = new Psr17Factory(); + } + + #[TestDox('initialize is answered without a session id')] + 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']); + } + + #[TestDox('setSession() turns sessions on')] + public function testSetSessionTurnsSessionsOn(): void + { + $server = Server::builder()->setServerInfo('sessions', '1.0.0')->setSession(new InMemorySessionStore())->build(); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->factory->createStream($this->initialize())); + + $response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertTrue($response->hasHeader(StreamableHttpTransport::SESSION_HEADER)); + } + + #[TestDox('initialize does not offer resource subscriptions, whose updates would reach the client outside a request')] + public function testInitializeDoesNotOfferResourceSubscriptions(): void + { + $capabilities = self::decode($this->send('POST', $this->initialize()))['result']['capabilities']; + + $this->assertSame([], $capabilities['resources']); + $this->assertArrayHasKey('logging', $capabilities); + } + + #[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)); + } + + #[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); + } + + #[TestDox('$method fails loud instead of waiting for an answer that cannot be matched')] + #[DataProvider('provideRequestsToTheClient')] + public function testRequestToTheClientFailsLoud(string $tool, string $method): void + { + $started = microtime(true); + $response = $this->send('POST', $this->request('tools/call', ['name' => $tool, 'arguments' => []])); + + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('Requests to the client need a session; configure one with Builder::setSession().', self::decode($response)['result']['content'][0]['text']); + $this->assertLessThan(5, microtime(true) - $started); + } + + /** + * @return iterable + */ + public static function provideRequestsToTheClient(): iterable + { + yield 'elicit' => ['elicit', 'elicit()']; + yield 'elicit-url' => ['elicit-url', 'elicitUrl()']; + yield 'sample' => ['sample', 'sample()']; + yield 'list-roots' => ['list-roots', 'listRoots()']; + } + + #[TestDox('a request to the client after a notification fails loud too')] + public function testRequestToTheClientAfterANotificationFailsLoud(): void + { + $started = microtime(true); + $response = $this->send('POST', $this->request('tools/call', ['name' => 'notify-then-elicit', 'arguments' => []])); + $stream = $this->readStream($response); + + $this->assertStringContainsString('notifications\/progress', $stream); + $this->assertStringContainsString('"text":"Requests to the client need a session; configure one with Builder::setSession()."', $stream); + $this->assertLessThan(5, microtime(true) - $started); + } + + #[TestDox('an input-required result fails loud, as the client cannot be asked for the input')] + public function testInputRequiredResultFailsLoud(): void + { + $response = $this->send('POST', $this->request('tools/call', ['name' => 'ask-for-input', 'arguments' => []])); + + $this->assertSame([ + 'code' => -32603, + 'message' => 'Asking the client for input needs a session, and this server runs without sessions.', + ], self::decode($response)['error']); + } + + /** + * @param array $params + */ + #[TestDox('$method fails loud, as its effect would have to outlast the request')] + #[DataProvider('provideSessionScopedRequests')] + public function testSessionScopedRequestFailsLoud(string $method, array $params): void + { + $response = $this->send('POST', $this->request($method, $params)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([ + 'code' => -32603, + 'message' => \sprintf('"%s" needs a session, and this server runs without sessions.', $method), + ], self::decode($response)['error']); + } + + /** + * @return iterable}> + */ + public static function provideSessionScopedRequests(): iterable + { + yield 'resources/subscribe' => ['resources/subscribe', ['uri' => 'config://app']]; + yield 'resources/unsubscribe' => ['resources/unsubscribe', ['uri' => 'config://app']]; + yield 'logging/setLevel' => ['logging/setLevel', ['level' => 'debug']]; + } + + /** + * @param array $headers + */ + #[TestDox('the protocol version comes from the header ($expected), as no session kept the negotiated one')] + #[DataProvider('provideProtocolVersionHeaders')] + public function testProtocolVersionComesFromTheHeader(array $headers, string $expected): void + { + $response = $this->send('POST', $this->request('tools/call', ['name' => 'protocol-version', 'arguments' => []]), $headers); + + $this->assertSame($expected, self::decode($response)['result']['content'][0]['text']); + } + + /** + * @return iterable, string}> + */ + public static function provideProtocolVersionHeaders(): iterable + { + yield 'named' => [[StreamableHttpTransport::PROTOCOL_VERSION_HEADER => ProtocolVersion::V2025_06_18->value], ProtocolVersion::V2025_06_18->value]; + yield 'absent' => [[StreamableHttpTransport::PROTOCOL_VERSION_HEADER => null], ProtocolVersion::DEFAULT_HEADER_VERSION->value]; + } + + #[TestDox('a client capability check fails loud, as the capabilities the client declared on initialize are not known to later requests')] + public function testClientCapabilityCheckFailsLoud(): void + { + $this->send('POST', $this->initialize()); + + $response = $this->send('POST', $this->request('tools/call', ['name' => 'supports-elicitation', 'arguments' => []])); + + $this->assertSame('The client capabilities need a session; configure one with Builder::setSession().', self::decode($response)['result']['content'][0]['text']); + } + + #[TestDox('what a handler sets on the session is gone on the next request, and saving it fails loud')] + public function testSessionDataDoesNotOutliveTheRequest(): void + { + $remember = $this->send('POST', $this->request('tools/call', ['name' => 'remember', 'arguments' => []])); + $recall = $this->send('POST', $this->request('tools/call', ['name' => 'recall', 'arguments' => []])); + $save = $this->send('POST', $this->request('tools/call', ['name' => 'save', 'arguments' => []])); + + $this->assertSame('remembered', self::decode($remember)['result']['content'][0]['text']); + $this->assertSame('forgotten', self::decode($recall)['result']['content'][0]['text']); + $this->assertSame(-32603, self::decode($save)['error']['code']); + } + + #[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') + ->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 fn (RequestContext $context): string => self::refusal(static fn () => $context->getClientGateway()->elicit('Name?', self::schema(), 5)), 'elicit') + ->addTool(static fn (RequestContext $context): string => self::refusal(static fn () => $context->getClientGateway()->elicitUrl('Sign in', 'https://example.com/consent', 5)), 'elicit-url') + ->addTool(static fn (RequestContext $context): string => self::refusal(static fn () => $context->getClientGateway()->sample('Hi', timeout: 5)), 'sample') + ->addTool(static fn (RequestContext $context): string => self::refusal(static fn () => $context->getClientGateway()->listRoots(5)), 'list-roots') + ->addTool(static function (RequestContext $context): string { + $context->getClientGateway()->notify(new ProgressNotification('p', 0.5)); + + return self::refusal(static fn () => $context->getClientGateway()->elicit('Name?', self::schema(), 5)); + }, 'notify-then-elicit') + ->addTool(static fn (): InputRequiredResult => new InputRequiredResult(['name' => ElicitRequest::forForm('Name?', self::schema())]), 'ask-for-input') + ->addTool(static fn (RequestContext $context): string => $context->getProtocolVersion()->value, 'protocol-version') + ->addTool(static fn (RequestContext $context): string => self::refusal(static fn (): bool => $context->getClientGateway()->supportsElicitation()), 'supports-elicitation') + ->addTool(static function (RequestContext $context): string { + $context->getSession()->set('memo', 'remembered'); + + return (string) $context->getSession()->get('memo'); + }, 'remember') + ->addTool(static fn (RequestContext $context): string => (string) $context->getSession()->get('memo', 'forgotten'), 'recall') + ->addTool(static function (RequestContext $context): string { + $context->getSession()->set('memo', 'remembered'); + $context->getSession()->save(); + + return 'saved'; + }, 'save') + ->addResource(static fn (): string => '{}', 'config://app', 'app-config') + ->build(); + } + + /** + * @param \Closure(): mixed $ask + */ + private static function refusal(\Closure $ask): string + { + try { + $ask(); + } catch (LogicException $e) { + return $e->getMessage(); + } + + return 'answered'; + } + + private static function schema(): ElicitationSchema + { + return new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']); + } + + 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(), 'sampling' => new \stdClass(), 'roots' => 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 null leaves the header out + */ + 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 = null === $value ? $request->withoutHeader($name) : $request->withHeader($name, $value); + } + + return $this->server()->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + } + + /** + * 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); + } +} diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 5451f8f8..9de8c087 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -154,7 +154,7 @@ public function testDuplicateSessionIdHeadersReturnBadRequest(): void #[TestDox('a DELETE of an unknown or already ended session is answered 404')] public function testDeleteOfAnEndedSessionIsNotFound(): void { - $server = Server::builder()->setServerInfo('test', '1.0')->build(); + $server = Server::builder()->setServerInfo('test', '1.0')->setSession(new InMemorySessionStore())->build(); $sessionId = $this->initialize($server); $this->assertSame(200, $this->delete($server, $sessionId)->getStatusCode());