<?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\LogicException;
use Mcp\Exception\MissingRequestMetaException;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Exception\RequestStateException;
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\Response;
use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\DiscoverResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\Configuration;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Protocol;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\Session;
use Mcp\Server\Subscription\NotificationBusInterface;
use Mcp\Server\Wire\CachePolicy;
use Mcp\Server\Wire\InboundClassifier;
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.
*
* Separate from {@see Protocol} because the modern era has no
* session to resolve, replay or keep a fiber against; the two eras share
* request handlers, not control flow.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class StatelessProtocol
{
private readonly WireCodecInterface $codec;
* Methods the modern era deleted. Answered as unknown methods, which is
* what they are to a modern server.
*
* A deny-list rather than an allow-list on purpose: extensions add methods
* this class has never heard of, so an unlisted method has to reach
* dispatch. Every removal named in the 2026-07-28 changelog belongs here —
* the handlers behind them stay registered for the handshake era, which is
* why the era guard, and not the registration, is what turns them off.
*/
public const REMOVED_METHODS = [
'initialize',
'notifications/initialized',
'ping',
'logging/setLevel',
'resources/subscribe',
'resources/unsubscribe',
'notifications/roots/list_changed',
];
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
* @param array<string, string> $extensionMethods RPC method to the extension identifier defining it
*/
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,
private readonly ?StandardHeaderValidator $headerValidator = null,
private readonly ?RequestStateCodec $requestStateCodec = null,
?CachePolicy $cachePolicy = null,
private readonly ?NotificationBusInterface $notificationBus = null,
private readonly array $extensionMethods = [],
) {
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy);
if (null === $this->headerValidator) {
$this->logger->warning('No StandardHeaderValidator configured; the SEP-2243 request headers will not be enforced. This is correct only for a transport without a header layer.');
}
}
* The modern revisions this dispatcher answers for.
*
* @return list<ProtocolVersion>
*/
public function supportedVersions(): array
{
return $this->supportedVersions;
}
* Whether the transport carrying this dispatcher has a header layer whose
* required members must be present.
*
* The validator's presence is the signal: it is what a header-bearing
* transport installs, and stdio carries its metadata inline instead
* (see the stdio binding's "Request Metadata").
*/
private function requiresTransportHeaders(): bool
{
return null !== $this->headerValidator;
}
* 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 {
$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);
}
$isNotification = !\array_key_exists('id', $decoded) || null === $decoded['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;
if ($isNotification) {
return $this->acknowledge($method);
}
if (null === $id) {
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400);
}
try {
$meta = RequestMeta::fromParams($params, $headers);
} catch (MissingRequestMetaException $e) {
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
}
if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) {
return $versionError;
}
if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) {
return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400);
}
if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) {
if (self::DISCOVER_METHOD === $method) {
return $this->encode($method, $id, $this->discover());
}
return $this->listen($params, $id);
}
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, self::acceptsEventStream($headers));
}
* Answers a notification.
*
* This revision's core defines no client-to-server notification over HTTP —
* `notifications/cancelled` is stdio-only, since closing the response
* stream is the cancellation signal here — so anything arriving is either
* an extension's or a client still speaking an older revision. Accepting
* the former and refusing the latter both come out as a status with no
* body; what must not happen is a JSON-RPC response.
*/
private function acknowledge(string $method): StatelessResult
{
if (\in_array($method, self::REMOVED_METHODS, true)) {
$this->logger->debug('Refused a notification this revision removed.', ['method' => $method]);
return StatelessResult::empty(400);
}
$this->logger->debug('Accepted a notification with no handler to run.', ['method' => $method]);
return StatelessResult::empty(202);
}
* Header and `_meta` must agree before the version can be judged supported:
* when they disagree the server cannot know which the client meant, so a
* mismatch outranks an unsupported version.
*
* @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 && $this->requiresTransportHeaders()) {
return StatelessResult::error(
Error::forHeaderMismatch(
\sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion),
$id,
),
400,
);
}
if (null !== $mismatch = InboundClassifier::crossCheckVersion($headerVersion, $meta->protocolVersion)) {
return StatelessResult::error(Error::forHeaderMismatch($mismatch, $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 id is the
* JSON-RPC id of this request, so there is none to mint.
*
* @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;
$bus = $this->notificationBus;
$codec = $this->codec;
return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec): \Generator {
// MUST be the first message carrying this subscription's id, and
// MUST precede any notification on it.
yield [
'jsonrpc' => '2.0',
'method' => self::ACKNOWLEDGED_NOTIFICATION,
'params' => [
'_meta' => [RequestMeta::SUBSCRIPTION_ID => $id],
'notifications' => (object) $agreed->toAcknowledgedArray(),
],
];
$cursor = $bus?->cursor() ?? 0;
$deadline = 0.0 >= $lifetime ? \INF : microtime(true) + $lifetime;
while (microtime(true) < $deadline) {
if (null !== $bus) {
[$notifications, $cursor] = $bus->since($cursor);
foreach ($notifications as $notification) {
if (!$agreed->carries($notification)) {
continue;
}
yield self::tagWithSubscription($notification, $id);
}
}
yield null;
if (connection_aborted()) {
return;
}
usleep(250_000);
}
yield [
'jsonrpc' => '2.0',
'id' => $id,
'result' => $codec->encodeResult(self::LISTEN_METHOD, [
'resultType' => 'complete',
'_meta' => [RequestMeta::SUBSCRIPTION_ID => $id],
], false),
];
});
}
* Every message on a listen stream carries the id of the subscription it
* belongs to, which is how a client demultiplexes them on stdio — where
* they all share one channel.
*
* @return array<string, mixed>
*/
private static function tagWithSubscription(Notification $notification, string|int $id): array
{
$frame = $notification->jsonSerialize();
$params = \is_array($frame['params'] ?? null) ? $frame['params'] : [];
$meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$meta[RequestMeta::SUBSCRIPTION_ID] = $id;
$params['_meta'] = $meta;
$frame['params'] = $params;
return $frame;
}
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, bool $wantsStream = false): 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($this->unknownMethod($method, $id), 404);
}
$request = $messages[0] ?? null;
if ($request instanceof InvalidInputMessageException) {
$unknownMethod = \sprintf('Unknown method "%s".', $method) === $request->getMessage();
return StatelessResult::error(
$unknownMethod
? $this->unknownMethod($method, $id)
: Error::forInvalidRequest($request->getMessage(), $id),
$unknownMethod ? 404 : 400,
);
}
if (!$request instanceof Request) {
return StatelessResult::error(Error::forInvalidRequest(\sprintf('"%s" is not a request this server can answer.', $method), $id), 400);
}
$id = $request->getId();
$session = new Session(new InMemorySessionStore());
$session->set(RequestMeta::class, $meta);
$session->set('client_capabilities', $meta->clientCapabilities->jsonSerialize());
$session->set('protocol_version', $meta->protocolVersion);
try {
$input = $this->liftInputContext($decoded['params'] ?? null);
} catch (RequestStateException $e) {
$this->logger->warning('Rejected a requestState that failed verification.', ['method' => $method, 'reason' => $e->getMessage()]);
return StatelessResult::error(Error::forInvalidParams('The supplied requestState failed verification.', $id), 400);
}
if (null !== $input) {
$session->set(InputContext::class, $input);
}
if (null !== $this->requestStateCodec) {
$session->set(RequestStateCodec::class, $this->requestStateCodec);
}
$session->set(Protocol::SESSION_ACTIVE_REQUEST_META, $request->getMeta());
foreach ($this->requestHandlers as $handler) {
if (!$handler->supports($request)) {
continue;
}
$run = $this->run($handler, $request, $session, $meta);
try {
$run->rewind();
} catch (\Throwable $e) {
return $this->toErrorResult($method, $id, $e);
}
if ($run->valid() && $wantsStream) {
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $meta, $method, $id, null === $input));
}
try {
while ($run->valid()) {
$this->logger->debug('Dropped a notification: the client did not accept a response stream.', [
'method' => $method,
'notification' => $run->current()::getMethod(),
]);
$run->next();
}
$result = $run->getReturn();
} catch (\Throwable $e) {
return $this->toErrorResult($method, $id, $e);
}
if ($result instanceof Error) {
return StatelessResult::error($result, 400);
}
if (null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
return $capabilityError;
}
return $this->encode($method, $id, $result->result, null === $input);
}
return StatelessResult::error($this->unknownMethod($method, $id), 404);
}
* A method with no handler, said as precisely as the server can.
*
* An extension's method is still `-32601` when the extension is off — the
* server genuinely does not implement it — but naming the extension turns
* an opaque refusal into something the caller can act on.
*/
private function unknownMethod(string $method, string|int $id): Error
{
$extension = $this->extensionMethods[$method] ?? null;
if (null !== $extension) {
return Error::forMethodNotFound(
\sprintf('Method "%s" belongs to the "%s" extension, which this server does not serve.', $method, $extension),
$id,
);
}
return Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id);
}
* Runs a handler, yielding the notifications it emits as it emits them and
* returning its result.
*
* The fiber is what makes a handler's `$gateway->progress(...)` look
* synchronous while the caller decides where the notification goes. Server
* -to-client *requests* are never forwarded: this revision carries what it
* needs in the result (MRTR), and putting a request on a response stream is
* something the transport binding forbids outright.
*
* An elicitation is answered here rather than refused. Already answered, it
* resumes the fiber and the handler runs on; not yet, and the ask becomes
* the result — abandoning the fiber, since this request has nothing left to
* say and the client will re-send it. Abandoning unwinds it, so a handler's
* `finally` still runs; what does not run is everything after the ask.
*
* That is what lets one handler serve both eras through
* {@see \Mcp\Server\ClientGateway::elicit()}; see {@see ElicitationReplay}
* for what it costs.
*
* @param RequestHandlerInterface<ResultInterface> $handler
*
* @return \Generator<int, Notification, null, Response<ResultInterface>|Error>
*/
private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator
{
$fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session));
$input = $session->get(InputContext::class);
$replay = new ElicitationReplay($input instanceof InputContext ? $input : null, $this->requestStateCodec);
$suspended = $fiber->start();
while (!$fiber->isTerminated()) {
if (null !== $elicitation = self::readElicitation($suspended)) {
[$named, $elicit] = $elicitation;
$key = $replay->key($named);
if (null === $answer = $replay->answer($key, $elicit->mode)) {
try {
return new Response($request->getId(), $replay->ask($key, $elicit));
} catch (LogicException $e) {
$this->logger->error('A handler asked for input across rounds on a server with no requestState signing key.', ['exception' => $e]);
return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId());
}
}
$suspended = $fiber->resume(new Response($request->getId(), $answer));
continue;
}
$notification = $this->readNotification($suspended, $meta);
if (null !== $notification) {
yield $notification;
}
$suspended = $fiber->resume(null);
}
$return = $fiber->getReturn();
return $return;
}
* Reads one fiber suspension, or null when it carries nothing to send.
*
* @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with
*/
private function readNotification(mixed $suspended, RequestMeta $meta): ?Notification
{
if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) {
if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) {
throw new LogicException('This protocol revision has no server-initiated requests: sampling and roots were removed with it, so take what you need through tool arguments, resource URIs or server configuration instead. Elicitation is the one ask that survived, as a multi round-trip request.');
}
return null;
}
$notification = $suspended['notification'] ?? null;
if (!$notification instanceof Notification) {
return null;
}
if ($notification instanceof LoggingMessageNotification) {
if (null === $meta->logLevel || !$notification->level->isAtLeast($meta->logLevel)) {
return null;
}
}
return $notification;
}
* One fiber suspension read as an elicitation, or null when it is not one.
*
* @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with
*
* @return array{0: string|null, 1: ElicitRequest}|null the name the handler gave the ask, and the ask
*/
private static function readElicitation(mixed $suspended): ?array
{
if (!\is_array($suspended) || 'request' !== ($suspended['type'] ?? null)) {
return null;
}
$request = $suspended['request'] ?? null;
if (!$request instanceof ElicitRequest) {
return null;
}
$key = $suspended['input_key'] ?? null;
return [\is_string($key) ? $key : null, $request];
}
* The frames of a request-scoped response stream: the notifications the
* handler emits, then the response that ends it.
*
* @param \Generator<int, Notification, null, Response<ResultInterface>|Error> $run
*
* @return \Generator<mixed>
*/
private function streamFrames(\Generator $run, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator
{
try {
while ($run->valid()) {
yield self::withTraceContext($run->current()->jsonSerialize(), $meta->traceContext);
$run->next();
}
$result = $run->getReturn();
} catch (\Throwable $e) {
yield $this->toErrorResult($method, $id, $e)->message?->jsonSerialize();
return;
}
if (!$result instanceof Error && null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
yield $capabilityError->message?->jsonSerialize();
return;
}
yield $result instanceof Error
? $result->jsonSerialize()
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize(), $cacheable)];
}
* Refuses to send an ask the client cannot answer.
*
* The handler's mistake rather than the client's, but the client is the one
* that has to hear about it, and `-32021` is precisely the code for
* "processing this needs a capability you did not declare" — so it is
* reported as that, and logged as the server-side bug it is.
*/
private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?StatelessResult
{
if (!$result instanceof InputRequiredResult) {
return null;
}
$missing = InputRequestCapabilities::missing($result, $meta->clientCapabilities);
if (null === $missing) {
return null;
}
$this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [
'method' => $method,
'required' => $missing->jsonSerialize(),
]);
return StatelessResult::error(
Error::forMissingRequiredClientCapability(
'The server needs input this client did not declare it can provide.',
$missing,
$id,
),
400,
);
}
* Puts the request's trace context back onto a notification it caused, so a
* collector can join the two without the handler carrying it by hand.
*
* @param array<string, mixed> $frame
* @param array<string, string> $traceContext
*
* @return array<string, mixed>
*/
private static function withTraceContext(array $frame, array $traceContext): array
{
if ([] === $traceContext) {
return $frame;
}
$params = \is_array($frame['params'] ?? null) ? $frame['params'] : [];
$frameMeta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$params['_meta'] = [...$traceContext, ...$frameMeta];
$frame['params'] = $params;
return $frame;
}
* The one place a handler's exception becomes an answer, so the streaming
* and non-streaming paths cannot disagree about which code it earns.
*/
private function toErrorResult(string $method, string|int $id, \Throwable $e): StatelessResult
{
if ($e instanceof MissingRequiredClientCapabilityException) {
return StatelessResult::error(
Error::forMissingRequiredClientCapability($e->getMessage(), $e->requiredCapabilities, $id),
400,
);
}
if ($e instanceof \InvalidArgumentException) {
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
}
if ($e instanceof LogicException) {
return StatelessResult::error(Error::forInternalError($e->getMessage(), $id), 500);
}
$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);
}
* Reads the multi round-trip material off a retry, verifying the state
* before any of it reaches a handler. Neither member means a first call,
* which is what a handler tests to decide whether it still needs to ask.
*
* @param array<string, mixed>|null $params
*
* @throws RequestStateException when a state is present but does not verify
*/
private function liftInputContext(?array $params): ?InputContext
{
$responses = \is_array($params['inputResponses'] ?? null) ? $params['inputResponses'] : null;
$state = \is_string($params['requestState'] ?? null) ? $params['requestState'] : null;
if (null === $responses && null === $state) {
return null;
}
if (null !== $responses) {
$responses = array_filter($responses, static fn (mixed $response): bool => \is_array($response));
}
$payload = [];
if (null !== $state) {
if (null === $this->requestStateCodec) {
throw new RequestStateException('mac');
}
$payload = $this->requestStateCodec->verify($state);
}
return new InputContext($responses ?? [], $payload);
}
* Runs a result through the wire codec. Passed as-is rather than via a
* json round trip, which would turn a nested `{}` into `[]`.
*/
private function encode(string $method, string|int $id, ResultInterface $result, bool $cacheable = true): StatelessResult
{
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize(), $cacheable));
}
* Whether the client will read a response stream.
*
* Clients MUST offer both content types, so this is normally true; a client
* that does not gets its notifications dropped rather than a stream it
* cannot parse.
*
* @param array<string, string> $headers
*/
private static function acceptsEventStream(array $headers): bool
{
foreach ($headers as $key => $value) {
if (0 === strcasecmp($key, 'Accept')) {
return str_contains(strtolower($value), 'text/event-stream');
}
}
return false;
}
* @param array<string, string> $headers
*/
private function header(array $headers, string $name): ?string
{
return InboundClassifier::header($headers, $name);
}
}