diff --git a/CHANGELOG.md b/CHANGELOG.md index 162c1801..52b054a8 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()`. +* [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/docs/mcp-elements.md b/docs/mcp-elements.md index 3617a2fc..eb76ed0c 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -170,6 +170,82 @@ public function getMultipleContent(): array } ``` +#### Structured Output + +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( + 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: [/* ... */]); +``` + +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 before `2026-07-28`, kept from it on | +| Array holding `Content` instances | Omitted (already carried in `content`) | +| Scalars, `null`, `Content` instances | Omitted | + +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 +// 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]]; +} + +#[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`. 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 Tool handlers can throw any exception, but the type determines how it's handled: 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 diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index beec1827..04316877 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,22 +60,39 @@ 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())->requiresObjectStructuredContent(); + if (\is_array($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; + } + 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. This holds in every revision — it is a duplication rule, + // not a shape rule. return null; } } @@ -88,9 +106,23 @@ 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 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; + } + + return $decoded; } 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/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 e78ce1b9..254e8388 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -22,6 +22,7 @@ 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; @@ -92,13 +93,37 @@ 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); + $protocolVersion = $context->getProtocolVersion(); + $structuredContent = null; if (!$result instanceof CallToolResult) { - $structuredContent = $reference->extractStructuredContent($result); + $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".', [ + 'name' => $toolName, + 'result_type' => get_debug_type($result), + ]); + } + $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/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/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..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,50 +497,142 @@ public function testExtractStructuredContentReturnsArrayDirectlyForAdditionalPro $this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done'])); } - public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema(): void + /** + * @dataProvider provideHandshakeVersions + */ + public function testExtractStructuredContentDropsListResultsBeforeSep2106(?ProtocolVersion $version): void { - // Arrange + // 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' => [ + 'foo' => ['type' => 'string'], + ], + 'required' => ['foo'], + ]; + + $tool = $this->createValidTool('list_static_data', $outputSchema); + $toolReturnValue = [ + ['foo' => 'bar'], + ['foo' => 'bar'], + ]; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_static_data'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue, $version)); + } + + /** + * 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', - 'description' => 'A static value', - ], - ], - 'required' => ['foo'], + 'properties' => ['foo' => ['type' => 'string']], ], ]; $tool = $this->createValidTool('list_static_data', $outputSchema); $toolReturnValue = [ ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], + ['foo' => 'baz'], ]; $this->registry->registerTool($tool, static fn () => $toolReturnValue); - // Act $toolRef = $this->registry->getTool('list_static_data'); - $structuredContent = $toolRef->extractStructuredContent($toolReturnValue); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } - // Assert - $this->assertNotNull($structuredContent); - $this->assertCount(4, $structuredContent); - $this->assertEquals([ - ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ['foo' => 'bar'], - ], $structuredContent); + public function testExtractStructuredContentDropsListOfScalarsBeforeSep2106(): 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, ProtocolVersion::V2025_11_25)); + $this->assertSame($toolReturnValue, $toolRef->extractStructuredContent($toolReturnValue, ProtocolVersion::V2026_07_28)); + } + + 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 testExtractStructuredContentAppliesTheListRuleToObjectResultsToo(): void + { + // `JsonSerializable` can hand back a list just as a raw array result can, + // 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 + { + return [['id' => 1], ['id' => 2]]; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('list_things'); + $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 + { + return 42; + } + }; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('count_things'); + $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.'), @@ -549,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/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.', + ]; } } 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 b804f76a..87a696be 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -477,6 +477,143 @@ 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); + } + + /** + * 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 negotiated revision' => [null, null]; + yield '2025-11-25' => ['2025-11-25', null]; + 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]]; + $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 = [ 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); + } +}