[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
11 changed files with 2060 additions and 323 deletions

View file

@ -34,6 +34,54 @@ final class Elements
public const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';
/**
* The focal `inputSchema` the `json-schema-2020-12` scenario expects a
* server to advertise verbatim.
*
* It is handed to `addTool()` raw rather than generated from a signature,
* because the point is that the SDK passes an author-supplied 2020-12
* schema through `tools/list` untouched: `$schema`/`$defs`/
* `additionalProperties` (SEP-1613) and the composition, conditional and
* `$anchor` keywords (SEP-2106) must all survive.
*
* Mirrors `JSON_SCHEMA_2020_12_FIXTURE` in the conformance suite; keep the
* two in sync.
*
* @return array<string, mixed>
*/
public static function jsonSchema2020_12Fixture(): array
{
return [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'$defs' => [
'address' => [
'$anchor' => 'addressDef',
'type' => 'object',
'properties' => [
'street' => ['type' => 'string'],
'city' => ['type' => 'string'],
],
],
],
'properties' => [
'name' => ['type' => 'string'],
'address' => ['$ref' => '#/$defs/address'],
'contactMethod' => ['type' => 'string', 'enum' => ['phone', 'email']],
'phone' => ['type' => 'string'],
'email' => ['type' => 'string'],
],
'allOf' => [['anyOf' => [['required' => ['phone']], ['required' => ['email']]]]],
'if' => [
'properties' => ['contactMethod' => ['const' => 'phone']],
'required' => ['contactMethod'],
],
'then' => ['required' => ['phone']],
'else' => ['required' => ['email']],
'additionalProperties' => false,
];
}
public function toolMultipleTypes(): CallToolResult
{
return new CallToolResult([

View file

@ -3,12 +3,10 @@ server {
server_name localhost;
root /app;
# / speaks the handshake era, /stateless the modern one (SEP-2575).
# Declared first so the prefix match wins.
location /stateless {
try_files $uri /tests/Conformance/server-stateless.php$is_args$args;
}
# One entry, both protocol eras: the fixture is built through
# Builder::build(), which carries a dispatcher for each, and the transport
# routes every request to the one it belongs to. Both conformance suites
# run against this same location.
location / {
try_files $uri /tests/Conformance/server.php$is_args$args;
}

View file

@ -1,6 +1,3 @@
server:
- json-schema-2020-12
client:
- elicitation-sep1034-client-defaults
- sse-retry

View file

@ -1,6 +1,3 @@
server:
- json-schema-2020-12
client:
- request-metadata
- http-standard-headers

View file

@ -1,202 +0,0 @@
<?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.
*/
/*
* Conformance fixture for the modern (SEP-2575) lifecycle, served at /stateless
* alongside the handshake-era fixture at /. The element set mirrors
* server.php's where the eras overlap, so a difference in results points at the
* lifecycle rather than at drifted fixtures.
*/
ini_set('display_errors', '0');
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Capability\Registry;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\AudioContent;
use Mcp\Schema\Content\EmbeddedResource;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Enum\CacheScope;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Prompt;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Schema\Tool;
use Mcp\Server;
use Mcp\Server\Subscription\Psr16NotificationBus;
use Mcp\Server\Subscription\PublishingEventDispatcher;
use Mcp\Server\Transport\StatelessHttpTransport;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Tests\Conformance\Elements;
use Mcp\Tests\Conformance\FileLogger;
use Mcp\Tests\Conformance\MrtrElements;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
chdir(__DIR__);
$logger = new FileLogger(__DIR__.'/logs/conformance-stateless.log', true);
$psr17Factory = new Psr17Factory();
$request = $psr17Factory->createServerRequestFromGlobals();
// Explicit rather than builder-built, so the diagnostic hooks below can mutate
// the live registry and the change reaches an open subscription.
//
// Filesystem-backed and not in-memory: under php-fpm the worker holding the
// listen stream open and the worker serving the tools/call that mutates the
// registry are different processes, so the only thing they share is storage.
$bus = new Psr16NotificationBus(
new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')),
logger: $logger,
);
$registry = new Registry(new PublishingEventDispatcher($bus), $logger);
$protocol = Server::builder()
->setServerInfo('mcp-conformance-test-server', '1.0.0')
->setLogger($logger)
->setRegistry($registry)
// Tools
->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response')
->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response')
->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response')
->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response')
->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types')
->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling')
// Exercises the -32021 path.
->addTool(
static function (): never {
throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.');
},
name: 'test_missing_capability',
description: 'Always reports a missing client capability, for testing -32021 handling',
)
// The ask travels back inside the result, never as its own request.
->addTool(
static fn (): InputRequiredResult => new InputRequiredResult(
[
'conformance_probe' => new ElicitRequest(
'Please provide a value for the conformance probe.',
new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']),
),
],
requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)),
),
name: 'test_streaming_elicitation',
description: 'Returns an InputRequiredResult asking for elicitation input',
)
// Logs server-side only; no logLevel was requested, so nothing goes out.
->addTool(
static function () use ($logger): string {
$logger->info('test_logging_tool executed');
return 'Logged.';
},
name: 'test_logging_tool',
description: 'Emits a server-side log message while returning normally',
)
// Mirrors its arguments into Mcp-Param-* headers (SEP-2243).
->addTool(
static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries),
name: 'test_custom_headers',
description: 'Tests custom header mirroring via x-mcp-header',
inputSchema: [
'type' => 'object',
'properties' => [
'region' => ['type' => 'string', 'x-mcp-header' => 'Region'],
'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'],
],
'required' => ['region'],
],
)
->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications')
// Diagnostic hooks the subscription scenarios call to make the lists change
// while a listen stream is open.
->addTool(
static function () use ($registry): string {
$registry->registerTool(
new Tool(
name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)),
title: null,
inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null],
description: 'Registered to trigger a list change',
annotations: null,
),
static fn (): string => 'ephemeral',
);
return 'Tool list mutated.';
},
name: 'test_trigger_tool_change',
description: 'Registers a tool so the tool list changes',
)
->addTool(
static function () use ($registry): string {
$registry->registerPrompt(
new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'),
static fn (): array => [['role' => 'user', 'content' => 'ephemeral']],
);
return 'Prompt list mutated.';
},
name: 'test_trigger_prompt_change',
description: 'Registers a prompt so the prompt list changes',
)
// Multi round-trip request tools (SEP-2322).
->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation')
->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion')
->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots')
->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping')
->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once')
->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds')
->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared')
->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies')
// Resources
->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing')
->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing')
->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json')
// Prompts
->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments')
->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments')
->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource')
->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content')
->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first')
// Fixed so a retry landing on another process still verifies.
->setRequestState(str_repeat('conformance-fixture-key-', 2))
// So a listen stream carries the registry's changes rather than only
// acknowledging. In-memory is right here: the conformance server is one
// FrankenPHP-less php-fpm pool, and the scenarios publish within a request.
->setNotificationBus($bus)
// Short, so a listen stream cannot tie up an fpm worker for the length of
// a whole run.
->setSubscriptionLifetime(5.0)
// Lists are the same for everyone here; a read is not.
->setCachePolicy(
CachePolicy::default(60_000)
->withMethod('tools/list', 3_600_000, CacheScope::Public)
->withMethod('prompts/list', 3_600_000, CacheScope::Public)
->withMethod('resources/list', 3_600_000, CacheScope::Public)
->withMethod('resources/templates/list', 3_600_000, CacheScope::Public)
->withMethod('server/discover', 3_600_000, CacheScope::Public),
)
->buildStateless([ProtocolVersion::V2026_07_28]);
$transport = new StatelessHttpTransport($protocol, logger: $logger);
(new SapiEmitter())->emit($transport->handle($request));

View file

@ -9,22 +9,49 @@
* file that was distributed with this source code.
*/
/*
* The conformance fixture, for both protocol eras, on one endpoint.
*
* A server built through Builder::build() carries a dispatcher for each era and
* StreamableHttpTransport routes every request to the one it belongs to, so
* there is nothing here that a revision has to be told about. The element set
* is the union of what both suites ask for: where they overlap they share the
* registration, so a difference in results points at the lifecycle rather than
* at drifted fixtures.
*/
ini_set('display_errors', '0');
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Capability\Registry;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Content\AudioContent;
use Mcp\Schema\Content\EmbeddedResource;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Enum\CacheScope;
use Mcp\Schema\Prompt;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Schema\Tool;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Subscription\Psr16NotificationBus;
use Mcp\Server\Subscription\PublishingEventDispatcher;
use Mcp\Server\Transport\StreamableHttpTransport;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Tests\Conformance\Elements;
use Mcp\Tests\Conformance\FileLogger;
use Mcp\Tests\Conformance\MrtrElements;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
chdir(__DIR__);
@ -33,25 +60,133 @@ $logger = new FileLogger(__DIR__.'/logs/conformance.log', true);
$psr17Factory = new Psr17Factory();
$request = $psr17Factory->createServerRequestFromGlobals();
$transport = new StreamableHttpTransport($request, logger: $logger);
// Explicit rather than builder-built, so the diagnostic hooks below can mutate
// the live registry and the change reaches an open subscription.
//
// Filesystem-backed and not in-memory: under php-fpm the worker holding the
// listen stream open and the worker serving the tools/call that mutates the
// registry are different processes, so the only thing they share is storage.
$bus = new Psr16NotificationBus(
new Psr16Cache(new FilesystemAdapter('mcp-conformance-notifications', 120, __DIR__.'/sessions')),
logger: $logger,
);
$registry = new Registry(new PublishingEventDispatcher($bus), $logger);
$server = Server::builder()
->setServerInfo('mcp-conformance-test-server', '1.0.0')
->setSession(new FileSessionStore(__DIR__.'/sessions'))
->setLogger($logger)
->setRegistry($registry)
// Only the handshake leg keeps one; the modern leg is sessionless either way.
->setSession(new FileSessionStore(__DIR__.'/sessions'))
// Tools
->addTool(static fn () => 'This is a simple text response for testing.', name: 'test_simple_text', description: 'Tests simple text content response')
->addTool(static fn () => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), name: 'test_image_content', description: 'Tests image content response')
->addTool(static fn () => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), name: 'test_audio_content', description: 'Tests audio content response')
->addTool(static fn () => EmbeddedResource::fromText('test://embedded-resource', 'This is an embedded resource content.'), name: 'test_embedded_resource', description: 'Tests embedded resource content response')
->addTool([Elements::class, 'toolMultipleTypes'], name: 'test_multiple_content_types', description: 'Tests response with multiple content types')
->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages')
->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications')
->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling')
->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), name: 'test_error_handling', description: 'Tests error response handling')
->addTool(
static fn () => 'ok',
name: 'json_schema_2020_12_tool',
description: 'Tool with JSON Schema 2020-12 features',
inputSchema: Elements::jsonSchema2020_12Fixture(),
)
// Exercises the -32021 path.
->addTool(
static function (): never {
throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, sampling: true), 'test_missing_capability requires the sampling capability.');
},
name: 'test_missing_capability',
description: 'Always reports a missing client capability, for testing -32021 handling',
)
// The ask travels back inside the result, never as its own request.
->addTool(
static fn (): InputRequiredResult => new InputRequiredResult(
[
'conformance_probe' => new ElicitRequest(
'Please provide a value for the conformance probe.',
new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']),
),
],
requestState: base64_encode(json_encode(['tool' => 'test_streaming_elicitation'], \JSON_THROW_ON_ERROR)),
),
name: 'test_streaming_elicitation',
description: 'Returns an InputRequiredResult asking for elicitation input',
)
// Logs server-side only; no logLevel was requested, so nothing goes out.
->addTool(
static function () use ($logger): string {
$logger->info('test_logging_tool executed');
return 'Logged.';
},
name: 'test_logging_tool',
description: 'Emits a server-side log message while returning normally',
)
// Mirrors its arguments into Mcp-Param-* headers (SEP-2243).
->addTool(
static fn (string $region = '', int $retries = 0): string => sprintf('region=%s retries=%d', $region, $retries),
name: 'test_custom_headers',
description: 'Tests custom header mirroring via x-mcp-header',
inputSchema: [
'type' => 'object',
'properties' => [
'region' => ['type' => 'string', 'x-mcp-header' => 'Region'],
'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'],
],
'required' => ['region'],
],
)
->addTool([Elements::class, 'toolWithProgress'], name: 'test_tool_with_progress', description: 'Tests tool that reports progress notifications')
// Handshake-era elements. The scenarios that drive them are 2025-11-25
// ones — server-initiated requests and session-scoped logging both went
// away in 2026-07-28 — but they are registered once, like everything else.
->addTool([Elements::class, 'toolWithLogging'], name: 'test_tool_with_logging', description: 'Tests tool that emits log messages')
->addTool([Elements::class, 'toolWithSampling'], name: 'test_sampling', description: 'Tests server-initiated sampling')
->addTool([Elements::class, 'toolWithElicitation'], name: 'test_elicitation', description: 'Tests server-initiated elicitation')
->addTool([Elements::class, 'toolWithElicitationDefaults'], name: 'test_elicitation_sep1034_defaults', description: 'Tests elicitation with default values')
->addTool([Elements::class, 'toolWithElicitationEnums'], name: 'test_elicitation_sep1330_enums', description: 'Tests elicitation with enum schemas')
// Diagnostic hooks the subscription scenarios call to make the lists change
// while a listen stream is open.
->addTool(
static function () use ($registry): string {
$registry->registerTool(
new Tool(
name: 'test_ephemeral_tool_'.bin2hex(random_bytes(4)),
title: null,
inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null],
description: 'Registered to trigger a list change',
annotations: null,
),
static fn (): string => 'ephemeral',
);
return 'Tool list mutated.';
},
name: 'test_trigger_tool_change',
description: 'Registers a tool so the tool list changes',
)
->addTool(
static function () use ($registry): string {
$registry->registerPrompt(
new Prompt('test_ephemeral_prompt_'.bin2hex(random_bytes(4)), null, 'Registered to trigger a list change'),
static fn (): array => [['role' => 'user', 'content' => 'ephemeral']],
);
return 'Prompt list mutated.';
},
name: 'test_trigger_prompt_change',
description: 'Registers a prompt so the prompt list changes',
)
// Multi round-trip request tools (SEP-2322).
->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_elicitation', description: 'MRTR: asks for a name via elicitation')
->addTool([MrtrElements::class, 'sampling'], name: 'test_input_required_result_sampling', description: 'MRTR: asks for a sampling completion')
->addTool([MrtrElements::class, 'listRoots'], name: 'test_input_required_result_list_roots', description: 'MRTR: asks for the client roots')
->addTool([MrtrElements::class, 'elicitation'], name: 'test_input_required_result_request_state', description: 'MRTR: exercises requestState round-tripping')
->addTool([MrtrElements::class, 'multipleInputs'], name: 'test_input_required_result_multiple_inputs', description: 'MRTR: asks for two inputs at once')
->addTool([MrtrElements::class, 'multiRound'], name: 'test_input_required_result_multi_round', description: 'MRTR: asks across two sequential rounds')
->addTool([MrtrElements::class, 'capabilities'], name: 'test_input_required_result_capabilities', description: 'MRTR: asks only for capabilities the client declared')
->addTool([MrtrElements::class, 'tamperedState'], name: 'test_input_required_result_tampered_state', description: 'MRTR: completes only when the echoed state verifies')
// Resources
->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing')
->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing')
@ -62,8 +197,24 @@ $server = Server::builder()
->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments')
->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource')
->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content')
->addPrompt([MrtrElements::class, 'prompt'], name: 'test_input_required_result_prompt', description: 'MRTR: a prompt that asks for input first')
// Fixed so a retry landing on another process still verifies.
->setRequestState(str_repeat('conformance-fixture-key-', 2))
// So a listen stream carries the registry's changes rather than only
// acknowledging; see $bus above for why it is filesystem-backed.
->setNotificationBus($bus)
// Short, so a listen stream cannot tie up an fpm worker for the length of
// a whole run.
->setSubscriptionLifetime(5.0)
// Lists are the same for everyone here; a read is not.
->setCachePolicy(
CachePolicy::default(60_000)
->withMethod('tools/list', 3_600_000, CacheScope::Public)
->withMethod('prompts/list', 3_600_000, CacheScope::Public)
->withMethod('resources/list', 3_600_000, CacheScope::Public)
->withMethod('resources/templates/list', 3_600_000, CacheScope::Public)
->withMethod('server/discover', 3_600_000, CacheScope::Public),
)
->build();
$response = $server->run($transport);
(new SapiEmitter())->emit($response);
(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request, logger: $logger)));

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
{

View 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;
}
}

View 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);
}
}

View file

@ -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
{

View 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);
}
}