diff --git a/CHANGELOG.md b/CHANGELOG.md index f1db766a..5467968e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request — a `2026-07-28` envelope, an `initialize` handshake, or a session-bound follow-up — through the new `Mcp\Server\Wire\InboundClassifier` and routes it to the dispatcher that owns it, so a single URL answers a modern client and a handshake-era one alike. `Server::builder()->build()` now carries both dispatchers; `Builder::withoutModernEra()` opts out and `Builder::setModernVersions()` narrows what the modern leg answers for. `Mcp\Server\InputRequiredShim` lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has. * Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added. * Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it". * Add `Mcp\Server\Wire\CachePolicy`, set with `Builder::setCachePolicy()`, to configure the SEP-2549 caching hints the 2026-07-28 lifecycle stamps on a cacheable result. The conservative `ttlMs: 0, cacheScope: private` stays the default, since `public` lets a shared proxy serve one caller's answer to another and only the operator can make that call. A `ReadResourceResult` may also carry its own `ttlMs`/`cacheScope`, which win over the policy. diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index cbe3fb5c..99fcfdaf 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -28,6 +28,13 @@ }); /** + * The transport every example runs on. + * + * Over HTTP that is one endpoint serving both protocol eras: `StreamableHttpTransport` + * classifies each request and routes it to the lifecycle it belongs to, so every + * example here answers an `initialize` handshake and a 2026-07-28 envelope alike. + * Over stdio there is no such choice to make — that binding carries the handshake era. + * * @return TransportInterface|TransportInterface */ function transport(): TransportInterface diff --git a/src/Server.php b/src/Server.php index 8657610a..a19e8592 100644 --- a/src/Server.php +++ b/src/Server.php @@ -13,6 +13,8 @@ use Mcp\Server\Builder; use Mcp\Server\Protocol; +use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Transport\StatelessAwareTransportInterface; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -23,9 +25,14 @@ */ final class Server { + /** + * @param StatelessProtocol|null $statelessProtocol the modern-era (SEP-2575) dispatcher, absent on a + * server that serves the handshake era alone + */ public function __construct( private readonly Protocol $protocol, private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?StatelessProtocol $statelessProtocol = null, ) { } @@ -47,6 +54,13 @@ public function run(TransportInterface $transport): mixed $this->protocol->connect($transport); + // The eras share the transport, not the dispatcher: a transport that + // can tell them apart takes both and picks per request. One that + // cannot — stdio — carries the handshake era alone. + if (null !== $this->statelessProtocol && $transport instanceof StatelessAwareTransportInterface) { + $transport->connectStateless($this->statelessProtocol); + } + $this->logger->info('Running server...'); try { diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 765ae19b..e38e6830 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -74,6 +74,17 @@ /** * @phpstan-import-type Handler from ElementReference * + * @phpstan-type AssembledParts array{ + * logger: LoggerInterface, + * eventDispatcher: ?EventDispatcherInterface, + * configuration: Configuration, + * messageFactory: MessageFactory, + * sessionManager: SessionManagerInterface, + * registry: RegistryInterface, + * requestHandlers: list>, + * notificationHandlers: list, + * } + * * @author Kyrian Obikwelu */ final class Builder @@ -118,6 +129,12 @@ final class Builder private float $subscriptionLifetime = 30.0; + /** @var array RPC method to the extension identifier defining it */ + private array $extensionMethods = []; + + /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ + private array $extensionMessages = []; + private ?string $requestStateKey = null; private int $requestStateTtl = 600; @@ -233,12 +250,6 @@ final class Builder */ private array $extensions = []; - /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ - private array $extensionMessages = []; - - /** @var array RPC method to the extension identifier defining it */ - private array $extensionMethods = []; - /** * @var LoaderInterface[] */ @@ -250,6 +261,22 @@ final class Builder private bool $headerValidation = true; + /** @var list|null null defaults to every modern revision, [] serves none */ + private ?array $modernVersions = null; + + private bool $inputRequiredShim = true; + + private int $inputRequiredRounds = InputRequiredShim::DEFAULT_MAX_ROUNDS; + + private int $inputRequiredTimeout = InputRequiredShim::DEFAULT_ROUND_TIMEOUT; + + /** + * @see self::assemble() for why this is memoized + * + * @var AssembledParts|null + */ + private ?array $parts = null; + /** * Sets the server's identity. Required. * @@ -807,8 +834,100 @@ public function addLoaders(iterable $loaders): self return $this; } + /** + * Stop serving multi round-trip handlers to handshake-era clients. + * + * A handler that returns an {@see \Mcp\Schema\Result\InputRequiredResult} + * is written for the modern era, where the client answers the embedded + * requests and retries the call. On a handshake-era connection the SDK + * fulfils it instead, by sending those requests over that connection's own + * channel and re-entering the handler with the answers — so one handler + * serves both eras. See {@see InputRequiredShim} for what re-entry costs. + * + * Turn it off to have such a handler fail on a handshake-era connection + * rather than be fulfilled behind your back. + */ + public function withoutInputRequiredShim(): self + { + $this->inputRequiredShim = false; + + return $this; + } + + /** + * Bounds on the shim's loop: how many times a handler may be re-entered for + * one request, and how long one answer is waited for. + * + * The wait holds the originating request open, so on a process-per-request + * runtime it holds a worker too. Size it against your pool, not against a + * user's patience. + */ + public function setInputRequiredLimits(int $maxRounds, int $roundTimeout): self + { + if ($maxRounds < 1) { + throw new InvalidArgumentException('maxRounds must be at least 1.'); + } + + if ($roundTimeout < 1) { + throw new InvalidArgumentException('roundTimeout must be at least 1 second.'); + } + + $this->inputRequiredRounds = $maxRounds; + $this->inputRequiredTimeout = $roundTimeout; + + return $this; + } + + private function requestStateCodec(): ?RequestStateCodec + { + return null !== $this->requestStateKey + ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) + : null; + } + + /** + * Serve only the handshake era, refusing modern-era traffic. + * + * The default is to serve both from whatever the server is run on, because + * an endpoint that turns a client away for speaking the newer revision is + * almost never what anyone wants. Call this when it is: a deployment that + * has to stay on the handshake wire, or one whose tools call back into the + * client and would fail the modern half anyway. + */ + public function withoutModernEra(): self + { + $this->modernVersions = []; + + return $this; + } + + /** + * Revisions the modern-era leg answers for. Defaults to every modern + * revision this SDK knows. + * + * @param list $versions + * + * @throws InvalidArgumentException if a version is not one {@see Wire\InboundClassifier} routes to this leg + */ + public function setModernVersions(array $versions): self + { + foreach ($versions as $version) { + if (!$version->isModern()) { + throw new InvalidArgumentException(\sprintf('"%s" is a handshake-era revision; a request claiming it never reaches the modern leg to be served.', $version->value)); + } + } + + $this->modernVersions = $versions; + + return $this; + } + /** * Builds the fully configured Server instance. + * + * The result carries a dispatcher for each era. Which one answers is a + * per-request decision the transport makes, so one server object — and one + * endpoint — serves handshake-era and modern-era clients alike. */ public function build(): Server { @@ -821,17 +940,28 @@ public function build(): Server sessionManager: $parts['sessionManager'], logger: $parts['logger'], eventDispatcher: $parts['eventDispatcher'], + inputRequiredShim: $this->inputRequiredShim + ? new InputRequiredShim($this->inputRequiredRounds, $this->inputRequiredTimeout, $parts['logger']) + : null, + requestStateCodec: $this->requestStateCodec(), ); - return new Server($protocol, $parts['logger']); + $modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions(); + + return new Server( + $protocol, + $parts['logger'], + [] === $modernVersions ? null : $this->buildStateless($modernVersions), + ); } /** - * Builds a dispatcher for the modern (SEP-2575) lifecycle. + * Builds a dispatcher for the modern (SEP-2575) lifecycle on its own. * * Tools, prompts, resources and their handlers are era-independent, so one - * builder configuration drives either lifecycle and a server can offer both - * by mounting each on its own endpoint. + * builder configuration drives either lifecycle. {@see self::build()} wires + * both together; this is the modern era by itself, for an endpoint that + * serves nothing else. * * @param list $supportedVersions revisions this dispatcher will answer for */ @@ -847,9 +977,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202 logger: $parts['logger'], subscriptionLifetime: $this->subscriptionLifetime, headerValidator: $this->headerValidation ? new StandardHeaderValidator($parts['registry']) : null, - requestStateCodec: null !== $this->requestStateKey - ? new RequestStateCodec($this->requestStateKey, $this->requestStateTtl) - : null, + requestStateCodec: $this->requestStateCodec(), cachePolicy: $this->cachePolicy, notificationBus: $this->notificationBus, extensionMethods: $this->extensionMethods, @@ -859,18 +987,21 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202 /** * Resolves the builder's configuration into the parts both lifecycles need. * - * @return array{ - * logger: LoggerInterface, - * eventDispatcher: ?EventDispatcherInterface, - * configuration: Configuration, - * messageFactory: MessageFactory, - * sessionManager: SessionManagerInterface, - * registry: RegistryInterface, - * requestHandlers: list>, - * notificationHandlers: list, - * } + * Memoized: the two eras share one registry, one session manager and one + * set of handler instances, so they answer for the same server rather than + * for two that merely started from the same configuration. + * + * @return AssembledParts */ private function assemble(): array + { + return $this->parts ??= $this->resolve(); + } + + /** + * @return AssembledParts + */ + private function resolve(): array { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 16a80f04..12756fc7 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -403,14 +403,20 @@ private function sendElicitation(ElicitRequest $request, int $timeout): ElicitRe * This suspends the Fiber and waits for the client to respond. The transport * handles polling the session for the response and resuming the Fiber when ready. * + * Public for {@see InputRequiredShim}, which sends the requests a handler + * embedded in an {@see \Mcp\Schema\Result\InputRequiredResult} and knows + * nothing about their kinds. Prefer the typed methods above. + * * @param Request $request The request to send * @param int $timeout Maximum time to wait for response (seconds) * * @return Response>|Error The client's response message * * @throws RuntimeException If Fiber support is not available + * + * @internal */ - private function request(Request $request, int $timeout = 120): Response|Error + public function request(Request $request, int $timeout = 120): Response|Error { $response = \Fiber::suspend([ 'type' => 'request', diff --git a/src/Server/InputRequiredShim.php b/src/Server/InputRequiredShim.php new file mode 100644 index 00000000..9dff786c --- /dev/null +++ b/src/Server/InputRequiredShim.php @@ -0,0 +1,228 @@ + + */ +final class InputRequiredShim +{ + /** + * Re-entries per originating request. Deliberately below the modern + * client driver's allowance: this loop holds a live request open. + */ + public const DEFAULT_MAX_ROUNDS = 8; + + /** Seconds to wait for one answer. Legs are human-paced, so the protocol's 120s default is wrong here. */ + public const DEFAULT_ROUND_TIMEOUT = 600; + + public function __construct( + private readonly int $maxRounds = self::DEFAULT_MAX_ROUNDS, + private readonly int $roundTimeout = self::DEFAULT_ROUND_TIMEOUT, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Runs a handler to a result the client can be given. + * + * Returns whatever the handler returned when it asks for nothing, which is + * every call that is not multi round-trip. + * + * @param Response|Error $result what the handler returned on its first entry + * @param RequestHandlerInterface> $handler the handler that produced it + * + * @return Response|Error + */ + public function fulfill( + Response|Error $result, + RequestHandlerInterface $handler, + Request $request, + SessionInterface $session, + ?RequestStateCodec $codec, + ): Response|Error { + $round = 0; + + while (($ask = self::askOf($result)) instanceof InputRequiredResult) { + 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(), + 'rounds' => $this->maxRounds, + ]); + + return Error::forInternalError( + \sprintf('The server asked for input more than %d times without reaching a result.', $this->maxRounds), + $request->getId(), + ); + } + + if (null !== $refusal = $this->refuseUndeclared($ask, $request, $session)) { + return $refusal; + } + + try { + $session->set(InputContext::class, new InputContext( + $this->collect($ask, $session), + self::payloadOf($ask, $codec), + )); + } catch (RequestStateException $e) { + $this->logger->error('A handler minted a requestState this server cannot verify.', ['exception' => $e]); + + return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId()); + } + + $result = $handler->handle($request, $session); + } + + return $result; + } + + /** + * Sends each embedded request and keeps the answer under the key it was + * asked under. + * + * Answers are stored as the raw result arrays {@see InputContext} parses, + * so this needs to know nothing about the kinds it is carrying — which is + * also why an extension's future kind rides through unchanged. + * + * @return array + */ + private function collect(InputRequiredResult $ask, SessionInterface $session): array + { + $gateway = new ClientGateway($session); + $responses = []; + + foreach ($ask->inputRequests as $key => $embedded) { + $answer = $gateway->request($embedded, $this->roundTimeout); + + if ($answer instanceof Error) { + // Not fatal to the call: a client that refuses one ask has + // answered it, and the handler decides what that means. + $this->logger->info('The client failed an input request; the handler is re-entered without it.', [ + 'key' => $key, + 'error' => $answer->message, + ]); + + continue; + } + + $responses[$key] = $answer->result; + } + + return $responses; + } + + /** + * Refuses an ask the client never said it could answer, the way the modern + * era does — rather than sending a request that can only come back as an + * error. + */ + private function refuseUndeclared(InputRequiredResult $ask, Request $request, SessionInterface $session): ?Error + { + $declared = ClientCapabilities::fromArray((array) $session->get('client_capabilities', [])); + $missing = InputRequestCapabilities::missing($ask, $declared); + + if (null === $missing) { + return null; + } + + $this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [ + 'method' => $request::getMethod(), + 'required' => $missing->jsonSerialize(), + ]); + + return Error::forMissingRequiredClientCapability( + 'The server needs input this client did not declare it can provide.', + $missing, + $request->getId(), + ); + } + + /** + * The ask a handler returned, if it returned one. + * + * @param Response|Error $result + */ + private static function askOf(Response|Error $result): ?InputRequiredResult + { + return $result instanceof Response && $result->result instanceof InputRequiredResult + ? $result->result + : null; + } + + /** + * The state the handler sealed last round, verified. + * + * Verified rather than trusted even though it never left this process: the + * handler reads it back through the same accessor either era, so it has to + * have been through the same check. + * + * @return array + * + * @throws RequestStateException when a state is present but does not verify + */ + private static function payloadOf(InputRequiredResult $ask, ?RequestStateCodec $codec): array + { + if (null === $ask->requestState) { + return []; + } + + if (null === $codec) { + throw new RequestStateException('mac'); + } + + return $codec->verify($ask->requestState); + } +} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index a6e9f1ab..4d6e5f5c 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -27,6 +27,8 @@ use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionManagerInterface; +use Mcp\Server\Stateless\InputContext; +use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Transport\TransportInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -78,6 +80,8 @@ public function __construct( private readonly SessionManagerInterface $sessionManager, private readonly LoggerInterface $logger = new NullLogger(), private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly ?InputRequiredShim $inputRequiredShim = null, + private readonly ?RequestStateCodec $requestStateCodec = null, ) { } @@ -257,6 +261,15 @@ private function handleRequest(TransportInterface $transport, Request $request, $session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); + // A request starts with nothing behind it: the shim fills this in as it + // collects answers, and clearing it here is what keeps one request's + // round from being read as another's. + $session->set(InputContext::class, null); + + if (null !== $this->requestStateCodec) { + $session->set(RequestStateCodec::class, $this->requestStateCodec); + } + $event = $this->dispatchEvent(new RequestEvent($request, $session)); $request = $event->getRequest(); @@ -270,8 +283,17 @@ private function handleRequest(TransportInterface $transport, Request $request, $handlerFound = true; try { + $shim = $this->inputRequiredShim; + $codec = $this->requestStateCodec; + + // One fiber for the whole exchange: with the shim, the handler + // re-enters inside it each round rather than needing a new one. /** @var McpFiber $fiber */ - $fiber = new \Fiber(static fn () => $handler->handle($request, $session)); + $fiber = new \Fiber(static function () use ($handler, $request, $session, $shim, $codec): Response|Error { + $result = $handler->handle($request, $session); + + return $shim?->fulfill($result, $handler, $request, $session, $codec) ?? $result; + }); $result = $fiber->start(); diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 39cc0237..b7469572 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -33,6 +33,7 @@ use Mcp\Server\Session\Session; use Mcp\Server\Subscription\NotificationBusInterface; use Mcp\Server\Wire\CachePolicy; +use Mcp\Server\Wire\InboundClassifier; use Mcp\Server\Wire\Rev2026Codec; use Mcp\Server\Wire\WireCodecInterface; use Psr\Log\LoggerInterface; @@ -116,6 +117,16 @@ public function __construct( } } + /** + * The modern revisions this dispatcher answers for. + * + * @return list + */ + public function supportedVersions(): array + { + return $this->supportedVersions; + } + /** * Whether the transport carrying this dispatcher has a header layer whose * required members must be present. @@ -265,14 +276,10 @@ private function checkVersion(RequestMeta $meta, array $headers, string|int|null ); } - if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) { - return StatelessResult::error( - Error::forHeaderMismatch( - \sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $meta->protocolVersion), - $id, - ), - 400, - ); + // The same check the HTTP entry runs before routing, so the edge and + // this dispatcher cannot disagree about what a request claims. + if (null !== $mismatch = InboundClassifier::crossCheckVersion($headerVersion, $meta->protocolVersion)) { + return StatelessResult::error(Error::forHeaderMismatch($mismatch, $id), 400); } $version = ProtocolVersion::tryFrom($meta->protocolVersion); @@ -803,12 +810,6 @@ private static function acceptsEventStream(array $headers): bool */ private function header(array $headers, string $name): ?string { - foreach ($headers as $key => $value) { - if (0 === strcasecmp($key, $name)) { - return $value; - } - } - - return null; + return InboundClassifier::header($headers, $name); } } diff --git a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php index e00a8492..e4ec0159 100644 --- a/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/ProtocolVersionMiddleware.php @@ -54,7 +54,7 @@ final class ProtocolVersionMiddleware implements MiddlewareInterface private readonly array $supported; /** - * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}; modern revisions are excluded as their per-request negotiation is not served yet. + * @param list|null $supportedVersions Versions the server accepts. Defaults to {@see ProtocolVersion::handshakeVersions()}. {@see StreamableHttpTransport::handshakeMiddleware()} always uses that default: it runs only on traffic already classified as handshake-era, so this middleware never needs to know about modern revisions there. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) */ diff --git a/src/Server/Transport/Http/StatelessResponder.php b/src/Server/Transport/Http/StatelessResponder.php new file mode 100644 index 00000000..f752fb87 --- /dev/null +++ b/src/Server/Transport/Http/StatelessResponder.php @@ -0,0 +1,99 @@ + + */ +final class StatelessResponder +{ + public function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function respond(StatelessResult $result): ResponseInterface + { + if ($result->isStream()) { + \assert(null !== $result->frames); + + return $this->sse($result->frames); + } + + if ($result->isEmpty()) { + return $this->responseFactory->createResponse($result->httpStatus); + } + + return $this->json($result->toJson(), $result->httpStatus); + } + + public function error(Error $error, int $httpStatus): ResponseInterface + { + return $this->json(json_encode($error, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES), $httpStatus); + } + + public function json(string $payload, int $status): ResponseInterface + { + return $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + } + + /** + * @param \Closure(): \Generator $frames + */ + private function sse(\Closure $frames): ResponseInterface + { + $logger = $this->logger; + + $callback = static function () use ($frames, $logger): void { + try { + foreach ($frames() as $frame) { + // A null frame is a keep-alive tick: an SSE comment the + // client ignores, and the write PHP needs to spot a drop. + echo null === $frame + ? ": keep-alive\n\n" + : 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n"; + flush(); + } + } catch (\Throwable $e) { + // Headers are long sent, so this cannot become an error + // response; the client sees a close without the closure frame. + $logger->error('Subscription stream ended with an error.', ['exception' => $e]); + } + }; + + return $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive') + ->withHeader('X-Accel-Buffering', 'no') + ->withBody(new CallbackStream($callback, $this->logger)); + } +} diff --git a/src/Server/Transport/StatelessAwareTransportInterface.php b/src/Server/Transport/StatelessAwareTransportInterface.php new file mode 100644 index 00000000..52815b3e --- /dev/null +++ b/src/Server/Transport/StatelessAwareTransportInterface.php @@ -0,0 +1,29 @@ + + */ +interface StatelessAwareTransportInterface +{ + public function connectStateless(StatelessProtocol $protocol): void; +} diff --git a/src/Server/Transport/StatelessHttpTransport.php b/src/Server/Transport/StatelessHttpTransport.php index 5348273b..c55caedf 100644 --- a/src/Server/Transport/StatelessHttpTransport.php +++ b/src/Server/Transport/StatelessHttpTransport.php @@ -17,6 +17,7 @@ use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\Http\MiddlewareRequestHandler; +use Mcp\Server\Transport\Http\StatelessResponder; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -45,6 +46,7 @@ final class StatelessHttpTransport private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; + private StatelessResponder $responder; /** @var list */ private array $middleware; @@ -66,6 +68,7 @@ public function __construct( $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->responder = new StatelessResponder($this->responseFactory, $this->streamFactory, $this->logger); } /** @@ -123,55 +126,11 @@ private function dispatch(ServerRequestInterface $request): ResponseInterface $headers[$name] = implode(', ', $values); } - $result = $this->protocol->handle($payload, $headers); - - if ($result->isStream()) { - return $this->sse($result->frames); - } - - if ($result->isEmpty()) { - return $this->responseFactory->createResponse($result->httpStatus); - } - - return $this->json($result->toJson(), $result->httpStatus); - } - - /** - * @param \Closure(): \Generator $frames - */ - private function sse(\Closure $frames): ResponseInterface - { - $logger = $this->logger; - - $callback = static function () use ($frames, $logger): void { - try { - foreach ($frames() as $frame) { - // A null frame is a keep-alive tick: an SSE comment the - // client ignores, and the write PHP needs to spot a drop. - echo null === $frame - ? ": keep-alive\n\n" - : 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n"; - flush(); - } - } catch (\Throwable $e) { - // Headers are long sent, so this cannot become an error - // response; the client sees a close without the closure frame. - $logger->error('Subscription stream ended with an error.', ['exception' => $e]); - } - }; - - return $this->responseFactory->createResponse(200) - ->withHeader('Content-Type', 'text/event-stream') - ->withHeader('Cache-Control', 'no-cache') - ->withHeader('Connection', 'keep-alive') - ->withHeader('X-Accel-Buffering', 'no') - ->withBody(new CallbackStream($callback, $this->logger)); + return $this->responder->respond($this->protocol->handle($payload, $headers)); } private function json(string $payload, int $status): ResponseInterface { - return $this->responseFactory->createResponse($status) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($payload)); + return $this->responder->json($payload, $status); } } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 098376b6..2bf088bc 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -13,11 +13,15 @@ use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\Http\MiddlewareRequestHandler; +use Mcp\Server\Transport\Http\StatelessResponder; +use Mcp\Server\Wire\InboundClassifier; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -28,11 +32,24 @@ use Symfony\Component\Uid\Uuid; /** + * Carries MCP over HTTP, in either protocol era. + * + * Every request is classified once, before anything else looks at it, and + * routed to the lifecycle it belongs to: a per-request envelope claiming a + * modern revision goes to {@see StatelessProtocol}, everything else — the + * `initialize` handshake, its session's later requests, its `DELETE` teardown — + * goes to the session machinery below. One endpoint, both eras, nothing for the + * client to pick. + * + * A server run without a modern-era dispatcher (see + * {@see \Mcp\Server\Builder::withoutModernEra()}) serves the handshake era + * alone and refuses modern claims, naming the revisions it does serve. + * * @extends BaseTransport * * @author Kyrian Obikwelu */ -class StreamableHttpTransport extends BaseTransport +class StreamableHttpTransport extends BaseTransport implements StatelessAwareTransportInterface { use ReadsBoundedBody; @@ -47,12 +64,16 @@ class StreamableHttpTransport extends BaseTransport private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; + private StatelessResponder $responder; + private InboundClassifier $classifier; + + private ?StatelessProtocol $stateless = null; private ?string $immediateResponse = null; private ?int $immediateStatusCode = null; - /** @var list */ - private array $middleware; + /** @var list|null null until {@see self::listen()} resolves the defaults */ + private ?array $middleware; /** * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware @@ -73,20 +94,42 @@ public function __construct( $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + $this->responder = new StatelessResponder($this->responseFactory, $this->streamFactory, $this->logger); + $this->classifier = new InboundClassifier(); if (null === $middleware) { - $this->middleware = self::defaultMiddleware(); + // Left unresolved: the default stack's version middleware has to + // know which revisions this endpoint serves, and the modern + // dispatcher arrives after the constructor. + $this->middleware = null; } else { $this->middleware = self::normalizeMiddleware($middleware); if ([] === $this->middleware) { $this->logger->warning('Streamable HTTP transport started with an empty middleware list. Default security protections (CORS, DNS rebinding, protocol version validation) are disabled. Pass null (or omit the argument) to use the secure defaults, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware].'); } + + // Custom middleware runs before the request's era is classified, so a + // ProtocolVersionMiddleware here rejects every modern-era request by + // default — it only accepts the handshake versions it was built for. + foreach ($this->middleware as $entry) { + if ($entry instanceof ProtocolVersionMiddleware) { + $this->logger->warning('A custom middleware list includes ProtocolVersionMiddleware. It runs before the modern (2026-07-28) era is classified and rejects that era\'s requests by default, since it only recognises handshake revisions. Remove it from the custom list — the transport already applies it to handshake-era traffic on its own via self::handshakeMiddleware().'); + + break; + } + } } } /** * Secure default middleware stack applied when no `$middleware` is provided to the constructor. * + * These run at the edge, before the request's era is known, because what + * they enforce — origin policy, DNS rebinding — is true of both eras. The + * `MCP-Protocol-Version` header rule is not: it belongs to the handshake + * era, so {@see self::handshakeMiddleware()} carries it instead and the + * modern leg answers for its own revisions. + * * @return list */ public static function defaultMiddleware(): array @@ -94,10 +137,26 @@ public static function defaultMiddleware(): array return [ new CorsMiddleware(), new DnsRebindingProtectionMiddleware(), + ]; + } + + /** + * Middleware applied only to requests classified as handshake-era traffic. + * + * @return list + */ + public static function handshakeMiddleware(): array + { + return [ new ProtocolVersionMiddleware(), ]; } + public function connectStateless(StatelessProtocol $protocol): void + { + $this->stateless = $protocol; + } + public function send(string $data, array $context): void { $this->immediateResponse = $data; @@ -107,7 +166,7 @@ public function send(string $data, array $context): void public function listen(): ResponseInterface { $handler = new MiddlewareRequestHandler( - $this->middleware, + $this->middleware ??= self::defaultMiddleware(), \Closure::fromCallable([$this, 'handleRequest']), ); @@ -119,15 +178,11 @@ protected function handleOptionsRequest(): ResponseInterface return $this->responseFactory->createResponse(204); } - protected function handlePostRequest(): ResponseInterface + /** + * @param string $body the request body, already read and bounded by {@see self::handleRequest()} + */ + protected function handlePostRequest(string $body): ResponseInterface { - $body = $this->readBody($this->request->getBody()); - if (null === $body) { - $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); - - return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); - } - $this->handleMessage($body, $this->sessionId); if (null !== $this->immediateResponse) { @@ -322,6 +377,48 @@ private static function normalizeMiddleware(iterable $middleware): array private function handleRequest(ServerRequestInterface $request): ResponseInterface { $this->request = $request; + + if ('OPTIONS' === $request->getMethod()) { + return $this->handleOptionsRequest(); + } + + // Read once, here: the era decision needs the body, and so does + // whichever leg it routes to. A PSR-7 stream over `php://input` cannot + // be read twice. + $body = null; + if ('POST' === $request->getMethod()) { + $body = $this->readBody($request->getBody()); + + if (null === $body) { + $this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]); + + return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413); + } + } + + $classification = $this->classifier->classify($request->getMethod(), $body, self::headers($request)); + + if ($classification->isRejected()) { + \assert(null !== $classification->error); + + return $this->responder->error($classification->error, $classification->httpStatus); + } + + if ($classification->modern) { + return $this->handleModernRequest($body ?? '', $classification->claimedVersion ?? ''); + } + + // The version-header rule only reaches the traffic it is about. Running + // it at the edge would let it answer a modern claim with the handshake + // era's revision list, ahead of the leg that knows better. + return (new MiddlewareRequestHandler( + self::handshakeMiddleware(), + fn (ServerRequestInterface $handshake): ResponseInterface => $this->handleHandshakeRequest($handshake, $body), + ))->handle($request); + } + + private function handleHandshakeRequest(ServerRequestInterface $request, ?string $body): ResponseInterface + { $sessionIdHeaders = $request->getHeader(self::SESSION_HEADER); if (\count($sessionIdHeaders) > 1) { return $this->createErrorResponse(Error::forInvalidRequest(self::SESSION_HEADER.' header must not be repeated.'), 400); @@ -337,10 +434,38 @@ private function handleRequest(ServerRequestInterface $request): ResponseInterfa } return match ($request->getMethod()) { - 'OPTIONS' => $this->handleOptionsRequest(), - 'POST' => $this->handlePostRequest(), + 'POST' => $this->handlePostRequest($body ?? ''), 'DELETE' => $this->handleDeleteRequest(), default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), }; } + + /** + * Answers a request that claimed the modern era's per-request envelope. + */ + private function handleModernRequest(string $body, string $claimedVersion): ResponseInterface + { + if (null === $this->stateless) { + return $this->responder->error( + Error::forUnsupportedProtocolVersion($claimedVersion, ProtocolVersion::handshakeVersions()), + 400, + ); + } + + return $this->responder->respond($this->stateless->handle($body, self::headers($this->request))); + } + + /** + * @return array + */ + private static function headers(ServerRequestInterface $request): array + { + $headers = []; + + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return $headers; + } } diff --git a/src/Server/Wire/EraClassification.php b/src/Server/Wire/EraClassification.php new file mode 100644 index 00000000..20c0aae6 --- /dev/null +++ b/src/Server/Wire/EraClassification.php @@ -0,0 +1,61 @@ + + */ +final class EraClassification +{ + private function __construct( + public readonly bool $modern, + public readonly ?string $claimedVersion, + public readonly ?Error $error, + public readonly int $httpStatus, + ) { + } + + /** + * The handshake era: everything that makes no per-request envelope claim. + */ + public static function legacy(?string $claimedVersion = null): self + { + return new self(false, $claimedVersion, null, 200); + } + + /** + * The modern era, claiming $version — which may be one this server does not + * serve. Routing and support are separate questions, and the dispatcher + * owns the second one so its answer can name what it does support. + */ + public static function modern(string $version): self + { + return new self(true, $version, null, 200); + } + + public static function reject(Error $error, int $httpStatus): self + { + return new self(false, null, $error, $httpStatus); + } + + public function isRejected(): bool + { + return null !== $this->error; + } +} diff --git a/src/Server/Wire/InboundClassifier.php b/src/Server/Wire/InboundClassifier.php new file mode 100644 index 00000000..826284df --- /dev/null +++ b/src/Server/Wire/InboundClassifier.php @@ -0,0 +1,222 @@ + + */ +final class InboundClassifier +{ + public const PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version'; + + /** + * @param string $httpMethod the request's HTTP method + * @param string|null $body the request body, already read + * @param array $headers request headers, case-insensitively matched + */ + public function classify(string $httpMethod, ?string $body, array $headers = []): EraClassification + { + if ('POST' !== strtoupper($httpMethod)) { + return EraClassification::legacy(); + } + + if (null === $body || '' === trim($body)) { + return EraClassification::legacy(); + } + + try { + $decoded = json_decode($body, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // Unreadable to both eras. Routed to the handshake leg so the one + // parse error the client sees is the one that leg already writes. + return EraClassification::legacy(); + } + + if (!\is_array($decoded)) { + return EraClassification::legacy(); + } + + $headerVersion = self::header($headers, self::PROTOCOL_VERSION_HEADER); + + if (array_is_list($decoded)) { + return $this->classifyBatch($decoded, $headerVersion); + } + + /* @var array $decoded */ + return $this->classifyMessage($decoded, $headerVersion); + } + + /** + * The header-against-body check both eras' entries share. + * + * Kept here rather than in the dispatcher so the edge and the leg it routes + * to cannot disagree about what a request claims. + * + * @return string|null the disagreement, or null when the two agree + */ + public static function crossCheckVersion(?string $headerVersion, string $claimedVersion): ?string + { + if (null === $headerVersion || $headerVersion === $claimedVersion) { + return null; + } + + return \sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $claimedVersion); + } + + /** + * Case-insensitive header lookup, since PSR-7 preserves the sender's casing. + * + * @param array $headers + */ + public static function header(array $headers, string $name): ?string + { + foreach ($headers as $key => $value) { + if (0 === strcasecmp($key, $name)) { + return '' === $value ? null : $value; + } + } + + return null; + } + + /** + * @param list $messages + */ + private function classifyBatch(array $messages, ?string $headerVersion): EraClassification + { + foreach ($messages as $message) { + if (!\is_array($message) || array_is_list($message)) { + continue; + } + + /** @var array $message */ + $classification = $this->classifyMessage($message, $headerVersion); + + if ($classification->isRejected()) { + return $classification; + } + + if ($classification->modern) { + return EraClassification::reject( + Error::forInvalidRequest(\sprintf('Protocol revision %s removed JSON-RPC batching; send one message per request.', $classification->claimedVersion)), + 400, + ); + } + } + + return EraClassification::legacy(); + } + + /** + * @param array $message + */ + private function classifyMessage(array $message, ?string $headerVersion): EraClassification + { + $id = $message['id'] ?? null; + $isNotification = !\array_key_exists('id', $message) || null === $id; + + if (!\is_string($id) && !\is_int($id)) { + $id = null; + } + + $params = \is_array($message['params'] ?? null) ? $message['params'] : null; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : null; + $claim = $meta[RequestMeta::PROTOCOL_VERSION] ?? null; + + if (null !== $claim) { + if (!\is_string($claim) || '' === $claim) { + return EraClassification::reject( + Error::forInvalidParams(\sprintf('Request "_meta" member "%s" must be a non-empty string.', RequestMeta::PROTOCOL_VERSION), $id), + 400, + ); + } + + if (null !== $mismatch = self::crossCheckVersion($headerVersion, $claim)) { + return EraClassification::reject(Error::forHeaderMismatch($mismatch, $id), 400); + } + + return self::eraOf($claim); + } + + if (!self::namesModern($headerVersion)) { + return EraClassification::legacy(); + } + + // The header names a revision that has no handshake, so the body has to + // carry the envelope. A notification is the exception: it has no claim + // to carry under this revision, so there the header is all there is. + if ($isNotification) { + return EraClassification::modern($headerVersion); + } + + return EraClassification::reject( + Error::forInvalidParams(\sprintf('Protocol revision %s requires the "%s" member in "params._meta".', $headerVersion, RequestMeta::PROTOCOL_VERSION), $id), + 400, + ); + } + + /** + * The era a claimed revision belongs to. + * + * An unknown revision counts as modern: it cannot be negotiated through a + * handshake, and the modern leg is the one that can name what it does serve. + */ + private static function eraOf(string $version): EraClassification + { + $known = ProtocolVersion::tryFrom($version); + + if (null !== $known && !$known->isModern()) { + return EraClassification::legacy($version); + } + + return EraClassification::modern($version); + } + + /** + * Only a *known* modern revision counts here. An unrecognised header with + * nothing in the body to back it up is not evidence of an era — it is a + * version this endpoint does not serve, and the handshake leg's version + * middleware is what says so, naming everything the endpoint does serve. + */ + private static function namesModern(?string $version): bool + { + return null !== $version && true === ProtocolVersion::tryFrom($version)?->isModern(); + } +} diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index 844ebbf6..d7eac2b3 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -34,6 +34,54 @@ final class Elements public const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + /** + * The focal `inputSchema` the `json-schema-2020-12` scenario expects a + * server to advertise verbatim. + * + * It is handed to `addTool()` raw rather than generated from a signature, + * because the point is that the SDK passes an author-supplied 2020-12 + * schema through `tools/list` untouched: `$schema`/`$defs`/ + * `additionalProperties` (SEP-1613) and the composition, conditional and + * `$anchor` keywords (SEP-2106) must all survive. + * + * Mirrors `JSON_SCHEMA_2020_12_FIXTURE` in the conformance suite; keep the + * two in sync. + * + * @return array + */ + public static function jsonSchema2020_12Fixture(): array + { + return [ + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', + '$defs' => [ + 'address' => [ + '$anchor' => 'addressDef', + 'type' => 'object', + 'properties' => [ + 'street' => ['type' => 'string'], + 'city' => ['type' => 'string'], + ], + ], + ], + 'properties' => [ + 'name' => ['type' => 'string'], + 'address' => ['$ref' => '#/$defs/address'], + 'contactMethod' => ['type' => 'string', 'enum' => ['phone', 'email']], + 'phone' => ['type' => 'string'], + 'email' => ['type' => 'string'], + ], + 'allOf' => [['anyOf' => [['required' => ['phone']], ['required' => ['email']]]]], + 'if' => [ + 'properties' => ['contactMethod' => ['const' => 'phone']], + 'required' => ['contactMethod'], + ], + 'then' => ['required' => ['phone']], + 'else' => ['required' => ['email']], + 'additionalProperties' => false, + ]; + } + public function toolMultipleTypes(): CallToolResult { return new CallToolResult([ diff --git a/tests/Conformance/Fixtures/nginx.conf b/tests/Conformance/Fixtures/nginx.conf index 269f55fe..b481038e 100644 --- a/tests/Conformance/Fixtures/nginx.conf +++ b/tests/Conformance/Fixtures/nginx.conf @@ -3,12 +3,10 @@ server { server_name localhost; root /app; - # / speaks the handshake era, /stateless the modern one (SEP-2575). - # Declared first so the prefix match wins. - location /stateless { - try_files $uri /tests/Conformance/server-stateless.php$is_args$args; - } - + # One entry, both protocol eras: the fixture is built through + # Builder::build(), which carries a dispatcher for each, and the transport + # routes every request to the one it belongs to. Both conformance suites + # run against this same location. location / { try_files $uri /tests/Conformance/server.php$is_args$args; } diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index 9a55b836..eb9db4ca 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,6 +1,3 @@ -server: - - json-schema-2020-12 - client: - elicitation-sep1034-client-defaults - sse-retry diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index 42051df1..b41bdc91 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,6 +1,3 @@ -server: - - json-schema-2020-12 - client: - request-metadata - http-standard-headers diff --git a/tests/Conformance/server-stateless.php b/tests/Conformance/server-stateless.php deleted file mode 100644 index ccdfdf38..00000000 --- a/tests/Conformance/server-stateless.php +++ /dev/null @@ -1,202 +0,0 @@ -createServerRequestFromGlobals(); - -// Explicit rather than builder-built, so the diagnostic hooks below can mutate -// the live registry and the change reaches an open subscription. -// -// Filesystem-backed and not in-memory: under php-fpm the worker holding the -// listen stream open and the worker serving the tools/call that mutates the -// registry are different processes, so the only thing they share is storage. -$bus = new Psr16NotificationBus( - new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')), - logger: $logger, -); -$registry = new Registry(new PublishingEventDispatcher($bus), $logger); - -$protocol = Server::builder() - ->setServerInfo('mcp-conformance-test-server', '1.0.0') - ->setLogger($logger) - ->setRegistry($registry) - // Tools - ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') - ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') - ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') - ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') - ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') - ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') - // Exercises the -32021 path. - ->addTool( - static function (): never { - throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.'); - }, - name: 'test_missing_capability', - description: 'Always reports a missing client capability, for testing -32021 handling', - ) - // The ask travels back inside the result, never as its own request. - ->addTool( - static fn (): InputRequiredResult => new InputRequiredResult( - [ - 'conformance_probe' => new ElicitRequest( - 'Please provide a value for the conformance probe.', - new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']), - ), - ], - requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)), - ), - name: 'test_streaming_elicitation', - description: 'Returns an InputRequiredResult asking for elicitation input', - ) - // Logs server-side only; no logLevel was requested, so nothing goes out. - ->addTool( - static function () use ($logger): string { - $logger->info('test_logging_tool executed'); - - return 'Logged.'; - }, - name: 'test_logging_tool', - description: 'Emits a server-side log message while returning normally', - ) - // Mirrors its arguments into Mcp-Param-* headers (SEP-2243). - ->addTool( - static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries), - name: 'test_custom_headers', - description: 'Tests custom header mirroring via x-mcp-header', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], - 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], - ], - 'required' => ['region'], - ], - ) - ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') - // Diagnostic hooks the subscription scenarios call to make the lists change - // while a listen stream is open. - ->addTool( - static function () use ($registry): string { - $registry->registerTool( - new Tool( - name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)), - title: null, - inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null], - description: 'Registered to trigger a list change', - annotations: null, - ), - static fn (): string => 'ephemeral', - ); - - return 'Tool list mutated.'; - }, - name: 'test_trigger_tool_change', - description: 'Registers a tool so the tool list changes', - ) - ->addTool( - static function () use ($registry): string { - $registry->registerPrompt( - new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'), - static fn (): array => [['role' => 'user', 'content' => 'ephemeral']], - ); - - return 'Prompt list mutated.'; - }, - name: 'test_trigger_prompt_change', - description: 'Registers a prompt so the prompt list changes', - ) - // Multi round-trip request tools (SEP-2322). - ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation') - ->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion') - ->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots') - ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping') - ->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once') - ->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds') - ->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared') - ->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies') - // Resources - ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') - ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') - ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') - // Prompts - ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments') - ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') - ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') - ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') - ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') - // Fixed so a retry landing on another process still verifies. - ->setRequestState(str_repeat('conformance-fixture-key-', 2)) - // So a listen stream carries the registry's changes rather than only - // acknowledging. In-memory is right here: the conformance server is one - // FrankenPHP-less php-fpm pool, and the scenarios publish within a request. - ->setNotificationBus($bus) - // Short, so a listen stream cannot tie up an fpm worker for the length of - // a whole run. - ->setSubscriptionLifetime(5.0) - // Lists are the same for everyone here; a read is not. - ->setCachePolicy( - CachePolicy::default(60_000) - ->withMethod('tools/list', 3_600_000, CacheScope::Public) - ->withMethod('prompts/list', 3_600_000, CacheScope::Public) - ->withMethod('resources/list', 3_600_000, CacheScope::Public) - ->withMethod('resources/templates/list', 3_600_000, CacheScope::Public) - ->withMethod('server/discover', 3_600_000, CacheScope::Public), - ) - ->buildStateless([ProtocolVersion::V2026_07_28]); - -$transport = new StatelessHttpTransport($protocol, logger: $logger); - -(new SapiEmitter())->emit($transport->handle($request)); diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 02c78c2a..b233d49b 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -9,22 +9,49 @@ * file that was distributed with this source code. */ +/* + * The conformance fixture, for both protocol eras, on one endpoint. + * + * A server built through Builder::build() carries a dispatcher for each era and + * StreamableHttpTransport routes every request to the one it belongs to, so + * there is nothing here that a revision has to be told about. The element set + * is the union of what both suites ask for: where they overlap they share the + * registration, so a difference in results points at the lifecycle rather than + * at drifted fixtures. + */ + ini_set('display_errors', '0'); require_once dirname(__DIR__, 2).'/vendor/autoload.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Capability\Registry; +use Mcp\Exception\MissingRequiredClientCapabilityException; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Enum\CacheScope; +use Mcp\Schema\Prompt; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Schema\Tool; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Subscription\Psr16NotificationBus; +use Mcp\Server\Subscription\PublishingEventDispatcher; use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Wire\CachePolicy; use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\FileLogger; +use Mcp\Tests\Conformance\MrtrElements; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; chdir(__DIR__); @@ -33,25 +60,133 @@ $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); -$transport = new StreamableHttpTransport($request, logger: $logger); +// Explicit rather than builder-built, so the diagnostic hooks below can mutate +// the live registry and the change reaches an open subscription. +// +// Filesystem-backed and not in-memory: under php-fpm the worker holding the +// listen stream open and the worker serving the tools/call that mutates the +// registry are different processes, so the only thing they share is storage. +$bus = new Psr16NotificationBus( + new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')), + logger: $logger, +); +$registry = new Registry(new PublishingEventDispatcher($bus), $logger); $server = Server::builder() ->setServerInfo('mcp-conformance-test-server', '1.0.0') - ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger($logger) + ->setRegistry($registry) + // Only the handshake leg keeps one; the modern leg is sessionless either way. + ->setSession(new FileSessionStore(__DIR__.'/sessions')) // Tools ->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response') ->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response') ->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response') ->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response') ->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types') - ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') + ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') + ->addTool( + static fn () => 'ok', + name: 'json_schema_2020_12_tool', + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: Elements::jsonSchema2020_12Fixture(), + ) + // Exercises the -32021 path. + ->addTool( + static function (): never { + throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.'); + }, + name: 'test_missing_capability', + description: 'Always reports a missing client capability, for testing -32021 handling', + ) + // The ask travels back inside the result, never as its own request. + ->addTool( + static fn (): InputRequiredResult => new InputRequiredResult( + [ + 'conformance_probe' => new ElicitRequest( + 'Please provide a value for the conformance probe.', + new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']), + ), + ], + requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)), + ), + name: 'test_streaming_elicitation', + description: 'Returns an InputRequiredResult asking for elicitation input', + ) + // Logs server-side only; no logLevel was requested, so nothing goes out. + ->addTool( + static function () use ($logger): string { + $logger->info('test_logging_tool executed'); + + return 'Logged.'; + }, + name: 'test_logging_tool', + description: 'Emits a server-side log message while returning normally', + ) + // Mirrors its arguments into Mcp-Param-* headers (SEP-2243). + ->addTool( + static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries), + name: 'test_custom_headers', + description: 'Tests custom header mirroring via x-mcp-header', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + ], + 'required' => ['region'], + ], + ) ->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications') + // Handshake-era elements. The scenarios that drive them are 2025-11-25 + // ones — server-initiated requests and session-scoped logging both went + // away in 2026-07-28 — but they are registered once, like everything else. + ->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages') ->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling') - ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling') ->addTool([Elements::class, 'toolWithElicitation'], name: 'test_elicitation', description: 'Tests server-initiated elicitation') ->addTool([Elements::class, 'toolWithElicitationDefaults'], name: 'test_elicitation_sep1034_defaults', description: 'Tests elicitation with default values') ->addTool([Elements::class, 'toolWithElicitationEnums'], name: 'test_elicitation_sep1330_enums', description: 'Tests elicitation with enum schemas') + // Diagnostic hooks the subscription scenarios call to make the lists change + // while a listen stream is open. + ->addTool( + static function () use ($registry): string { + $registry->registerTool( + new Tool( + name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)), + title: null, + inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null], + description: 'Registered to trigger a list change', + annotations: null, + ), + static fn (): string => 'ephemeral', + ); + + return 'Tool list mutated.'; + }, + name: 'test_trigger_tool_change', + description: 'Registers a tool so the tool list changes', + ) + ->addTool( + static function () use ($registry): string { + $registry->registerPrompt( + new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'), + static fn (): array => [['role' => 'user', 'content' => 'ephemeral']], + ); + + return 'Prompt list mutated.'; + }, + name: 'test_trigger_prompt_change', + description: 'Registers a prompt so the prompt list changes', + ) + // Multi round-trip request tools (SEP-2322). + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation') + ->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion') + ->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots') + ->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping') + ->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once') + ->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds') + ->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared') + ->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') @@ -62,8 +197,24 @@ ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') + ->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first') + // Fixed so a retry landing on another process still verifies. + ->setRequestState(str_repeat('conformance-fixture-key-', 2)) + // So a listen stream carries the registry's changes rather than only + // acknowledging; see $bus above for why it is filesystem-backed. + ->setNotificationBus($bus) + // Short, so a listen stream cannot tie up an fpm worker for the length of + // a whole run. + ->setSubscriptionLifetime(5.0) + // Lists are the same for everyone here; a read is not. + ->setCachePolicy( + CachePolicy::default(60_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('prompts/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/templates/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ) ->build(); -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); +(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request, logger: $logger))); diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 5ddb551f..9cdf3292 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -18,6 +18,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Extension\Apps\McpApps; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Response; @@ -30,6 +31,7 @@ use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Tests\Unit\Server\Extension\ThingExtension; use Mcp\Tests\Unit\Server\Extension\ThingListHandler; use Mcp\Tests\Unit\Server\Extension\ThingListRequest; @@ -178,6 +180,67 @@ public function testSetLazyLoadingReturnsSelf(): void $this->assertSame($builder, $builder->setLazyLoading(false)); } + #[TestDox('One builder configuration is resolved once, however many dispatchers come out of it')] + public function testAssembledPartsAreSharedAcrossEras(): void + { + $loader = $this->createMock(LoaderInterface::class); + // Twice would mean two registries behind one endpoint, and a change + // made through one of them invisible to the other. + $loader->expects($this->once())->method('load'); + + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setLazyLoading(false) + ->addLoader($loader); + + $builder->build(); + $builder->buildStateless(); + } + + #[TestDox('A built server carries a dispatcher for each era, so one endpoint serves both')] + public function testBuildProducesBothEras(): void + { + $server = Server::builder()->setServerInfo('test', '1.0.0')->build(); + + $this->assertInstanceOf(StatelessProtocol::class, self::statelessProtocol($server)); + } + + #[TestDox('withoutModernEra() leaves the server with the handshake era alone')] + public function testWithoutModernEra(): void + { + $builder = Server::builder()->setServerInfo('test', '1.0.0'); + + $this->assertSame($builder, $builder->withoutModernEra()); + $this->assertNull(self::statelessProtocol($builder->build())); + } + + #[TestDox('setModernVersions() narrows what the modern leg answers for')] + public function testSetModernVersions(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setModernVersions([ProtocolVersion::V2026_07_28]) + ->build(); + + $this->assertSame([ProtocolVersion::V2026_07_28], self::statelessProtocol($server)?->supportedVersions()); + } + + #[TestDox('setModernVersions() rejects a handshake-era revision, which the classifier would never route there')] + public function testSetModernVersionsRejectsHandshakeRevision(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(ProtocolVersion::V2025_11_25->value); + + Server::builder()->setModernVersions([ProtocolVersion::V2025_11_25]); + } + + private static function statelessProtocol(Server $server): ?StatelessProtocol + { + $property = new \ReflectionProperty(Server::class, 'statelessProtocol'); + + return $property->getValue($server); + } + #[TestDox('An extension identifier must be a valid _meta prefix')] public function testEnableExtensionRejectsUnprefixedIdentifier(): void { diff --git a/tests/Unit/Server/InputRequiredShimTest.php b/tests/Unit/Server/InputRequiredShimTest.php new file mode 100644 index 00000000..137270f5 --- /dev/null +++ b/tests/Unit/Server/InputRequiredShimTest.php @@ -0,0 +1,229 @@ + new Response(1, new CallToolResult([new TextContent('done')]))); + $expected = $handler->handle(self::request(), self::session()); + + $answer = $this->drive($handler, $expected, self::session()); + + $this->assertSame('done', self::text($answer)); + } + + #[TestDox('an ask is sent to the client and the handler re-entered with the answer')] + public function testOneRoundTrip(): void + { + $entries = 0; + $handler = self::handler(static function (Request $request, SessionInterface $session) use (&$entries): Response { + ++$entries; + $answer = self::inputContext($session)?->elicitResult('who'); + + if (null === $answer) { + return new Response(1, new InputRequiredResult(['who' => self::ask()])); + } + + return new Response(1, new CallToolResult([new TextContent('Hello, '.($answer->content['name'] ?? '?').'!')])); + }); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ + new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']]), + ]); + + $this->assertSame('Hello, Ada!', self::text($answer)); + $this->assertSame(2, $entries, 're-entry is re-execution: the handler runs once per round'); + } + + #[TestDox('a client that declines is an answer, and the handler decides what it means')] + public function testDeclineReachesTheHandler(): void + { + $handler = self::handler(static function (Request $request, SessionInterface $session): Response { + $answer = self::inputContext($session)?->elicitResult('who'); + + if (null === $answer) { + return new Response(1, new InputRequiredResult(['who' => self::ask()])); + } + + return new Response(1, new CallToolResult([new TextContent($answer->isDeclined() ? 'declined' : 'accepted')])); + }); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ + new Response(7, ['action' => 'decline']), + ]); + + $this->assertSame('declined', self::text($answer)); + } + + #[TestDox('a handler that never stops asking is failed rather than looped forever')] + public function testRoundLimit(): void + { + $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); + + $answer = $this->drive( + $handler, + $handler->handle(self::request(), $session = self::session()), + $session, + array_fill(0, 3, new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']])), + new InputRequiredShim(maxRounds: 2), + ); + + $this->assertInstanceOf(Error::class, $answer); + $this->assertStringContainsString('more than 2 times', $answer->message); + } + + #[TestDox('an ask the client never declared it could answer is refused, and nothing is sent')] + public function testUndeclaredCapabilityIsRefused(): void + { + $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); + + // No `elicitation` in the session's declared capabilities. + $session = self::session(declares: []); + + $answer = $this->drive($handler, $handler->handle(self::request(), $session), $session); + + $this->assertInstanceOf(Error::class, $answer); + $this->assertSame(-32021, $answer->jsonSerialize()['error']['code']); + } + + /** + * Runs the shim the way the transport does: inside a fiber, answering each + * suspension with the next queued client response. + * + * @param RequestHandlerInterface $handler + * @param Response|Error $first + * @param list|Error> $answers + * + * @return Response|Error + */ + private function drive( + RequestHandlerInterface $handler, + Response|Error $first, + SessionInterface $session, + array $answers = [], + ?InputRequiredShim $shim = null, + ): Response|Error { + $shim ??= new InputRequiredShim(); + $request = self::request(); + + $fiber = new \Fiber(static fn (): Response|Error => $shim->fulfill($first, $handler, $request, $session, null)); + + $suspended = $fiber->start(); + + while ($fiber->isSuspended()) { + $this->assertIsArray($suspended); + $this->assertSame('request', $suspended['type'], 'the shim only ever suspends to send a client request'); + $this->assertNotEmpty($answers, 'the shim sent more requests than the test queued answers for'); + + $suspended = $fiber->resume(array_shift($answers)); + } + + /** @var Response|Error $return */ + $return = $fiber->getReturn(); + + return $return; + } + + private static function ask(): ElicitRequest + { + return new ElicitRequest('Who?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name'])); + } + + private static function request(): CallToolRequest + { + return (new CallToolRequest('greet', []))->withId(1); + } + + /** + * @param array $declares + */ + private static function session(array $declares = ['elicitation' => []]): SessionInterface + { + $session = new Session(new InMemorySessionStore()); + $session->set('client_capabilities', $declares); + + return $session; + } + + private static function inputContext(SessionInterface $session): ?InputContext + { + $context = $session->get(InputContext::class); + + return $context instanceof InputContext ? $context : null; + } + + /** + * @param \Closure(Request, SessionInterface): (Response|Error) $handle + * + * @return RequestHandlerInterface + */ + private static function handler(\Closure $handle): RequestHandlerInterface + { + return new class($handle) implements RequestHandlerInterface { + public function __construct(private readonly \Closure $handle) + { + } + + public function supports(Request $request): bool + { + return true; + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + return ($this->handle)($request, $session); + } + }; + } + + /** + * @param Response|Error $result + */ + private static function text(Response|Error $result): string + { + self::assertInstanceOf(Response::class, $result); + self::assertInstanceOf(CallToolResult::class, $result->result); + $first = $result->result->content[0] ?? null; + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } +} diff --git a/tests/Unit/Server/Transport/DualEraRoutingTest.php b/tests/Unit/Server/Transport/DualEraRoutingTest.php new file mode 100644 index 00000000..d73d94b4 --- /dev/null +++ b/tests/Unit/Server/Transport/DualEraRoutingTest.php @@ -0,0 +1,280 @@ +factory = new Psr17Factory(); + } + + #[TestDox('the initialize handshake is answered on the endpoint that also serves the modern era')] + public function testHandshakeStillWorks(): void + { + $answer = $this->post($this->server(), $this->handshake()); + + $this->assertSame(200, $answer['status']); + $this->assertSame(ProtocolVersion::V2025_11_25->value, $answer['body']['result']['protocolVersion']); + } + + #[TestDox('server/discover is answered on that same endpoint, with no handshake before it')] + public function testDiscoverOnTheSameEndpoint(): void + { + $answer = $this->post($this->server(), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(200, $answer['status']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['result']['supportedVersions']); + } + + #[TestDox('one tool answers both eras, from one registry')] + public function testOneToolServesBothEras(): void + { + $server = $this->server(); + + $handshake = $this->post($server, $this->handshake()); + $session = $handshake['session']; + $this->assertNotSame('', $session, 'the handshake leg still mints a session'); + + $legacy = $this->post($server, json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => ['name' => 'echo_tool', 'arguments' => ['text' => 'legacy']], + ], \JSON_THROW_ON_ERROR), [ + 'Mcp-Session-Id' => $session, + 'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value, + ]); + + $modern = $this->post($server, $this->enveloped('tools/call', [ + 'name' => 'echo_tool', + 'arguments' => ['text' => 'modern'], + ]), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => 'echo_tool', + ]); + + $this->assertSame('echo:legacy', $legacy['body']['result']['content'][0]['text']); + $this->assertSame('echo:modern', $modern['body']['result']['content'][0]['text']); + + // The modern answer carries the wire fields its revision adds; the + // handshake one does not. Same handler, two codecs. + $this->assertSame('complete', $modern['body']['result']['resultType']); + $this->assertArrayNotHasKey('resultType', $legacy['body']['result']); + } + + #[TestDox('a modern claim contradicted by the header is refused before either leg sees it')] + public function testHeaderContradictingTheClaimIsRefused(): void + { + $answer = $this->post($this->server(), $this->enveloped('tools/list'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value, + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32020, $answer['body']['error']['code']); + } + + #[TestDox('a modern header the body does not back up is refused, naming the member it wants')] + public function testModernHeaderWithoutAnEnvelopeIsRefused(): void + { + $answer = $this->post($this->server(), json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32602, $answer['body']['error']['code']); + $this->assertStringContainsString(RequestMeta::PROTOCOL_VERSION, $answer['body']['error']['message']); + } + + #[TestDox('the version middleware lets a modern header through instead of turning it away')] + public function testVersionMiddlewareAcceptsModernRevisions(): void + { + $answer = $this->post($this->server(), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(200, $answer['status']); + } + + #[TestDox('an unknown header with no claim behind it is refused by the handshake leg, offering its revisions')] + public function testUnknownVersionHeaderIsRefused(): void + { + $answer = $this->post($this->server(), json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => '2030-01-01']); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + $this->assertSame( + array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()), + $answer['body']['error']['data']['supported'], + ); + } + + #[TestDox('an unknown revision claimed in the envelope is answered by the modern leg, offering its own')] + public function testUnknownClaimReachesTheModernLeg(): void + { + $body = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => [ + RequestMeta::PROTOCOL_VERSION => '2099-01-01', + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]], + ], \JSON_THROW_ON_ERROR); + + $answer = $this->post($this->server(), $body, ['MCP-Protocol-Version' => '2099-01-01']); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + // The claim named the envelope mechanism, so the answer names the + // revisions that mechanism has — not the handshake ones it cannot use. + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']); + } + + #[TestDox('a server built without the modern era refuses a modern claim, naming what it does serve')] + public function testHandshakeOnlyServerRefusesModernTraffic(): void + { + $answer = $this->post($this->server(modern: false), $this->enveloped('server/discover'), [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32022, $answer['body']['error']['code']); + $this->assertNotContains(ProtocolVersion::V2026_07_28->value, $answer['body']['error']['data']['supported']); + } + + #[TestDox('a DELETE still ends a handshake-era session')] + public function testDeleteReachesTheHandshakeLeg(): void + { + $server = $this->server(); + $session = $this->post($server, $this->handshake())['session']; + + $request = $this->factory->createServerRequest('DELETE', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Mcp-Session-Id', $session); + + $response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + + $this->assertSame(200, $response->getStatusCode()); + } + + private function server(bool $modern = true): Server + { + $builder = Server::builder() + ->setServerInfo('dual-era-server', '1.0.0') + ->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument'); + + if (!$modern) { + $builder->withoutModernEra(); + } + + return $builder->build(); + } + + private function handshake(): string + { + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'handshake-client', 'version' => '1.0.0'], + ], + ], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $params + */ + private function enveloped(string $method, array $params = []): string + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]; + + return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR); + } + + /** + * @param array $headers + * + * @return array{status: int, session: string, body: array} + */ + private function post(Server $server, string $body, array $headers = []): array + { + $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($body)); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory)); + + return [ + 'status' => $response->getStatusCode(), + 'session' => $response->getHeaderLine('Mcp-Session-Id'), + 'body' => self::decode($response), + ]; + } + + /** + * @return array + */ + private static function decode(ResponseInterface $response): array + { + $payload = (string) $response->getBody(); + + if ('' === $payload) { + return []; + } + + return json_decode($payload, true, flags: \JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 22b7fef2..932f5c77 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -147,6 +147,23 @@ public function testEmptyMiddlewareListDisablesDefaultsAndWarns(): void $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); } + #[TestDox('a custom middleware list carrying ProtocolVersionMiddleware warns that it will reject the modern era')] + public function testCustomMiddlewareWithProtocolVersionMiddlewareWarns(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('ProtocolVersionMiddleware')); + + new StreamableHttpTransport( + $this->factory->createServerRequest('POST', 'http://localhost/')->withHeader('Host', 'localhost'), + $this->factory, + $this->factory, + $logger, + [new CorsMiddleware(), new DnsRebindingProtectionMiddleware(), new ProtocolVersionMiddleware()], + ); + } + #[TestDox('null middleware does not trigger the empty-list warning')] public function testNullMiddlewareDoesNotWarn(): void { diff --git a/tests/Unit/Server/Wire/InboundClassifierTest.php b/tests/Unit/Server/Wire/InboundClassifierTest.php new file mode 100644 index 00000000..282fa0ab --- /dev/null +++ b/tests/Unit/Server/Wire/InboundClassifierTest.php @@ -0,0 +1,254 @@ + $headers + */ + #[DataProvider('provideLegacyTraffic')] + #[TestDox('$_dataName is handshake-era traffic')] + public function testClassifiesAsLegacy(string $method, ?string $body, array $headers = []): void + { + $classification = (new InboundClassifier())->classify($method, $body, $headers); + + $this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection'); + $this->assertFalse($classification->modern); + } + + /** + * @return iterable}> + */ + public static function provideLegacyTraffic(): iterable + { + yield 'the initialize handshake' => ['POST', self::message('initialize')]; + + yield 'a request with no envelope claim' => ['POST', self::message('tools/list')]; + + yield 'a notification with no envelope claim' => ['POST', self::notification('notifications/initialized')]; + + yield 'a GET, which the modern era has no use for' => ['GET', null]; + + yield 'a DELETE ending a session' => ['DELETE', null]; + + yield 'a claim naming a handshake revision' => [ + 'POST', + self::message('tools/list', ProtocolVersion::V2025_11_25->value), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + ]; + + yield 'a header naming a handshake revision' => [ + 'POST', + self::message('tools/list'), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + ]; + + // The endpoint's version middleware is what answers this, naming every + // revision the endpoint serves. Routing it modern would answer with the + // modern leg's shorter list instead. + yield 'an unknown header with nothing in the body to back it' => [ + 'POST', + self::message('tools/list'), + [self::VERSION_HEADER => '2030-01-01'], + ]; + + yield 'a batch of handshake-era messages' => [ + 'POST', + '['.self::message('tools/list').','.self::message('prompts/list').']', + ]; + + yield 'an empty body' => ['POST', '']; + + yield 'a body that is not JSON' => ['POST', 'not json at all']; + + yield 'a JSON body that is not an object' => ['POST', '"a string"']; + } + + /** + * @param array $headers + */ + #[DataProvider('provideModernTraffic')] + #[TestDox('$_dataName is modern-era traffic')] + public function testClassifiesAsModern(string $body, array $headers, string $expectedVersion): void + { + $classification = (new InboundClassifier())->classify('POST', $body, $headers); + + $this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection'); + $this->assertTrue($classification->modern); + $this->assertSame($expectedVersion, $classification->claimedVersion); + } + + /** + * @return iterable, string}> + */ + public static function provideModernTraffic(): iterable + { + yield 'a request claiming the modern revision' => [ + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + + // The header is a cross-check, not the decision: a claim on its own is + // enough evidence, and the leg it routes to is what says the header was + // required. + yield 'a claim with no header at all' => [ + self::message('server/discover', ProtocolVersion::V2026_07_28->value), + [], + ProtocolVersion::V2026_07_28->value, + ]; + + // Routed rather than refused so the answer can name what this endpoint + // does serve, which only the modern leg knows. + yield 'a claim naming a revision this SDK has never heard of' => [ + self::message('tools/list', '2099-01-01'), + [self::VERSION_HEADER => '2099-01-01'], + '2099-01-01', + ]; + + // `initialize` is handshake-era by definition — but a claim outranks the + // method name, and the modern leg answers it with method-not-found. + yield 'an initialize carrying a modern claim' => [ + self::message('initialize', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + + // A notification has no claim of its own under this revision, so the + // header is all the evidence there is. + yield 'a notification under a modern header' => [ + self::notification('notifications/progress'), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + ProtocolVersion::V2026_07_28->value, + ]; + } + + /** + * @param array $headers + */ + #[DataProvider('provideRejectedTraffic')] + #[TestDox('$_dataName is refused at the edge')] + public function testRejects(string $body, array $headers, int $code, int $status): void + { + $classification = (new InboundClassifier())->classify('POST', $body, $headers); + + $this->assertTrue($classification->isRejected()); + $this->assertSame($code, $classification->error?->jsonSerialize()['error']['code']); + $this->assertSame($status, $classification->httpStatus); + } + + /** + * @return iterable, int, int}> + */ + public static function provideRejectedTraffic(): iterable + { + yield 'a header contradicting the claim' => [ + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value], + -32020, + 400, + ]; + + yield 'a handshake claim contradicted by a modern header' => [ + self::message('tools/list', ProtocolVersion::V2025_11_25->value), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + -32020, + 400, + ]; + + yield 'a modern header on a request carrying no envelope' => [ + self::message('tools/list'), + [self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value], + -32602, + 400, + ]; + + yield 'a claim that is not a string' => [ + '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"'.RequestMeta::PROTOCOL_VERSION.'":42}}}', + [], + -32602, + 400, + ]; + + yield 'a batch holding a modern claim' => [ + '['.self::message('tools/list').','.self::message('prompts/list', ProtocolVersion::V2026_07_28->value).']', + [], + -32600, + 400, + ]; + } + + #[TestDox('the header cross-check is case-insensitive, as HTTP field names are')] + public function testHeaderLookupIgnoresCase(): void + { + $classification = (new InboundClassifier())->classify( + 'POST', + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + ['mcp-protocol-version' => ProtocolVersion::V2025_11_25->value], + ); + + $this->assertTrue($classification->isRejected()); + } + + #[TestDox('an empty header value counts as no header, not as a contradiction')] + public function testEmptyHeaderIsAbsent(): void + { + $classification = (new InboundClassifier())->classify( + 'POST', + self::message('tools/list', ProtocolVersion::V2026_07_28->value), + [self::VERSION_HEADER => ''], + ); + + $this->assertFalse($classification->isRejected()); + $this->assertTrue($classification->modern); + } + + #[TestDox('the shared cross-check reports only a genuine disagreement')] + public function testCrossCheckVersion(): void + { + $this->assertNull(InboundClassifier::crossCheckVersion(null, '2026-07-28')); + $this->assertNull(InboundClassifier::crossCheckVersion('2026-07-28', '2026-07-28')); + $this->assertNotNull(InboundClassifier::crossCheckVersion('2025-11-25', '2026-07-28')); + } + + private static function message(string $method, ?string $claim = null): string + { + $params = null === $claim ? [] : ['_meta' => [ + RequestMeta::PROTOCOL_VERSION => $claim, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]]; + + return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR); + } + + private static function notification(string $method): string + { + return json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => []], \JSON_THROW_ON_ERROR); + } +}