[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:
parent
60711951d4
commit
3d596ca75b
21 changed files with 1735 additions and 16 deletions
38
Makefile
38
Makefile
|
|
@ -1,4 +1,10 @@
|
||||||
.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client docs
|
.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client check-conformance-repo docs
|
||||||
|
|
||||||
|
# The published @modelcontextprotocol/conformance release carries no 2026-07-28
|
||||||
|
# scenarios, so the draft targets below run a local checkout of the conformance
|
||||||
|
# repo instead. Override with `make conformance-draft CONFORMANCE_REPO=/path/to/conformance`.
|
||||||
|
CONFORMANCE_REPO ?= $(CURDIR)/../conformance
|
||||||
|
CONFORMANCE_DRAFT = node $(CONFORMANCE_REPO)/dist/index.js
|
||||||
|
|
||||||
deps-stable:
|
deps-stable:
|
||||||
composer update --prefer-stable
|
composer update --prefer-stable
|
||||||
|
|
@ -31,15 +37,41 @@ conformance-server:
|
||||||
@echo "Waiting for server to start..."
|
@echo "Waiting for server to start..."
|
||||||
@sleep 5
|
@sleep 5
|
||||||
rm -rf tests/Conformance/results
|
rm -rf tests/Conformance/results
|
||||||
cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --output-dir results || true
|
cd tests/Conformance && npx @modelcontextprotocol/conformance server --url http://localhost:8000/ --spec-version 2025-11-25 --output-dir results || true
|
||||||
php tests/Conformance/score.php server
|
php tests/Conformance/score.php server
|
||||||
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
|
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
|
||||||
|
|
||||||
conformance-client:
|
conformance-client:
|
||||||
rm -rf tests/Conformance/results
|
rm -rf tests/Conformance/results
|
||||||
cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --expected-failures conformance-baseline.yml --output-dir results || true
|
cd tests/Conformance && npx @modelcontextprotocol/conformance client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2025-11-25 --expected-failures conformance-baseline.yml --output-dir results || true
|
||||||
php tests/Conformance/score.php client
|
php tests/Conformance/score.php client
|
||||||
|
|
||||||
|
# --- 2026-07-28 (SEP-2575 stateless lifecycle) ------------------------------
|
||||||
|
# Local-checkout only for now; not wired into CI until upstream publishes a
|
||||||
|
# release carrying the draft scenarios.
|
||||||
|
|
||||||
|
conformance-draft: conformance-draft-server conformance-draft-client
|
||||||
|
|
||||||
|
check-conformance-repo:
|
||||||
|
@test -f $(CONFORMANCE_REPO)/dist/index.js || { \
|
||||||
|
echo "No conformance build at $(CONFORMANCE_REPO)/dist/index.js."; \
|
||||||
|
echo "Clone modelcontextprotocol/conformance and run 'npm install && npm run build' there,"; \
|
||||||
|
echo "or point CONFORMANCE_REPO at an existing checkout."; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
|
||||||
|
conformance-draft-server: check-conformance-repo
|
||||||
|
docker compose -f tests/Conformance/Fixtures/docker-compose.yml up -d
|
||||||
|
@echo "Waiting for server to start..."
|
||||||
|
@sleep 5
|
||||||
|
rm -rf tests/Conformance/results-2026-07-28
|
||||||
|
cd tests/Conformance && $(CONFORMANCE_DRAFT) server --url http://localhost:8000/stateless --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true
|
||||||
|
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
|
||||||
|
|
||||||
|
conformance-draft-client: check-conformance-repo
|
||||||
|
rm -rf tests/Conformance/results-2026-07-28
|
||||||
|
cd tests/Conformance && $(CONFORMANCE_DRAFT) client --command "php $(CURDIR)/tests/Conformance/client.php" --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage
|
XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage
|
||||||
|
|
||||||
|
|
|
||||||
24
src/Exception/MissingRequestMetaException.php
Normal file
24
src/Exception/MissingRequestMetaException.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?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\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modern-era request omitted metadata the server structurally needs to
|
||||||
|
* answer it — the protocol version or the client's capabilities.
|
||||||
|
*
|
||||||
|
* Answered with a JSON-RPC `-32602 Invalid params` error and HTTP 400.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
class MissingRequestMetaException extends InvalidArgumentException
|
||||||
|
{
|
||||||
|
}
|
||||||
38
src/Exception/MissingRequiredClientCapabilityException.php
Normal file
38
src/Exception/MissingRequiredClientCapabilityException.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?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\Exception;
|
||||||
|
|
||||||
|
use Mcp\Schema\ClientCapabilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answering the request needs a client capability the request never declared.
|
||||||
|
*
|
||||||
|
* In the handshake era a server learned the client's capabilities once, at
|
||||||
|
* `initialize`, and could refuse to advertise features the client could not
|
||||||
|
* use. A modern request restates its capabilities every time, so the check
|
||||||
|
* moves to call time — a handler that needs to call back into the client
|
||||||
|
* throws this, and the protocol answers `-32021` with HTTP 400.
|
||||||
|
*
|
||||||
|
* The capabilities travel as a {@see ClientCapabilities} object rather than a
|
||||||
|
* list of names so the client can compare them against what it would send.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
class MissingRequiredClientCapabilityException extends \RuntimeException implements ExceptionInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ClientCapabilities $requiredCapabilities,
|
||||||
|
string $message = 'Request requires a client capability that was not declared.',
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Schema/Enum/CacheScope.php
Normal file
34
src/Schema/Enum/CacheScope.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?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\Schema\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How widely a cacheable result may be reused (SEP-2549).
|
||||||
|
*
|
||||||
|
* The analogue of HTTP's `Cache-Control: public` vs `private`. The distinction
|
||||||
|
* is a security boundary, not a performance knob: marking a result public
|
||||||
|
* allows shared gateways and proxies to serve it to *other* authorization
|
||||||
|
* contexts, so anything shaped by who asked must stay private.
|
||||||
|
*
|
||||||
|
* {@see self::Private} is therefore the default everywhere in this SDK — an
|
||||||
|
* over-cautious hint costs a cache hit, an over-broad one leaks a response.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
enum CacheScope: string
|
||||||
|
{
|
||||||
|
/** Contains no caller-specific data; any cache may share it. */
|
||||||
|
case Public = 'public';
|
||||||
|
|
||||||
|
/** Reusable only within the same authorization context. */
|
||||||
|
case Private = 'private';
|
||||||
|
}
|
||||||
34
src/Schema/Enum/ResultType.php
Normal file
34
src/Schema/Enum/ResultType.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?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\Schema\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the client how to read a result before it looks at the body.
|
||||||
|
*
|
||||||
|
* Introduced in 2026-07-28, where a request can come back either finished or
|
||||||
|
* asking for more input; without a discriminator the client would have to
|
||||||
|
* infer which by probing for fields. Servers on this revision MUST send it.
|
||||||
|
*
|
||||||
|
* Earlier revisions have no such field, and a client receiving a result
|
||||||
|
* without one MUST read it as {@see self::Complete} — which is what makes it
|
||||||
|
* safe to emit unconditionally rather than only on the newer revision.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
enum ResultType: string
|
||||||
|
{
|
||||||
|
/** The request finished; the result holds the final content. */
|
||||||
|
case Complete = 'complete';
|
||||||
|
|
||||||
|
/** The request needs more input before it can finish (MRTR). */
|
||||||
|
case InputRequired = 'input_required';
|
||||||
|
}
|
||||||
75
src/Schema/Result/DiscoverResult.php
Normal file
75
src/Schema/Result/DiscoverResult.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?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\Schema\Result;
|
||||||
|
|
||||||
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use Mcp\Schema\Enum\ResultType;
|
||||||
|
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||||
|
use Mcp\Schema\ServerCapabilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's response to a `server/discover` request.
|
||||||
|
*
|
||||||
|
* This is the modern era's replacement for `InitializeResult`. The difference
|
||||||
|
* is not just the method name: `initialize` *negotiates* a single version and
|
||||||
|
* remembers it for the connection, whereas `server/discover` merely *reports*
|
||||||
|
* every version this server speaks and lets each subsequent request pick one.
|
||||||
|
*
|
||||||
|
* Server identity travels in the result `_meta` rather than in a body
|
||||||
|
* `serverInfo` member (spec PR #3002 removed the latter).
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
class DiscoverResult implements ResultInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<ProtocolVersion> $supportedVersions every revision this server can answer
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $supportedVersions,
|
||||||
|
public readonly ServerCapabilities $capabilities,
|
||||||
|
public readonly ?string $instructions = null,
|
||||||
|
) {
|
||||||
|
if ([] === $this->supportedVersions) {
|
||||||
|
throw new InvalidArgumentException('A DiscoverResult must advertise at least one supported version.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries no `resultType`, caching hints or serverInfo identity: those are
|
||||||
|
* wire vocabulary, stamped by {@see \Mcp\Server\Wire\Rev2026Codec} on the
|
||||||
|
* way out. What is modelled here is only what discovery actually answers.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* supportedVersions: list<string>,
|
||||||
|
* capabilities: ServerCapabilities,
|
||||||
|
* instructions?: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'supportedVersions' => array_values(array_map(
|
||||||
|
static fn (ProtocolVersion $version): string => $version->value,
|
||||||
|
$this->supportedVersions,
|
||||||
|
)),
|
||||||
|
'capabilities' => $this->capabilities,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (null !== $this->instructions) {
|
||||||
|
$data['instructions'] = $this->instructions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/Schema/Result/InputRequiredResult.php
Normal file
100
src/Schema/Result/InputRequiredResult.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?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\Schema\Result;
|
||||||
|
|
||||||
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
|
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||||
|
use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
||||||
|
use Mcp\Schema\Request\ElicitRequest;
|
||||||
|
use Mcp\Schema\Request\ListRootsRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the client the server needs more input before it can finish (MRTR).
|
||||||
|
*
|
||||||
|
* This is how a modern-era server asks the client for anything — elicitation,
|
||||||
|
* sampling, roots. The handshake era let a server open its own request to the
|
||||||
|
* client mid-call; 2026-07-28 removed that outright, so the ask travels back as
|
||||||
|
* part of the *result* and the client re-sends the original call with the
|
||||||
|
* answers attached. The exchange is two independent requests, which is what
|
||||||
|
* lets any server instance serve the retry.
|
||||||
|
*
|
||||||
|
* `requestState` is the server's own context, opaque to the client and echoed
|
||||||
|
* back verbatim. Because it round-trips through the client it is
|
||||||
|
* attacker-controlled on return: anything that influences authorization or
|
||||||
|
* business logic MUST be integrity-protected and verified, and rejected when
|
||||||
|
* verification fails.
|
||||||
|
*
|
||||||
|
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
class InputRequiredResult implements ResultInterface
|
||||||
|
{
|
||||||
|
public const RESULT_TYPE = 'input_required';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, ElicitRequest|CreateSamplingMessageRequest|ListRootsRequest> $inputRequests server-assigned keys, unique within this request
|
||||||
|
* @param string|null $requestState opaque server context the client echoes back
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $inputRequests = [],
|
||||||
|
public readonly ?string $requestState = null,
|
||||||
|
) {
|
||||||
|
// "Servers MUST include at least one of inputRequests or requestState
|
||||||
|
// in every InputRequiredResult response." A result carrying neither
|
||||||
|
// tells the client to retry with nothing new, which loops forever.
|
||||||
|
if ([] === $this->inputRequests && null === $this->requestState) {
|
||||||
|
throw new InvalidArgumentException('An InputRequiredResult must carry at least one of "inputRequests" or "requestState".');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* resultType: string,
|
||||||
|
* inputRequests?: array<string, mixed>,
|
||||||
|
* requestState?: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
$data = ['resultType' => self::RESULT_TYPE];
|
||||||
|
|
||||||
|
if ([] !== $this->inputRequests) {
|
||||||
|
$requests = [];
|
||||||
|
foreach ($this->inputRequests as $key => $request) {
|
||||||
|
// The map values are bare method/params pairs rather than whole
|
||||||
|
// JSON-RPC requests: they are not messages in their own right,
|
||||||
|
// and giving them ids would imply a correlation the retry does
|
||||||
|
// not use — the client keys its answers by the map key instead.
|
||||||
|
//
|
||||||
|
// Params are reachable only through the JSON-RPC envelope
|
||||||
|
// (getParams() is protected), and rendering that envelope needs
|
||||||
|
// an id these requests never have. Hence the throwaway id: it
|
||||||
|
// exists for the length of this expression and is discarded
|
||||||
|
// with the envelope it was needed to build.
|
||||||
|
$envelope = $request->withId(0)->jsonSerialize();
|
||||||
|
|
||||||
|
$requests[$key] = [
|
||||||
|
'method' => $request::getMethod(),
|
||||||
|
'params' => $envelope['params'] ?? new \stdClass(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$data['inputRequests'] = $requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->requestState) {
|
||||||
|
$data['requestState'] = $this->requestState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ use Mcp\Server\Session\InMemorySessionStore;
|
||||||
use Mcp\Server\Session\SessionManager;
|
use Mcp\Server\Session\SessionManager;
|
||||||
use Mcp\Server\Session\SessionManagerInterface;
|
use Mcp\Server\Session\SessionManagerInterface;
|
||||||
use Mcp\Server\Session\SessionStoreInterface;
|
use Mcp\Server\Session\SessionStoreInterface;
|
||||||
|
use Mcp\Server\Stateless\StatelessProtocol;
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
@ -680,6 +681,58 @@ final class Builder
|
||||||
* Builds the fully configured Server instance.
|
* Builds the fully configured Server instance.
|
||||||
*/
|
*/
|
||||||
public function build(): Server
|
public function build(): Server
|
||||||
|
{
|
||||||
|
$parts = $this->assemble();
|
||||||
|
|
||||||
|
$protocol = new Protocol(
|
||||||
|
requestHandlers: $parts['requestHandlers'],
|
||||||
|
notificationHandlers: $parts['notificationHandlers'],
|
||||||
|
messageFactory: $parts['messageFactory'],
|
||||||
|
sessionManager: $parts['sessionManager'],
|
||||||
|
logger: $parts['logger'],
|
||||||
|
eventDispatcher: $this->eventDispatcher,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Server($protocol, $parts['logger']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a dispatcher for the modern (SEP-2575) lifecycle.
|
||||||
|
*
|
||||||
|
* Everything the server exposes — tools, prompts, resources, and the
|
||||||
|
* handlers that serve them — is era-independent and shared with
|
||||||
|
* {@see self::build()}; only the dispatch model differs. So one builder
|
||||||
|
* configuration can drive either lifecycle, and a server can offer both at
|
||||||
|
* once by mounting each on its own endpoint.
|
||||||
|
*
|
||||||
|
* @param list<ProtocolVersion> $supportedVersions revisions this dispatcher will answer for
|
||||||
|
*/
|
||||||
|
public function buildStateless(array $supportedVersions = [ProtocolVersion::V2026_07_28]): StatelessProtocol
|
||||||
|
{
|
||||||
|
$parts = $this->assemble();
|
||||||
|
|
||||||
|
return new StatelessProtocol(
|
||||||
|
requestHandlers: $parts['requestHandlers'],
|
||||||
|
messageFactory: $parts['messageFactory'],
|
||||||
|
configuration: $parts['configuration'],
|
||||||
|
supportedVersions: $supportedVersions,
|
||||||
|
logger: $parts['logger'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the builder's configuration into the parts both lifecycles need.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* logger: LoggerInterface,
|
||||||
|
* configuration: Configuration,
|
||||||
|
* messageFactory: MessageFactory,
|
||||||
|
* sessionManager: SessionManagerInterface,
|
||||||
|
* requestHandlers: list<RequestHandlerInterface<mixed>>,
|
||||||
|
* notificationHandlers: list<NotificationHandlerInterface>,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function assemble(): array
|
||||||
{
|
{
|
||||||
$logger = $this->logger ?? new NullLogger();
|
$logger = $this->logger ?? new NullLogger();
|
||||||
$container = $this->container ?? new Container();
|
$container = $this->container ?? new Container();
|
||||||
|
|
@ -769,16 +822,14 @@ final class Builder
|
||||||
new Handler\Notification\InitializedHandler(),
|
new Handler\Notification\InitializedHandler(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$protocol = new Protocol(
|
return [
|
||||||
requestHandlers: $requestHandlers,
|
'logger' => $logger,
|
||||||
notificationHandlers: $notificationHandlers,
|
'configuration' => $configuration,
|
||||||
messageFactory: $messageFactory,
|
'messageFactory' => $messageFactory,
|
||||||
sessionManager: $sessionManager,
|
'sessionManager' => $sessionManager,
|
||||||
logger: $logger,
|
'requestHandlers' => $requestHandlers,
|
||||||
eventDispatcher: $this->eventDispatcher,
|
'notificationHandlers' => $notificationHandlers,
|
||||||
);
|
];
|
||||||
|
|
||||||
return new Server($protocol, $logger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ namespace Mcp\Server\Handler\Request;
|
||||||
use Mcp\Capability\Discovery\SchemaValidator;
|
use Mcp\Capability\Discovery\SchemaValidator;
|
||||||
use Mcp\Capability\Registry\ReferenceHandlerInterface;
|
use Mcp\Capability\Registry\ReferenceHandlerInterface;
|
||||||
use Mcp\Capability\RegistryInterface;
|
use Mcp\Capability\RegistryInterface;
|
||||||
|
use Mcp\Exception\MissingRequiredClientCapabilityException;
|
||||||
use Mcp\Exception\ToolCallException;
|
use Mcp\Exception\ToolCallException;
|
||||||
use Mcp\Exception\ToolNotFoundException;
|
use Mcp\Exception\ToolNotFoundException;
|
||||||
use Mcp\Schema\Content\TextContent;
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
|
@ -22,13 +23,17 @@ use Mcp\Schema\JsonRpc\Request;
|
||||||
use Mcp\Schema\JsonRpc\Response;
|
use Mcp\Schema\JsonRpc\Response;
|
||||||
use Mcp\Schema\Request\CallToolRequest;
|
use Mcp\Schema\Request\CallToolRequest;
|
||||||
use Mcp\Schema\Result\CallToolResult;
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Mcp\Schema\Result\InputRequiredResult;
|
||||||
use Mcp\Server\RequestContext;
|
use Mcp\Server\RequestContext;
|
||||||
use Mcp\Server\Session\SessionInterface;
|
use Mcp\Server\Session\SessionInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements RequestHandlerInterface<CallToolResult>
|
* A tools/call can answer with the tool's output or, under MRTR, with a request
|
||||||
|
* for the input the tool still needs — hence the two result types.
|
||||||
|
*
|
||||||
|
* @implements RequestHandlerInterface<CallToolResult|InputRequiredResult>
|
||||||
*
|
*
|
||||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||||
|
|
@ -52,7 +57,7 @@ final class CallToolHandler implements RequestHandlerInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Response<CallToolResult>|Error
|
* @return Response<CallToolResult|InputRequiredResult>|Error
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, SessionInterface $session): Response|Error
|
public function handle(Request $request, SessionInterface $session): Response|Error
|
||||||
{
|
{
|
||||||
|
|
@ -98,6 +103,11 @@ final class CallToolHandler implements RequestHandlerInterface
|
||||||
try {
|
try {
|
||||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||||
|
|
||||||
|
// Tool asking for more input as response
|
||||||
|
if ($result instanceof InputRequiredResult) {
|
||||||
|
return new Response($request->getId(), $result);
|
||||||
|
}
|
||||||
|
|
||||||
$protocolVersion = $context->getProtocolVersion();
|
$protocolVersion = $context->getProtocolVersion();
|
||||||
|
|
||||||
$structuredContent = null;
|
$structuredContent = null;
|
||||||
|
|
@ -135,6 +145,8 @@ final class CallToolHandler implements RequestHandlerInterface
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new Response($request->getId(), $result);
|
return new Response($request->getId(), $result);
|
||||||
|
} catch (MissingRequiredClientCapabilityException $e) {
|
||||||
|
throw $e;
|
||||||
} catch (ToolCallException $e) {
|
} catch (ToolCallException $e) {
|
||||||
$this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [
|
$this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [
|
||||||
'tool' => $toolName,
|
'tool' => $toolName,
|
||||||
|
|
|
||||||
100
src/Server/Stateless/NotificationFilter.php
Normal file
100
src/Server/Stateless/NotificationFilter.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?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\Server\Stateless;
|
||||||
|
|
||||||
|
use Mcp\Schema\ServerCapabilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which notification types a `subscriptions/listen` stream will carry.
|
||||||
|
*
|
||||||
|
* The filter is an allow-list, not a hint: "The server MUST NOT send
|
||||||
|
* notification types the client has not explicitly requested." Omitting a
|
||||||
|
* field is the same as declining that type, so absence and `false` mean the
|
||||||
|
* same thing here and the type never needs to distinguish them.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class NotificationFilter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $resourceSubscriptions resource URIs to report updates for
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $toolsListChanged = false,
|
||||||
|
public readonly bool $promptsListChanged = false,
|
||||||
|
public readonly bool $resourcesListChanged = false,
|
||||||
|
public readonly array $resourceSubscriptions = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $notifications the request's `params.notifications` member
|
||||||
|
*/
|
||||||
|
public static function fromParams(?array $notifications): self
|
||||||
|
{
|
||||||
|
$notifications ??= [];
|
||||||
|
|
||||||
|
$uris = $notifications['resourceSubscriptions'] ?? [];
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
true === ($notifications['toolsListChanged'] ?? false),
|
||||||
|
true === ($notifications['promptsListChanged'] ?? false),
|
||||||
|
true === ($notifications['resourcesListChanged'] ?? false),
|
||||||
|
\is_array($uris) ? array_values(array_filter($uris, \is_string(...))) : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows the filter to what this server can actually deliver.
|
||||||
|
*
|
||||||
|
* The acknowledgment must reflect "the subset the server agreed to honor",
|
||||||
|
* so a type the server does not support is dropped here rather than
|
||||||
|
* acknowledged and then silently never sent — a client comparing the
|
||||||
|
* acknowledgment against its request would otherwise wait forever for
|
||||||
|
* notifications that were never coming.
|
||||||
|
*/
|
||||||
|
public function intersect(ServerCapabilities $capabilities): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->toolsListChanged && true === $capabilities->toolsListChanged,
|
||||||
|
$this->promptsListChanged && true === $capabilities->promptsListChanged,
|
||||||
|
$this->resourcesListChanged && true === $capabilities->resourcesListChanged,
|
||||||
|
true === $capabilities->resourcesSubscribe ? $this->resourceSubscriptions : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The acknowledgment's `notifications` member: agreed types only, with
|
||||||
|
* declined ones omitted rather than reported as `false`.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toAcknowledgedArray(): array
|
||||||
|
{
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
if ($this->toolsListChanged) {
|
||||||
|
$data['toolsListChanged'] = true;
|
||||||
|
}
|
||||||
|
if ($this->promptsListChanged) {
|
||||||
|
$data['promptsListChanged'] = true;
|
||||||
|
}
|
||||||
|
if ($this->resourcesListChanged) {
|
||||||
|
$data['resourcesListChanged'] = true;
|
||||||
|
}
|
||||||
|
if ([] !== $this->resourceSubscriptions) {
|
||||||
|
$data['resourceSubscriptions'] = $this->resourceSubscriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Server/Stateless/RequestMeta.php
Normal file
95
src/Server/Stateless/RequestMeta.php
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<?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\Server\Stateless;
|
||||||
|
|
||||||
|
use Mcp\Exception\MissingRequestMetaException;
|
||||||
|
use Mcp\Schema\ClientCapabilities;
|
||||||
|
use Mcp\Schema\Enum\LoggingLevel;
|
||||||
|
use Mcp\Schema\Implementation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-request metadata that replaces the `initialize` handshake in the
|
||||||
|
* modern era (SEP-2575).
|
||||||
|
*
|
||||||
|
* A handshake-era connection learns the protocol version, the client's
|
||||||
|
* identity and its capabilities once, then keeps them in session state. With
|
||||||
|
* no session to keep them in, every modern request restates them in
|
||||||
|
* `params._meta` — so this object is the stateless equivalent of the session
|
||||||
|
* a {@see \Mcp\Server\Protocol} run would have resolved.
|
||||||
|
*
|
||||||
|
* `protocolVersion` and `clientCapabilities` are structurally required: a
|
||||||
|
* server cannot decide how to answer without them. `clientInfo` is not — it is
|
||||||
|
* a SHOULD, and servers MUST NOT refuse a request that omits it.
|
||||||
|
*/
|
||||||
|
final class RequestMeta
|
||||||
|
{
|
||||||
|
public const PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
||||||
|
public const CLIENT_INFO = 'io.modelcontextprotocol/clientInfo';
|
||||||
|
public const CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
|
||||||
|
public const LOG_LEVEL = 'io.modelcontextprotocol/logLevel';
|
||||||
|
public const SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
|
||||||
|
public const SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $protocolVersion,
|
||||||
|
public readonly ClientCapabilities $clientCapabilities,
|
||||||
|
public readonly ?Implementation $clientInfo = null,
|
||||||
|
public readonly ?LoggingLevel $logLevel = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $params the request's `params` member, if any
|
||||||
|
*
|
||||||
|
* @throws MissingRequestMetaException when a structurally required member is absent or malformed
|
||||||
|
*/
|
||||||
|
public static function fromParams(?array $params): self
|
||||||
|
{
|
||||||
|
$meta = $params['_meta'] ?? null;
|
||||||
|
|
||||||
|
if (!\is_array($meta)) {
|
||||||
|
throw new MissingRequestMetaException('Request is missing the required "params._meta" member.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = $meta[self::PROTOCOL_VERSION] ?? null;
|
||||||
|
if (!\is_string($version) || '' === $version) {
|
||||||
|
throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::PROTOCOL_VERSION));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required, but an empty object is a perfectly valid value: it declares
|
||||||
|
// a client that supports none of the optional capabilities. Only its
|
||||||
|
// absence — or a non-object — is a protocol error.
|
||||||
|
$capabilities = $meta[self::CLIENT_CAPABILITIES] ?? null;
|
||||||
|
if (!\is_array($capabilities) && !$capabilities instanceof \stdClass) {
|
||||||
|
throw new MissingRequestMetaException(\sprintf('Request "_meta" is missing the required "%s" member.', self::CLIENT_CAPABILITIES));
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientInfo = $meta[self::CLIENT_INFO] ?? null;
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
$version,
|
||||||
|
ClientCapabilities::fromArray((array) $capabilities),
|
||||||
|
\is_array($clientInfo) ? Implementation::fromArray($clientInfo) : null,
|
||||||
|
self::parseLogLevel($meta[self::LOG_LEVEL] ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unparseable level is treated as "no level requested" rather than as a
|
||||||
|
* protocol error: log verbosity is a diagnostic preference, and rejecting
|
||||||
|
* the whole request over it would fail the caller's actual work.
|
||||||
|
*/
|
||||||
|
private static function parseLogLevel(mixed $level): ?LoggingLevel
|
||||||
|
{
|
||||||
|
return \is_string($level) ? LoggingLevel::tryFrom($level) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
395
src/Server/Stateless/StatelessProtocol.php
Normal file
395
src/Server/Stateless/StatelessProtocol.php
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
<?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\Server\Stateless;
|
||||||
|
|
||||||
|
use Mcp\Exception\InvalidInputMessageException;
|
||||||
|
use Mcp\Exception\MissingRequestMetaException;
|
||||||
|
use Mcp\Exception\MissingRequiredClientCapabilityException;
|
||||||
|
use Mcp\JsonRpc\MessageFactory;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use Mcp\Schema\JsonRpc\Error;
|
||||||
|
use Mcp\Schema\JsonRpc\Notification;
|
||||||
|
use Mcp\Schema\JsonRpc\Request;
|
||||||
|
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||||
|
use Mcp\Schema\Result\DiscoverResult;
|
||||||
|
use Mcp\Server\Configuration;
|
||||||
|
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||||
|
use Mcp\Server\Session\InMemorySessionStore;
|
||||||
|
use Mcp\Server\Session\Session;
|
||||||
|
use Mcp\Server\Wire\Rev2026Codec;
|
||||||
|
use Mcp\Server\Wire\WireCodecInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a single modern-era (SEP-2575) request.
|
||||||
|
*
|
||||||
|
* The handshake-era {@see \Mcp\Server\Protocol} is built around a session that
|
||||||
|
* outlives the request: it resolves one, replays `initialize` state into it,
|
||||||
|
* and keeps a fiber attached to it across HTTP round-trips. None of that
|
||||||
|
* survives into the modern era, where each request is self-describing and
|
||||||
|
* independent, so this is a separate dispatcher rather than a mode flag on the
|
||||||
|
* old one — the two eras share handlers, not control flow.
|
||||||
|
*
|
||||||
|
* Request handlers are reused verbatim. They expect a session, so each request
|
||||||
|
* gets an ephemeral one that is discarded when the request ends; handlers that
|
||||||
|
* merely stash per-request scratch state keep working, and handlers that tried
|
||||||
|
* to persist across requests would have had nothing to persist into anyway.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class StatelessProtocol
|
||||||
|
{
|
||||||
|
private readonly WireCodecInterface $codec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Methods the modern era deleted outright. They are answered as unknown
|
||||||
|
* methods rather than as errors specific to each, because that is exactly
|
||||||
|
* what they are to a modern server — listing them separately only serves
|
||||||
|
* to document the intent.
|
||||||
|
*/
|
||||||
|
public const REMOVED_METHODS = [
|
||||||
|
'initialize',
|
||||||
|
'notifications/initialized',
|
||||||
|
'ping',
|
||||||
|
'logging/setLevel',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const DISCOVER_METHOD = 'server/discover';
|
||||||
|
public const LISTEN_METHOD = 'subscriptions/listen';
|
||||||
|
public const ACKNOWLEDGED_NOTIFICATION = 'notifications/subscriptions/acknowledged';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a client is told when a handler fails in a way nothing anticipated.
|
||||||
|
*
|
||||||
|
* Deliberately generic rather than {@see \Throwable::getMessage()}: the
|
||||||
|
* real message is logged, not returned, so an internal detail (a
|
||||||
|
* connection string, a file path, another library's error text) never
|
||||||
|
* reaches the client that triggered it.
|
||||||
|
*/
|
||||||
|
private const INTERNAL_ERROR_MESSAGE = 'Internal server error.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<RequestHandlerInterface<ResultInterface>> $requestHandlers
|
||||||
|
* @param list<ProtocolVersion> $supportedVersions
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly iterable $requestHandlers,
|
||||||
|
private readonly MessageFactory $messageFactory,
|
||||||
|
private readonly Configuration $configuration,
|
||||||
|
private readonly array $supportedVersions = [ProtocolVersion::V2026_07_28],
|
||||||
|
private readonly LoggerInterface $logger = new NullLogger(),
|
||||||
|
private readonly float $subscriptionLifetime = 30.0,
|
||||||
|
?WireCodecInterface $codec = null,
|
||||||
|
) {
|
||||||
|
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers one JSON-RPC request read from an HTTP request body.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $headers request headers, case-insensitively matched
|
||||||
|
*/
|
||||||
|
public function handle(string $body, array $headers = []): StatelessResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
/** @var array<string, mixed>|null $decoded */
|
||||||
|
$decoded = json_decode($body, true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
return StatelessResult::error(Error::forParseError($e->getMessage()), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_array($decoded)) {
|
||||||
|
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must be a JSON object.'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The id is read before anything is validated so that every error below
|
||||||
|
// can echo it when there is one to echo. A missing or malformed id is
|
||||||
|
// left as null rather than coerced to "" — Error::jsonSerialize()
|
||||||
|
// already knows to omit a null id instead of fabricating one, and
|
||||||
|
// "id": "" would falsely claim the sender issued a request with an
|
||||||
|
// empty-string id.
|
||||||
|
$id = $decoded['id'] ?? null;
|
||||||
|
if (!\is_string($id) && !\is_int($id)) {
|
||||||
|
$id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $decoded['method'] ?? null;
|
||||||
|
if (!\is_string($method) || '' === $method) {
|
||||||
|
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC message must carry a "method".', $id), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = \is_array($decoded['params'] ?? null) ? $decoded['params'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$meta = RequestMeta::fromParams($params);
|
||||||
|
} catch (MissingRequestMetaException $e) {
|
||||||
|
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) {
|
||||||
|
return $versionError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) {
|
||||||
|
// Both answer with a single response tied to this request's id,
|
||||||
|
// unlike a genuine notification — so unlike the general dispatch
|
||||||
|
// path below, a missing id here is this request being invalid
|
||||||
|
// rather than this request needing no answer at all.
|
||||||
|
if (null === $id) {
|
||||||
|
return StatelessResult::error(Error::forInvalidRequest(\sprintf('Method "%s" requires an "id".', $method)), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::DISCOVER_METHOD === $method) {
|
||||||
|
return $this->encode($method, $id, $this->discover());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->listen($params, $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both the deleted methods and never-known ones are "not found". The
|
||||||
|
// 404 pairs the HTTP layer with the JSON-RPC code so a client that
|
||||||
|
// routes on status alone reaches the same conclusion.
|
||||||
|
if (\in_array($method, self::REMOVED_METHODS, true)) {
|
||||||
|
return StatelessResult::error(
|
||||||
|
Error::forMethodNotFound(\sprintf('Method "%s" does not exist in protocol version %s.', $method, $meta->protocolVersion), $id),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->dispatch($method, $decoded, $meta, $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header and `_meta` must agree on the version before that version can
|
||||||
|
* be judged supported or not.
|
||||||
|
*
|
||||||
|
* The order matters: when the two disagree, the server has been told two
|
||||||
|
* different things and cannot know which the client meant, so "these
|
||||||
|
* contradict" is the honest answer even if one of the two values happens to
|
||||||
|
* be unsupported. Reporting it as an unsupported version instead would send
|
||||||
|
* the client off to renegotiate a version that was never the problem.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $headers
|
||||||
|
*/
|
||||||
|
private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id): ?StatelessResult
|
||||||
|
{
|
||||||
|
$headerVersion = $this->header($headers, 'MCP-Protocol-Version');
|
||||||
|
|
||||||
|
if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) {
|
||||||
|
return StatelessResult::error(
|
||||||
|
Error::forHeaderMismatch(
|
||||||
|
\sprintf('MCP-Protocol-Version header "%s" contradicts the "%s" declared in _meta.', $headerVersion, $meta->protocolVersion),
|
||||||
|
$id,
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = ProtocolVersion::tryFrom($meta->protocolVersion);
|
||||||
|
|
||||||
|
if (null === $version || !\in_array($version, $this->supportedVersions, true)) {
|
||||||
|
return StatelessResult::error(
|
||||||
|
Error::forUnsupportedProtocolVersion($meta->protocolVersion, $this->supportedVersions, $id),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a `subscriptions/listen` stream.
|
||||||
|
*
|
||||||
|
* The subscription needs no identifier of its own: the spec defines it as
|
||||||
|
* the JSON-RPC id of this very request, which both sides already have. So
|
||||||
|
* there is no id to mint, store, or hand back — every frame is tagged with
|
||||||
|
* something the client chose.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $params
|
||||||
|
*/
|
||||||
|
private function listen(?array $params, string|int $id): StatelessResult
|
||||||
|
{
|
||||||
|
$notifications = \is_array($params['notifications'] ?? null) ? $params['notifications'] : null;
|
||||||
|
$agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities);
|
||||||
|
|
||||||
|
$lifetime = $this->subscriptionLifetime;
|
||||||
|
|
||||||
|
return StatelessResult::stream(static function () use ($agreed, $id, $lifetime): \Generator {
|
||||||
|
yield [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'method' => self::ACKNOWLEDGED_NOTIFICATION,
|
||||||
|
'params' => [
|
||||||
|
'_meta' => [RequestMeta::SUBSCRIPTION_ID => $id],
|
||||||
|
'notifications' => (object) $agreed->toAcknowledgedArray(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Nothing else is emitted yet: delivering list-changed and
|
||||||
|
// resource-updated notifications means observing mutations made by
|
||||||
|
// *other* requests, which under a share-nothing runtime needs a
|
||||||
|
// cross-process channel this SDK does not yet define. Holding the
|
||||||
|
// stream open for a bounded window keeps the subscription honest —
|
||||||
|
// the client sees an open, acknowledged stream — without pinning a
|
||||||
|
// worker indefinitely for events that cannot arrive.
|
||||||
|
//
|
||||||
|
// The tick is what makes that bound real. PHP only learns the peer
|
||||||
|
// hung up by trying to write to it, so a loop that merely sleeps
|
||||||
|
// holds its worker for the full lifetime after the client has gone.
|
||||||
|
// On a fixed-size FPM pool a handful of abandoned subscriptions is
|
||||||
|
// enough to starve every other request, which is exactly what makes
|
||||||
|
// it worth the extra yield.
|
||||||
|
$deadline = microtime(true) + $lifetime;
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
yield null;
|
||||||
|
|
||||||
|
if (connection_aborted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(250_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful closure (SHOULD): tell the client the subscription ended
|
||||||
|
// deliberately, so it can distinguish this from a dropped transport.
|
||||||
|
yield [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => $id,
|
||||||
|
'result' => [
|
||||||
|
'resultType' => 'complete',
|
||||||
|
'_meta' => [RequestMeta::SUBSCRIPTION_ID => $id],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function discover(): DiscoverResult
|
||||||
|
{
|
||||||
|
return new DiscoverResult(
|
||||||
|
$this->supportedVersions,
|
||||||
|
$this->configuration->capabilities,
|
||||||
|
$this->configuration->instructions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decoded
|
||||||
|
*/
|
||||||
|
private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id): StatelessResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$messages = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]);
|
||||||
|
|
||||||
|
return StatelessResult::error(Error::forMethodNotFound(\sprintf('Method "%s" is not supported.', $method), $id), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $messages[0] ?? null;
|
||||||
|
|
||||||
|
// A notification (no id) is never answered, successful or not — this is
|
||||||
|
// one of the SDK's own registered message classes, just not a Request.
|
||||||
|
if ($request instanceof Notification) {
|
||||||
|
return StatelessResult::accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The factory hands back an exception object rather than throwing one,
|
||||||
|
// distinguishing a genuinely unknown method (-32601, the client should
|
||||||
|
// stop asking) from a known method the message could not otherwise be
|
||||||
|
// parsed into (-32600, the request itself is malformed).
|
||||||
|
if ($request instanceof InvalidInputMessageException) {
|
||||||
|
$unknownMethod = \sprintf('Unknown method "%s".', $method) === $request->getMessage();
|
||||||
|
|
||||||
|
return StatelessResult::error(
|
||||||
|
$unknownMethod
|
||||||
|
? Error::forMethodNotFound($request->getMessage(), $id)
|
||||||
|
: Error::forInvalidRequest($request->getMessage(), $id),
|
||||||
|
$unknownMethod ? 404 : 400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$request instanceof Request) {
|
||||||
|
// Reachable only for a well-formed Response/Error object posted to
|
||||||
|
// this endpoint — decodable by the factory, but not a request this
|
||||||
|
// server can answer.
|
||||||
|
return StatelessResult::error(Error::forInvalidRequest(\sprintf('"%s" is not a request this server can answer.', $method), $id), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request::fromArray() already rejected a missing/invalid id (as an
|
||||||
|
// InvalidInputMessageException, handled above), so this request's id
|
||||||
|
// is always present here — reusing it narrows $id for the rest of this
|
||||||
|
// method instead of trusting the raw, still-nullable value from above.
|
||||||
|
$id = $request->getId();
|
||||||
|
|
||||||
|
$session = new Session(new InMemorySessionStore());
|
||||||
|
$session->set(RequestMeta::class, $meta);
|
||||||
|
|
||||||
|
foreach ($this->requestHandlers as $handler) {
|
||||||
|
if (!$handler->supports($request)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $handler->handle($request, $session);
|
||||||
|
} catch (MissingRequiredClientCapabilityException $e) {
|
||||||
|
return StatelessResult::error(
|
||||||
|
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('Uncaught exception handling a modern-era request.', ['method' => $method, 'exception' => $e]);
|
||||||
|
|
||||||
|
return StatelessResult::error(Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result instanceof Error) {
|
||||||
|
return StatelessResult::error($result, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->encode($method, $id, $result->result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a result through the wire codec on its way out.
|
||||||
|
*
|
||||||
|
* The round-trip through JSON is what flattens the result — and everything
|
||||||
|
* nested inside it — into the plain arrays the codec stamps. Handing the
|
||||||
|
* codec objects instead would make it responsible for knowing how each one
|
||||||
|
* serializes, which is exactly the coupling this layer exists to avoid.
|
||||||
|
*/
|
||||||
|
private function encode(string $method, string|int $id, ResultInterface $result): StatelessResult
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $body */
|
||||||
|
$body = json_decode(json_encode($result, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return StatelessResult::ok($id, $this->codec->encodeResult($method, $body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $headers
|
||||||
|
*/
|
||||||
|
private function header(array $headers, string $name): ?string
|
||||||
|
{
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
if (0 === strcasecmp($key, $name)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/Server/Stateless/StatelessResult.php
Normal file
121
src/Server/Stateless/StatelessResult.php
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<?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\Server\Stateless;
|
||||||
|
|
||||||
|
use Mcp\Schema\JsonRpc\Error;
|
||||||
|
use Mcp\Schema\JsonRpc\MessageInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One modern-era answer, paired with the HTTP status that carries it.
|
||||||
|
*
|
||||||
|
* The pairing is the point. SEP-2575 fixes specific HTTP statuses to specific
|
||||||
|
* JSON-RPC error codes — 404 for a method that does not exist, 400 for a
|
||||||
|
* request the server could parse but not accept — so the status is part of the
|
||||||
|
* answer rather than something the transport re-derives from the error code.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class StatelessResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param (\Closure(): \Generator<mixed>)|null $frames set instead of $message when the answer is a stream
|
||||||
|
* @param array<string, mixed>|null $body a result body already through the wire codec
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
public readonly ?MessageInterface $message,
|
||||||
|
public readonly int $httpStatus,
|
||||||
|
public readonly ?\Closure $frames = null,
|
||||||
|
private readonly ?array $body = null,
|
||||||
|
private readonly string|int|null $id = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A successful answer whose result body the wire codec has already stamped.
|
||||||
|
*
|
||||||
|
* The body arrives as a plain array rather than a result object because the
|
||||||
|
* codec's job is to add fields the result classes deliberately do not model
|
||||||
|
* — once it has run, there is no object left that describes the payload.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $body
|
||||||
|
*/
|
||||||
|
public static function ok(string|int $id, array $body): self
|
||||||
|
{
|
||||||
|
return new self(null, 200, null, $body, $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(Error $error, int $httpStatus): self
|
||||||
|
{
|
||||||
|
return new self($error, $httpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notification never gets an answer, successful or not — per JSON-RPC, a
|
||||||
|
* message with no id must never receive a response. This is the "I read
|
||||||
|
* it, there is nothing to say back" answer: HTTP 202 with no body.
|
||||||
|
*/
|
||||||
|
public static function accepted(): self
|
||||||
|
{
|
||||||
|
return new self(null, 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A long-lived answer delivered as a sequence of frames rather than one
|
||||||
|
* message — `subscriptions/listen` is the only such method today.
|
||||||
|
*
|
||||||
|
* The generator is deferred rather than eager because the frames are
|
||||||
|
* produced over the life of the connection: building them up front would
|
||||||
|
* defeat the point of streaming and hold the whole subscription in memory.
|
||||||
|
*
|
||||||
|
* @param \Closure(): \Generator<mixed> $frames
|
||||||
|
*/
|
||||||
|
public static function stream(\Closure $frames): self
|
||||||
|
{
|
||||||
|
return new self(null, 200, $frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStream(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An accepted notification: no message, no body, no frames — just the
|
||||||
|
* status code.
|
||||||
|
*/
|
||||||
|
public function isEmpty(): bool
|
||||||
|
{
|
||||||
|
return null === $this->message && null === $this->body && null === $this->frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isError(): bool
|
||||||
|
{
|
||||||
|
return $this->message instanceof Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toJson(): string
|
||||||
|
{
|
||||||
|
if (null !== $this->body) {
|
||||||
|
return json_encode([
|
||||||
|
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
|
||||||
|
'id' => $this->id,
|
||||||
|
'result' => $this->body,
|
||||||
|
], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->message) {
|
||||||
|
throw new \LogicException('A streaming or empty result has no single JSON body; check isStream()/isEmpty() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($this->message, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/Server/Transport/StatelessHttpTransport.php
Normal file
180
src/Server/Transport/StatelessHttpTransport.php
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<?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\Server\Transport;
|
||||||
|
|
||||||
|
use Http\Discovery\Psr17FactoryDiscovery;
|
||||||
|
use Mcp\Schema\JsonRpc\Error;
|
||||||
|
use Mcp\Server\Stateless\StatelessProtocol;
|
||||||
|
use Psr\Http\Message\ResponseFactoryInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Message\StreamFactoryInterface;
|
||||||
|
use Psr\Http\Message\StreamInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries the modern (SEP-2575) lifecycle over HTTP.
|
||||||
|
*
|
||||||
|
* Deliberately not a mode of {@see StreamableHttpTransport}: that transport's
|
||||||
|
* job is largely session management — minting an `Mcp-Session-Id`, resuming a
|
||||||
|
* suspended fiber against it, tearing it down on DELETE — and a modern request
|
||||||
|
* has no session to manage. What is left is a POST in, one JSON-RPC message
|
||||||
|
* out, and a status code the protocol has already chosen.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class StatelessHttpTransport
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Upper bound on the request body read for a POST, guarding against memory
|
||||||
|
* exhaustion from an oversized (or unbounded chunked) payload.
|
||||||
|
*/
|
||||||
|
public const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
private ResponseFactoryInterface $responseFactory;
|
||||||
|
private StreamFactoryInterface $streamFactory;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly StatelessProtocol $protocol,
|
||||||
|
?ResponseFactoryInterface $responseFactory = null,
|
||||||
|
?StreamFactoryInterface $streamFactory = null,
|
||||||
|
private readonly LoggerInterface $logger = new NullLogger(),
|
||||||
|
private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES,
|
||||||
|
) {
|
||||||
|
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
|
||||||
|
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
if ('OPTIONS' === $request->getMethod()) {
|
||||||
|
return $this->responseFactory->createResponse(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No GET-opened notification stream and no DELETE teardown: without a
|
||||||
|
// session there is nothing for either to address.
|
||||||
|
if ('POST' !== $request->getMethod()) {
|
||||||
|
return $this->json(
|
||||||
|
json_encode(Error::forInvalidRequest(\sprintf('The modern lifecycle accepts POST only, got %s.', $request->getMethod())), \JSON_THROW_ON_ERROR),
|
||||||
|
405,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getBody();
|
||||||
|
if ($body->isSeekable()) {
|
||||||
|
$body->rewind();
|
||||||
|
}
|
||||||
|
$payload = $this->readBody($body);
|
||||||
|
|
||||||
|
if (null === $payload) {
|
||||||
|
$this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]);
|
||||||
|
|
||||||
|
return $this->json(
|
||||||
|
json_encode(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), \JSON_THROW_ON_ERROR),
|
||||||
|
413,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = [];
|
||||||
|
foreach ($request->getHeaders() as $name => $values) {
|
||||||
|
$headers[$name] = implode(', ', $values);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->protocol->handle($payload, $headers);
|
||||||
|
|
||||||
|
if ($result->isStream()) {
|
||||||
|
return $this->sse($result->frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->isEmpty()) {
|
||||||
|
return $this->responseFactory->createResponse($result->httpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($result->toJson(), $result->httpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Closure(): \Generator<mixed> $frames
|
||||||
|
*/
|
||||||
|
private function sse(\Closure $frames): ResponseInterface
|
||||||
|
{
|
||||||
|
$logger = $this->logger;
|
||||||
|
|
||||||
|
$callback = static function () use ($frames, $logger): void {
|
||||||
|
try {
|
||||||
|
foreach ($frames() as $frame) {
|
||||||
|
// A null frame is a keep-alive tick, not a message: it goes
|
||||||
|
// out as an SSE comment, which the client ignores and which
|
||||||
|
// gives PHP the write it needs to notice a dropped peer.
|
||||||
|
echo null === $frame
|
||||||
|
? ": keep-alive\n\n"
|
||||||
|
: 'data: '.json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)."\n\n";
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// The status line and headers are long gone by the time a frame
|
||||||
|
// fails, so there is no way to turn this into an error response.
|
||||||
|
// Log it and let the stream end; the client sees a close without
|
||||||
|
// the graceful-closure frame, which is exactly what it means.
|
||||||
|
$logger->error('Subscription stream ended with an error.', ['exception' => $e]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->responseFactory->createResponse(200)
|
||||||
|
->withHeader('Content-Type', 'text/event-stream')
|
||||||
|
->withHeader('Cache-Control', 'no-cache')
|
||||||
|
->withHeader('Connection', 'keep-alive')
|
||||||
|
->withHeader('X-Accel-Buffering', 'no')
|
||||||
|
->withBody(new CallbackStream($callback, $this->logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the request body, bounded by {@see self::$maxBodyBytes}.
|
||||||
|
*
|
||||||
|
* Returns the body contents, or `null` when the payload exceeds the cap.
|
||||||
|
* A single `read()` call is not enough: PSR-7 allows it to return fewer
|
||||||
|
* bytes than requested, so a body arriving across more than one physical
|
||||||
|
* read would otherwise be silently truncated. Reading incrementally to
|
||||||
|
* EOF is what {@see StreamableHttpTransport::readBody()} already does for
|
||||||
|
* the same reason.
|
||||||
|
*/
|
||||||
|
private function readBody(StreamInterface $body): ?string
|
||||||
|
{
|
||||||
|
$size = $body->getSize();
|
||||||
|
if (null !== $size && $size > $this->maxBodyBytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = '';
|
||||||
|
while (!$body->eof()) {
|
||||||
|
$chunk = $body->read(8192);
|
||||||
|
if ('' === $chunk) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents .= $chunk;
|
||||||
|
if (\strlen($contents) > $this->maxBodyBytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function json(string $payload, int $status): ResponseInterface
|
||||||
|
{
|
||||||
|
return $this->responseFactory->createResponse($status)
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withBody($this->streamFactory->createStream($payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Server/Wire/Rev2025Codec.php
Normal file
32
src/Server/Wire/Rev2025Codec.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?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\Server\Wire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wire codec for every revision through 2025-11-25.
|
||||||
|
*
|
||||||
|
* The identity transform, and deliberately so. These revisions have no
|
||||||
|
* `resultType`, no caching hints and no `_meta` serverInfo requirement, so the
|
||||||
|
* neutral result body *is* the wire body — there is nothing to stamp and,
|
||||||
|
* more importantly, nothing newer that may leak in. A server negotiating one
|
||||||
|
* of these revisions emits exactly the bytes it emitted before the modern era
|
||||||
|
* existed.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class Rev2025Codec implements WireCodecInterface
|
||||||
|
{
|
||||||
|
public function encodeResult(string $method, array $result): array
|
||||||
|
{
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/Server/Wire/Rev2026Codec.php
Normal file
159
src/Server/Wire/Rev2026Codec.php
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<?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\Server\Wire;
|
||||||
|
|
||||||
|
use Mcp\Schema\Enum\CacheScope;
|
||||||
|
use Mcp\Schema\Enum\ResultType;
|
||||||
|
use Mcp\Schema\Implementation;
|
||||||
|
use Mcp\Server\Stateless\RequestMeta;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wire codec for 2026-07-28.
|
||||||
|
*
|
||||||
|
* Applies the three additions this revision makes to every outbound result, in
|
||||||
|
* a fixed order:
|
||||||
|
*
|
||||||
|
* 1. the `resultType` discriminator;
|
||||||
|
* 2. the SEP-2549 caching hints, on cacheable methods only;
|
||||||
|
* 3. the `_meta` serverInfo identity (spec PR #3002).
|
||||||
|
*
|
||||||
|
* The order is load-bearing rather than incidental: the cache fill only runs
|
||||||
|
* on a result that came out of step 1 as `complete`, so a result still asking
|
||||||
|
* for input never acquires a TTL for content it has not produced.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
final class Rev2026Codec implements WireCodecInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Methods whose result vocabulary goes beyond `complete`, because they can
|
||||||
|
* come back asking for input (MRTR).
|
||||||
|
*
|
||||||
|
* `subscriptions/listen` is deliberately absent: it never sends an ordinary
|
||||||
|
* result, so there is nothing for the stamp to reach.
|
||||||
|
*/
|
||||||
|
public const EXTENDED_RESULT_TYPE_METHODS = [
|
||||||
|
'tools/call',
|
||||||
|
'prompts/get',
|
||||||
|
'resources/read',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Methods whose results a client may cache, and which therefore MUST carry hints. */
|
||||||
|
public const CACHEABLE_METHODS = [
|
||||||
|
'server/discover',
|
||||||
|
'tools/list',
|
||||||
|
'prompts/list',
|
||||||
|
'resources/list',
|
||||||
|
'resources/templates/list',
|
||||||
|
'resources/read',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?Implementation $serverInfo = null,
|
||||||
|
private readonly CacheScope $defaultCacheScope = CacheScope::Private,
|
||||||
|
private readonly int $defaultTtlMs = 0,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function encodeResult(string $method, array $result): array
|
||||||
|
{
|
||||||
|
return $this->stampServerInfo(
|
||||||
|
$this->fillCacheHints($method, $this->stampResultType($method, $result)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A result that already names its own type keeps it, but only where the
|
||||||
|
* method's vocabulary allows more than `complete`. Elsewhere the value
|
||||||
|
* would be meaningless to the client, and quietly rewriting it would bury
|
||||||
|
* a server bug that is better surfaced by the handler.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function stampResultType(string $method, array $result): array
|
||||||
|
{
|
||||||
|
$provided = $result['resultType'] ?? null;
|
||||||
|
|
||||||
|
if (null === $provided) {
|
||||||
|
return [...$result, 'resultType' => ResultType::Complete->value];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ResultType::Complete->value === $provided || \in_array($method, self::EXTENDED_RESULT_TYPE_METHODS, true)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...$result, 'resultType' => ResultType::Complete->value];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills `ttlMs`/`cacheScope`, most-specific author first: a valid value the
|
||||||
|
* result already carries wins over the server's configured policy, which
|
||||||
|
* wins over the conservative default of "private, do not cache".
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function fillCacheHints(string $method, array $result): array
|
||||||
|
{
|
||||||
|
if (ResultType::Complete->value !== ($result['resultType'] ?? null)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\in_array($method, self::CACHEABLE_METHODS, true)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ttl = $result['ttlMs'] ?? null;
|
||||||
|
$scope = $result['cacheScope'] ?? null;
|
||||||
|
|
||||||
|
// An invalid authored value is dropped rather than repaired: it cannot
|
||||||
|
// go on the wire, and the next author down already has a usable answer.
|
||||||
|
if (!\is_int($ttl) || $ttl < 0) {
|
||||||
|
$ttl = $this->defaultTtlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_string($scope) || null === CacheScope::tryFrom($scope)) {
|
||||||
|
$scope = $this->defaultCacheScope->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...$result, 'ttlMs' => $ttl, 'cacheScope' => $scope];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servers SHOULD identify themselves on every response. A result that
|
||||||
|
* already carries an identity keeps it; without a configured one this is
|
||||||
|
* the identity function.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function stampServerInfo(array $result): array
|
||||||
|
{
|
||||||
|
if (null === $this->serverInfo) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = \is_array($result['_meta'] ?? null) ? $result['_meta'] : [];
|
||||||
|
|
||||||
|
if (isset($meta[RequestMeta::SERVER_INFO])) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta[RequestMeta::SERVER_INFO] = $this->serverInfo;
|
||||||
|
|
||||||
|
return [...$result, '_meta' => $meta];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/Server/Wire/WireCodecInterface.php
Normal file
49
src/Server/Wire/WireCodecInterface.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?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\Server\Wire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates between the SDK's revision-neutral result model and the wire
|
||||||
|
* shape one protocol era expects.
|
||||||
|
*
|
||||||
|
* The split this exists to protect: {@see \Mcp\Schema\Result} classes describe
|
||||||
|
* *what* a server answered, not what any particular revision requires on the
|
||||||
|
* wire. Fields that exist only from 2026-07-28 — the `resultType`
|
||||||
|
* discriminator, the SEP-2549 caching hints, the `_meta` serverInfo identity —
|
||||||
|
* are therefore not modelled on those classes at all. They are stamped here,
|
||||||
|
* on the way out, by the codec for the negotiated version.
|
||||||
|
*
|
||||||
|
* Keeping them out of the model is what lets one set of result classes serve
|
||||||
|
* every revision without a server on 2025-11-25 suddenly emitting vocabulary
|
||||||
|
* its clients have never seen. The alternative — emitting the newer fields
|
||||||
|
* unconditionally — is legal per the spec's forward-compatibility rule, but it
|
||||||
|
* changes what every already-deployed client receives, which is not a decision
|
||||||
|
* a result class should be making on its own.
|
||||||
|
*
|
||||||
|
* Eras, not versions: every revision through 2025-11-25 shares one wire
|
||||||
|
* vocabulary and one codec. A new codec appears only where the vocabulary
|
||||||
|
* actually diverges.
|
||||||
|
*
|
||||||
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||||
|
*/
|
||||||
|
interface WireCodecInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Stamps an already-serialized result with whatever this era requires.
|
||||||
|
*
|
||||||
|
* @param string $method the request method the result answers
|
||||||
|
* @param array<string, mixed> $result the neutral result body
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function encodeResult(string $method, array $result): array;
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@ services:
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.26-alpine
|
image: nginx:1.26-alpine
|
||||||
ports:
|
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:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- ../../..:/app:ro
|
- ../../..:/app:ro
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ server {
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /app;
|
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 / {
|
location / {
|
||||||
try_files $uri /tests/Conformance/server.php$is_args$args;
|
try_files $uri /tests/Conformance/server.php$is_args$args;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
tests/Conformance/conformance-baseline-2026-07-28.yml
Normal file
71
tests/Conformance/conformance-baseline-2026-07-28.yml
Normal 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: []
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue