[Server] Serve both protocol eras from one endpoint (#455)
* [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.
This commit is contained in:
parent
b97389325f
commit
cbceef06eb
1 changed files with 2060 additions and 323 deletions
228
src/Server/InputRequiredShim.php
Normal file
228
src/Server/InputRequiredShim.php
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue