[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
11 changed files with 2060 additions and 323 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
server:
|
||||
- json-schema-2020-12
|
||||
|
||||
client:
|
||||
- elicitation-sep1034-client-defaults
|
||||
- sse-retry
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
server:
|
||||
- json-schema-2020-12
|
||||
|
||||
client:
|
||||
- request-metadata
|
||||
- http-standard-headers
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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