From 8082a5dae78b3c3ae224ff067898bc16fa00f139 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 2 Nov 2026 23:04:14 +0100 Subject: [PATCH] [Server] Fix result lists being sent as JSON objects Non-list handler results of tools, prompts and resources now serialize as an array, and named keys in mixed results no longer overwrite each other. --- CHANGELOG.md | 1 + phpstan-baseline.neon | 5 --- .../Formatter/PromptResultFormatter.php | 8 +--- .../Formatter/ResourceResultFormatter.php | 8 +--- .../Formatter/ToolResultFormatter.php | 4 +- src/Schema/Result/CallToolResult.php | 4 +- src/Schema/Result/GetPromptResult.php | 4 +- src/Schema/Result/ReadResourceResult.php | 4 +- .../Formatter/PromptResultFormatterTest.php | 8 +--- .../Formatter/ResourceResultFormatterTest.php | 8 +--- .../Formatter/ToolResultFormatterTest.php | 15 ++++++++ .../Unit/Schema/Result/CallToolResultTest.php | 16 ++++++++ .../Schema/Result/GetPromptResultTest.php | 37 +++++++++++++++++++ .../Schema/Result/ReadResourceResultTest.php | 35 ++++++++++++++++++ 14 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 tests/Unit/Schema/Result/GetPromptResultTest.php create mode 100644 tests/Unit/Schema/Result/ReadResourceResultTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 74954a863..c3302570e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix `Psr16NotificationBus` subscribers missing the notifications published after the bus stayed quiet for longer than its TTL: the sequence counter no longer expires, and a subscriber whose cursor is ahead of a lost counter reads what the bus holds. * Fix `Session::save()` wiping the other keys of a session that the store failed to read, e.g. when `Psr16SessionStore` hits a cache error: it now returns `false` instead of writing the changed keys alone. * Fix `EnumCompletionProvider` and `ListCompletionProvider` offering all values for the prefix `"0"` instead of only the values that start with it. +* Fix tools, prompts and resources sending `content`, `messages` and `contents` as a JSON object instead of an array when the handler returned a non-list array, e.g. the result of `array_filter()`. Named keys in nested results of mixed arrays no longer overwrite each other. * Add `Psr6SessionStore` and `Psr6NotificationBus`, which work like `Psr16SessionStore` and `Psr16NotificationBus` over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool as `$cache`. ### Client diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a84b39c17..364905f71 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,7 +1,2 @@ parameters: ignoreErrors: - - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\, ttlMs\?\: int, cacheScope\?\: string\} but returns array\{contents\: array\, ttlMs\?\: int\<0, max\>, cacheScope\?\: ''private''\|''public''\}\.$#' - identifier: return.type - count: 1 - path: src/Schema/Result/ReadResourceResult.php diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php index ad5356e4e..30dc2b1c7 100644 --- a/src/Capability/Formatter/PromptResultFormatter.php +++ b/src/Capability/Formatter/PromptResultFormatter.php @@ -74,12 +74,8 @@ public function format(mixed $promptGenerationResult): array if ($item instanceof PromptMessage) { $result[] = $item; } else { - foreach ($this->format($item) as $key => $message) { - if (\is_int($key)) { - $result[] = $message; - } else { - $result[$key] = $message; - } + foreach ($this->format($item) as $message) { + $result[] = $message; } } } diff --git a/src/Capability/Formatter/ResourceResultFormatter.php b/src/Capability/Formatter/ResourceResultFormatter.php index 025f915e4..80d45d267 100644 --- a/src/Capability/Formatter/ResourceResultFormatter.php +++ b/src/Capability/Formatter/ResourceResultFormatter.php @@ -97,12 +97,8 @@ public function format(mixed $readResult, string $uri, ?string $mimeType = null, } elseif ($item instanceof EmbeddedResource) { $result[] = $item->resource; } else { - foreach ($this->format($item, $uri, $mimeType, $meta) as $key => $content) { - if (\is_int($key)) { - $result[] = $content; - } else { - $result[$key] = $content; - } + foreach ($this->format($item, $uri, $mimeType, $meta) as $content) { + $result[] = $content; } } } diff --git a/src/Capability/Formatter/ToolResultFormatter.php b/src/Capability/Formatter/ToolResultFormatter.php index 7583bc6ac..c2c0a5b06 100644 --- a/src/Capability/Formatter/ToolResultFormatter.php +++ b/src/Capability/Formatter/ToolResultFormatter.php @@ -73,7 +73,9 @@ public function format(mixed $toolExecutionResult): array if ($item instanceof Content) { $result[] = $item; } else { - $result = array_merge($result, $this->format($item)); + foreach ($this->format($item) as $content) { + $result[] = $content; + } } } diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index e2836a42a..d4a88694b 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -130,7 +130,7 @@ public static function fromArray(array $data): self /** * @return array{ - * content: array, + * content: list, * isError: bool, * structuredContent?: mixed, * _meta?: array, @@ -139,7 +139,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $result = [ - 'content' => $this->content, + 'content' => array_values($this->content), 'isError' => $this->isError, ]; diff --git a/src/Schema/Result/GetPromptResult.php b/src/Schema/Result/GetPromptResult.php index 0083a83eb..ffc7ff3ea 100644 --- a/src/Schema/Result/GetPromptResult.php +++ b/src/Schema/Result/GetPromptResult.php @@ -69,14 +69,14 @@ public static function fromArray(array $data): self /** * @return array{ - * messages: array, + * messages: list, * description?: string, * } */ public function jsonSerialize(): array { $result = [ - 'messages' => $this->messages, + 'messages' => array_values($this->messages), ]; if (null !== $this->description) { diff --git a/src/Schema/Result/ReadResourceResult.php b/src/Schema/Result/ReadResourceResult.php index 7c6d0c53e..e97e3e077 100644 --- a/src/Schema/Result/ReadResourceResult.php +++ b/src/Schema/Result/ReadResourceResult.php @@ -79,14 +79,14 @@ public static function fromArray(array $data): self /** * @return array{ - * contents: array, + * contents: list, * ttlMs?: int, * cacheScope?: string, * } */ public function jsonSerialize(): array { - $data = ['contents' => $this->contents]; + $data = ['contents' => array_values($this->contents)]; // Only what this result actually decided; the wire codec fills the rest // from policy, and an absent member is the signal for it to do so. diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index 6ac3a40fc..875147809 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -24,7 +24,7 @@ class PromptResultFormatterTest extends TestCase { - public function testMixedMessagesPreserveNestedKeysAndOrder(): void + public function testMixedMessagesAreFlattenedInOrder(): void { $first = new PromptMessage(Role::User, new TextContent('first')); $second = new PromptMessage(Role::Assistant, new TextContent('second')); @@ -36,11 +36,7 @@ public function testMixedMessagesPreserveNestedKeysAndOrder(): void ['named' => $third, 5 => $second], ]); - $this->assertSame([0, 'named', 1, 2], array_keys($result)); - $this->assertSame($first, $result[0]); - $this->assertSame($third, $result['named']); - $this->assertSame($first, $result[1]); - $this->assertSame($second, $result[2]); + $this->assertSame([$first, $second, $first, $third, $second], $result); } public function testFormatPromptMessage(): void diff --git a/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php b/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php index 5aa61bd38..172eff439 100644 --- a/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php @@ -17,7 +17,7 @@ class ResourceResultFormatterTest extends TestCase { - public function testMixedResourcesPreserveNestedKeysAndOrder(): void + public function testMixedResourcesAreFlattenedInOrder(): void { $first = new TextResourceContents('file://test', 'text/plain', 'first'); $second = new TextResourceContents('file://test', 'text/plain', 'second'); @@ -29,11 +29,7 @@ public function testMixedResourcesPreserveNestedKeysAndOrder(): void ['named' => $third, 5 => $second], ], 'file://test'); - $this->assertSame([0, 'named', 1, 2], array_keys($result)); - $this->assertSame($first, $result[0]); - $this->assertSame($third, $result['named']); - $this->assertSame($first, $result[1]); - $this->assertSame($second, $result[2]); + $this->assertSame([$first, $second, $first, $third, $second], $result); } public function testFormatStringResult(): void diff --git a/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php b/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php index 1c5ee7c1f..8b1c445b2 100644 --- a/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php @@ -17,6 +17,21 @@ class ToolResultFormatterTest extends TestCase { + public function testMixedContentIsFlattenedInOrder(): void + { + $first = new TextContent('first'); + $second = new TextContent('second'); + $third = new TextContent('third'); + + $result = (new ToolResultFormatter())->format([ + $first, + ['named' => $second, 8 => $first], + ['named' => $third, 5 => $second], + ]); + + $this->assertSame([$first, $second, $first, $third, $second], $result); + } + public function testFormatStringResult(): void { $result = (new ToolResultFormatter())->format('hello'); diff --git a/tests/Unit/Schema/Result/CallToolResultTest.php b/tests/Unit/Schema/Result/CallToolResultTest.php index 42095d0c0..76be9a038 100644 --- a/tests/Unit/Schema/Result/CallToolResultTest.php +++ b/tests/Unit/Schema/Result/CallToolResultTest.php @@ -18,10 +18,26 @@ use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Result\CallToolResult; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; final class CallToolResultTest extends TestCase { + /** + * @param array{int|string, int|string} $keys + */ + #[TestWith([['first', 'second']])] + #[TestWith([[1, 3]])] + public function testContentSerializesAsListForNonListKeys(array $keys): void + { + $first = new TextContent('first'); + $second = new TextContent('second'); + + $result = new CallToolResult([$keys[0] => $first, $keys[1] => $second]); + + $this->assertSame([$first, $second], $result->jsonSerialize()['content']); + } + public function testFromArrayDeserializesResourceLinkContent(): void { $result = CallToolResult::fromArray([ diff --git a/tests/Unit/Schema/Result/GetPromptResultTest.php b/tests/Unit/Schema/Result/GetPromptResultTest.php new file mode 100644 index 000000000..4af68e199 --- /dev/null +++ b/tests/Unit/Schema/Result/GetPromptResultTest.php @@ -0,0 +1,37 @@ + $first, $keys[1] => $second]); + + $this->assertSame([$first, $second], $result->jsonSerialize()['messages']); + } +} diff --git a/tests/Unit/Schema/Result/ReadResourceResultTest.php b/tests/Unit/Schema/Result/ReadResourceResultTest.php new file mode 100644 index 000000000..e3ad15dc2 --- /dev/null +++ b/tests/Unit/Schema/Result/ReadResourceResultTest.php @@ -0,0 +1,35 @@ + $first, $keys[1] => $second]); + + $this->assertSame([$first, $second], $result->jsonSerialize()['contents']); + } +}