[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

@ -18,6 +18,7 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface;
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\CallToolHandler;
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 @@ final class BuilderTest extends TestCase
$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
{