diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1cc684..e873dd58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Reject a recognized `Mcp-Param-*` header whose mirrored argument is absent from the body with `-32020`, instead of accepting the request (SEP-2243). * Fix `JwtTokenValidator` with several issuers always fetching the keys of the first one: keys now come from the issuer the token claims, which must be configured. * Fix `RequestEvent`, `ResponseEvent` and `ErrorEvent` not being dispatched for `2026-07-28` requests. +* [BC Break] Validate a tool result's `structuredContent` against the tool's `outputSchema`, which the specification requires the server to honour. A mismatch is answered with a `CallToolResult` carrying `isError: true` instead of the non-conforming value, matching the TypeScript, Python and Java SDKs. Skipped when the tool declares no `outputSchema`, when the result carries no `structuredContent`, and when the result is already an error. 0.8.0 ----- diff --git a/docs/servers/tools.md b/docs/servers/tools.md index a919b401..258039f3 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -200,6 +200,32 @@ Either way the data reaches the client: a return value with no structured repres A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see [Talking back to the client](../handlers/client-communication.md#clientgateway). +#### Output validation + +The specification states that a server **must** produce structured results that conform to the declared schema. The SDK +holds you to it: when a tool declares an `outputSchema` and the result carries `structuredContent`, the SDK validates +that value against the schema before sending it. A mismatch becomes a tool error result, so the model reads the reason +and can retry: + +```json +{ + "content": [ + { + "type": "text", + "text": "Invalid structured output for tool 'get_weather': Missing required properties: `temperature`." + } + ], + "isError": true +} +``` + +The check applies to a `CallToolResult` you build yourself as well as to one the SDK wraps for you. It is skipped in +three cases: + +- The tool declares no `outputSchema`. +- The result carries no `structuredContent`, which is the warning case described above. +- The result is already marked `isError: true`, because its content is a failure message and not the declared output. + [sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content ### Error Handling diff --git a/src/Capability/Discovery/SchemaValidator.php b/src/Capability/Discovery/SchemaValidator.php index 95eb68de..9476d8f8 100644 --- a/src/Capability/Discovery/SchemaValidator.php +++ b/src/Capability/Discovery/SchemaValidator.php @@ -46,10 +46,6 @@ public function __construct( */ public function validateAgainstJsonSchema(mixed $data, array|object $schema): array { - if (\is_array($data) && empty($data)) { - $data = new \stdClass(); - } - try { // --- Schema Preparation --- if (\is_array($schema)) { diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 97ace43e..c346431c 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -116,6 +116,10 @@ public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVe return $this->acceptsScalarStructuredContent($objectOnly) ? $decoded : null; } + if ([] === $decoded && '{}' === $jsonResult) { + return new \stdClass(); + } + if ($objectOnly && array_is_list($decoded)) { return null; } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 304bb664..dc81a672 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -24,6 +24,7 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\InputRequiredResult; +use Mcp\Schema\Tool; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -79,20 +80,10 @@ public function handle(Request $request, SessionInterface $session): Response|Er } $inputSchema = $reference->tool->inputSchema; - $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); + // Arguments are always a JSON object, but an empty one decodes to `[]`. + $validationErrors = $this->schemaValidator->validateAgainstJsonSchema([] === $arguments ? new \stdClass() : $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.'; - } + $summaryMessage = "Invalid parameters for tool '{$toolName}': ".self::summarizeValidationErrors($validationErrors); return Error::forInvalidParams($summaryMessage, $request->getId(), ['validation_errors' => $validationErrors]); } @@ -126,7 +117,6 @@ public function handle(Request $request, SessionInterface $session): Response|Er $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 @@ -146,7 +136,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er 'structured_content' => $structuredContent, ]); - return new Response($request->getId(), $result); + return new Response($request->getId(), $this->validateStructuredContent($reference->tool, $result) ?? $result); } catch (MissingRequiredClientCapabilityException $e) { // Not a tool failure — the request was unservable, and the client // needs to retry declaring the capability. Rendered as -32021. @@ -171,12 +161,65 @@ public function handle(Request $request, SessionInterface $session): Response|Er } } + /** + * A tool declaring an `outputSchema` promises every `structuredContent` it sends + * conforms to it, in every revision. A mismatch is the server's own bug, but it + * is reported as a tool execution error rather than a protocol error so that the + * model sees it and can fall back to `content`. + * + * @return CallToolResult|null the error result to send instead, or null when there is nothing to report + */ + private function validateStructuredContent(Tool $tool, CallToolResult $result): ?CallToolResult + { + // An error result carries a failure message, not the tool's declared output. + // A null `structuredContent` is absent from the wire, and the caller has + // already warned about it. + if (null === $tool->outputSchema || $result->isError || null === $result->structuredContent) { + return null; + } + + $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($result->structuredContent, $tool->outputSchema); + if ([] === $validationErrors) { + return null; + } + + $summaryMessage = "Invalid structured output for tool '{$tool->name}': ".self::summarizeValidationErrors($validationErrors); + + $this->logger->error($summaryMessage, [ + 'name' => $tool->name, + 'validation_errors' => $validationErrors, + ]); + + return CallToolResult::error([new TextContent($summaryMessage)]); + } + + /** + * @param list $validationErrors + */ + private static function summarizeValidationErrors(array $validationErrors): string + { + $errorMessages = []; + + foreach ($validationErrors as $errorDetail) { + $pointer = $errorDetail['pointer'] ?? ''; + $message = $errorDetail['message'] ?? 'Unknown validation error'; + $errorMessages[] = ('/' !== $pointer && '' !== $pointer ? "Property '{$pointer}': " : '').$message; + } + + $summary = implode('; ', \array_slice($errorMessages, 0, 3)); + if (\count($errorMessages) > 3) { + $summary .= '; ...and more errors.'; + } + + return $summary; + } + /** * 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); + return $value instanceof \stdClass || (\is_array($value) && !array_is_list($value)); } } diff --git a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php index 9464dcac..739bc156 100644 --- a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php @@ -221,7 +221,7 @@ public function testHandlesInvalidSchemaStructureGracefully(): void public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties(): void { $schema = $this->getSimpleSchema(); // Requires name, age etc. - $data = []; // Empty data + $data = new \stdClass(); // Empty data $errors = $this->validator->validateAgainstJsonSchema($data, $schema); @@ -229,6 +229,24 @@ public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties(): vo $this->assertEquals('required', $errors[0]['keyword']); } + public function testEmptyArrayValidatesAgainstArraySchema(): void + { + $this->assertSame([], $this->validator->validateAgainstJsonSchema([], ['type' => 'array'])); + } + + public function testEmptyArrayIsNotAnObject(): void + { + $errors = $this->validator->validateAgainstJsonSchema([], ['type' => 'object']); + + $this->assertNotEmpty($errors); + $this->assertSame('type', $errors[0]['keyword']); + } + + public function testEmptyStdClassValidatesAgainstObjectSchema(): void + { + $this->assertSame([], $this->validator->validateAgainstJsonSchema(new \stdClass(), ['type' => 'object'])); + } + public function testHandlesEmptySchemaAllowsAnything(): void { $schema = []; // Empty schema object/array implies no constraints diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 92782ab9..118632a4 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -29,6 +29,7 @@ use Mcp\Schema\Prompt; use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; +use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Tool; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -499,6 +500,17 @@ public function testExtractStructuredContentReturnsArrayDirectlyForAdditionalPro $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); } + public function testExtractStructuredContentKeepsAnEmptyObjectAsAnObject(): void + { + $tool = $this->createValidTool('test_tool', ['type' => 'object']); + $this->registry->registerTool($tool, static fn () => new \stdClass()); + + $structuredContent = $this->registry->getTool('test_tool')->extractStructuredContent(new \stdClass()); + + $this->assertInstanceOf(\stdClass::class, $structuredContent); + $this->assertStringContainsString('"structuredContent":{}', json_encode(new CallToolResult([], structuredContent: $structuredContent))); + } + /** * @dataProvider provideHandshakeVersions */ diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 308c9809..572c1b0f 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -31,6 +31,15 @@ class CallToolHandlerTest extends TestCase { + private const WEATHER_OUTPUT_SCHEMA = [ + 'type' => 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'conditions' => ['type' => 'string'], + ], + 'required' => ['temperature', 'conditions'], + ]; + private CallToolHandler $handler; private RegistryInterface&MockObject $registry; private ReferenceHandlerInterface&MockObject $referenceHandler; @@ -577,7 +586,7 @@ public static function provideStructuredContentRevisions(): iterable */ public function testSelfBuiltResultIsSentUnchangedAndOnlyWarnedAbout( ?string $negotiated, - ?array $structuredContent, + array|\stdClass|null $structuredContent, int $expectedWarnings, ): void { $request = $this->createCallToolRequest('build_result', []); @@ -614,7 +623,7 @@ public function testSelfBuiltResultIsSentUnchangedAndOnlyWarnedAbout( } /** - * @return iterable, int}> + * @return iterable|\stdClass|null, int}> */ public static function provideSelfBuiltResults(): iterable { @@ -623,8 +632,8 @@ public static function provideSelfBuiltResults(): iterable yield 'list from SEP-2106 on' => ['2026-07-28', [['id' => 1]], 0]; yield 'object before SEP-2106' => ['2025-11-25', ['items' => [['id' => 1]]], 0]; yield 'none at all' => ['2025-11-25', null, 0]; - // Dropped by `CallToolResult::jsonSerialize()` anyway, so nothing to warn about. - yield 'empty' => ['2025-11-25', [], 0]; + yield 'empty list before SEP-2106' => ['2025-11-25', [], 1]; + yield 'empty object before SEP-2106' => ['2025-11-25', new \stdClass(), 0]; } public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void @@ -699,6 +708,150 @@ public function testValidationError(): void $this->assertEquals(Error::INVALID_PARAMS, $response->code); } + public function testStructuredContentMissingARequiredPropertyIsReportedAsAToolError(): void + { + $request = $this->createCallToolRequest('get_weather', []); + $toolReference = $this->createToolReference('get_weather', static fn () => ['conditions' => 'sunny'], self::WEATHER_OUTPUT_SCHEMA); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn(['conditions' => 'sunny']); + $toolReference->method('formatResult')->willReturn([new TextContent('{"conditions":"sunny"}')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->result->isError); + $this->assertNull($response->result->structuredContent); + $this->assertStringContainsString("Invalid structured output for tool 'get_weather'", $this->firstText($response->result)); + $this->assertStringContainsString('temperature', $this->firstText($response->result)); + } + + public function testStructuredContentOfTheWrongTypeIsReportedAsAToolError(): void + { + $structuredContent = ['temperature' => 'warm', 'conditions' => 'sunny']; + $request = $this->createCallToolRequest('get_weather', []); + $toolReference = $this->createToolReference('get_weather', static fn () => $structuredContent, self::WEATHER_OUTPUT_SCHEMA); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($structuredContent); + $toolReference->method('formatResult')->willReturn([new TextContent('{"temperature":"warm","conditions":"sunny"}')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->result->isError); + $this->assertStringContainsString("Invalid structured output for tool 'get_weather'", $this->firstText($response->result)); + } + + public function testConformingStructuredContentIsSentUnchanged(): void + { + $structuredContent = ['temperature' => 22.5, 'conditions' => 'sunny']; + $request = $this->createCallToolRequest('get_weather', []); + $toolReference = $this->createToolReference('get_weather', static fn () => $structuredContent, self::WEATHER_OUTPUT_SCHEMA); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($structuredContent); + $toolReference->method('formatResult')->willReturn([new TextContent('{"temperature":22.5,"conditions":"sunny"}')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->result->isError); + $this->assertSame($structuredContent, $response->result->structuredContent); + } + + public function testStructuredContentIsNotValidatedWithoutAnOutputSchema(): void + { + $structuredContent = ['conditions' => 'sunny']; + $request = $this->createCallToolRequest('get_weather', []); + $toolReference = $this->createToolReference('get_weather', static fn () => $structuredContent); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($structuredContent); + $toolReference->method('formatResult')->willReturn([new TextContent('{"conditions":"sunny"}')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->result->isError); + $this->assertSame($structuredContent, $response->result->structuredContent); + } + + public function testSelfBuiltResultIsValidatedAgainstTheOutputSchema(): void + { + $request = $this->createCallToolRequest('get_weather', []); + $toolReference = $this->createToolReference('get_weather', static fn () => null, self::WEATHER_OUTPUT_SCHEMA); + $callToolResult = new CallToolResult([new TextContent('Built by hand')], false, ['conditions' => 'sunny']); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($callToolResult); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNotSame($callToolResult, $response->result); + $this->assertTrue($response->result->isError); + $this->assertStringContainsString("Invalid structured output for tool 'get_weather'", $this->firstText($response->result)); + } + + public function testErrorResultIsNotValidatedAgainstTheOutputSchema(): void + { + $request = $this->createCallToolRequest('get_weather', []); + $toolReference = $this->createToolReference('get_weather', static fn () => null, self::WEATHER_OUTPUT_SCHEMA); + $callToolResult = new CallToolResult([new TextContent('The weather service is down.')], true, ['reason' => 'timeout']); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($callToolResult); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($callToolResult, $response->result); + $this->assertSame('The weather service is down.', $this->firstText($response->result)); + } + + public function testEmptyArrayStructuredContentConformsToAnArrayOutputSchema(): void + { + $request = $this->createCallToolRequest('list_items', []); + $toolReference = $this->createToolReference('list_items', static fn () => null, ['type' => 'array']); + $callToolResult = new CallToolResult([new TextContent('[]')], false, []); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($callToolResult); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->result->isError); + $this->assertSame([], $response->result->structuredContent); + } + + public function testEmptyObjectResultConformsToAnObjectOutputSchema(): void + { + $result = new \stdClass(); + $request = $this->createCallToolRequest('get_nothing', []); + $toolReference = $this->createToolReference('get_nothing', static fn () => $result, ['type' => 'object']); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($result); + $toolReference->method('formatResult')->willReturn([new TextContent('{}')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->result->isError); + $this->assertEquals(new \stdClass(), $response->result->structuredContent); + $this->assertStringContainsString('"structuredContent":{}', json_encode($response->result)); + } + + private function firstText(CallToolResult $result): string + { + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + + return $content->text; + } + /** * @param array $arguments */