From 4ba126f46871418e178fcad5086bdd25cdc9fdb7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 23:15:05 +0200 Subject: [PATCH 1/7] Address review feedback on resource_link Three follow-ups from review of the resource_link work: * PromptResultFormatter: add a regression test proving the optional fields of a typed resource_link block (title, description, mimeType, size, annotations, _meta) survive formatting. The delegation to ResourceLink::fromArray() already landed while rebasing onto the PromptResultFormatter refactor, but the existing test only supplied uri/name and so could not catch a reintroduced field drop. * ResourceLink::fromArray(): validate optional fields consistently, the way the equivalent ResourceDefinition::fromArray() already does. description, mimeType and size now raise InvalidArgumentException instead of surfacing a TypeError (or silently coercing, for size's (int) cast); annotations goes through Annotations::tryFromArray(), which carries the is_array() guard that icons already had; icons uses Icon::listFromArray() so a non-array entry is reported in context. * ToolReference::extractStructuredContent(): never emit a list as structuredContent. The Content guard added earlier fixed only one instance of the real invariant - structuredContent must be a JSON object, and a PHP list can never be one. Tool::fromArray() already enforces the matching rule by rejecting an outputSchema whose type is not "object", so this aligns the runtime path with it. The test asserting the opposite for an array-typed outputSchema contradicted the phpstan type and the fromArray() check introduced alongside it in the same commit, so it is replaced by tests for the two list shapes. --- CHANGELOG.md | 1 + src/Capability/Registry/ToolReference.php | 13 ++++-- src/Schema/Content/ResourceLink.php | 15 +++++-- .../Formatter/PromptResultFormatterTest.php | 33 ++++++++++++++ tests/Unit/Capability/RegistryTest.php | 44 ++++++++----------- .../Unit/Schema/Content/ResourceLinkTest.php | 20 +++++++++ 6 files changed, 94 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 162c1801..2cbe33b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. +* Never emit a list as `structuredContent`: `ToolReference::extractStructuredContent()` now returns `null` when a tool's raw return value is a PHP list, or an array holding `Content` instances. Both serialize to something the spec doesn't allow (a JSON array, and content that is already carried in `content`), and strict clients reject the whole tool call over it. This matches the rule `Tool::fromArray()` already enforces by requiring an `outputSchema` of type `object`. Tools returning a list keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index beec1827..e5158b34 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -68,13 +68,18 @@ public function formatResult(mixed $toolExecutionResult): array public function extractStructuredContent(mixed $toolExecutionResult): ?array { if (\is_array($toolExecutionResult)) { + // `structuredContent` must be a JSON object. A PHP list serializes to a + // JSON array, so it can never be valid structured data — strict clients + // reject the whole tool call when one is sent. + if (array_is_list($toolExecutionResult)) { + return null; + } + foreach ($toolExecutionResult as $item) { if ($item instanceof Content) { // Content items are already reflected in the result's `content` - // array; a raw array holding one or more of them isn't - // structured data and, if it were serialized as-is, could - // produce a `structuredContent` value that isn't a JSON object - // (e.g. a list), which the spec doesn't allow. + // array; an array holding one or more of them isn't structured + // data, even when its keys make it serialize to a JSON object. return null; } } diff --git a/src/Schema/Content/ResourceLink.php b/src/Schema/Content/ResourceLink.php index 874cc85a..946c9b82 100644 --- a/src/Schema/Content/ResourceLink.php +++ b/src/Schema/Content/ResourceLink.php @@ -84,6 +84,15 @@ public static function fromArray(array $data): self if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceLink data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceLink data.'); + } + if (isset($data['size']) && !\is_int($data['size'])) { + throw new InvalidArgumentException('Invalid "size" in ResourceLink data; expected an integer.'); + } return new self( uri: $data['uri'], @@ -91,9 +100,9 @@ public static function fromArray(array $data): self title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, - size: isset($data['size']) ? (int) $data['size'] : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceLink'), + size: $data['size'] ?? null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceLink') : null, meta: $data['_meta'] ?? null, ); } diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index eedba5bc..52bb1767 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -55,6 +55,39 @@ public function testFormatRoleContentArrayWithResourceLinkContent(): void $this->assertSame('a.png', $result[0]->content->name); } + public function testFormatTypedResourceLinkContentPreservesOptionalFields(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///a.png', + 'name' => 'a.png', + 'title' => 'A picture', + 'description' => 'The first picture', + 'mimeType' => 'image/png', + 'size' => 1024, + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + '_meta' => ['origin' => 'test'], + ], + ], + ]); + + $content = $result[0]->content; + $this->assertInstanceOf(ResourceLink::class, $content); + $this->assertSame('file:///a.png', $content->uri); + $this->assertSame('a.png', $content->name); + $this->assertSame('A picture', $content->title); + $this->assertSame('The first picture', $content->description); + $this->assertSame('image/png', $content->mimeType); + $this->assertSame(1024, $content->size); + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + $this->assertSame(['origin' => 'test'], $content->meta); + } + public function testFormatUserAssistantShorthand(): void { $result = (new PromptResultFormatter())->format([ diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 3b2d7d7b..b12e87cb 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -496,46 +496,40 @@ public function testExtractStructuredContentReturnsArrayDirectlyForAdditionalPro $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); } - public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema(): void + public function testExtractStructuredContentReturnsNullForListResults(): void { - // Arrange + // A PHP list serializes to a JSON array, but `structuredContent` must be a + // JSON object — `Tool::fromArray()` enforces the matching rule by rejecting + // any outputSchema whose type is not "object". $outputSchema = [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'foo' => [ - 'type' => 'string', - 'description' => 'A static value', - ], - ], - 'required' => ['foo'], + 'type' => 'object', + 'properties' => [ + 'foo' => ['type' => 'string'], ], + 'required' => ['foo'], ]; $tool = $this->createValidTool('list_static_data', $outputSchema); $toolReturnValue = [ ['foo' => 'bar'], ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], ]; $this->registry->registerTool($tool, static fn () => $toolReturnValue); - // Act $toolRef = $this->registry->getTool('list_static_data'); - $structuredContent = $toolRef->extractStructuredContent($toolReturnValue); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + } - // Assert - $this->assertNotNull($structuredContent); - $this->assertCount(4, $structuredContent); - $this->assertEquals([ - ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ], $structuredContent); + public function testExtractStructuredContentReturnsNullForListOfScalars(): void + { + $tool = $this->createValidTool('list_ids', null); + $toolReturnValue = ['101', '102', '103']; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_ids'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); } public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void diff --git a/tests/Unit/Schema/Content/ResourceLinkTest.php b/tests/Unit/Schema/Content/ResourceLinkTest.php index 3e643774..ca064558 100644 --- a/tests/Unit/Schema/Content/ResourceLinkTest.php +++ b/tests/Unit/Schema/Content/ResourceLinkTest.php @@ -227,5 +227,25 @@ public static function provideInvalidData(): iterable ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'], 'Invalid "_meta" in ResourceLink data.', ]; + yield 'invalid description' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'description' => ['not-a-string']], + 'Invalid "description" in ResourceLink data.', + ]; + yield 'invalid mimeType' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'mimeType' => ['not-a-string']], + 'Invalid "mimeType" in ResourceLink data.', + ]; + yield 'invalid size' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'size' => 'not-an-int'], + 'Invalid "size" in ResourceLink data; expected an integer.', + ]; + yield 'invalid annotations' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'annotations' => 'not-an-array'], + 'Invalid "annotations" in ResourceLink data; expected an array.', + ]; + yield 'invalid icons entry' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', 'icons' => ['not-an-array']], + 'Each entry in "icons" of ResourceLink data must be an array.', + ]; } } From e9d231a685f573e71b5faba04afcd313047d7c71 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 23:38:44 +0200 Subject: [PATCH 2/7] Apply the structuredContent object rule to object results too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractStructuredContent()` guards raw array results against being emitted as a JSON array, but the object branch handed back whatever `json_decode()` produced. A `JsonSerializable` returning a list or a scalar slipped straight through, producing exactly the `structuredContent` the array guard exists to prevent — and a return value that contradicts the method's own `array|null` signature. Check the decoded value before returning it, and cover the object branch, which had no tests at all. --- CHANGELOG.md | 2 +- src/Capability/Registry/ToolReference.php | 11 +++++- tests/Unit/Capability/RegistryTest.php | 47 +++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbe33b5..b9ca2c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. -* Never emit a list as `structuredContent`: `ToolReference::extractStructuredContent()` now returns `null` when a tool's raw return value is a PHP list, or an array holding `Content` instances. Both serialize to something the spec doesn't allow (a JSON array, and content that is already carried in `content`), and strict clients reject the whole tool call over it. This matches the rule `Tool::fromArray()` already enforces by requiring an `outputSchema` of type `object`. Tools returning a list keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. +* Never emit a list as `structuredContent`: `ToolReference::extractStructuredContent()` now returns `null` when a tool's raw return value is a PHP list, an object that serializes to a JSON array or scalar (e.g. via `JsonSerializable`), or an array holding `Content` instances. Both serialize to something the spec doesn't allow (a JSON array, and content that is already carried in `content`), and strict clients reject the whole tool call over it. This matches the rule `Tool::fromArray()` already enforces by requiring an `outputSchema` of type `object`. Tools returning a list keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index e5158b34..73c83fd8 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -93,9 +93,18 @@ public function extractStructuredContent(mixed $toolExecutionResult): ?array \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE ); - return json_decode( + $decoded = json_decode( $jsonResult, true, 512, \JSON_THROW_ON_ERROR ); + + // A plain object always encodes to a JSON object, but `JsonSerializable` + // can hand back anything — a list or a scalar included. Only keep what + // the same rule as above allows: a JSON object. + if (!\is_array($decoded) || array_is_list($decoded)) { + return null; + } + + return $decoded; } return null; diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index b12e87cb..52afae6f 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -532,6 +532,53 @@ public function testExtractStructuredContentReturnsNullForListOfScalars(): void $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); } + public function testExtractStructuredContentEncodesObjectResults(): void + { + $tool = $this->createValidTool('describe_thing', null); + $toolReturnValue = new \stdClass(); + $toolReturnValue->id = 1; + $toolReturnValue->label = 'thing'; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('describe_thing'); + $this->assertSame(['id' => 1, 'label' => 'thing'], $toolRef->extractStructuredContent($toolReturnValue)); + } + + public function testExtractStructuredContentReturnsNullForObjectsSerializingToAList(): void + { + // `JsonSerializable` can hand back a list just as a raw array result can, + // and it is no more valid as `structuredContent` for having come from an object. + $tool = $this->createValidTool('list_things', null); + $toolReturnValue = new class implements \JsonSerializable { + public function jsonSerialize(): array + { + return [['id' => 1], ['id' => 2]]; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_things'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + } + + public function testExtractStructuredContentReturnsNullForObjectsSerializingToAScalar(): void + { + $tool = $this->createValidTool('count_things', null); + $toolReturnValue = new class implements \JsonSerializable { + public function jsonSerialize(): int + { + return 42; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('count_things'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + } + public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void { $tool = $this->createValidTool('lookup_thing', null); From 254c1cff1c98a3d32f977a29c35c733aa17660b7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 23:38:48 +0200 Subject: [PATCH 3/7] Document structured tool output `outputSchema` and `structuredContent` were undocumented: the tool return value docs covered only the `content` side, and the schema generation section is about tool parameters. Add a "Structured Output" subsection covering how to declare the schema, which return values populate `structuredContent`, and why a list has to be wrapped in a key to get structured output at all. --- docs/mcp-elements.md | 67 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 3617a2fc..aad28664 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -170,6 +170,73 @@ public function getMultipleContent(): array } ``` +#### Structured Output + +Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` object. Declare its +shape with `outputSchema`, which the MCP specification requires to be a JSON Schema of type `object`: + +```php +#[McpTool( + name: 'get_weather', + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'temperature' => ['type' => 'number'], + 'conditions' => ['type' => 'string'], + ], + 'required' => ['temperature', 'conditions'], + ] +)] +public function getWeather(string $city): array +{ + // Sent as `structuredContent`, and JSON-encoded into `content` for clients that ignore it + return ['temperature' => 22.5, 'conditions' => 'sunny']; +} +``` + +The same schema can be passed to manual registration: + +```php +$builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); +``` + +Only object-shaped return values become `structuredContent`. The SDK fills it whenever the return value qualifies — +`outputSchema` is what tells clients to expect it and lets them validate it. + +| Return value | `structuredContent` | +|---|---| +| Associative array (`['temperature' => 22.5]`) | The array | +| Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | +| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted | +| Array holding `Content` instances | Omitted (already carried in `content`) | +| Scalars, `null`, `Content` instances | Omitted | + +Lists are omitted because a PHP list serializes to a JSON array, and `structuredContent` must be a JSON object — strict +clients reject the whole tool call otherwise. Wrap the list in a key to give it structured output: + +```php +// No structured content: a list can't be a JSON object +public function listUsersFlat(): array +{ + return [['id' => 1], ['id' => 2]]; +} + +#[McpTool(outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['items'] +])] +public function listUsers(): array +{ + return ['items' => [['id' => 1], ['id' => 2]]]; +} +``` + +Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into +`content` as a `TextContent`. + #### Error Handling Tool handlers can throw any exception, but the type determines how it's handled: From 5dcd4a96a01b7a256d73e3727aa9b525fb1fb036 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:29:32 +0200 Subject: [PATCH 4/7] Gate structuredContent on the negotiated protocol revision SEP-2106 (revision 2026-07-28) widens structuredContent to any JSON value; earlier revisions require an object. Resolve the revision per request and apply the matching rule, and warn when a declared outputSchema yields none. --- CHANGELOG.md | 2 +- docs/mcp-elements.md | 24 ++-- src/Capability/Registry/ToolReference.php | 40 +++++-- .../Handler/Request/CallToolHandler.php | 39 +++++- tests/Unit/Capability/RegistryTest.php | 77 ++++++++++-- .../Handler/Request/CallToolHandlerTest.php | 111 ++++++++++++++++++ 6 files changed, 259 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ca2c1c..d67e14d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. -* Never emit a list as `structuredContent`: `ToolReference::extractStructuredContent()` now returns `null` when a tool's raw return value is a PHP list, an object that serializes to a JSON array or scalar (e.g. via `JsonSerializable`), or an array holding `Content` instances. Both serialize to something the spec doesn't allow (a JSON array, and content that is already carried in `content`), and strict clients reject the whole tool call over it. This matches the rule `Tool::fromArray()` already enforces by requiring an `outputSchema` of type `object`. Tools returning a list keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. +* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent. Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index aad28664..17ca1a33 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -172,8 +172,8 @@ public function getMultipleContent(): array #### Structured Output -Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` object. Declare its -shape with `outputSchema`, which the MCP specification requires to be a JSON Schema of type `object`: +Besides the human-readable `content`, a tool result can carry a machine-readable `structuredContent` value. Declare its +shape with `outputSchema`, a JSON Schema of type `object`: ```php #[McpTool( @@ -200,22 +200,25 @@ The same schema can be passed to manual registration: $builder->addTool([WeatherHandler::class, 'getWeather'], outputSchema: [/* ... */]); ``` -Only object-shaped return values become `structuredContent`. The SDK fills it whenever the return value qualifies — -`outputSchema` is what tells clients to expect it and lets them validate it. +The SDK fills `structuredContent` whenever the return value qualifies — `outputSchema` is what tells clients to expect it +and lets them validate it. What qualifies depends on the protocol revision the call is served under: | Return value | `structuredContent` | |---|---| | Associative array (`['temperature' => 22.5]`) | The array | | Object (`stdClass`, DTO, `JsonSerializable`) that serializes to a JSON object | Its JSON representation | -| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted | +| List (`[1, 2, 3]`, `[['id' => 1], ['id' => 2]]`), or an object serializing to one | Omitted before `2026-07-28`, kept from it on | | Array holding `Content` instances | Omitted (already carried in `content`) | | Scalars, `null`, `Content` instances | Omitted | -Lists are omitted because a PHP list serializes to a JSON array, and `structuredContent` must be a JSON object — strict -clients reject the whole tool call otherwise. Wrap the list in a key to give it structured output: +Up to revision `2025-11-25`, `structuredContent` had to be a JSON object, so a PHP list — which serializes to a JSON +array — was not emittable and strict clients rejected the whole tool call over one. [SEP-2106][sep-2106], part of +revision `2026-07-28`, widened `outputSchema` to any JSON Schema 2020-12 and `structuredContent` to any JSON value +conforming to it. The SDK picks the rule from the revision negotiated for the call, so a tool serving both eras needs the +object shape to produce structured output everywhere. Wrap the list in a key for that: ```php -// No structured content: a list can't be a JSON object +// Structured content only from 2026-07-28 on: a bare list is not a JSON object public function listUsersFlat(): array { return [['id' => 1], ['id' => 2]]; @@ -235,7 +238,10 @@ public function listUsers(): array ``` Either way the data reaches the client: a return value with no structured representation is still JSON-encoded into -`content` as a `TextContent`. +`content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as +`structuredContent`, the SDK logs a warning — the value is not silently dropped. + +[sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content #### Error Handling diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 73c83fd8..bb167926 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -13,6 +13,7 @@ use Mcp\Capability\Formatter\ToolResultFormatter; use Mcp\Schema\Content\Content; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Tool; /** @@ -59,19 +60,30 @@ public function formatResult(mixed $toolExecutionResult): array /** * Extracts structured content from a tool result using the output schema. * - * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * What may be sent as `structuredContent` depends on the protocol revision in + * use. Up to `2025-11-25` it has to be a JSON object, and `outputSchema` is + * restricted to `type: "object"` to match. From `2026-07-28` on (SEP-2106) + * `outputSchema` is any JSON Schema 2020-12 and `structuredContent` is any JSON + * value conforming to it — a list included. + * + * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * @param ?ProtocolVersion $protocolVersion revision the result is produced for; defaults to the + * newest handshake revision, whose stricter rule is what + * every revision reachable through `initialize` requires * - * @return array|null the structured content, or null if not extractable + * @return array|null the structured content, or null if not extractable * * @throws \JsonException if JSON encoding fails for non-Content array/object results */ - public function extractStructuredContent(mixed $toolExecutionResult): ?array + public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array { + $objectOnly = !($protocolVersion ?? ProtocolVersion::latestHandshake())->isAtLeast(ProtocolVersion::V2026_07_28); + if (\is_array($toolExecutionResult)) { - // `structuredContent` must be a JSON object. A PHP list serializes to a - // JSON array, so it can never be valid structured data — strict clients - // reject the whole tool call when one is sent. - if (array_is_list($toolExecutionResult)) { + // A PHP list serializes to a JSON array, which the revisions predating + // SEP-2106 do not allow as `structuredContent` — strict clients reject + // the whole tool call when one is sent. + if ($objectOnly && array_is_list($toolExecutionResult)) { return null; } @@ -79,7 +91,8 @@ public function extractStructuredContent(mixed $toolExecutionResult): ?array if ($item instanceof Content) { // Content items are already reflected in the result's `content` // array; an array holding one or more of them isn't structured - // data, even when its keys make it serialize to a JSON object. + // data. This holds in every revision — it is a duplication rule, + // not a shape rule. return null; } } @@ -98,9 +111,14 @@ public function extractStructuredContent(mixed $toolExecutionResult): ?array ); // A plain object always encodes to a JSON object, but `JsonSerializable` - // can hand back anything — a list or a scalar included. Only keep what - // the same rule as above allows: a JSON object. - if (!\is_array($decoded) || array_is_list($decoded)) { + // can hand back anything. A scalar is dropped whatever the revision + // allows: `CallToolResult::$structuredContent` is typed `?array` and + // cannot carry one. + if (!\is_array($decoded)) { + return null; + } + + 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 e78ce1b9..6f828c73 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -17,6 +17,7 @@ use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -34,6 +35,14 @@ */ final class CallToolHandler implements RequestHandlerInterface { + /** + * `_meta` key carrying the protocol revision of a single request, introduced + * with the modern era that replaced the `initialize` handshake. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning + */ + private const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + private SchemaValidator $schemaValidator; public function __construct( @@ -97,7 +106,15 @@ public function handle(Request $request, SessionInterface $session): Response|Er $structuredContent = null; if (!$result instanceof CallToolResult) { - $structuredContent = $reference->extractStructuredContent($result); + $structuredContent = $reference->extractStructuredContent($result, $this->resolveProtocolVersion($request, $session)); + + if (null === $structuredContent && null !== $reference->tool->outputSchema) { + $this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [ + 'name' => $toolName, + 'result_type' => get_debug_type($result), + ]); + } + $result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent); } @@ -127,4 +144,24 @@ public function handle(Request $request, SessionInterface $session): Response|Er return Error::forInternalError('Error while executing tool', $request->getId()); } } + + /** + * Resolves the revision this call is served under. + * + * Modern revisions declare it per request in `_meta`, handshake ones negotiate + * it once and keep it on the session. Neither is guaranteed to be present — a + * transport may skip `initialize` entirely — so this falls back to the newest + * handshake revision, whose rules hold for every revision below it too. + */ + private function resolveProtocolVersion(Request $request, SessionInterface $session): ProtocolVersion + { + $meta = $request->getMeta(); + $requested = $meta[self::PROTOCOL_VERSION_META_KEY] ?? $session->get('protocol_version'); + + if (!\is_string($requested)) { + return ProtocolVersion::latestHandshake(); + } + + return ProtocolVersion::tryFrom($requested) ?? ProtocolVersion::latestHandshake(); + } } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 52afae6f..9ea4b24e 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -24,6 +24,7 @@ use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Prompt; use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; @@ -496,11 +497,14 @@ public function testExtractStructuredContentReturnsArrayDirectlyForAdditionalPro $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); } - public function testExtractStructuredContentReturnsNullForListResults(): void + /** + * @dataProvider provideHandshakeVersions + */ + public function testExtractStructuredContentDropsListResultsBeforeSep2106(?ProtocolVersion $version): void { - // A PHP list serializes to a JSON array, but `structuredContent` must be a - // JSON object — `Tool::fromArray()` enforces the matching rule by rejecting - // any outputSchema whose type is not "object". + // Up to 2025-11-25 a PHP list serializes to something `structuredContent` + // does not allow — a JSON array — and `Tool::fromArray()` enforces the + // matching rule by rejecting any outputSchema whose type is not "object". $outputSchema = [ 'type' => 'object', 'properties' => [ @@ -518,10 +522,50 @@ public function testExtractStructuredContentReturnsNullForListResults(): void $this->registry->registerTool($tool, static fn () => $toolReturnValue); $toolRef = $this->registry->getTool('list_static_data'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, $version)); } - public function testExtractStructuredContentReturnsNullForListOfScalars(): void + /** + * The revision is optional, and omitting it has to keep the strict rule: it is + * what every revision reachable through the `initialize` handshake requires. + * + * @return iterable + */ + public static function provideHandshakeVersions(): iterable + { + yield 'unspecified' => [null]; + + foreach (ProtocolVersion::handshakeVersions() as $version) { + yield $version->value => [$version]; + } + } + + public function testExtractStructuredContentKeepsListResultsFromSep2106On(): void + { + // SEP-2106 widened `structuredContent` to any JSON value conforming to + // `outputSchema`, and `outputSchema` to any JSON Schema 2020-12 — the spec's + // own example of a legal result is a list of records like this one. + $outputSchema = [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => ['foo' => ['type' => 'string']], + ], + ]; + + $tool = $this->createValidTool('list_static_data', $outputSchema); + $toolReturnValue = [ + ['foo' => 'bar'], + ['foo' => 'baz'], + ]; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_static_data'); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + public function testExtractStructuredContentDropsListOfScalarsBeforeSep2106(): void { $tool = $this->createValidTool('list_ids', null); $toolReturnValue = ['101', '102', '103']; @@ -529,7 +573,8 @@ public function testExtractStructuredContentReturnsNullForListOfScalars(): void $this->registry->registerTool($tool, static fn () => $toolReturnValue); $toolRef = $this->registry->getTool('list_ids'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); } public function testExtractStructuredContentEncodesObjectResults(): void @@ -545,10 +590,10 @@ public function testExtractStructuredContentEncodesObjectResults(): void $this->assertSame(['id' => 1, 'label' => 'thing'], $toolRef->extractStructuredContent($toolReturnValue)); } - public function testExtractStructuredContentReturnsNullForObjectsSerializingToAList(): void + public function testExtractStructuredContentAppliesTheListRuleToObjectResultsToo(): void { // `JsonSerializable` can hand back a list just as a raw array result can, - // and it is no more valid as `structuredContent` for having come from an object. + // and it is no more — and no less — valid for having come from an object. $tool = $this->createValidTool('list_things', null); $toolReturnValue = new class implements \JsonSerializable { public function jsonSerialize(): array @@ -560,11 +605,15 @@ public function jsonSerialize(): array $this->registry->registerTool($tool, static fn () => $toolReturnValue); $toolRef = $this->registry->getTool('list_things'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertSame([['id' => 1], ['id' => 2]], $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); } public function testExtractStructuredContentReturnsNullForObjectsSerializingToAScalar(): void { + // SEP-2106 allows a scalar `structuredContent`, but `CallToolResult` types + // the field as `?array` and cannot carry one — so it is dropped in every + // revision until that type widens. $tool = $this->createValidTool('count_things', null); $toolReturnValue = new class implements \JsonSerializable { public function jsonSerialize(): int @@ -576,11 +625,14 @@ public function jsonSerialize(): int $this->registry->registerTool($tool, static fn () => $toolReturnValue); $toolRef = $this->registry->getTool('count_things'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); } public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void { + // Unlike the list rule, this one is revision-independent: the items are + // already carried in the result's `content`. $tool = $this->createValidTool('lookup_thing', null); $toolReturnValue = [ new TextContent('Found it.'), @@ -590,7 +642,8 @@ public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): $this->registry->registerTool($tool, static fn () => $toolReturnValue); $toolRef = $this->registry->getTool('lookup_thing'); - $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2025_11_25)); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); } public function testConfiguredLoaderIsNotRunUntilFirstRead(): void diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index b804f76a..612aba87 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -477,6 +477,117 @@ public function testHandleReturnsCallToolResult(): void $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); } + /** + * @dataProvider provideStructuredContentRevisions + */ + public function testStructuredContentFollowsTheNegotiatedRevision(?string $negotiated, ?array $expected): void + { + $listResult = [['id' => 1], ['id' => 2]]; + $request = $this->createCallToolRequest('list_things', []); + $toolReference = $this->createToolReference('list_things', static fn () => $listResult); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($listResult); + + $toolReference + ->method('formatResult') + ->willReturn([new TextContent('[{"id":1},{"id":2}]')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expected, $response->result->structuredContent); + } + + /** + * @return iterable}> + */ + public static function provideStructuredContentRevisions(): iterable + { + // A list is only emittable from 2026-07-28 (SEP-2106) on. Without a + // negotiated revision the handler assumes the stricter handshake rule. + yield 'no session revision' => [null, null]; + yield 'unknown revision' => ['1999-01-01', null]; + yield '2025-06-18' => ['2025-06-18', null]; + yield '2025-11-25' => ['2025-11-25', null]; + yield '2026-07-28' => ['2026-07-28', [['id' => 1], ['id' => 2]]]; + } + + public function testPerRequestRevisionTakesPrecedenceOverTheSession(): void + { + $listResult = [['id' => 1]]; + $request = $this->createCallToolRequest('list_things', []) + ->withMeta(['io.modelcontextprotocol/protocolVersion' => '2026-07-28']); + $toolReference = $this->createToolReference('list_things', static fn () => $listResult); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn('2025-11-25'); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($listResult); + + $toolReference + ->method('formatResult') + ->willReturn([new TextContent('[{"id":1}]')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($listResult, $response->result->structuredContent); + } + + public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void + { + $listResult = [['id' => 1]]; + $request = $this->createCallToolRequest('list_things', []); + $toolReference = $this->createToolReference('list_things', static fn () => $listResult, [ + 'type' => 'object', + 'properties' => ['items' => ['type' => 'array']], + ]); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($listResult); + + $toolReference + ->method('formatResult') + ->willReturn([new TextContent('[{"id":1}]')]); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with( + $this->stringContains('outputSchema'), + $this->callback(static fn (array $context): bool => 'list_things' === $context['name'] && 'array' === $context['result_type']), + ); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNull($response->result->structuredContent); + } + public function testValidationError(): void { $schema = [ From d0c749e8563346f1e73c66a5a4e47123e656e139 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:35:33 +0200 Subject: [PATCH 5/7] Resolve the protocol revision on RequestContext Keeps the `_meta`-then-session lookup in one place instead of per handler, and exposes the negotiated revision to tool handlers. --- .../Handler/Request/CallToolHandler.php | 34 +------ src/Server/RequestContext.php | 29 ++++++ .../Handler/Request/CallToolHandlerTest.php | 37 +------- tests/Unit/Server/RequestContextTest.php | 89 +++++++++++++++++++ 4 files changed, 126 insertions(+), 63 deletions(-) create mode 100644 tests/Unit/Server/RequestContextTest.php diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 6f828c73..94ad849a 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -17,12 +17,12 @@ use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -35,14 +35,6 @@ */ final class CallToolHandler implements RequestHandlerInterface { - /** - * `_meta` key carrying the protocol revision of a single request, introduced - * with the modern era that replaced the `initialize` handshake. - * - * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning - */ - private const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; - private SchemaValidator $schemaValidator; public function __construct( @@ -101,12 +93,14 @@ public function handle(Request $request, SessionInterface $session): Response|Er $arguments['_session'] = $session; $arguments['_request'] = $request; + $context = new RequestContext($session, $request); + try { $result = $this->referenceHandler->handle($reference, $arguments); $structuredContent = null; if (!$result instanceof CallToolResult) { - $structuredContent = $reference->extractStructuredContent($result, $this->resolveProtocolVersion($request, $session)); + $structuredContent = $reference->extractStructuredContent($result, $context->getProtocolVersion()); if (null === $structuredContent && null !== $reference->tool->outputSchema) { $this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [ @@ -144,24 +138,4 @@ public function handle(Request $request, SessionInterface $session): Response|Er return Error::forInternalError('Error while executing tool', $request->getId()); } } - - /** - * Resolves the revision this call is served under. - * - * Modern revisions declare it per request in `_meta`, handshake ones negotiate - * it once and keep it on the session. Neither is guaranteed to be present — a - * transport may skip `initialize` entirely — so this falls back to the newest - * handshake revision, whose rules hold for every revision below it too. - */ - private function resolveProtocolVersion(Request $request, SessionInterface $session): ProtocolVersion - { - $meta = $request->getMeta(); - $requested = $meta[self::PROTOCOL_VERSION_META_KEY] ?? $session->get('protocol_version'); - - if (!\is_string($requested)) { - return ProtocolVersion::latestHandshake(); - } - - return ProtocolVersion::tryFrom($requested) ?? ProtocolVersion::latestHandshake(); - } } diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php index 158057cf..1a4f8375 100644 --- a/src/Server/RequestContext.php +++ b/src/Server/RequestContext.php @@ -12,6 +12,7 @@ namespace Mcp\Server; use Mcp\Capability\Logger\ClientLogger; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Request; use Mcp\Server\Session\SessionInterface; @@ -25,6 +26,14 @@ */ final class RequestContext { + /** + * `_meta` key carrying the protocol revision of a single request, introduced + * with the modern era that replaced the `initialize` handshake. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning + */ + private const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + private ?ClientGateway $clientGateway = null; private ?ClientLogger $clientLogger = null; @@ -44,6 +53,26 @@ public function getSession(): SessionInterface return $this->session; } + /** + * The protocol revision this request is served under. + * + * Modern revisions declare it per request in `_meta`, handshake ones negotiate + * it once and keep it on the session. Neither is guaranteed to be present — a + * transport may skip `initialize` entirely — so this falls back to the newest + * handshake revision, whose rules hold for every revision below it too. + */ + public function getProtocolVersion(): ProtocolVersion + { + $requested = $this->request->getMeta()[self::PROTOCOL_VERSION_META_KEY] + ?? $this->session->get('protocol_version'); + + if (!\is_string($requested)) { + return ProtocolVersion::latestHandshake(); + } + + return ProtocolVersion::tryFrom($requested) ?? ProtocolVersion::latestHandshake(); + } + public function getClientGateway(): ClientGateway { if (null == $this->clientGateway) { diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 612aba87..e765c834 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -510,49 +510,20 @@ public function testStructuredContentFollowsTheNegotiatedRevision(?string $negot } /** + * How a revision is resolved is {@see \Mcp\Server\RequestContext}'s business + * and covered there; this only pins that the handler applies it. + * * @return iterable}> */ public static function provideStructuredContentRevisions(): iterable { // A list is only emittable from 2026-07-28 (SEP-2106) on. Without a // negotiated revision the handler assumes the stricter handshake rule. - yield 'no session revision' => [null, null]; - yield 'unknown revision' => ['1999-01-01', null]; - yield '2025-06-18' => ['2025-06-18', null]; + yield 'no negotiated revision' => [null, null]; yield '2025-11-25' => ['2025-11-25', null]; yield '2026-07-28' => ['2026-07-28', [['id' => 1], ['id' => 2]]]; } - public function testPerRequestRevisionTakesPrecedenceOverTheSession(): void - { - $listResult = [['id' => 1]]; - $request = $this->createCallToolRequest('list_things', []) - ->withMeta(['io.modelcontextprotocol/protocolVersion' => '2026-07-28']); - $toolReference = $this->createToolReference('list_things', static fn () => $listResult); - - $this->session - ->method('get') - ->with('protocol_version') - ->willReturn('2025-11-25'); - - $this->registry - ->method('getTool') - ->willReturn($toolReference); - - $this->referenceHandler - ->method('handle') - ->willReturn($listResult); - - $toolReference - ->method('formatResult') - ->willReturn([new TextContent('[{"id":1}]')]); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($listResult, $response->result->structuredContent); - } - public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void { $listResult = [['id' => 1]]; diff --git a/tests/Unit/Server/RequestContextTest.php b/tests/Unit/Server/RequestContextTest.php new file mode 100644 index 00000000..fcbe65d4 --- /dev/null +++ b/tests/Unit/Server/RequestContextTest.php @@ -0,0 +1,89 @@ +createSession('2025-06-18'), + $this->createRequest(), + ); + + $this->assertSame(ProtocolVersion::V2025_06_18, $context->getProtocolVersion()); + } + + public function testPerRequestMetaTakesPrecedenceOverTheSession(): void + { + // Modern revisions have no `initialize`, so the revision travels with every + // single request instead of being negotiated once. + $context = new RequestContext( + $this->createSession('2025-11-25'), + $this->createRequest(['io.modelcontextprotocol/protocolVersion' => '2026-07-28']), + ); + + $this->assertSame(ProtocolVersion::V2026_07_28, $context->getProtocolVersion()); + } + + /** + * @dataProvider provideUnusableVersions + */ + public function testUnusableVersionFallsBackToTheNewestHandshakeRevision(mixed $stored): void + { + $context = new RequestContext( + $this->createSession($stored), + $this->createRequest(), + ); + + $this->assertSame(ProtocolVersion::latestHandshake(), $context->getProtocolVersion()); + } + + /** + * @return iterable + */ + public static function provideUnusableVersions(): iterable + { + yield 'never negotiated' => [null]; + yield 'unknown revision' => ['1999-01-01']; + yield 'not a string' => [20260728]; + } + + private function createSession(mixed $protocolVersion): SessionInterface + { + $session = $this->createMock(SessionInterface::class); + $session->method('get')->with('protocol_version')->willReturn($protocolVersion); + + return $session; + } + + /** + * @param array|null $meta + */ + private function createRequest(?array $meta = null): CallToolRequest + { + $request = CallToolRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => CallToolRequest::getMethod(), + 'id' => 'test-request', + 'params' => ['name' => 'test_tool', 'arguments' => []], + ]); + + return null === $meta ? $request : $request->withMeta($meta); + } +} From 5d78ea836b7be0ec71006a92a5229e57644bbb86 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:42:18 +0200 Subject: [PATCH 6/7] Document reading the negotiated revision from RequestContext --- docs/mcp-elements.md | 3 +++ docs/server-client-communication.md | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 17ca1a33..eb76ed0c 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -241,6 +241,9 @@ Either way the data reaches the client: a return value with no structured repres `content` as a `TextContent`. When a tool declares an `outputSchema` but returns something that cannot be sent as `structuredContent`, the SDK logs a warning — the value is not silently dropped. +A tool that wants to branch on the revision itself can read it from the injected `RequestContext`, see +[Client Communication](server-client-communication.md#client-gateway). + [sep-2106]: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content #### Error Handling diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index f54294bc..4d367f61 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -30,6 +30,17 @@ class MyService $context->getClientGateway()->log(...); ``` +The same object also carries the protocol revision negotiated for the current request, which is useful when a feature is +only available from a certain revision on: + +```php +use Mcp\Schema\Enum\ProtocolVersion; + +if ($context->getProtocolVersion()->isAtLeast(ProtocolVersion::V2026_07_28)) { + // e.g. a bare list is only valid as `structuredContent` from this revision on +} +``` + ## Sampling With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to From fc2dff6e9a1b33ac7ad63075a4ac2c996e3be571 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 15 Aug 2026 03:58:46 +0200 Subject: [PATCH 7/7] Warn when a self-built CallToolResult carries an invalid structuredContent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning a CallToolResult opts out of the extraction rules, so the value is still sent unchanged — but a JSON array is not valid before SEP-2106. --- CHANGELOG.md | 2 +- src/Capability/Registry/ToolReference.php | 2 +- src/Schema/Enum/ProtocolVersion.php | 14 +++++ .../Handler/Request/CallToolHandler.php | 16 +++++- .../Unit/Schema/Enum/ProtocolVersionTest.php | 10 ++++ .../Handler/Request/CallToolHandlerTest.php | 55 +++++++++++++++++++ 6 files changed, 96 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d67e14d0..52b054a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`. * Add client-side `roots/list` handler (`ListRootsRequestHandler` + `RootsCallbackInterface`) and `Client::sendRootsListChanged()`, plus server-side `ClientGateway::listRoots()` / `supportsRoots()` and `ListRootsResult::fromArray()`. * Add `ClientGateway::supportsSampling()`, so a tool can check the client's advertised capabilities before issuing a `sampling/createMessage` request instead of asking and catching the refusal. Matches the existing `supportsRoots()` and `supportsElicitation()`. -* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent. Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. +* [BC Break] Gate `structuredContent` on the negotiated protocol revision: `ToolReference::extractStructuredContent()` takes an optional `ProtocolVersion` and, for revisions predating SEP-2106 (`2025-11-25` and earlier, where `structuredContent` must be a JSON object), returns `null` for a tool result that is a PHP list or an object serializing to a JSON array. From `2026-07-28` on both are emitted as-is. Objects serializing to a scalar and arrays holding `Content` instances are never emitted, in any revision. `CallToolHandler` resolves the revision from the request's `_meta` (modern era) or the session (handshake era) and falls back to the strictest rule; it logs a warning when a tool declares an `outputSchema` but returns a value that cannot be sent, and when a self-built `CallToolResult` carries a `structuredContent` the revision does not allow (that one is passed through unchanged). Tools returning a list against an older client keep their JSON-encoded value in `content`; they just no longer advertise an invalid `structuredContent`. * Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. * Negotiate the protocol revision during the `initialize` handshake: the server echoes a revision it supports and counter-offers `ProtocolVersion::latestHandshake()` otherwise (`Builder::setProtocolVersion()` pins it to exactly one), and the client fails the handshake on a counter-offer it cannot speak rather than continuing on an unagreed revision. Adds `Client::getProtocolVersion()`, the `2026-07-28` revision, and the era helpers on `ProtocolVersion` — revisions from `2026-07-28` on have no `initialize`, so they are excluded from negotiation and from `ProtocolVersionMiddleware`'s default supported set. diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index bb167926..04316877 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -77,7 +77,7 @@ public function formatResult(mixed $toolExecutionResult): array */ public function extractStructuredContent(mixed $toolExecutionResult, ?ProtocolVersion $protocolVersion = null): ?array { - $objectOnly = !($protocolVersion ?? ProtocolVersion::latestHandshake())->isAtLeast(ProtocolVersion::V2026_07_28); + $objectOnly = ($protocolVersion ?? ProtocolVersion::latestHandshake())->requiresObjectStructuredContent(); if (\is_array($toolExecutionResult)) { // A PHP list serializes to a JSON array, which the revisions predating diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index 9b127e1d..b62396bb 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -101,6 +101,20 @@ public function isModern(): bool return $this->isAtLeast(self::FIRST_MODERN_VERSION); } + /** + * Whether this revision restricts `structuredContent` to a JSON object. + * + * SEP-2106, part of {@see self::V2026_07_28}, widened `outputSchema` to any + * JSON Schema 2020-12 and `structuredContent` to any JSON value conforming to + * it. Up to `2025-11-25` both are restricted to an object. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + */ + public function requiresObjectStructuredContent(): bool + { + return !$this->isAtLeast(self::V2026_07_28); + } + /** * Whether this revision is at least as new as $minimum. */ diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 94ad849a..254e8388 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -98,9 +98,11 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $result = $this->referenceHandler->handle($reference, $arguments); + $protocolVersion = $context->getProtocolVersion(); + $structuredContent = null; if (!$result instanceof CallToolResult) { - $structuredContent = $reference->extractStructuredContent($result, $context->getProtocolVersion()); + $structuredContent = $reference->extractStructuredContent($result, $protocolVersion); if (null === $structuredContent && null !== $reference->tool->outputSchema) { $this->logger->warning('Tool declares an "outputSchema" but returned a value that cannot be sent as "structuredContent"; the value is only carried in "content".', [ @@ -110,6 +112,18 @@ public function handle(Request $request, SessionInterface $session): Response|Er } $result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent); + } elseif ($protocolVersion->requiresObjectStructuredContent() + && \is_array($result->structuredContent) + && [] !== $result->structuredContent + && array_is_list($result->structuredContent) + ) { + // A tool building its own `CallToolResult` bypasses the extraction + // rules on purpose, so the value is sent as it was set — but a JSON + // array is not valid here before SEP-2106 and clients may reject it. + $this->logger->warning('Tool returned a "CallToolResult" whose "structuredContent" is a JSON array, which the negotiated protocol revision does not allow; sending it unchanged.', [ + 'name' => $toolName, + 'protocol_version' => $protocolVersion->value, + ]); } $this->logger->debug('Tool executed successfully', [ diff --git a/tests/Unit/Schema/Enum/ProtocolVersionTest.php b/tests/Unit/Schema/Enum/ProtocolVersionTest.php index 1870a79d..00c50e93 100644 --- a/tests/Unit/Schema/Enum/ProtocolVersionTest.php +++ b/tests/Unit/Schema/Enum/ProtocolVersionTest.php @@ -131,4 +131,14 @@ public function testDefaultHeaderVersion(): void { $this->assertSame(ProtocolVersion::V2025_03_26, ProtocolVersion::DEFAULT_HEADER_VERSION); } + + #[TestDox('SEP-2106 lifts the object-only rule for structuredContent')] + public function testRequiresObjectStructuredContent(): void + { + foreach (ProtocolVersion::handshakeVersions() as $version) { + $this->assertTrue($version->requiresObjectStructuredContent(), \sprintf('%s predates SEP-2106.', $version->value)); + } + + $this->assertFalse(ProtocolVersion::V2026_07_28->requiresObjectStructuredContent()); + } } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index e765c834..87a696be 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -524,6 +524,61 @@ public static function provideStructuredContentRevisions(): iterable yield '2026-07-28' => ['2026-07-28', [['id' => 1], ['id' => 2]]]; } + /** + * @dataProvider provideSelfBuiltResults + */ + public function testSelfBuiltResultIsSentUnchangedAndOnlyWarnedAbout( + ?string $negotiated, + ?array $structuredContent, + int $expectedWarnings, + ): void { + $request = $this->createCallToolRequest('build_result', []); + $toolReference = $this->createToolReference('build_result', static fn () => null); + $callToolResult = new CallToolResult([new TextContent('Built by hand')], false, $structuredContent); + + $this->session + ->method('get') + ->with('protocol_version') + ->willReturn($negotiated); + + $this->registry + ->method('getTool') + ->willReturn($toolReference); + + $this->referenceHandler + ->method('handle') + ->willReturn($callToolResult); + + $toolReference + ->expects($this->never()) + ->method('formatResult'); + + $this->logger + ->expects($this->exactly($expectedWarnings)) + ->method('warning'); + + $response = $this->handler->handle($request, $this->session); + + // Warned about, never rewritten: building the result is an explicit opt-out. + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($callToolResult, $response->result); + $this->assertSame($structuredContent, $response->result->structuredContent); + } + + /** + * @return iterable, int}> + */ + public static function provideSelfBuiltResults(): iterable + { + yield 'list before SEP-2106' => ['2025-11-25', [['id' => 1]], 1]; + yield 'list without a negotiated revision' => [null, [['id' => 1]], 1]; + 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]; + } + public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void { $listResult = [['id' => 1]];