* [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.
182 lines
7.3 KiB
PHP
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);
|
|
}
|
|
}
|