Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/Mcp/State/StructuredContentProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -63,7 +67,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
'operation' => $operation,
]);
$serializerContext['uri_variables'] = $uriVariables;
$format = $request->getRequestFormat('') ?: 'jsonld';
$format = $outputFormats ? array_key_first($outputFormats) : ($request->getRequestFormat('') ?: 'jsonld');
$normalized = $this->serializer->normalize($result, $format, $serializerContext);
$result = $this->serializer->encode($normalized, $format, $serializerContext);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
52 changes: 52 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/McpFormatListTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
54 changes: 54 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/McpFormatTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
224 changes: 224 additions & 0 deletions tests/Functional/McpFormatTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\McpFormatListTool;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatTool;
use ApiPlatform\Tests\RecreateSchemaTrait;
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 RecreateSchemaTrait;
use SetupClassResourcesTrait;

protected static ?bool $alwaysBootKernel = false;

/**
* @return class-string[]
*/
public static function getResources(): array
{
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
{
$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');
}

// 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)');
}

\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<string, mixed> $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,
],
],
]);
}
}
Loading