* [Server] Serve both protocol eras from one endpoint The two lifecycles share a transport, not a dispatcher. StreamableHttpTransport classifies each request - a 2026-07-28 envelope, an initialize handshake, or a session-bound follow-up - through InboundClassifier and routes it to the dispatcher that owns it, so one URL answers a modern client and a handshake-era one alike. Builder::build() carries both; withoutModernEra() opts out and setModernVersions() narrows what the modern leg answers for. InputRequiredShim lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has - so a handler is written once rather than twice. The conformance fixture collapses into one server for the same reason: both legs now hit the same URL. * Trim redundant comments, fix stale in-memory claim in conformance fixture * Drop stale json-schema-2020-12 baseline entries The new fixture tool makes this scenario pass in both eras now. * Reject handshake revisions in setModernVersions(), warn on unsafe custom middleware Copilot review: setModernVersions() accepted revisions InboundClassifier would never route to the modern leg. StreamableHttpTransport now warns when a custom middleware list carries ProtocolVersionMiddleware, since it runs before era classification and rejects modern-era traffic by default. Also fixed a misleading docblock claim on the same middleware.
228 lines
8.4 KiB
PHP
228 lines
8.4 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;
|
|
|
|
use Mcp\Exception\RequestStateException;
|
|
use Mcp\Schema\ClientCapabilities;
|
|
use Mcp\Schema\JsonRpc\Error;
|
|
use Mcp\Schema\JsonRpc\Request;
|
|
use Mcp\Schema\JsonRpc\Response;
|
|
use Mcp\Schema\JsonRpc\ResultInterface;
|
|
use Mcp\Schema\Result\InputRequiredResult;
|
|
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
|
use Mcp\Server\Session\SessionInterface;
|
|
use Mcp\Server\Stateless\InputContext;
|
|
use Mcp\Server\Stateless\InputRequestCapabilities;
|
|
use Mcp\Server\Stateless\RequestStateCodec;
|
|
use Psr\Log\LoggerInterface;
|
|
use Psr\Log\NullLogger;
|
|
|
|
/**
|
|
* Serves a modern-era handler on a handshake-era connection.
|
|
*
|
|
* Revision 2026-07-28 removed server-initiated requests: a handler that needs
|
|
* something from the user returns an {@see InputRequiredResult} naming it, and
|
|
* the client retries the whole call with the answers. The handshake era has no
|
|
* such retry — it has a channel back to the client instead.
|
|
*
|
|
* This closes that gap from the server side. When a handler returns an
|
|
* input-required result on a handshake-era connection, each embedded request
|
|
* goes out as the real server-to-client request it describes
|
|
* (`elicitation/create`, `sampling/createMessage`, `roots/list`), and the
|
|
* handler is re-entered with the answers under the keys it asked for. A handler
|
|
* written once for the modern era therefore serves both, and cannot tell which
|
|
* fulfilled it.
|
|
*
|
|
* The cost is that re-entry is re-execution: the handler runs again from the
|
|
* top each round, so it must re-derive its position from what came back rather
|
|
* than from anything it kept. That is already true of the modern era — the
|
|
* client retries the whole call there — so a portable handler is written that
|
|
* way regardless; it is only new for handlers that until now could rely on
|
|
* {@see ClientGateway::elicit()} suspending mid-body and keeping their locals.
|
|
* Those keep working untouched: nothing here runs unless a handler *returns* an
|
|
* ask.
|
|
*
|
|
* Runs inside the handler's own fiber, so the wait for each answer is the same
|
|
* suspension {@see ClientGateway} already uses, driven by the same transport
|
|
* loop.
|
|
*
|
|
* @author Christopher Hertel <mail@christopher-hertel.de>
|
|
*/
|
|
final class InputRequiredShim
|
|
{
|
|
/**
|
|
* Re-entries per originating request. Deliberately below the modern
|
|
* client driver's allowance: this loop holds a live request open.
|
|
*/
|
|
public const DEFAULT_MAX_ROUNDS = 8;
|
|
|
|
/** Seconds to wait for one answer. Legs are human-paced, so the protocol's 120s default is wrong here. */
|
|
public const DEFAULT_ROUND_TIMEOUT = 600;
|
|
|
|
public function __construct(
|
|
private readonly int $maxRounds = self::DEFAULT_MAX_ROUNDS,
|
|
private readonly int $roundTimeout = self::DEFAULT_ROUND_TIMEOUT,
|
|
private readonly LoggerInterface $logger = new NullLogger(),
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Runs a handler to a result the client can be given.
|
|
*
|
|
* Returns whatever the handler returned when it asks for nothing, which is
|
|
* every call that is not multi round-trip.
|
|
*
|
|
* @param Response<mixed>|Error $result what the handler returned on its first entry
|
|
* @param RequestHandlerInterface<ResultInterface|array<string, mixed>> $handler the handler that produced it
|
|
*
|
|
* @return Response<mixed>|Error
|
|
*/
|
|
public function fulfill(
|
|
Response|Error $result,
|
|
RequestHandlerInterface $handler,
|
|
Request $request,
|
|
SessionInterface $session,
|
|
?RequestStateCodec $codec,
|
|
): Response|Error {
|
|
$round = 0;
|
|
|
|
while (($ask = self::askOf($result)) instanceof InputRequiredResult) {
|
|
if (++$round > $this->maxRounds) {
|
|
$this->logger->warning('A handler kept asking for input past the round limit; the call was failed instead.', [
|
|
'method' => $request::getMethod(),
|
|
'rounds' => $this->maxRounds,
|
|
]);
|
|
|
|
return Error::forInternalError(
|
|
\sprintf('The server asked for input more than %d times without reaching a result.', $this->maxRounds),
|
|
$request->getId(),
|
|
);
|
|
}
|
|
|
|
if (null !== $refusal = $this->refuseUndeclared($ask, $request, $session)) {
|
|
return $refusal;
|
|
}
|
|
|
|
try {
|
|
$session->set(InputContext::class, new InputContext(
|
|
$this->collect($ask, $session),
|
|
self::payloadOf($ask, $codec),
|
|
));
|
|
} catch (RequestStateException $e) {
|
|
$this->logger->error('A handler minted a requestState this server cannot verify.', ['exception' => $e]);
|
|
|
|
return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId());
|
|
}
|
|
|
|
$result = $handler->handle($request, $session);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Sends each embedded request and keeps the answer under the key it was
|
|
* asked under.
|
|
*
|
|
* Answers are stored as the raw result arrays {@see InputContext} parses,
|
|
* so this needs to know nothing about the kinds it is carrying — which is
|
|
* also why an extension's future kind rides through unchanged.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function collect(InputRequiredResult $ask, SessionInterface $session): array
|
|
{
|
|
$gateway = new ClientGateway($session);
|
|
$responses = [];
|
|
|
|
foreach ($ask->inputRequests as $key => $embedded) {
|
|
$answer = $gateway->request($embedded, $this->roundTimeout);
|
|
|
|
if ($answer instanceof Error) {
|
|
// Not fatal to the call: a client that refuses one ask has
|
|
// answered it, and the handler decides what that means.
|
|
$this->logger->info('The client failed an input request; the handler is re-entered without it.', [
|
|
'key' => $key,
|
|
'error' => $answer->message,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$responses[$key] = $answer->result;
|
|
}
|
|
|
|
return $responses;
|
|
}
|
|
|
|
/**
|
|
* Refuses an ask the client never said it could answer, the way the modern
|
|
* era does — rather than sending a request that can only come back as an
|
|
* error.
|
|
*/
|
|
private function refuseUndeclared(InputRequiredResult $ask, Request $request, SessionInterface $session): ?Error
|
|
{
|
|
$declared = ClientCapabilities::fromArray((array) $session->get('client_capabilities', []));
|
|
$missing = InputRequestCapabilities::missing($ask, $declared);
|
|
|
|
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' => $request::getMethod(),
|
|
'required' => $missing->jsonSerialize(),
|
|
]);
|
|
|
|
return Error::forMissingRequiredClientCapability(
|
|
'The server needs input this client did not declare it can provide.',
|
|
$missing,
|
|
$request->getId(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The ask a handler returned, if it returned one.
|
|
*
|
|
* @param Response<mixed>|Error $result
|
|
*/
|
|
private static function askOf(Response|Error $result): ?InputRequiredResult
|
|
{
|
|
return $result instanceof Response && $result->result instanceof InputRequiredResult
|
|
? $result->result
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* The state the handler sealed last round, verified.
|
|
*
|
|
* Verified rather than trusted even though it never left this process: the
|
|
* handler reads it back through the same accessor either era, so it has to
|
|
* have been through the same check.
|
|
*
|
|
* @return array<string, mixed>
|
|
*
|
|
* @throws RequestStateException when a state is present but does not verify
|
|
*/
|
|
private static function payloadOf(InputRequiredResult $ask, ?RequestStateCodec $codec): array
|
|
{
|
|
if (null === $ask->requestState) {
|
|
return [];
|
|
}
|
|
|
|
if (null === $codec) {
|
|
throw new RequestStateException('mac');
|
|
}
|
|
|
|
return $codec->verify($ask->requestState);
|
|
}
|
|
}
|