[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

@ -33,6 +33,7 @@ use Mcp\Server\Session\InMemorySessionStore;
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 @@ final class StatelessProtocol
}
}
/**
* The modern revisions this dispatcher answers for.
*
* @return list<ProtocolVersion>
*/
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 @@ final class StatelessProtocol
);
}
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 @@ final class StatelessProtocol
*/
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);
}
}