diff --git a/CHANGELOG.md b/CHANGELOG.md index e77f0621..e2caf2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument. * Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. * Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). +* [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/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 72f3c21f..eb4ccaaf 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; @@ -81,18 +82,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er $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.'; - } + $summaryMessage = "Invalid parameters for tool '{$toolName}': ".self::summarizeValidationErrors($validationErrors); return Error::forInvalidParams($summaryMessage, $request->getId(), ['validation_errors' => $validationErrors]); } @@ -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,6 +161,59 @@ 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. diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 308c9809..d1a457d5 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; @@ -699,6 +708,116 @@ 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)); + } + + private function firstText(CallToolResult $result): string + { + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + + return $content->text; + } + /** * @param array $arguments */