[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:
parent
b97389325f
commit
cbceef06eb
1 changed files with 2060 additions and 323 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue