diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2e82f2..02890ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ All notable changes to `mcp/sdk` will be documented in this file. ----- * [BC Break] `SchemaValidator` takes an optional `Opis\JsonSchema\Validator` as its first constructor argument, moving `$logger` to second. Pass `logger:` by name. -* Add `Builder::setSchemaValidator()` to configure the validator used for `tools/call` input, e.g. with a resolver for external `$ref` schemas. +* [BC Break] `SchemaValidator::validateAgainstJsonSchema()` no longer validates an empty array as an object. Pass `new \stdClass()` for an empty object. +* Add `Builder::setSchemaValidator()` to configure the validator used for `tools/call` input and output, e.g. with a resolver for external `$ref` schemas. * [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). @@ -14,7 +15,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. +* [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. Return `new \stdClass()` for an empty object, since `[]` is sent as an array. * Stop the server `Protocol` from logging full JSON-RPC payloads (tool arguments, client replies) at info level: info records now carry only the method and id, the raw message is logged at debug level. * Add `PassthroughMiddleware` to opt `StreamableHttpTransport` out of its default middleware without the warning an empty `$middleware` list logs. * Add `ElicitationSchema::getDefaults()`, returning the declared `default` of each field to accept a form elicitation with. diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index c346431c..32bacd9d 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -106,9 +106,13 @@ public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVe \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE ); - $decoded = json_decode( - $jsonResult, true, 512, \JSON_THROW_ON_ERROR - ); + $decoded = $this->objectsToArrays(json_decode( + $jsonResult, false, 512, \JSON_THROW_ON_ERROR + )); + + if ($decoded instanceof \stdClass) { + return $decoded; + } // A plain object always encodes to a JSON object, but `JsonSerializable` // can hand back anything, scalars included. @@ -116,10 +120,6 @@ 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; } @@ -143,4 +143,20 @@ private function acceptsScalarStructuredContent(bool $objectOnly): bool { return !$objectOnly && null !== $this->tool->outputSchema; } + + /** + * Turns decoded JSON objects into arrays, except empty ones, which would be sent as `[]`. + */ + private function objectsToArrays(mixed $value): mixed + { + if ($value instanceof \stdClass) { + $value = get_object_vars($value); + + if ([] === $value) { + return new \stdClass(); + } + } + + return \is_array($value) ? array_map($this->objectsToArrays(...), $value) : $value; + } } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index dc81a672..d58a8485 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -165,7 +165,7 @@ 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`. + * model sees what went wrong. * * @return CallToolResult|null the error result to send instead, or null when there is nothing to report */ @@ -178,7 +178,10 @@ private function validateStructuredContent(Tool $tool, CallToolResult $result): return null; } - $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($result->structuredContent, $tool->outputSchema); + // Validate the value as it is sent, incl. `JsonSerializable` and `{}` vs. `[]`. + $sent = json_decode(json_encode($result->structuredContent, \JSON_THROW_ON_ERROR), false, 512, \JSON_THROW_ON_ERROR); + + $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($sent, $tool->outputSchema); if ([] === $validationErrors) { return null; } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 572c1b0f..dd0f4984 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -844,6 +844,65 @@ public function testEmptyObjectResultConformsToAnObjectOutputSchema(): void $this->assertStringContainsString('"structuredContent":{}', json_encode($response->result)); } + public function testSelfBuiltJsonSerializableStructuredContentIsValidatedAsSent(): void + { + $structuredContent = new class(22.5) implements \JsonSerializable { + public function __construct(private float $temperature) + { + } + + public function jsonSerialize(): array + { + return ['temperature' => $this->temperature, 'conditions' => 'sunny']; + } + }; + $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, $structuredContent); + + $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->firstText($response->result)); + $this->assertStringContainsString('"structuredContent":{"temperature":22.5,"conditions":"sunny"}', json_encode($response->result)); + } + + public function testNestedEmptyObjectIsValidatedAndSentAsAnObject(): void + { + $result = new class { + public float $temperature = 22.5; + public \stdClass $meta; + + public function __construct() + { + $this->meta = new \stdClass(); + } + }; + $outputSchema = [ + 'type' => 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'meta' => ['type' => 'object'], + ], + 'required' => ['temperature', 'meta'], + ]; + $request = $this->createCallToolRequest('get_weather', []); + $toolReference = $this->createToolReference('get_weather', static fn () => $result, $outputSchema); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($result); + $toolReference->method('formatResult')->willReturn([new TextContent('{"temperature":22.5,"meta":{}}')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->result->isError, $this->firstText($response->result)); + $this->assertStringContainsString('"structuredContent":{"temperature":22.5,"meta":{}}', json_encode($response->result)); + } + private function firstText(CallToolResult $result): string { $content = $result->content[0];