[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
4 changed files with 1735 additions and 16 deletions
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue