Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ 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).
* Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled.
* 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.
Expand Down
30 changes: 23 additions & 7 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,20 @@ 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.
if (!\is_array($decoded)) {
return $this->acceptsScalarStructuredContent($objectOnly) ? $decoded : null;
}

if ([] === $decoded && '{}' === $jsonResult) {
return new \stdClass();
}

if ($objectOnly && array_is_list($decoded)) {
return null;
}
Expand All @@ -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;
}
}
7 changes: 5 additions & 2 deletions src/Server/Handler/Request/CallToolHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
}
Expand Down
59 changes: 59 additions & 0 deletions tests/Unit/Server/Handler/Request/CallToolHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading