php-sdk/src/Server/Handler/Request/CallToolHandler.php
Christopher Hertel 5e73683079
[Server] Close the conformance gaps in the modern lifecycle (#447)
* [Server] Let the client gateway see modern-era capabilities

The six supports*() probes read session state only InitializeHandler wrote,
so every request served statelessly reported no client capabilities at all —
a tool guarding on them took the unsupported branch without a signal.
StatelessProtocol now writes the request's declaration under the same keys.

* [Server] Reject the RPCs 2026-07-28 removed

resources/subscribe and resources/unsubscribe were still dispatched and
answered 200 OK, recording subscriptions the modern era never reads. They
join initialize, ping and logging/setLevel in the era guard; the handlers
stay registered because the handshake era still serves them.

* [Server] Decode a wrapped Mcp-Name before comparing it

The Base64 sentinel was only unwrapped for Mcp-Param-*, so any resource URI
or tool name outside the header-safe ASCII set — which the spec explicitly
tells clients to wrap — was compared encoded and refused with -32020.

* [Server] Require the MCP-Protocol-Version header

It was only checked for contradicting the body, so omitting it entirely
passed — leaving the value intermediaries route on unenforced. Tied to the
header validator's presence, since that is what a header-bearing transport
installs and stdio carries its metadata inline.

* [Server] Read the modern-era request body whole

A single read() takes whatever the stream cares to give — 64 bytes from a
chunked transfer is legal — so an oversized-but-valid POST came back as a
parse error. Shares the legacy transport's incremental read as a trait.

* [Schema][Server] Answer a notification with a status, not a response

A body with no id was dispatched as a request and came back as a JSON-RPC
error carrying "id": "" — an id nobody issued, and on a path a client uses
to tell a modern server from a legacy one. Notifications now get 202 and no
body, and Error omits an id it could not read instead of emptying it.

* [Server] Stop emitting the error codes 2026-07-28 reserved

resources/read answered -32002, which this revision forbids; prompts/get
and completion/complete answered it for an unknown *name*, which it never
meant; tools/call answered -32601, which belongs to an unknown method.
Only the resources/read code is revision-gated — older peers expect -32002
there, and nowhere else. Also routes the -32021 capability exception out of
the prompt and resource handlers, which were swallowing it into -32603.
2026-08-18 23:09:34 +02:00

182 lines
7.3 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\Handler\Request;
use Mcp\Capability\Discovery\SchemaValidator;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Exception\MissingRequiredClientCapabilityException;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\RequestContext;
use Mcp\Server\Session\SessionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* A tools/call answers with the tool's output or, under MRTR, with a request
* for the input it still needs.
*
* @implements RequestHandlerInterface<CallToolResult|InputRequiredResult>
*
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class CallToolHandler implements RequestHandlerInterface
{
private SchemaValidator $schemaValidator;
public function __construct(
private readonly RegistryInterface $registry,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
?SchemaValidator $schemaValidator = null,
) {
$this->schemaValidator = $schemaValidator ?? new SchemaValidator($logger);
}
public function supports(Request $request): bool
{
return $request instanceof CallToolRequest;
}
/**
* @return Response<CallToolResult|InputRequiredResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
\assert($request instanceof CallToolRequest);
$toolName = $request->name;
$arguments = $request->arguments;
$this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]);
try {
$reference = $this->registry->getTool($toolName);
} catch (ToolNotFoundException $e) {
$this->logger->error('Tool not found', ['name' => $toolName, 'exception' => $e]);
// -32601 answers an unknown *method*; tools/call exists, it is the
// name in its params that does not.
return Error::forInvalidParams($e->getMessage(), $request->getId());
}
$inputSchema = $reference->tool->inputSchema;
$validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema);
if (!empty($validationErrors)) {
$errorMessages = [];
foreach ($validationErrors as $errorDetail) {
$pointer = $errorDetail['pointer'] ?? '';
$message = $errorDetail['message'] ?? 'Unknown validation error';
$errorMessages[] = ('/' !== $pointer && '' !== $pointer ? "Property '{$pointer}': " : '').$message;
}
$summaryMessage = "Invalid parameters for tool '{$toolName}': ".implode('; ', \array_slice($errorMessages, 0, 3));
if (\count($errorMessages) > 3) {
$summaryMessage .= '; ...and more errors.';
}
return Error::forInvalidParams($summaryMessage, $request->getId(), ['validation_errors' => $validationErrors]);
}
$arguments['_session'] = $session;
$arguments['_request'] = $request;
$context = new RequestContext($session, $request);
try {
$result = $this->referenceHandler->handle($reference, $arguments);
// An ask is a result in its own right, not tool output.
if ($result instanceof InputRequiredResult) {
return new Response($request->getId(), $result);
}
$protocolVersion = $context->getProtocolVersion();
$structuredContent = null;
if (!$result instanceof CallToolResult) {
$structuredContent = $reference->extractStructuredContent($result, $protocolVersion);
if (null === $structuredContent && null !== $reference->tool->outputSchema) {
$this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [
'name' => $toolName,
'result_type' => get_debug_type($result),
]);
}
$result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent);
} elseif ($protocolVersion->requiresObjectStructuredContent()
&& null !== $result->structuredContent
&& [] !== $result->structuredContent
&& !self::isJsonObject($result->structuredContent)
) {
// A tool building its own `CallToolResult` bypasses the extraction
// rules on purpose, so the value is sent as it was set — but before
// SEP-2106 only a JSON object is valid here, whether the value is a
// list or a scalar, and clients may reject it.
$this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is not a JSON object, which the negotiated protocol revision requires; sending it unchanged.', [
'name' => $toolName,
'protocol_version' => $protocolVersion->value,
'structured_content_type' => get_debug_type($result->structuredContent),
]);
}
$this->logger->debug('Tool executed successfully', [
'name' => $toolName,
'result_type' => \gettype($result),
'structured_content' => $structuredContent,
]);
return new Response($request->getId(), $result);
} catch (MissingRequiredClientCapabilityException $e) {
// Not a tool failure — the request was unservable, and the client
// needs to retry declaring the capability. Rendered as -32021.
throw $e;
} catch (ToolCallException $e) {
$this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [
'tool' => $toolName,
'arguments' => $arguments,
'exception' => $e,
]);
$errorContent = [new TextContent($e->getMessage())];
return new Response($request->getId(), CallToolResult::error($errorContent));
} catch (\Throwable $e) {
$this->logger->error('Unhandled error during tool execution', [
'name' => $toolName,
'exception' => $e,
]);
return Error::forInternalError('Error while executing tool', $request->getId());
}
}
/**
* Whether a `structuredContent` value encodes as a JSON object — the only shape
* revisions predating SEP-2106 accept.
*/
private static function isJsonObject(mixed $value): bool
{
return \is_array($value) && !array_is_list($value);
}
}