[Server] Serve both protocol eras from one endpoint (#455)

* [Server] Serve both protocol eras from one endpoint

The two lifecycles share a transport, not a dispatcher. StreamableHttpTransport
classifies each request - a 2026-07-28 envelope, an initialize handshake, or a
session-bound follow-up - through InboundClassifier and routes it to the
dispatcher that owns it, so one URL answers a modern client and a handshake-era
one alike. Builder::build() carries both; withoutModernEra() opts out and
setModernVersions() narrows what the modern leg answers for.

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 - so a handler is written once rather than twice.

The conformance fixture collapses into one server for the same reason: both
legs now hit the same URL.

* Trim redundant comments, fix stale in-memory claim in conformance fixture

* Drop stale json-schema-2020-12 baseline entries

The new fixture tool makes this scenario pass in both eras now.

* Reject handshake revisions in setModernVersions(), warn on unsafe custom middleware

Copilot review: setModernVersions() accepted revisions InboundClassifier
would never route to the modern leg. StreamableHttpTransport now warns
when a custom middleware list carries ProtocolVersionMiddleware, since
it runs before era classification and rejects modern-era traffic by
default. Also fixed a misleading docblock claim on the same middleware.
This commit is contained in:
Christopher Hertel 2026-08-19 01:36:56 +02:00 • committed by GitHub
commit cbceef06eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -13,11 +13,15 @@ namespace Mcp\Server\Transport;
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 Psr\Log\LoggerInterface;
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<ResponseInterface>
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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<MiddlewareInterface> */
private array $middleware;
/** @var list<MiddlewareInterface>|null null until {@see self::listen()} resolves the defaults */
private ?array $middleware;
/**
* @param iterable<MiddlewareInterface>|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware
@ -73,20 +94,42 @@ class StreamableHttpTransport extends BaseTransport
$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<MiddlewareInterface>
*/
public static function defaultMiddleware(): array
@ -94,10 +137,26 @@ class StreamableHttpTransport extends BaseTransport
return [
new CorsMiddleware(),
new DnsRebindingProtectionMiddleware(),
];
}
/**
* Middleware applied only to requests classified as handshake-era traffic.
*
* @return list<MiddlewareInterface>
*/
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 @@ class StreamableHttpTransport extends BaseTransport
public function listen(): ResponseInterface
{
$handler = new MiddlewareRequestHandler(
$this->middleware,
$this->middleware ??= self::defaultMiddleware(),
\Closure::fromCallable([$this, 'handleRequest']),
);
@ -119,15 +178,11 @@ class StreamableHttpTransport extends BaseTransport
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 @@ class StreamableHttpTransport extends BaseTransport
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 @@ class StreamableHttpTransport extends BaseTransport
}
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<string, string>
*/
private static function headers(ServerRequestInterface $request): array
{
$headers = [];
foreach ($request->getHeaders() as $name => $values) {
$headers[$name] = implode(', ', $values);
}
return $headers;
}
}