[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
5 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
|
||||
{
|
||||
|
|
|
|||
229
tests/Unit/Server/InputRequiredShimTest.php
Normal file
229
tests/Unit/Server/InputRequiredShimTest.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Server;
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\CallToolRequest;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\InputRequiredShim;
|
||||
use Mcp\Server\Session\InMemorySessionStore;
|
||||
use Mcp\Server\Session\Session;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
use Mcp\Server\Stateless\InputContext;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The shim that serves a modern-era handler to a handshake-era client.
|
||||
*
|
||||
* Driven at the fiber boundary the way the transport drives it: the shim
|
||||
* suspends to send each embedded request, and the test resumes it with the
|
||||
* client's answer.
|
||||
*/
|
||||
final class InputRequiredShimTest extends TestCase
|
||||
{
|
||||
#[TestDox('a handler that asks for nothing is handed straight back')]
|
||||
public function testPassesThroughAResultThatIsNotAnAsk(): void
|
||||
{
|
||||
$handler = self::handler(static fn (): Response => new Response(1, new CallToolResult([new TextContent('done')])));
|
||||
$expected = $handler->handle(self::request(), self::session());
|
||||
|
||||
$answer = $this->drive($handler, $expected, self::session());
|
||||
|
||||
$this->assertSame('done', self::text($answer));
|
||||
}
|
||||
|
||||
#[TestDox('an ask is sent to the client and the handler re-entered with the answer')]
|
||||
public function testOneRoundTrip(): void
|
||||
{
|
||||
$entries = 0;
|
||||
$handler = self::handler(static function (Request $request, SessionInterface $session) use (&$entries): Response {
|
||||
++$entries;
|
||||
$answer = self::inputContext($session)?->elicitResult('who');
|
||||
|
||||
if (null === $answer) {
|
||||
return new Response(1, new InputRequiredResult(['who' => self::ask()]));
|
||||
}
|
||||
|
||||
return new Response(1, new CallToolResult([new TextContent('Hello, '.($answer->content['name'] ?? '?').'!')]));
|
||||
});
|
||||
|
||||
$answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [
|
||||
new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']]),
|
||||
]);
|
||||
|
||||
$this->assertSame('Hello, Ada!', self::text($answer));
|
||||
$this->assertSame(2, $entries, 're-entry is re-execution: the handler runs once per round');
|
||||
}
|
||||
|
||||
#[TestDox('a client that declines is an answer, and the handler decides what it means')]
|
||||
public function testDeclineReachesTheHandler(): void
|
||||
{
|
||||
$handler = self::handler(static function (Request $request, SessionInterface $session): Response {
|
||||
$answer = self::inputContext($session)?->elicitResult('who');
|
||||
|
||||
if (null === $answer) {
|
||||
return new Response(1, new InputRequiredResult(['who' => self::ask()]));
|
||||
}
|
||||
|
||||
return new Response(1, new CallToolResult([new TextContent($answer->isDeclined() ? 'declined' : 'accepted')]));
|
||||
});
|
||||
|
||||
$answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [
|
||||
new Response(7, ['action' => 'decline']),
|
||||
]);
|
||||
|
||||
$this->assertSame('declined', self::text($answer));
|
||||
}
|
||||
|
||||
#[TestDox('a handler that never stops asking is failed rather than looped forever')]
|
||||
public function testRoundLimit(): void
|
||||
{
|
||||
$handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()])));
|
||||
|
||||
$answer = $this->drive(
|
||||
$handler,
|
||||
$handler->handle(self::request(), $session = self::session()),
|
||||
$session,
|
||||
array_fill(0, 3, new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']])),
|
||||
new InputRequiredShim(maxRounds: 2),
|
||||
);
|
||||
|
||||
$this->assertInstanceOf(Error::class, $answer);
|
||||
$this->assertStringContainsString('more than 2 times', $answer->message);
|
||||
}
|
||||
|
||||
#[TestDox('an ask the client never declared it could answer is refused, and nothing is sent')]
|
||||
public function testUndeclaredCapabilityIsRefused(): void
|
||||
{
|
||||
$handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()])));
|
||||
|
||||
// No `elicitation` in the session's declared capabilities.
|
||||
$session = self::session(declares: []);
|
||||
|
||||
$answer = $this->drive($handler, $handler->handle(self::request(), $session), $session);
|
||||
|
||||
$this->assertInstanceOf(Error::class, $answer);
|
||||
$this->assertSame(-32021, $answer->jsonSerialize()['error']['code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the shim the way the transport does: inside a fiber, answering each
|
||||
* suspension with the next queued client response.
|
||||
*
|
||||
* @param RequestHandlerInterface<mixed> $handler
|
||||
* @param Response<mixed>|Error $first
|
||||
* @param list<Response<mixed>|Error> $answers
|
||||
*
|
||||
* @return Response<mixed>|Error
|
||||
*/
|
||||
private function drive(
|
||||
RequestHandlerInterface $handler,
|
||||
Response|Error $first,
|
||||
SessionInterface $session,
|
||||
array $answers = [],
|
||||
?InputRequiredShim $shim = null,
|
||||
): Response|Error {
|
||||
$shim ??= new InputRequiredShim();
|
||||
$request = self::request();
|
||||
|
||||
$fiber = new \Fiber(static fn (): Response|Error => $shim->fulfill($first, $handler, $request, $session, null));
|
||||
|
||||
$suspended = $fiber->start();
|
||||
|
||||
while ($fiber->isSuspended()) {
|
||||
$this->assertIsArray($suspended);
|
||||
$this->assertSame('request', $suspended['type'], 'the shim only ever suspends to send a client request');
|
||||
$this->assertNotEmpty($answers, 'the shim sent more requests than the test queued answers for');
|
||||
|
||||
$suspended = $fiber->resume(array_shift($answers));
|
||||
}
|
||||
|
||||
/** @var Response<mixed>|Error $return */
|
||||
$return = $fiber->getReturn();
|
||||
|
||||
return $return;
|
||||
}
|
||||
|
||||
private static function ask(): ElicitRequest
|
||||
{
|
||||
return new ElicitRequest('Who?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']));
|
||||
}
|
||||
|
||||
private static function request(): CallToolRequest
|
||||
{
|
||||
return (new CallToolRequest('greet', []))->withId(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $declares
|
||||
*/
|
||||
private static function session(array $declares = ['elicitation' => []]): SessionInterface
|
||||
{
|
||||
$session = new Session(new InMemorySessionStore());
|
||||
$session->set('client_capabilities', $declares);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
private static function inputContext(SessionInterface $session): ?InputContext
|
||||
{
|
||||
$context = $session->get(InputContext::class);
|
||||
|
||||
return $context instanceof InputContext ? $context : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure(Request, SessionInterface): (Response<mixed>|Error) $handle
|
||||
*
|
||||
* @return RequestHandlerInterface<mixed>
|
||||
*/
|
||||
private static function handler(\Closure $handle): RequestHandlerInterface
|
||||
{
|
||||
return new class($handle) implements RequestHandlerInterface {
|
||||
public function __construct(private readonly \Closure $handle)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||
{
|
||||
return ($this->handle)($request, $session);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Response<mixed>|Error $result
|
||||
*/
|
||||
private static function text(Response|Error $result): string
|
||||
{
|
||||
self::assertInstanceOf(Response::class, $result);
|
||||
self::assertInstanceOf(CallToolResult::class, $result->result);
|
||||
$first = $result->result->content[0] ?? null;
|
||||
self::assertInstanceOf(TextContent::class, $first);
|
||||
|
||||
return $first->text;
|
||||
}
|
||||
}
|
||||
280
tests/Unit/Server/Transport/DualEraRoutingTest.php
Normal file
280
tests/Unit/Server/Transport/DualEraRoutingTest.php
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Server\Transport;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* One endpoint, both protocol eras.
|
||||
*
|
||||
* The same server object, the same transport class and the same URL answer a
|
||||
* handshake-era client and a modern-era one — which is the whole point of
|
||||
* classifying the request instead of asking the client to pick an endpoint.
|
||||
*/
|
||||
final class DualEraRoutingTest extends TestCase
|
||||
{
|
||||
private Psr17Factory $factory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->factory = new Psr17Factory();
|
||||
}
|
||||
|
||||
#[TestDox('the initialize handshake is answered on the endpoint that also serves the modern era')]
|
||||
public function testHandshakeStillWorks(): void
|
||||
{
|
||||
$answer = $this->post($this->server(), $this->handshake());
|
||||
|
||||
$this->assertSame(200, $answer['status']);
|
||||
$this->assertSame(ProtocolVersion::V2025_11_25->value, $answer['body']['result']['protocolVersion']);
|
||||
}
|
||||
|
||||
#[TestDox('server/discover is answered on that same endpoint, with no handshake before it')]
|
||||
public function testDiscoverOnTheSameEndpoint(): void
|
||||
{
|
||||
$answer = $this->post($this->server(), $this->enveloped('server/discover'), [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'server/discover',
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $answer['status']);
|
||||
$this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['result']['supportedVersions']);
|
||||
}
|
||||
|
||||
#[TestDox('one tool answers both eras, from one registry')]
|
||||
public function testOneToolServesBothEras(): void
|
||||
{
|
||||
$server = $this->server();
|
||||
|
||||
$handshake = $this->post($server, $this->handshake());
|
||||
$session = $handshake['session'];
|
||||
$this->assertNotSame('', $session, 'the handshake leg still mints a session');
|
||||
|
||||
$legacy = $this->post($server, json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 2,
|
||||
'method' => 'tools/call',
|
||||
'params' => ['name' => 'echo_tool', 'arguments' => ['text' => 'legacy']],
|
||||
], \JSON_THROW_ON_ERROR), [
|
||||
'Mcp-Session-Id' => $session,
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value,
|
||||
]);
|
||||
|
||||
$modern = $this->post($server, $this->enveloped('tools/call', [
|
||||
'name' => 'echo_tool',
|
||||
'arguments' => ['text' => 'modern'],
|
||||
]), [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'tools/call',
|
||||
'Mcp-Name' => 'echo_tool',
|
||||
]);
|
||||
|
||||
$this->assertSame('echo:legacy', $legacy['body']['result']['content'][0]['text']);
|
||||
$this->assertSame('echo:modern', $modern['body']['result']['content'][0]['text']);
|
||||
|
||||
// The modern answer carries the wire fields its revision adds; the
|
||||
// handshake one does not. Same handler, two codecs.
|
||||
$this->assertSame('complete', $modern['body']['result']['resultType']);
|
||||
$this->assertArrayNotHasKey('resultType', $legacy['body']['result']);
|
||||
}
|
||||
|
||||
#[TestDox('a modern claim contradicted by the header is refused before either leg sees it')]
|
||||
public function testHeaderContradictingTheClaimIsRefused(): void
|
||||
{
|
||||
$answer = $this->post($this->server(), $this->enveloped('tools/list'), [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2025_11_25->value,
|
||||
]);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(-32020, $answer['body']['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('a modern header the body does not back up is refused, naming the member it wants')]
|
||||
public function testModernHeaderWithoutAnEnvelopeIsRefused(): void
|
||||
{
|
||||
$answer = $this->post($this->server(), json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/list',
|
||||
'params' => [],
|
||||
], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value]);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(-32602, $answer['body']['error']['code']);
|
||||
$this->assertStringContainsString(RequestMeta::PROTOCOL_VERSION, $answer['body']['error']['message']);
|
||||
}
|
||||
|
||||
#[TestDox('the version middleware lets a modern header through instead of turning it away')]
|
||||
public function testVersionMiddlewareAcceptsModernRevisions(): void
|
||||
{
|
||||
$answer = $this->post($this->server(), $this->enveloped('server/discover'), [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'server/discover',
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $answer['status']);
|
||||
}
|
||||
|
||||
#[TestDox('an unknown header with no claim behind it is refused by the handshake leg, offering its revisions')]
|
||||
public function testUnknownVersionHeaderIsRefused(): void
|
||||
{
|
||||
$answer = $this->post($this->server(), json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/list',
|
||||
], \JSON_THROW_ON_ERROR), ['MCP-Protocol-Version' => '2030-01-01']);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(-32022, $answer['body']['error']['code']);
|
||||
$this->assertSame(
|
||||
array_map(static fn (ProtocolVersion $v): string => $v->value, ProtocolVersion::handshakeVersions()),
|
||||
$answer['body']['error']['data']['supported'],
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('an unknown revision claimed in the envelope is answered by the modern leg, offering its own')]
|
||||
public function testUnknownClaimReachesTheModernLeg(): void
|
||||
{
|
||||
$body = json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/list',
|
||||
'params' => ['_meta' => [
|
||||
RequestMeta::PROTOCOL_VERSION => '2099-01-01',
|
||||
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
||||
]],
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
|
||||
$answer = $this->post($this->server(), $body, ['MCP-Protocol-Version' => '2099-01-01']);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(-32022, $answer['body']['error']['code']);
|
||||
// The claim named the envelope mechanism, so the answer names the
|
||||
// revisions that mechanism has — not the handshake ones it cannot use.
|
||||
$this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']);
|
||||
}
|
||||
|
||||
#[TestDox('a server built without the modern era refuses a modern claim, naming what it does serve')]
|
||||
public function testHandshakeOnlyServerRefusesModernTraffic(): void
|
||||
{
|
||||
$answer = $this->post($this->server(modern: false), $this->enveloped('server/discover'), [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'server/discover',
|
||||
]);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(-32022, $answer['body']['error']['code']);
|
||||
$this->assertNotContains(ProtocolVersion::V2026_07_28->value, $answer['body']['error']['data']['supported']);
|
||||
}
|
||||
|
||||
#[TestDox('a DELETE still ends a handshake-era session')]
|
||||
public function testDeleteReachesTheHandshakeLeg(): void
|
||||
{
|
||||
$server = $this->server();
|
||||
$session = $this->post($server, $this->handshake())['session'];
|
||||
|
||||
$request = $this->factory->createServerRequest('DELETE', 'http://localhost/')
|
||||
->withHeader('Host', 'localhost')
|
||||
->withHeader('Mcp-Session-Id', $session);
|
||||
|
||||
$response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory));
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
private function server(bool $modern = true): Server
|
||||
{
|
||||
$builder = Server::builder()
|
||||
->setServerInfo('dual-era-server', '1.0.0')
|
||||
->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument');
|
||||
|
||||
if (!$modern) {
|
||||
$builder->withoutModernEra();
|
||||
}
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
|
||||
private function handshake(): string
|
||||
{
|
||||
return json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'initialize',
|
||||
'params' => [
|
||||
'protocolVersion' => ProtocolVersion::V2025_11_25->value,
|
||||
'capabilities' => new \stdClass(),
|
||||
'clientInfo' => ['name' => 'handshake-client', 'version' => '1.0.0'],
|
||||
],
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function enveloped(string $method, array $params = []): string
|
||||
{
|
||||
$params['_meta'] = [
|
||||
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
||||
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
||||
];
|
||||
|
||||
return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*
|
||||
* @return array{status: int, session: string, body: array<string, mixed>}
|
||||
*/
|
||||
private function post(Server $server, string $body, array $headers = []): array
|
||||
{
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
|
||||
->withHeader('Host', 'localhost')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withHeader('Accept', 'application/json, text/event-stream')
|
||||
->withBody($this->factory->createStream($body));
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
$request = $request->withHeader($name, $value);
|
||||
}
|
||||
|
||||
$response = $server->run(new StreamableHttpTransport($request, $this->factory, $this->factory));
|
||||
|
||||
return [
|
||||
'status' => $response->getStatusCode(),
|
||||
'session' => $response->getHeaderLine('Mcp-Session-Id'),
|
||||
'body' => self::decode($response),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function decode(ResponseInterface $response): array
|
||||
{
|
||||
$payload = (string) $response->getBody();
|
||||
|
||||
if ('' === $payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode($payload, true, flags: \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +147,23 @@ final class StreamableHttpTransportTest extends TestCase
|
|||
$this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
|
||||
}
|
||||
|
||||
#[TestDox('a custom middleware list carrying ProtocolVersionMiddleware warns that it will reject the modern era')]
|
||||
public function testCustomMiddlewareWithProtocolVersionMiddlewareWarns(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('ProtocolVersionMiddleware'));
|
||||
|
||||
new StreamableHttpTransport(
|
||||
$this->factory->createServerRequest('POST', 'http://localhost/')->withHeader('Host', 'localhost'),
|
||||
$this->factory,
|
||||
$this->factory,
|
||||
$logger,
|
||||
[new CorsMiddleware(), new DnsRebindingProtectionMiddleware(), new ProtocolVersionMiddleware()],
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('null middleware does not trigger the empty-list warning')]
|
||||
public function testNullMiddlewareDoesNotWarn(): void
|
||||
{
|
||||
|
|
|
|||
254
tests/Unit/Server/Wire/InboundClassifierTest.php
Normal file
254
tests/Unit/Server/Wire/InboundClassifierTest.php
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Server\Wire;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Mcp\Server\Wire\InboundClassifier;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The era decision one endpoint serving both lifecycles makes per request.
|
||||
*
|
||||
* Written as a truth table because that is what it is: the claim, the header
|
||||
* and the HTTP method are the only evidence, and every combination has to land
|
||||
* somewhere deliberate rather than by accident.
|
||||
*/
|
||||
final class InboundClassifierTest extends TestCase
|
||||
{
|
||||
private const VERSION_HEADER = 'MCP-Protocol-Version';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
#[DataProvider('provideLegacyTraffic')]
|
||||
#[TestDox('$_dataName is handshake-era traffic')]
|
||||
public function testClassifiesAsLegacy(string $method, ?string $body, array $headers = []): void
|
||||
{
|
||||
$classification = (new InboundClassifier())->classify($method, $body, $headers);
|
||||
|
||||
$this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection');
|
||||
$this->assertFalse($classification->modern);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string|null, 2?: array<string, string>}>
|
||||
*/
|
||||
public static function provideLegacyTraffic(): iterable
|
||||
{
|
||||
yield 'the initialize handshake' => ['POST', self::message('initialize')];
|
||||
|
||||
yield 'a request with no envelope claim' => ['POST', self::message('tools/list')];
|
||||
|
||||
yield 'a notification with no envelope claim' => ['POST', self::notification('notifications/initialized')];
|
||||
|
||||
yield 'a GET, which the modern era has no use for' => ['GET', null];
|
||||
|
||||
yield 'a DELETE ending a session' => ['DELETE', null];
|
||||
|
||||
yield 'a claim naming a handshake revision' => [
|
||||
'POST',
|
||||
self::message('tools/list', ProtocolVersion::V2025_11_25->value),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value],
|
||||
];
|
||||
|
||||
yield 'a header naming a handshake revision' => [
|
||||
'POST',
|
||||
self::message('tools/list'),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value],
|
||||
];
|
||||
|
||||
// The endpoint's version middleware is what answers this, naming every
|
||||
// revision the endpoint serves. Routing it modern would answer with the
|
||||
// modern leg's shorter list instead.
|
||||
yield 'an unknown header with nothing in the body to back it' => [
|
||||
'POST',
|
||||
self::message('tools/list'),
|
||||
[self::VERSION_HEADER => '2030-01-01'],
|
||||
];
|
||||
|
||||
yield 'a batch of handshake-era messages' => [
|
||||
'POST',
|
||||
'['.self::message('tools/list').','.self::message('prompts/list').']',
|
||||
];
|
||||
|
||||
yield 'an empty body' => ['POST', ''];
|
||||
|
||||
yield 'a body that is not JSON' => ['POST', 'not json at all'];
|
||||
|
||||
yield 'a JSON body that is not an object' => ['POST', '"a string"'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
#[DataProvider('provideModernTraffic')]
|
||||
#[TestDox('$_dataName is modern-era traffic')]
|
||||
public function testClassifiesAsModern(string $body, array $headers, string $expectedVersion): void
|
||||
{
|
||||
$classification = (new InboundClassifier())->classify('POST', $body, $headers);
|
||||
|
||||
$this->assertFalse($classification->isRejected(), 'expected a routing decision, got a rejection');
|
||||
$this->assertTrue($classification->modern);
|
||||
$this->assertSame($expectedVersion, $classification->claimedVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, array<string, string>, string}>
|
||||
*/
|
||||
public static function provideModernTraffic(): iterable
|
||||
{
|
||||
yield 'a request claiming the modern revision' => [
|
||||
self::message('tools/list', ProtocolVersion::V2026_07_28->value),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value],
|
||||
ProtocolVersion::V2026_07_28->value,
|
||||
];
|
||||
|
||||
// The header is a cross-check, not the decision: a claim on its own is
|
||||
// enough evidence, and the leg it routes to is what says the header was
|
||||
// required.
|
||||
yield 'a claim with no header at all' => [
|
||||
self::message('server/discover', ProtocolVersion::V2026_07_28->value),
|
||||
[],
|
||||
ProtocolVersion::V2026_07_28->value,
|
||||
];
|
||||
|
||||
// Routed rather than refused so the answer can name what this endpoint
|
||||
// does serve, which only the modern leg knows.
|
||||
yield 'a claim naming a revision this SDK has never heard of' => [
|
||||
self::message('tools/list', '2099-01-01'),
|
||||
[self::VERSION_HEADER => '2099-01-01'],
|
||||
'2099-01-01',
|
||||
];
|
||||
|
||||
// `initialize` is handshake-era by definition — but a claim outranks the
|
||||
// method name, and the modern leg answers it with method-not-found.
|
||||
yield 'an initialize carrying a modern claim' => [
|
||||
self::message('initialize', ProtocolVersion::V2026_07_28->value),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value],
|
||||
ProtocolVersion::V2026_07_28->value,
|
||||
];
|
||||
|
||||
// A notification has no claim of its own under this revision, so the
|
||||
// header is all the evidence there is.
|
||||
yield 'a notification under a modern header' => [
|
||||
self::notification('notifications/progress'),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value],
|
||||
ProtocolVersion::V2026_07_28->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
#[DataProvider('provideRejectedTraffic')]
|
||||
#[TestDox('$_dataName is refused at the edge')]
|
||||
public function testRejects(string $body, array $headers, int $code, int $status): void
|
||||
{
|
||||
$classification = (new InboundClassifier())->classify('POST', $body, $headers);
|
||||
|
||||
$this->assertTrue($classification->isRejected());
|
||||
$this->assertSame($code, $classification->error?->jsonSerialize()['error']['code']);
|
||||
$this->assertSame($status, $classification->httpStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, array<string, string>, int, int}>
|
||||
*/
|
||||
public static function provideRejectedTraffic(): iterable
|
||||
{
|
||||
yield 'a header contradicting the claim' => [
|
||||
self::message('tools/list', ProtocolVersion::V2026_07_28->value),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2025_11_25->value],
|
||||
-32020,
|
||||
400,
|
||||
];
|
||||
|
||||
yield 'a handshake claim contradicted by a modern header' => [
|
||||
self::message('tools/list', ProtocolVersion::V2025_11_25->value),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value],
|
||||
-32020,
|
||||
400,
|
||||
];
|
||||
|
||||
yield 'a modern header on a request carrying no envelope' => [
|
||||
self::message('tools/list'),
|
||||
[self::VERSION_HEADER => ProtocolVersion::V2026_07_28->value],
|
||||
-32602,
|
||||
400,
|
||||
];
|
||||
|
||||
yield 'a claim that is not a string' => [
|
||||
'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"'.RequestMeta::PROTOCOL_VERSION.'":42}}}',
|
||||
[],
|
||||
-32602,
|
||||
400,
|
||||
];
|
||||
|
||||
yield 'a batch holding a modern claim' => [
|
||||
'['.self::message('tools/list').','.self::message('prompts/list', ProtocolVersion::V2026_07_28->value).']',
|
||||
[],
|
||||
-32600,
|
||||
400,
|
||||
];
|
||||
}
|
||||
|
||||
#[TestDox('the header cross-check is case-insensitive, as HTTP field names are')]
|
||||
public function testHeaderLookupIgnoresCase(): void
|
||||
{
|
||||
$classification = (new InboundClassifier())->classify(
|
||||
'POST',
|
||||
self::message('tools/list', ProtocolVersion::V2026_07_28->value),
|
||||
['mcp-protocol-version' => ProtocolVersion::V2025_11_25->value],
|
||||
);
|
||||
|
||||
$this->assertTrue($classification->isRejected());
|
||||
}
|
||||
|
||||
#[TestDox('an empty header value counts as no header, not as a contradiction')]
|
||||
public function testEmptyHeaderIsAbsent(): void
|
||||
{
|
||||
$classification = (new InboundClassifier())->classify(
|
||||
'POST',
|
||||
self::message('tools/list', ProtocolVersion::V2026_07_28->value),
|
||||
[self::VERSION_HEADER => ''],
|
||||
);
|
||||
|
||||
$this->assertFalse($classification->isRejected());
|
||||
$this->assertTrue($classification->modern);
|
||||
}
|
||||
|
||||
#[TestDox('the shared cross-check reports only a genuine disagreement')]
|
||||
public function testCrossCheckVersion(): void
|
||||
{
|
||||
$this->assertNull(InboundClassifier::crossCheckVersion(null, '2026-07-28'));
|
||||
$this->assertNull(InboundClassifier::crossCheckVersion('2026-07-28', '2026-07-28'));
|
||||
$this->assertNotNull(InboundClassifier::crossCheckVersion('2025-11-25', '2026-07-28'));
|
||||
}
|
||||
|
||||
private static function message(string $method, ?string $claim = null): string
|
||||
{
|
||||
$params = null === $claim ? [] : ['_meta' => [
|
||||
RequestMeta::PROTOCOL_VERSION => $claim,
|
||||
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
||||
]];
|
||||
|
||||
return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private static function notification(string $method): string
|
||||
{
|
||||
return json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => []], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue