[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

@ -27,6 +27,8 @@ use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
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 @@ class Protocol
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 @@ class Protocol
$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 @@ class Protocol
$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();