Skip to content

Commit 7a7b53e

Browse files
feat: add test of MCP
1 parent 65e6449 commit 7a7b53e

File tree

7 files changed

+207
-4
lines changed

7 files changed

+207
-4
lines changed

src/Mcp/Capability/Registry/Loader.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ public function load(RegistryInterface $registry): void
6262
annotations: $mcp->getAnnotations() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null,
6363
icons: $mcp->getIcons(),
6464
meta: $mcp->getMeta(),
65-
outputSchema: $outputSchema->getDefinitions()[$outputSchema->getRootDefinitionKey()]->getArrayCopy(),
66-
// outputSchema: $outputSchema->getArrayCopy(),
65+
outputSchema: $outputSchema->getArrayCopy(),
6766
),
6867
self::HANDLER,
6968
true,

src/Mcp/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"php": ">=8.2",
3131
"api-platform/metadata": "^4.2",
3232
"api-platform/json-schema": "^4.2",
33-
"mcp/sdk": "^0.3.0"
33+
"mcp/sdk": "^0.3.0",
34+
"symfony/polyfill-php85": "^1.32"
3435
},
3536
"autoload": {
3637
"psr-4": {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Dto;
15+
16+
final class SearchDto
17+
{
18+
public string $search;
19+
}

tests/Fixtures/TestBundle/Entity/McpBook.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515

1616
use ApiPlatform\Metadata\ApiResource;
1717
use ApiPlatform\Metadata\McpTool;
18+
use ApiPlatform\Metadata\McpToolCollection;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Dto\SearchDto;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\State\McpBookListProcessor;
1821
use Doctrine\ORM\Mapping as ORM;
1922

2023
#[ApiResource(
@@ -27,6 +30,12 @@
2730
'update_book_status' => new McpTool(
2831
processor: [self::class, 'process']
2932
),
33+
'list_books' => new McpToolCollection(
34+
description: 'List Books',
35+
input: SearchDto::class,
36+
processor: McpBookListProcessor::class,
37+
structuredContent: true,
38+
),
3039
]
3140
)]
3241
#[ORM\Entity]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\State;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\State\ProcessorInterface;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\McpBook;
19+
use Doctrine\Persistence\ManagerRegistry;
20+
21+
class McpBookListProcessor implements ProcessorInterface
22+
{
23+
public function __construct(private readonly ManagerRegistry $managerRegistry)
24+
{
25+
}
26+
27+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?iterable
28+
{
29+
return $this->managerRegistry->getRepository(McpBook::class)->findAll();
30+
}
31+
}

tests/Fixtures/app/config/config_common.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ services:
142142
tags:
143143
- name: 'api_platform.state_processor'
144144

145+
ApiPlatform\Tests\Fixtures\TestBundle\State\McpBookListProcessor:
146+
class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\McpBookListProcessor'
147+
arguments: [ '@doctrine' ]
148+
tags:
149+
- name: 'api_platform.state_processor'
145150

146151
ApiPlatform\Tests\Fixtures\TestBundle\State\ContainNonResourceProvider:
147152
class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\ContainNonResourceProvider'

tests/Functional/McpTest.php

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ public function testToolsList(): void
405405
],
406406
]);
407407

408+
self::assertResponseIsSuccessful();
408409
$data = $res->toArray();
409410
self::assertArrayHasKey('result', $data);
410411
self::assertArrayHasKey('tools', $data['result']);
@@ -418,14 +419,68 @@ public function testToolsList(): void
418419
self::assertContains('validate_input', $toolNames);
419420
self::assertContains('generate_markdown', $toolNames);
420421
self::assertContains('process_message', $toolNames);
422+
self::assertContains('list_books', $toolNames);
421423

422424
foreach ($tools as $tool) {
423425
self::assertArrayHasKey('name', $tool);
424426
self::assertArrayHasKey('inputSchema', $tool);
425427
self::assertEquals('object', $tool['inputSchema']['type']);
426428
}
427429

428-
self::assertResponseIsSuccessful();
430+
$listBooks = array_filter($tools, static function (array $input) {
431+
return 'list_books' === $input['name'];
432+
});
433+
434+
self::assertCount(1, $listBooks);
435+
436+
$listBooks = array_first($listBooks);
437+
438+
self::assertArrayHasKeyAndValue('inputSchema', [
439+
'type' => 'object',
440+
'properties' => [
441+
'search' => ['type' => 'string'],
442+
],
443+
], $listBooks);
444+
self::assertArrayHasKeyAndValue('description', 'List Books', $listBooks);
445+
446+
$outputSchema = $listBooks['outputSchema'];
447+
self::assertArrayHasKeyAndValue('$schema', 'http://json-schema.org/draft-07/schema#', $outputSchema);
448+
self::assertArrayHasKeyAndValue('type', 'object', $outputSchema);
449+
450+
self::assertArrayHasKey('definitions', $outputSchema);
451+
$definitions = $outputSchema['definitions'];
452+
self::assertArrayHasKey('McpBook.jsonld', $definitions);
453+
$McpBookJsonLd = $definitions['McpBook.jsonld'];
454+
self::assertArrayHasKeyAndValue('allOf', [
455+
[
456+
'$ref' => '#/definitions/HydraItemBaseSchema',
457+
],
458+
[
459+
'type' => 'object',
460+
'properties' => [
461+
'id' => ['readOnly' => true, 'type' => 'integer'],
462+
'title' => ['type' => 'string'],
463+
'isbn' => ['type' => 'string'],
464+
'status' => ['type' => ['string', 'null']],
465+
]
466+
],
467+
], $McpBookJsonLd);
468+
469+
self::assertArrayHasKeyAndValue('allOf', [
470+
['$ref' => '#/definitions/HydraCollectionBaseSchema'],
471+
[
472+
'type' => 'object',
473+
'required' => ['hydra:member'],
474+
'properties' => [
475+
'hydra:member' => [
476+
'type' => 'array',
477+
'items' => [
478+
'$ref' => '#/definitions/McpBook.jsonld'
479+
],
480+
],
481+
]
482+
],
483+
], $outputSchema);
429484
}
430485

431486
public function testMcpToolAttribute(): void
@@ -651,4 +706,88 @@ public function testMcpMarkdownContent(): void
651706
self::assertStringContainsString("echo 'Hello, World!';", $content);
652707
self::assertStringContainsString('```', $content);
653708
}
709+
710+
public function testMcpListBooks(): void
711+
{
712+
if (!class_exists(McpBundle::class)) {
713+
$this->markTestSkipped('MCP bundle is not installed');
714+
}
715+
716+
if ($this->isMongoDB()) {
717+
$this->markTestSkipped('MCP is not supported with MongoDB');
718+
}
719+
720+
if (!$this->isPsr17FactoryAvailable()) {
721+
$this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)');
722+
}
723+
724+
$this->recreateSchema([
725+
McpBook::class,
726+
]);
727+
728+
$book = new McpBook();
729+
$book->setTitle('API Platform Guide for MCP');
730+
$book->setIsbn('1-528491');
731+
$book->setStatus('available');
732+
$manager = $this->getContainer()->get('doctrine.orm.entity_manager');
733+
$manager->persist($book);
734+
$manager->flush();
735+
736+
$client = self::createClient();
737+
$sessionId = $this->initializeMcpSession($client);
738+
739+
$res = $client->request('POST', '/mcp', [
740+
'headers' => [
741+
'Accept' => 'application/json, text/event-stream',
742+
'Content-Type' => 'application/json',
743+
'mcp-session-id' => $sessionId,
744+
],
745+
'json' => [
746+
'jsonrpc' => '2.0',
747+
'id' => 2,
748+
'method' => 'tools/call',
749+
'params' => [
750+
'name' => 'list_books',
751+
'arguments' => [
752+
'search' => '',
753+
],
754+
],
755+
],
756+
]);
757+
758+
self::assertResponseIsSuccessful();
759+
$result = $res->toArray()['result'] ?? null;
760+
self::assertIsArray($result);
761+
self::assertArrayHasKey('content', $result);
762+
$content = $result['content'][0]['text'] ?? null;
763+
self::assertNotNull($content, 'No text content in result');
764+
self::assertStringContainsString('API Platform Guide for MCP', $content);
765+
self::assertStringContainsString('1-528491', $content);
766+
767+
$structuredContent = $result['structuredContent'] ?? null;
768+
$this->assertIsArray($structuredContent);
769+
770+
// when api_platform.use_symfony_listeners is true, the result is formatted as JSON-LD
771+
if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) {
772+
self::assertArrayHasKeyAndValue('@context', '/contexts/McpBook', $structuredContent);
773+
self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent);
774+
$members = $structuredContent['hydra:member'];
775+
} else {
776+
$members = $structuredContent;
777+
}
778+
779+
$this->assertCount(1, $members, json_encode($members, \JSON_PRETTY_PRINT));
780+
$actualBook = array_first($members);
781+
782+
self::assertArrayHasKeyAndValue('id', 1, $actualBook);
783+
self::assertArrayHasKeyAndValue('title', 'API Platform Guide for MCP', $actualBook);
784+
self::assertArrayHasKeyAndValue('isbn', '1-528491', $actualBook);
785+
self::assertArrayHasKeyAndValue('status', 'available', $actualBook);
786+
}
787+
788+
private static function assertArrayHasKeyAndValue(string $key, mixed $value, array $data): void
789+
{
790+
self::assertArrayHasKey($key, $data);
791+
self::assertSame($value, $data[$key]);
792+
}
654793
}

0 commit comments

Comments
 (0)