<?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;
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) {
$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);
}
}