diff --git a/CHANGELOG.md b/CHANGELOG.md index 74954a86..c3302570 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 a84b39c1..364905f7 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 ad5356e4..30dc2b1c 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 025f915e..80d45d26 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 7583bc6a..c2c0a5b0 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 e2836a42..d4a88694 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 0083a83e..ffc7ff3e 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 7c6d0c53..e97e3e07 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 6ac3a40f..87514780 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 5aa61bd3..172eff43 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 1c5ee7c1..8b1c445b 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 42095d0c..76be9a03 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 00000000..4af68e19 --- /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 00000000..e3ad15dc --- /dev/null +++ b/tests/Unit/Schema/Result/ReadResourceResultTest.php @@ -0,0 +1,35 @@ + $first, $keys[1] => $second]); + + $this->assertSame([$first, $second], $result->jsonSerialize()['contents']); + } +}