Skip to content

Commit 71072ad

Browse files
feat: add test of MCP
1 parent 65e6449 commit 71072ad

File tree

7 files changed

+153
-3
lines changed

7 files changed

+153
-3
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: 10 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,9 +30,16 @@
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]
42+
#[ORM\Table(name: 'mcp_book')]
3343
class McpBook
3444
{
3545
#[ORM\Column(type: 'integer')]
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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ public function testToolsList(): void
418418
self::assertContains('validate_input', $toolNames);
419419
self::assertContains('generate_markdown', $toolNames);
420420
self::assertContains('process_message', $toolNames);
421+
self::assertContains('list_books', $toolNames);
421422

422423
foreach ($tools as $tool) {
423424
self::assertArrayHasKey('name', $tool);
@@ -651,4 +652,88 @@ public function testMcpMarkdownContent(): void
651652
self::assertStringContainsString("echo 'Hello, World!';", $content);
652653
self::assertStringContainsString('```', $content);
653654
}
655+
656+
public function testMcpListBooks(): void
657+
{
658+
if (!class_exists(McpBundle::class)) {
659+
$this->markTestSkipped('MCP bundle is not installed');
660+
}
661+
662+
if ($this->isMongoDB()) {
663+
$this->markTestSkipped('MCP is not supported with MongoDB');
664+
}
665+
666+
if (!$this->isPsr17FactoryAvailable()) {
667+
$this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)');
668+
}
669+
670+
$this->recreateSchema([
671+
McpBook::class,
672+
]);
673+
674+
$book = new McpBook();
675+
$book->setTitle('API Platform Guide for MCP');
676+
$book->setIsbn('1-528491');
677+
$book->setStatus('available');
678+
$manager = $this->getContainer()->get('doctrine.orm.entity_manager');
679+
$manager->persist($book);
680+
$manager->flush();
681+
682+
$client = self::createClient();
683+
$sessionId = $this->initializeMcpSession($client);
684+
685+
$res = $client->request('POST', '/mcp', [
686+
'headers' => [
687+
'Accept' => 'application/json, text/event-stream',
688+
'Content-Type' => 'application/json',
689+
'mcp-session-id' => $sessionId,
690+
],
691+
'json' => [
692+
'jsonrpc' => '2.0',
693+
'id' => 2,
694+
'method' => 'tools/call',
695+
'params' => [
696+
'name' => 'list_books',
697+
'arguments' => [
698+
'search' => '',
699+
],
700+
],
701+
],
702+
]);
703+
704+
self::assertResponseIsSuccessful();
705+
$result = $res->toArray()['result'] ?? null;
706+
self::assertIsArray($result);
707+
self::assertArrayHasKey('content', $result);
708+
$content = $result['content'][0]['text'] ?? null;
709+
self::assertNotNull($content, 'No text content in result');
710+
self::assertStringContainsString('API Platform Guide for MCP', $content);
711+
self::assertStringContainsString('1-528491', $content);
712+
713+
$structuredContent = $result['structuredContent'] ?? null;
714+
$this->assertIsArray($structuredContent);
715+
716+
// when api_platform.use_symfony_listeners is true, the result has JSON-LD format
717+
if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) {
718+
self::assertArrayHasKeyAndValue('@context', '/contexts/McpBook', $structuredContent);
719+
self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent);
720+
$members = $structuredContent['hydra:member'];
721+
} else {
722+
$members = $structuredContent;
723+
}
724+
725+
$this->assertCount(1, $members, json_encode($members, \JSON_PRETTY_PRINT));
726+
$actualBook = array_first($members);
727+
728+
self::assertArrayHasKeyAndValue('id', 1, $actualBook);
729+
self::assertArrayHasKeyAndValue('title', 'API Platform Guide for MCP', $actualBook);
730+
self::assertArrayHasKeyAndValue('isbn', '1-528491', $actualBook);
731+
self::assertArrayHasKeyAndValue('status', 'available', $actualBook);
732+
}
733+
734+
private static function assertArrayHasKeyAndValue(string $key, mixed $value, array $data): void
735+
{
736+
self::assertArrayHasKey($key, $data);
737+
self::assertSame($value, $data[$key]);
738+
}
654739
}

0 commit comments

Comments
 (0)