From c8c1e8004361c36447faff42f41d29531a4c2a0b Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:02:53 +0200 Subject: [PATCH 1/4] test: reproduce the MCP output format being ignored StructuredContentProcessor serializes a tool result with $format = $request->getRequestFormat('') ?: 'jsonld'; and never reads $operation->getOutputFormats(), although $operation is in scope and FormatsResourceMetadataCollectionFactory has populated it. The MCP route carries no _format placeholder or default either, so getRequestFormat() is always empty and the expression is a constant 'jsonld' on every call. McpFormatTool declares outputFormats: ['json' => ['application/json']]. The first test asserts the metadata honours it (passes); the two others assert the payload and structuredContent honour it (both fail, showing the JSON-LD envelope). Declaring the format on the operation exercises the same code path as api_platform.mcp.format, which is implemented as withOutputFormats() on the operation -- so this single reproducer covers both ways of configuring it. --- .../TestBundle/ApiResource/McpFormatTool.php | 54 ++++++ tests/Functional/McpFormatTest.php | 180 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpFormatTool.php create mode 100644 tests/Functional/McpFormatTest.php diff --git a/tests/Fixtures/TestBundle/ApiResource/McpFormatTool.php b/tests/Fixtures/TestBundle/ApiResource/McpFormatTool.php new file mode 100644 index 0000000000..8b8fc757c1 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpFormatTool.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\McpTool; + +/** + * Reproducer fixture: a tool that explicitly asks for the `json` format. + * + * `api_platform.mcp.format` is applied by FormatsResourceMetadataCollectionFactory as + * `$operation->withInputFormats(...)->withOutputFormats(...)`, i.e. the global option is + * implemented by writing the operation's own formats. Declaring them here therefore + * exercises the same code path as the global option, without touching the test app's + * shared configuration. + */ +#[McpTool( + name: 'format_message', + description: 'Echo a message, serialized with the format declared on the operation', + outputFormats: ['json' => ['application/json']], + processor: [McpFormatTool::class, 'process'], +)] +class McpFormatTool +{ + public function __construct( + private string $message = '', + ) { + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public static function process($data): mixed + { + return $data; + } +} diff --git a/tests/Functional/McpFormatTest.php b/tests/Functional/McpFormatTest.php new file mode 100644 index 0000000000..dd0ec49e17 --- /dev/null +++ b/tests/Functional/McpFormatTest.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatTool; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\AI\McpBundle\McpBundle; + +/** + * Reproducer: the format configured for an MCP operation has no effect on its output. + * + * `ApiPlatform\Mcp\State\StructuredContentProcessor::process()` serializes with + * + * $format = $request->getRequestFormat('') ?: 'jsonld'; + * + * and never reads `$operation->getOutputFormats()`, although `$operation` is in scope and + * `FormatsResourceMetadataCollectionFactory` has populated it. The MCP route carries no + * `_format` placeholder or default either, so `getRequestFormat()` is always empty and + * the expression is a constant `'jsonld'` on every call. + * + * This also covers `api_platform.mcp.format`: that option is implemented by writing the + * operation's own formats, so it travels through the very same getter. + */ +class McpFormatTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [McpFormatTool::class]; + } + + public function testTheOperationMetadataCarriesTheDeclaredFormat(): void + { + $this->skipUnlessMcpIsUsable(); + + self::createClient(); + + /** @var ResourceMetadataCollectionFactoryInterface $factory */ + $factory = self::getContainer()->get('api_platform.metadata.resource.metadata_collection_factory'); + + $outputFormats = null; + foreach ($factory->create(McpFormatTool::class) as $resource) { + foreach ($resource->getMcp() ?? [] as $name => $operation) { + if ('format_message' === $name) { + $outputFormats = $operation->getOutputFormats(); + } + } + } + + self::assertSame( + ['json' => ['application/json']], + $outputFormats, + 'Precondition: the metadata layer honours the declared format.', + ); + } + + public function testTheOutputHonoursTheDeclaredFormat(): void + { + $this->skipUnlessMcpIsUsable(); + + $client = self::createClient(); + $sessionId = $this->initializeMcpSession($client); + + $res = $this->callTool($client, $sessionId, 'format_message', ['message' => 'hello']); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(false); + self::assertArrayNotHasKey('error', $result, 'MCP error: '.json_encode($result['error'] ?? null)); + + $text = $result['result']['content'][0]['text'] ?? null; + self::assertNotNull($text); + + self::assertStringNotContainsString( + '@context', + $text, + 'The tool declares the "json" output format but the payload is JSON-LD: '. + 'StructuredContentProcessor ignores $operation->getOutputFormats().', + ); + } + + public function testStructuredContentHonoursTheDeclaredFormatToo(): void + { + $this->skipUnlessMcpIsUsable(); + + $client = self::createClient(); + $sessionId = $this->initializeMcpSession($client); + + $res = $this->callTool($client, $sessionId, 'format_message', ['message' => 'hello']); + + $structured = $res->toArray(false)['result']['structuredContent'] ?? []; + + self::assertArrayNotHasKey( + '@context', + $structured, + 'structuredContent carries the JSON-LD envelope as well: it is normalized with the same format.', + ); + } + + private function skipUnlessMcpIsUsable(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + try { + if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + \Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory(); + } catch (\Throwable) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + } + + private function initializeMcpSession($client): string + { + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => ['name' => 'ApiPlatform Test Suite', 'version' => '1.0'], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + return $res->getHeaders()['mcp-session-id'][0]; + } + + /** + * @param array $arguments + */ + private function callTool($client, string $sessionId, string $toolName, array $arguments = []) + { + return $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $arguments, + ], + ], + ]); + } +} From cfc5a542d0ac2021a6a8a2b8c7b2528801e32adc Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:04:07 +0200 Subject: [PATCH 2/4] fix(mcp): honour the operation output format when serializing a tool result Read $operation->getOutputFormats() before falling back to the request format and to jsonld. The change is strictly additive: when no format is declared the array is empty and the previous expression applies unchanged, which is why the 26 existing MCP functional tests are untouched. Covers api_platform.mcp.format as well, since FormatsResourceMetadataCollectionFactory implements that option by calling withOutputFormats() on the operation. --- src/Mcp/State/StructuredContentProcessor.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php index 842304c995..f7f685ce74 100644 --- a/src/Mcp/State/StructuredContentProcessor.php +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -63,7 +63,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = 'operation' => $operation, ]); $serializerContext['uri_variables'] = $uriVariables; - $format = $request->getRequestFormat('') ?: 'jsonld'; + $outputFormats = $operation->getOutputFormats() ?? []; + $format = $outputFormats ? array_key_first($outputFormats) : ($request->getRequestFormat('') ?: 'jsonld'); $normalized = $this->serializer->normalize($result, $format, $serializerContext); $result = $this->serializer->encode($normalized, $format, $serializerContext); From 994fee717d77a85486d1ff8c3b09e00572a004f9 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:25:08 +0200 Subject: [PATCH 3/4] fix(metadata): normalize MCP operation formats like HTTP ones MCP operations that declare their own formats never went through normalizeFormats(), which every HTTP operation gets in normalize(). The list form -- ['json'], meaning "reuse the already configured json format" -- was therefore left as [0 => 'json'] instead of being resolved to ['json' => ['application/json']], and the call failed once the processor started reading it. The loop now mirrors normalize() exactly, with the MCP format standing in for the resource-level defaults. Behaviour is unchanged when no format is declared: the operation still receives api_platform.mcp.format, which is what the 26 existing MCP functional tests exercise. The map form ['json' => ['application/json']] keeps passing through untouched -- normalizeFormats() accepts it as an inline mime-type declaration, which is the documented way to use a format that is not registered globally. McpFormatListTool covers the list form so both shapes stay green. --- ...rmatsResourceMetadataCollectionFactory.php | 17 +++++- .../ApiResource/McpFormatListTool.php | 52 +++++++++++++++++++ tests/Functional/McpFormatTest.php | 37 ++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpFormatListTool.php diff --git a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php index 965d615d2e..98e1fdeddd 100644 --- a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php @@ -75,9 +75,22 @@ public function create(string $resourceClass): ResourceMetadataCollection $mcpFormats = [$this->mcpFormat => $this->formats[$this->mcpFormat]]; $newMcp = []; foreach ($mcp as $key => $operation) { - if (($operation instanceof McpTool || $operation instanceof McpResource) && null === $operation->getFormats() && null === $operation->getInputFormats() && null === $operation->getOutputFormats()) { - $operation = $operation->withInputFormats($mcpFormats)->withOutputFormats($mcpFormats); + if (!$operation instanceof McpTool && !$operation instanceof McpResource) { + $newMcp[$key] = $operation; + continue; } + + // Same normalization as HTTP operations get in normalize(), with the + // MCP format standing in for the resource-level defaults: without it a + // format declared as a list (["json"]) would never be resolved against + // api_platform.formats and would stay as [0 => "json"]. + if ($operation->getFormats()) { + $operation = $operation->withFormats($this->normalizeFormats($operation->getFormats())); + } + + $operation = $operation->withInputFormats($operation->getInputFormats() ? $this->normalizeFormats($operation->getInputFormats()) : $operation->getFormats() ?? $mcpFormats); + $operation = $operation->withOutputFormats($operation->getOutputFormats() ? $this->normalizeFormats($operation->getOutputFormats()) : $operation->getFormats() ?? $mcpFormats); + $newMcp[$key] = $operation; } $resourceMetadataCollection[$index] = $resourceMetadataCollection[$index]->withMcp($newMcp); diff --git a/tests/Fixtures/TestBundle/ApiResource/McpFormatListTool.php b/tests/Fixtures/TestBundle/ApiResource/McpFormatListTool.php new file mode 100644 index 0000000000..8b8d772a8d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpFormatListTool.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\McpTool; + +/** + * Reproducer fixture: a tool declaring its format as a LIST rather than a map. + * + * `['json']` means "reuse the already configured `json` format", and every HTTP operation + * gets it resolved to `['json' => ['application/json']]` by normalizeFormats(). MCP + * operations used to skip that normalization entirely, leaving `[0 => 'json']`. + */ +#[McpTool( + name: 'format_message_list', + description: 'Echo a message, with the output format declared as a list', + outputFormats: ['json'], + processor: [McpFormatListTool::class, 'process'], +)] +class McpFormatListTool +{ + public function __construct( + private string $message = '', + ) { + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public static function process($data): mixed + { + return $data; + } +} diff --git a/tests/Functional/McpFormatTest.php b/tests/Functional/McpFormatTest.php index dd0ec49e17..0687498981 100644 --- a/tests/Functional/McpFormatTest.php +++ b/tests/Functional/McpFormatTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatListTool; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatTool; use ApiPlatform\Tests\SetupClassResourcesTrait; use Symfony\AI\McpBundle\McpBundle; @@ -45,7 +46,41 @@ class McpFormatTest extends ApiTestCase */ public static function getResources(): array { - return [McpFormatTool::class]; + return [McpFormatTool::class, McpFormatListTool::class]; + } + + public function testAFormatDeclaredAsAListIsResolved(): void + { + $this->skipUnlessMcpIsUsable(); + + $client = self::createClient(); + + /** @var ResourceMetadataCollectionFactoryInterface $factory */ + $factory = self::getContainer()->get('api_platform.metadata.resource.metadata_collection_factory'); + + $outputFormats = null; + foreach ($factory->create(McpFormatListTool::class) as $resource) { + foreach ($resource->getMcp() ?? [] as $name => $operation) { + if ('format_message_list' === $name) { + $outputFormats = $operation->getOutputFormats(); + } + } + } + + self::assertSame( + ['json' => ['application/json']], + $outputFormats, + 'A list form such as ["json"] must be resolved against api_platform.formats, '. + 'exactly as normalizeFormats() does for every HTTP operation.', + ); + + $sessionId = $this->initializeMcpSession($client); + $res = $this->callTool($client, $sessionId, 'format_message_list', ['message' => 'hello']); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(false); + self::assertArrayNotHasKey('error', $result, 'MCP error: '.json_encode($result['error'] ?? null)); + self::assertStringNotContainsString('@context', (string) ($result['result']['content'][0]['text'] ?? '')); } public function testTheOperationMetadataCarriesTheDeclaredFormat(): void From aa9f9bf1726fa96b18396f2cdeaac7eb6bd3716b Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:19:52 +0200 Subject: [PATCH 4/4] fix: guard the format lookup and skip the MCP format test on MongoDB Two CI failures, both mine. PHPStan: process() receives the base Operation type, which does not declare getOutputFormats() -- only HttpOperation does. The lookup now sits behind the same McpTool/McpResource check the structuredContent flag already used, hoisted into a local so both read it. PHPUnit: the MongoDB test app loads routing_mongodb.yml, the only environment that does not import routing_test.php and therefore has no /mcp route, so the tests answered 404 instead of skipping. McpTest guards on isMongoDB() for the same reason; McpFormatTest now does too, via RecreateSchemaTrait. --- src/Mcp/State/StructuredContentProcessor.php | 7 +++++-- tests/Functional/McpFormatTest.php | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php index f7f685ce74..774767dbfc 100644 --- a/src/Mcp/State/StructuredContentProcessor.php +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -54,7 +54,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $request = $context['request'] ?? null; $context['original_data'] = $result; $class = $operation->getClass(); - $includeStructuredContent = $operation instanceof McpTool || $operation instanceof McpResource ? $operation->getStructuredContent() ?? true : false; + $isMcpOperation = $operation instanceof McpTool || $operation instanceof McpResource; + $includeStructuredContent = $isMcpOperation ? $operation->getStructuredContent() ?? true : false; + // Only an HttpOperation carries formats, and the base Operation type does not + // declare getOutputFormats(). + $outputFormats = $isMcpOperation ? $operation->getOutputFormats() ?? [] : []; $structuredContent = null; if ($request && $this->serializer instanceof NormalizerInterface && $this->serializer instanceof EncoderInterface) { @@ -63,7 +67,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = 'operation' => $operation, ]); $serializerContext['uri_variables'] = $uriVariables; - $outputFormats = $operation->getOutputFormats() ?? []; $format = $outputFormats ? array_key_first($outputFormats) : ($request->getRequestFormat('') ?: 'jsonld'); $normalized = $this->serializer->normalize($result, $format, $serializerContext); $result = $this->serializer->encode($normalized, $format, $serializerContext); diff --git a/tests/Functional/McpFormatTest.php b/tests/Functional/McpFormatTest.php index 0687498981..ba5fb320bb 100644 --- a/tests/Functional/McpFormatTest.php +++ b/tests/Functional/McpFormatTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatListTool; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatTool; +use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use Symfony\AI\McpBundle\McpBundle; @@ -37,6 +38,7 @@ */ class McpFormatTest extends ApiTestCase { + use RecreateSchemaTrait; use SetupClassResourcesTrait; protected static ?bool $alwaysBootKernel = false; @@ -156,6 +158,13 @@ private function skipUnlessMcpIsUsable(): void $this->markTestSkipped('MCP bundle is not installed'); } + // The MongoDB test app loads routing_mongodb.yml, which — unlike every other + // environment — does not import routing_test.php and therefore has no /mcp + // route. McpTest skips for the same reason. + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + try { if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)');