[Server][Tests] Serve the modern (SEP-2575) lifecycle (#444)

* [Server][Tests] Serve the modern (SEP-2575) lifecycle

The revision removed the initialize handshake: every request carries its own
_meta - protocol version, client capabilities, client info - and answers
server/discover for what initialize used to return. There is no session to
keep, so there is nothing for the handshake-era dispatcher to do.

Builder::buildStateless() produces StatelessProtocol, and
StatelessHttpTransport serves it over one POST-only endpoint - GET and
DELETE are 405, and no Mcp-Session-Id exists to hand out.

Results go through a per-era wire codec rather than the handlers, so
resultType and the caching hints are stamped for 2026-07-28 and left off the
revisions that do not define them.

Covered by the modern conformance suite (make conformance-draft-server)
against its own baseline; unit coverage arrives with the fixes stacked on
this.

* Apply suggestions from code review

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>

* Fix critical stateless-lifecycle bugs from review

- Don't leak internal exception messages to clients on uncaught
  \Throwable; use a generic message like the handshake-era protocol
  and every built-in handler already do.
- Default a missing/invalid request id to null instead of "", and
  stop answering notifications and malformed messages as if they
  were "method not found" 404s.
- Read the POST body in a loop instead of a single read() call,
  which PSR-7 allows to return short.
This commit is contained in:
Christopher Hertel 2026-08-18 22:08:54 +02:00 • committed by GitHub
commit 3d596ca75b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1735 additions and 16 deletions

View file

@ -2,7 +2,9 @@ services:
nginx:
image: nginx:1.26-alpine
ports:
- "8000:80"
# Overridable so the fixture can coexist with whatever else a developer
# already has on 8000; CI and the Makefile default keep using 8000.
- "${CONFORMANCE_PORT:-8000}:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../../..:/app:ro

View file

@ -3,6 +3,14 @@ server {
server_name localhost;
root /app;
# The two lifecycles are served side by side so one conformance run can
# target either without restarting the fixture: / speaks the handshake era
# (2025-11-25 and earlier), /stateless speaks the modern era (SEP-2575).
# Declared before `location /` so the prefix match wins.
location /stateless {
try_files $uri /tests/Conformance/server-stateless.php$is_args$args;
}
location / {
try_files $uri /tests/Conformance/server.php$is_args$args;
}

View file

@ -0,0 +1,71 @@
# Expected conformance failures for spec revision 2026-07-28.
#
# Separate from conformance-baseline.yml because the two revisions exercise
# different lifecycles against different endpoints: the dated baseline covers
# the handshake era served at /, this one covers the modern (SEP-2575) era
# served at /stateless.
#
# Entries are per-check (scenario:check-id) rather than whole scenarios, so a
# scenario that is mostly conformant keeps reporting the parts that work and a
# new regression inside it still fails the run.
#
# Run with:
# make conformance-draft-server
#
# Absent by design, because they pass in full: server-stateless (the SEP-2575
# lifecycle), caching (SEP-2549 hints), and every wire-schema-valid check bar
# one noted below.
server:
# --- SEP-2243: standard HTTP headers ---------------------------------------
# Mcp-Method / Mcp-Name validation and the X-MCP-* parameter rules are not
# implemented. Self-contained and the largest remaining block; a good next
# chunk after MRTR.
- http-header-validation:sep-2243-server-reject-invalid-headers
- http-custom-header-server-validation:sep-2243-server-no-xmcp-tool
- http-custom-header-server-validation:sep-2243-server-decode-base64
- http-custom-header-server-validation:sep-2243-server-validate-param-match
- http-custom-header-server-validation:sep-2243-server-reject-invalid-param-chars
- http-custom-header-server-validation:sep-2243-server-reject-param-mismatch
# --- SEP-2322: the MRTR retry half -----------------------------------------
# InputRequiredResult exists and tools/call can answer with one — that is what
# makes server-stateless green. What is missing is the second round trip: the
# SDK does not read inputResponses, verify requestState integrity, or resume a
# call from it. Until it does, every scenario that retries fails at the retry.
- input-required-result-basic-elicitation:sep-2322-elicitation-incomplete
- input-required-result-basic-list-roots:sep-2322-list-roots-incomplete
- input-required-result-basic-sampling:sep-2322-sampling-incomplete
- input-required-result-capability-check:sep-2322-respect-client-capabilities
- input-required-result-multi-round:sep-2322-multi-round-r1
- input-required-result-multiple-input-requests:sep-2322-multiple-inputs-incomplete
- input-required-result-non-tool-request:sep-2322-non-tool-incomplete
- input-required-result-request-state:sep-2322-request-state-incomplete
- input-required-result-result-type:sep-2322-result-type-included
- input-required-result-tampered-state:sep-2322-reject-tampered-state
# --- SHOULD-level warnings -------------------------------------------------
# These report WARNING rather than FAILURE (the severity follows the spec
# keyword), so the run summary counts them as passed — but the baseline
# evaluator still wants them declared, and declaring them is right: each is a
# SHOULD the SDK does not yet honour.
- http-header-validation:sep-2243-server-reject-error-code
- input-required-result-ignore-extra-params:sep-2322-ignore-unexpected-params
- input-required-result-missing-input-response:sep-2322-missing-response-rerequests
- sep-2164-resource-not-found:sep-2164-data-uri
- sep-2164-resource-not-found:sep-2164-error-code
# --- Pre-existing, not lifecycle-specific ----------------------------------
# Each of these fails identically on the handshake endpoint at /, so none is
# caused by (or fixable in) the modern lifecycle. Listed so the draft run is
# honest, not to excuse them — resources-templates-read in particular is the
# single wire-schema failure left, a resource-template URI the fixture emits
# in a shape the schema rejects on both revisions.
- dns-rebinding-protection:localhost-host-rebinding-rejected
- json-schema-2020-12:json-schema-2020-12-tool-found
- prompts-get-embedded-resource:prompts-get-embedded-resource
- prompts-get-with-image:prompts-get-with-image
- resources-templates-read:wire-schema-valid
- tools-call-with-progress:tools-call-with-progress
client: []

View file

@ -0,0 +1,107 @@
<?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 is
* deliberately the same as server.php's where the two eras overlap, so that a
* difference in conformance results points at the lifecycle rather than at the
* fixtures having drifted apart.
*/
ini_set('display_errors', '0');
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
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\ProtocolVersion;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server;
use Mcp\Server\Transport\StatelessHttpTransport;
use Mcp\Tests\Conformance\Elements;
use Mcp\Tests\Conformance\FileLogger;
chdir(__DIR__);
$logger = new FileLogger(__DIR__.'/logs/conformance-stateless.log', true);
$psr17Factory = new Psr17Factory();
$request = $psr17Factory->createServerRequestFromGlobals();
$protocol = Server::builder()
->setServerInfo('mcp-conformance-test-server', '1.0.0')
->setLogger($logger)
// 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: the tool needs to call back into the client,
// which a client that never declared `sampling` cannot service.
->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',
)
// Asks for input the MRTR way: the ask travels back inside the result, so
// the response stream never carries a server-initiated 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',
)
// The logger writes to the fixture's log file, never to the wire: a modern
// server emits notifications/message only when the request asked for a log
// level, and this one deliberately does not.
->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',
)
// 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')
->buildStateless([ProtocolVersion::V2026_07_28]);
$transport = new StatelessHttpTransport($protocol, logger: $logger);
(new SapiEmitter())->emit($transport->handle($request));