The stream acknowledged, slept thirty seconds and closed, so tools and resource changes never reached a client — and with resources/subscribe gone, that left the revision with no server-push at all. A notification bus carries them: in-memory where publisher and stream share a process, PSR-16 where php-fpm puts them in different ones. The conformance server proves the latter — a tools/call in one worker reaches a stream held in another.
766 lines
30 KiB
PHP
766 lines
30 KiB
PHP
<?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\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\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',
|
|
// Replaced by the `resourceSubscriptions` filter of subscriptions/listen.
|
|
'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
|
|
*/
|
|
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,
|
|
) {
|
|
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy);
|
|
|
|
if (null === $this->headerValidator) {
|
|
// Not fatal: a transport without a header layer — stdio — has
|
|
// nothing to validate. But on HTTP the headers are REQUIRED for
|
|
// compliance, so an absent validator there is a silently
|
|
// non-conformant server and worth saying out loud once.
|
|
$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.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
/** @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.
|
|
//
|
|
// An absent id and an unreadable one are different messages: the first
|
|
// is a notification, the second a malformed request. JSON-RPC 2.0
|
|
// writes "no id" as an explicit null, so that counts as absent too.
|
|
$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;
|
|
|
|
// No id is a notification. It gets an acknowledgment, never a response
|
|
// — answering one with a JSON-RPC message would invent a correlation
|
|
// the client has no request to match it against. Checked before the
|
|
// `_meta` parse: notification params carry `NotificationMetaObject`,
|
|
// which has none of a request's required members.
|
|
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);
|
|
} catch (MissingRequestMetaException $e) {
|
|
return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400);
|
|
}
|
|
|
|
if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) {
|
|
return $versionError;
|
|
}
|
|
|
|
// After the version check: a peer on the wrong revision has a more
|
|
// fundamental problem than headers that disagree with its body.
|
|
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');
|
|
|
|
// REQUIRED on every POST. The 2025-03-26 fallback for a header-less
|
|
// request exists only for servers choosing to serve pre-2025-06-18
|
|
// clients, which a modern-only endpoint is not.
|
|
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 !== $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 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(),
|
|
],
|
|
];
|
|
|
|
// From now, not from the beginning: a subscriber wants what happens
|
|
// next, not a replay of the server's history.
|
|
$cursor = $bus?->cursor() ?? 0;
|
|
|
|
// The tick is not optional: PHP spots a dropped peer by writing,
|
|
// and a sleeping loop would pin an FPM worker for the full lifetime.
|
|
$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);
|
|
}
|
|
|
|
// Graceful closure (SHOULD), so the client can tell this from a
|
|
// dropped transport.
|
|
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
|
|
{
|
|
/** @var array<string, mixed> $frame */
|
|
$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(Error::forMethodNotFound(\sprintf('Method "%s" is not supported.', $method), $id), 404);
|
|
}
|
|
|
|
$request = $messages[0] ?? null;
|
|
|
|
// 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);
|
|
|
|
// Under the same keys the handshake era writes, so everything reading
|
|
// connection state — ClientGateway's capability probes above all — sees
|
|
// this request's declaration instead of an empty session.
|
|
$session->set('client_capabilities', $meta->clientCapabilities->jsonSerialize());
|
|
$session->set('protocol_version', $meta->protocolVersion);
|
|
|
|
try {
|
|
$input = $this->liftInputContext($decoded['params'] ?? null);
|
|
} catch (RequestStateException $e) {
|
|
// Invalid params, not an authorization failure: the client only
|
|
// echoes what it was given, and the reason stays out of the answer.
|
|
$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);
|
|
}
|
|
|
|
// What ClientGateway::progress() reads to find the progress token, and
|
|
// the handshake era sets under the same key.
|
|
$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 {
|
|
// Runs the handler up to its first notification, or to the end
|
|
// if it emits none. Deciding here and not earlier is what keeps
|
|
// the status codes honest: a request that turns out to need
|
|
// -32021 has said nothing yet, so it can still be answered
|
|
// with 400 rather than an error frame under a 200.
|
|
$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 {
|
|
// Stepped rather than foreach()ed: rewind() already advanced it,
|
|
// and a generator will not be traversed a second time.
|
|
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(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404);
|
|
}
|
|
|
|
/**
|
|
* 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 refused rather than forwarded: this revision
|
|
* carries those in the result (MRTR), and putting one on a response stream
|
|
* is something the transport binding forbids outright.
|
|
*
|
|
* @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));
|
|
|
|
$suspended = $fiber->start();
|
|
|
|
while (!$fiber->isTerminated()) {
|
|
$notification = $this->readNotification($suspended, $meta);
|
|
|
|
if (null !== $notification) {
|
|
yield $notification;
|
|
}
|
|
|
|
$suspended = $fiber->resume(null);
|
|
}
|
|
|
|
/** @var Response<ResultInterface>|Error $return */
|
|
$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: return an InputRequiredResult naming what you need instead, and read the answers back through RequestContext::getInputContext(). See the multi round-trip requests pattern.');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
$notification = $suspended['notification'] ?? null;
|
|
|
|
if (!$notification instanceof Notification) {
|
|
return null;
|
|
}
|
|
|
|
// The client opts into logs per request; with no level named the server
|
|
// MUST NOT send any, which is why an absent level drops rather than
|
|
// defaults.
|
|
if ($notification instanceof LoggingMessageNotification) {
|
|
if (null === $meta->logLevel || !$notification->level->isAtLeast($meta->logLevel)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $notification;
|
|
}
|
|
|
|
/**
|
|
* 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 $run->current()->jsonSerialize();
|
|
|
|
$run->next();
|
|
}
|
|
|
|
$result = $run->getReturn();
|
|
} catch (\Throwable $e) {
|
|
// Headers left long ago, so the status is already 200 and the only
|
|
// way left to report this is a frame.
|
|
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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// Guidance for the tool author, not a detail leaked from their
|
|
// code or a dependency's — safe to echo back verbatim.
|
|
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;
|
|
}
|
|
|
|
// Answers are result objects; dropping non-objects leaves the handler
|
|
// to ask again rather than read a malformed retry as satisfied.
|
|
if (null !== $responses) {
|
|
$responses = array_filter($responses, static fn (mixed $response): bool => \is_array($response));
|
|
}
|
|
|
|
$payload = [];
|
|
|
|
if (null !== $state) {
|
|
// A server with no codec never minted a state, so this one cannot
|
|
// have come from here.
|
|
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
|
|
{
|
|
foreach ($headers as $key => $value) {
|
|
if (0 === strcasecmp($key, $name)) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|