diff --git a/src/Mcp/Capability/Registry/Loader.php b/src/Mcp/Capability/Registry/Loader.php index cf159ad536..dceb15bdd4 100644 --- a/src/Mcp/Capability/Registry/Loader.php +++ b/src/Mcp/Capability/Registry/Loader.php @@ -47,17 +47,22 @@ public function load(RegistryInterface $registry): void foreach ($resource->getMcp() ?? [] as $mcp) { if ($mcp instanceof McpTool) { $inputClass = $mcp->getInput()['class'] ?? $mcp->getClass(); - $schema = $this->schemaFactory->buildSchema($inputClass, 'json', Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); - $outputSchema = $this->schemaFactory->buildSchema($inputClass, 'json', Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); + $inputFormat = array_first($mcp->getInputFormats() ?? ['json']); + $inputSchema = $this->schemaFactory->buildSchema($inputClass, $inputFormat, Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); + + $outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass(); + $outputFormat = array_first($mcp->getOutputFormats() ?? ['jsonld']); + $outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); + $registry->registerTool( new Tool( name: $mcp->getName(), - inputSchema: $schema->getDefinitions()[$schema->getRootDefinitionKey()]->getArrayCopy(), + inputSchema: $inputSchema->getDefinitions()[$inputSchema->getRootDefinitionKey()]->getArrayCopy(), description: $mcp->getDescription(), annotations: $mcp->getAnnotations() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null, icons: $mcp->getIcons(), meta: $mcp->getMeta(), - outputSchema: $outputSchema->getDefinitions()[$outputSchema->getRootDefinitionKey()]->getArrayCopy(), + outputSchema: $outputSchema->getArrayCopy(), ), self::HANDLER, true, diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php index e56df3cb44..3a6b4324e3 100644 --- a/src/Mcp/State/StructuredContentProcessor.php +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -63,7 +63,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = 'operation' => $operation, ]); $serializerContext['uri_variables'] = $uriVariables; - $format = $request->getRequestFormat('') ?: 'json'; + $format = $request->getRequestFormat('') ?: 'jsonld'; $structuredContent = $this->serializer->normalize($result, $format, $serializerContext); $result = $this->serializer->encode($structuredContent, $format, $serializerContext); } diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index f39023cbe2..7593940d79 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -30,7 +30,8 @@ "php": ">=8.2", "api-platform/metadata": "^4.2", "api-platform/json-schema": "^4.2", - "mcp/sdk": "^0.3.0" + "mcp/sdk": "^0.3.0", + "symfony/polyfill-php85": "^1.32" }, "autoload": { "psr-4": { diff --git a/src/Metadata/McpTool.php b/src/Metadata/McpTool.php index 1f326bb3d7..9f2c8802b7 100644 --- a/src/Metadata/McpTool.php +++ b/src/Metadata/McpTool.php @@ -20,7 +20,7 @@ use Symfony\Component\WebLink\Link as WebLink; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] -final class McpTool extends HttpOperation +class McpTool extends HttpOperation { /** * @param string|null $name The name of the tool (defaults to the method name) diff --git a/src/Metadata/McpToolCollection.php b/src/Metadata/McpToolCollection.php new file mode 100644 index 0000000000..9c76bdb41b --- /dev/null +++ b/src/Metadata/McpToolCollection.php @@ -0,0 +1,19 @@ + + * + * 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\Metadata; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class McpToolCollection extends McpTool implements CollectionOperationInterface +{ +} diff --git a/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php index b7f6d722a1..00d2c6ea25 100644 --- a/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php @@ -48,6 +48,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resourceMetadata = $resourceMetadata->withGraphQlOperations($this->getTransformedOperations($resourceMetadata->getGraphQlOperations(), $resourceMetadata)); } + if ($resourceMetadata->getMcp()) { + $resourceMetadata = $resourceMetadata->withMcp($this->getTransformedOperations($resourceMetadata->getMcp(), $resourceMetadata)); + } + $resourceMetadataCollection[$key] = $resourceMetadata; } diff --git a/tests/Fixtures/TestBundle/Dto/SearchDto.php b/tests/Fixtures/TestBundle/Dto/SearchDto.php new file mode 100644 index 0000000000..fb9bcf7ad7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/SearchDto.php @@ -0,0 +1,19 @@ + + * + * 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\Dto; + +final class SearchDto +{ + public string $search; +} diff --git a/tests/Fixtures/TestBundle/Entity/McpBook.php b/tests/Fixtures/TestBundle/Entity/McpBook.php index c2b0f5b71b..f50390e05a 100644 --- a/tests/Fixtures/TestBundle/Entity/McpBook.php +++ b/tests/Fixtures/TestBundle/Entity/McpBook.php @@ -15,6 +15,9 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\SearchDto; +use ApiPlatform\Tests\Fixtures\TestBundle\State\McpBookListProcessor; use Doctrine\ORM\Mapping as ORM; #[ApiResource( @@ -27,6 +30,12 @@ 'update_book_status' => new McpTool( processor: [self::class, 'process'] ), + 'list_books' => new McpToolCollection( + description: 'List Books', + input: SearchDto::class, + processor: McpBookListProcessor::class, + structuredContent: true, + ), ] )] #[ORM\Entity] diff --git a/tests/Fixtures/TestBundle/State/McpBookListProcessor.php b/tests/Fixtures/TestBundle/State/McpBookListProcessor.php new file mode 100644 index 0000000000..842b9a4f77 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/McpBookListProcessor.php @@ -0,0 +1,31 @@ + + * + * 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\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\McpBook; +use Doctrine\Persistence\ManagerRegistry; + +class McpBookListProcessor implements ProcessorInterface +{ + public function __construct(private readonly ManagerRegistry $managerRegistry) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?iterable + { + return $this->managerRegistry->getRepository(McpBook::class)->findAll(); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index e2935e6fc1..224bc89dd3 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -142,6 +142,11 @@ services: tags: - name: 'api_platform.state_processor' + ApiPlatform\Tests\Fixtures\TestBundle\State\McpBookListProcessor: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\McpBookListProcessor' + arguments: [ '@doctrine' ] + tags: + - name: 'api_platform.state_processor' ApiPlatform\Tests\Fixtures\TestBundle\State\ContainNonResourceProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\ContainNonResourceProvider' diff --git a/tests/Functional/McpTest.php b/tests/Functional/McpTest.php index da26d1c1b8..ca895724cc 100644 --- a/tests/Functional/McpTest.php +++ b/tests/Functional/McpTest.php @@ -405,6 +405,7 @@ public function testToolsList(): void ], ]); + self::assertResponseIsSuccessful(); $data = $res->toArray(); self::assertArrayHasKey('result', $data); self::assertArrayHasKey('tools', $data['result']); @@ -418,6 +419,7 @@ public function testToolsList(): void self::assertContains('validate_input', $toolNames); self::assertContains('generate_markdown', $toolNames); self::assertContains('process_message', $toolNames); + self::assertContains('list_books', $toolNames); foreach ($tools as $tool) { self::assertArrayHasKey('name', $tool); @@ -425,7 +427,60 @@ public function testToolsList(): void self::assertEquals('object', $tool['inputSchema']['type']); } - self::assertResponseIsSuccessful(); + $listBooks = array_filter($tools, static function (array $input) { + return 'list_books' === $input['name']; + }); + + self::assertCount(1, $listBooks); + + $listBooks = array_first($listBooks); + + self::assertArrayHasKeyAndValue('inputSchema', [ + 'type' => 'object', + 'properties' => [ + 'search' => ['type' => 'string'], + ], + ], $listBooks); + self::assertArrayHasKeyAndValue('description', 'List Books', $listBooks); + + $outputSchema = $listBooks['outputSchema']; + self::assertArrayHasKeyAndValue('$schema', 'http://json-schema.org/draft-07/schema#', $outputSchema); + self::assertArrayHasKeyAndValue('type', 'object', $outputSchema); + + self::assertArrayHasKey('definitions', $outputSchema); + $definitions = $outputSchema['definitions']; + self::assertArrayHasKey('McpBook.jsonld', $definitions); + $McpBookJsonLd = $definitions['McpBook.jsonld']; + self::assertArrayHasKeyAndValue('allOf', [ + [ + '$ref' => '#/definitions/HydraItemBaseSchema', + ], + [ + 'type' => 'object', + 'properties' => [ + 'id' => ['readOnly' => true, 'type' => 'integer'], + 'title' => ['type' => 'string'], + 'isbn' => ['type' => 'string'], + 'status' => ['type' => ['string', 'null']], + ], + ], + ], $McpBookJsonLd); + + self::assertArrayHasKeyAndValue('allOf', [ + ['$ref' => '#/definitions/HydraCollectionBaseSchema'], + [ + 'type' => 'object', + 'required' => ['hydra:member'], + 'properties' => [ + 'hydra:member' => [ + 'type' => 'array', + 'items' => [ + '$ref' => '#/definitions/McpBook.jsonld', + ], + ], + ], + ], + ], $outputSchema); } public function testMcpToolAttribute(): void @@ -651,4 +706,88 @@ public function testMcpMarkdownContent(): void self::assertStringContainsString("echo 'Hello, World!';", $content); self::assertStringContainsString('```', $content); } + + public function testMcpListBooks(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $this->recreateSchema([ + McpBook::class, + ]); + + $book = new McpBook(); + $book->setTitle('API Platform Guide for MCP'); + $book->setIsbn('1-528491'); + $book->setStatus('available'); + $manager = $this->getContainer()->get('doctrine.orm.entity_manager'); + $manager->persist($book); + $manager->flush(); + + $client = self::createClient(); + $sessionId = $this->initializeMcpSession($client); + + $res = $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' => 'list_books', + 'arguments' => [ + 'search' => '', + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray()['result'] ?? null; + self::assertIsArray($result); + self::assertArrayHasKey('content', $result); + $content = $result['content'][0]['text'] ?? null; + self::assertNotNull($content, 'No text content in result'); + self::assertStringContainsString('API Platform Guide for MCP', $content); + self::assertStringContainsString('1-528491', $content); + + $structuredContent = $result['structuredContent'] ?? null; + $this->assertIsArray($structuredContent); + + // when api_platform.use_symfony_listeners is true, the result is formatted as JSON-LD + if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) { + self::assertArrayHasKeyAndValue('@context', '/contexts/McpBook', $structuredContent); + self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent); + $members = $structuredContent['hydra:member']; + } else { + $members = $structuredContent; + } + + $this->assertCount(1, $members, json_encode($members, \JSON_PRETTY_PRINT)); + $actualBook = array_first($members); + + self::assertArrayHasKeyAndValue('id', 1, $actualBook); + self::assertArrayHasKeyAndValue('title', 'API Platform Guide for MCP', $actualBook); + self::assertArrayHasKeyAndValue('isbn', '1-528491', $actualBook); + self::assertArrayHasKeyAndValue('status', 'available', $actualBook); + } + + private static function assertArrayHasKeyAndValue(string $key, mixed $value, array $data): void + { + self::assertArrayHasKey($key, $data); + self::assertSame($value, $data[$key]); + } }