Use SchemaValidator to validate tool input (#203)

* Use SchemaValidator to validate tool input

* add test

* add changelog

* fix cs

* Improve error message

* fix tests

* fix tests

* fix baseline

* update snapshot files

* Remove BC compat while switching to v0.3.0

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
Tobias Nyholm 2026-01-11 21:46:53 +01:00 • committed by GitHub
commit ed205ef793
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 103 additions and 29 deletions

View file

@ -2,10 +2,11 @@
All notable changes to `mcp/sdk` will be documented in this file. All notable changes to `mcp/sdk` will be documented in this file.
0.3 0.3.0
----- -----
* Add output schema support to MCP tools * Add output schema support to MCP tools
* Add validation of the input parameters given to a Tool.
0.2.2 0.2.2
----- -----

View file

@ -11,9 +11,9 @@
namespace Mcp\Example\Server\ComplexToolSchema\Model; namespace Mcp\Example\Server\ComplexToolSchema\Model;
enum EventPriority: int enum EventPriority: string
{ {
case Low = 0; case Low = 'low';
case Normal = 1; case Normal = 'normal';
case High = 2; case High = 'high';
} }

View file

@ -220,7 +220,7 @@ class SchemaValidator
break; break;
case 'type': case 'type':
$expected = implode('|', (array) ($args['expected'] ?? [])); $expected = implode('|', (array) ($args['expected'] ?? []));
$used = $args['used'] ?? 'unknown'; $used = $error->data()->type() ?? 'unknown';
$message = "Invalid type. Expected `{$expected}`, but received `{$used}`."; $message = "Invalid type. Expected `{$expected}`, but received `{$used}`.";
break; break;
case 'enum': case 'enum':

View file

@ -91,9 +91,9 @@ class Error implements MessageInterface
return new self($id, self::METHOD_NOT_FOUND, $message); return new self($id, self::METHOD_NOT_FOUND, $message);
} }
public static function forInvalidParams(string $message, string|int $id = ''): self public static function forInvalidParams(string $message, string|int $id = '', mixed $data = null): self
{ {
return new self($id, self::INVALID_PARAMS, $message); return new self($id, self::INVALID_PARAMS, $message, $data);
} }
public static function forInternalError(string $message, string|int $id = ''): self public static function forInternalError(string $message, string|int $id = ''): self

View file

@ -11,6 +11,7 @@
namespace Mcp\Server\Handler\Request; namespace Mcp\Server\Handler\Request;
use Mcp\Capability\Discovery\SchemaValidator;
use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\RegistryInterface; use Mcp\Capability\RegistryInterface;
use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolCallException;
@ -33,11 +34,15 @@ use Psr\Log\NullLogger;
*/ */
final class CallToolHandler implements RequestHandlerInterface final class CallToolHandler implements RequestHandlerInterface
{ {
private SchemaValidator $schemaValidator;
public function __construct( public function __construct(
private readonly RegistryInterface $registry, private readonly RegistryInterface $registry,
private readonly ReferenceHandlerInterface $referenceHandler, private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(), private readonly LoggerInterface $logger = new NullLogger(),
?SchemaValidator $schemaValidator = null,
) { ) {
$this->schemaValidator = $schemaValidator ?? new SchemaValidator($logger);
} }
public function supports(Request $request): bool public function supports(Request $request): bool
@ -59,10 +64,35 @@ final class CallToolHandler implements RequestHandlerInterface
try { try {
$reference = $this->registry->getTool($toolName); $reference = $this->registry->getTool($toolName);
} catch (ToolNotFoundException $e) {
$this->logger->error('Tool not found', ['name' => $toolName]);
$arguments['_session'] = $session; return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
$arguments['_request'] = $request; }
$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;
try {
$result = $this->referenceHandler->handle($reference, $arguments); $result = $this->referenceHandler->handle($reference, $arguments);
$structuredContent = null; $structuredContent = null;
@ -87,10 +117,6 @@ final class CallToolHandler implements RequestHandlerInterface
$errorContent = [new TextContent($e->getMessage())]; $errorContent = [new TextContent($e->getMessage())];
return new Response($request->getId(), CallToolResult::error($errorContent)); return new Response($request->getId(), CallToolResult::error($errorContent));
} catch (ToolNotFoundException $e) {
$this->logger->error('Tool not found', ['name' => $toolName]);
return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('Unhandled error during tool execution', [ $this->logger->error('Unhandled error during tool execution', [
'name' => $toolName, 'name' => $toolName,

View file

@ -21,7 +21,7 @@ final class HttpDiscoveryUserProfileTest extends HttpInspectorSnapshotTestCase
'method' => 'tools/call', 'method' => 'tools/call',
'options' => [ 'options' => [
'toolName' => 'send_welcome', 'toolName' => 'send_welcome',
'toolArgs' => ['userId' => '101', 'customMessage' => 'Welcome to our platform!'], 'toolArgs' => ['userId' => '"101"', 'customMessage' => 'Welcome to our platform!'],
], ],
'testName' => 'send_welcome', 'testName' => 'send_welcome',
], ],

View file

@ -2,7 +2,7 @@
"content": [ "content": [
{ {
"type": "text", "type": "text",
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Project Deadline\\\" scheduled successfully for \\\"2024-12-15\\\".\",\n \"event_details\": {\n \"title\": \"Project Deadline\",\n \"date\": \"2024-12-15\",\n \"type\": \"reminder\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false\n }\n}" "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Project Deadline\\\" scheduled successfully for \\\"2024-12-15\\\".\",\n \"event_details\": {\n \"title\": \"Project Deadline\",\n \"date\": \"2024-12-15\",\n \"type\": \"reminder\",\n \"time\": \"All day\",\n \"priority\": \"High\",\n \"attendees\": [],\n \"invites_will_be_sent\": false\n }\n}"
} }
], ],
"structuredContent": { "structuredContent": {
@ -13,7 +13,7 @@
"date": "2024-12-15", "date": "2024-12-15",
"type": "reminder", "type": "reminder",
"time": "All day", "time": "All day",
"priority": "Normal", "priority": "High",
"attendees": [], "attendees": [],
"invites_will_be_sent": false "invites_will_be_sent": false
} }

View file

@ -2,7 +2,7 @@
"content": [ "content": [
{ {
"type": "text", "type": "text",
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Client Call\\\" scheduled successfully for \\\"2024-12-02\\\".\",\n \"event_details\": {\n \"title\": \"Client Call\",\n \"date\": \"2024-12-02\",\n \"type\": \"call\",\n \"time\": \"14:30\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"client@example.com\"\n ],\n \"invites_will_be_sent\": false\n }\n}" "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Client Call\\\" scheduled successfully for \\\"2024-12-02\\\".\",\n \"event_details\": {\n \"title\": \"Client Call\",\n \"date\": \"2024-12-02\",\n \"type\": \"call\",\n \"time\": \"14:30\",\n \"priority\": \"High\",\n \"attendees\": [\n \"client@example.com\"\n ],\n \"invites_will_be_sent\": false\n }\n}"
} }
], ],
"structuredContent": { "structuredContent": {
@ -13,7 +13,7 @@
"date": "2024-12-02", "date": "2024-12-02",
"type": "call", "type": "call",
"time": "14:30", "time": "14:30",
"priority": "Normal", "priority": "High",
"attendees": [ "attendees": [
"client@example.com" "client@example.com"
], ],

View file

@ -2,7 +2,7 @@
"content": [ "content": [
{ {
"type": "text", "type": "text",
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Office Party\\\" scheduled successfully for \\\"2024-12-20\\\".\",\n \"event_details\": {\n \"title\": \"Office Party\",\n \"date\": \"2024-12-20\",\n \"type\": \"other\",\n \"time\": \"18:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"team@company.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}" "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Office Party\\\" scheduled successfully for \\\"2024-12-20\\\".\",\n \"event_details\": {\n \"title\": \"Office Party\",\n \"date\": \"2024-12-20\",\n \"type\": \"other\",\n \"time\": \"18:00\",\n \"priority\": \"Low\",\n \"attendees\": [\n \"team@company.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}"
} }
], ],
"structuredContent": { "structuredContent": {
@ -13,7 +13,7 @@
"date": "2024-12-20", "date": "2024-12-20",
"type": "other", "type": "other",
"time": "18:00", "time": "18:00",
"priority": "Normal", "priority": "Low",
"attendees": [ "attendees": [
"team@company.com" "team@company.com"
], ],

View file

@ -33,13 +33,13 @@
"default": null "default": null
}, },
"priority": { "priority": {
"type": "integer", "type": "string",
"description": "The priority of the event. Defaults to Normal.", "description": "The priority of the event. Defaults to Normal.",
"default": 1, "default": "normal",
"enum": [ "enum": [
0, "low",
1, "normal",
2 "high"
] ]
}, },
"attendees": { "attendees": {

View file

@ -50,7 +50,7 @@ abstract class InspectorSnapshotTestCase extends TestCase
if (\is_array($value)) { if (\is_array($value)) {
$args[] = \sprintf('%s=%s', $key, json_encode($value)); $args[] = \sprintf('%s=%s', $key, json_encode($value));
} elseif (\is_bool($value)) { } elseif (\is_bool($value)) {
$args[] = \sprintf('%s=%s', $key, $value ? '1' : '0'); $args[] = \sprintf('%s=%s', $key, $value ? 'true' : 'false');
} else { } else {
$args[] = \sprintf('%s=%s', $key, $value); $args[] = \sprintf('%s=%s', $key, $value);
} }
@ -73,7 +73,7 @@ abstract class InspectorSnapshotTestCase extends TestCase
if (\is_array($value)) { if (\is_array($value)) {
$args[] = \sprintf('%s=%s', $key, json_encode($value)); $args[] = \sprintf('%s=%s', $key, json_encode($value));
} elseif (\is_bool($value)) { } elseif (\is_bool($value)) {
$args[] = \sprintf('%s=%s', $key, $value ? '1' : '0'); $args[] = \sprintf('%s=%s', $key, $value ? 'true' : 'false');
} else { } else {
$args[] = \sprintf('%s=%s', $key, $value); $args[] = \sprintf('%s=%s', $key, $value);
} }

View file

@ -466,6 +466,43 @@ class CallToolHandlerTest extends TestCase
$this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize());
} }
public function testValidationError(): void
{
$schema = [
'type' => 'object',
'properties' => [
'favorite_number' => [
'type' => 'number',
'description' => 'Your favorite number',
],
],
'required' => [
'favorite_number',
],
];
$request = $this->createCallToolRequest('result_tool', ['query' => 'php']);
$toolReference = $this->getMockBuilder(ToolReference::class)
->setConstructorArgs([new Tool('simple_tool', $schema, null, null), function () {}])
->getMock();
$this->registry
->expects($this->once())
->method('getTool')
->with('result_tool')
->willReturn($toolReference);
$this->referenceHandler
->expects($this->never())
->method('handle');
$response = $this->handler->handle($request, $this->session);
$this->assertInstanceOf(Error::class, $response);
$this->assertEquals($request->getId(), $response->id);
$this->assertEquals(Error::INVALID_PARAMS, $response->code);
}
/** /**
* @param array<string, mixed> $arguments * @param array<string, mixed> $arguments
*/ */
@ -488,7 +525,17 @@ class CallToolHandlerTest extends TestCase
?array $outputSchema = null, ?array $outputSchema = null,
array $methodsToMock = ['formatResult'], array $methodsToMock = ['formatResult'],
): ToolReference&MockObject { ): ToolReference&MockObject {
$tool = new Tool($name, ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null, $outputSchema); $schema = [
'type' => 'object',
'properties' => [
'example' => [
'type' => 'string',
'description' => 'This is just a dummy',
],
],
'required' => [],
];
$tool = new Tool($name, $schema, null, null, null, null, $outputSchema);
$builder = $this->getMockBuilder(ToolReference::class) $builder = $this->getMockBuilder(ToolReference::class)
->setConstructorArgs([$tool, $handler]); ->setConstructorArgs([$tool, $handler]);