Skip to content
Open
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
13 changes: 9 additions & 4 deletions src/Mcp/Capability/Registry/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/Mcp/State/StructuredContentProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/Mcp/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/Metadata/McpTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions src/Metadata/McpToolCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?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\Metadata;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class McpToolCollection extends McpTool implements CollectionOperationInterface
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
19 changes: 19 additions & 0 deletions tests/Fixtures/TestBundle/Dto/SearchDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?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\Dto;

final class SearchDto
{
public string $search;
}
9 changes: 9 additions & 0 deletions tests/Fixtures/TestBundle/Entity/McpBook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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]
Expand Down
31 changes: 31 additions & 0 deletions tests/Fixtures/TestBundle/State/McpBookListProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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\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();
}
}
5 changes: 5 additions & 0 deletions tests/Fixtures/app/config/config_common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
141 changes: 140 additions & 1 deletion tests/Functional/McpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ public function testToolsList(): void
],
]);

self::assertResponseIsSuccessful();
$data = $res->toArray();
self::assertArrayHasKey('result', $data);
self::assertArrayHasKey('tools', $data['result']);
Expand All @@ -418,14 +419,68 @@ 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);
self::assertArrayHasKey('inputSchema', $tool);
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
Expand Down Expand Up @@ -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]);
}
}
Loading