Skip to content

Commit 5864cf9

Browse files
feat: add an output DTO to MCP
1 parent 96b02e9 commit 5864cf9

File tree

3 files changed

+123
-0
lines changed

3 files changed

+123
-0
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\McpBook as McpBookEntity;
17+
use Symfony\Component\ObjectMapper\Attribute\Map;
18+
19+
#[Map(source: McpBookEntity::class)]
20+
final class McpBookDto
21+
{
22+
public int $id;
23+
24+
#[Map(source: 'title')]
25+
public string $name;
26+
27+
public string $isbn;
28+
}

tests/Fixtures/TestBundle/Entity/McpBook.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Metadata\ApiResource;
1717
use ApiPlatform\Metadata\McpTool;
1818
use ApiPlatform\Metadata\McpToolCollection;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Dto\McpBookDto;
1920
use ApiPlatform\Tests\Fixtures\TestBundle\Dto\SearchDto;
2021
use ApiPlatform\Tests\Fixtures\TestBundle\State\McpBookListProcessor;
2122
use Doctrine\ORM\Mapping as ORM;
@@ -36,6 +37,13 @@
3637
processor: McpBookListProcessor::class,
3738
structuredContent: true,
3839
),
40+
'list_books_dto' => new McpToolCollection(
41+
description: 'List Books and return a DTO',
42+
input: SearchDto::class,
43+
output: McpBookDto::class,
44+
processor: [self::class, 'processDto'],
45+
structuredContent: true,
46+
),
3947
]
4048
)]
4149
#[ORM\Entity]
@@ -106,4 +114,14 @@ public static function process($data): mixed
106114

107115
return $data;
108116
}
117+
118+
public static function processDto(): McpBookDto
119+
{
120+
$book = new McpBookDto();
121+
$book->id = 528491;
122+
$book->name = 'Raiders of the Lost Ark';
123+
$book->isbn = '1-528491';
124+
125+
return $book;
126+
}
109127
}

tests/Functional/McpTest.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ public function testToolsList(): void
420420
self::assertContains('generate_markdown', $toolNames);
421421
self::assertContains('process_message', $toolNames);
422422
self::assertContains('list_books', $toolNames);
423+
self::assertContains('list_books_dto', $toolNames);
423424

424425
foreach ($tools as $tool) {
425426
self::assertArrayHasKey('name', $tool);
@@ -785,6 +786,82 @@ public function testMcpListBooks(): void
785786
self::assertArrayHasKeyAndValue('status', 'available', $actualBook);
786787
}
787788

789+
public function testMcpListBooksDto(): void
790+
{
791+
if (!class_exists(McpBundle::class)) {
792+
$this->markTestSkipped('MCP bundle is not installed');
793+
}
794+
795+
if ($this->isMongoDB()) {
796+
$this->markTestSkipped('MCP is not supported with MongoDB');
797+
}
798+
799+
if (!$this->isPsr17FactoryAvailable()) {
800+
$this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)');
801+
}
802+
803+
$this->recreateSchema([
804+
McpBook::class,
805+
]);
806+
807+
$book = new McpBook();
808+
$book->setTitle('API Platform Guide for MCP');
809+
$book->setIsbn('1-528491');
810+
$book->setStatus('available');
811+
$manager = $this->getContainer()->get('doctrine.orm.entity_manager');
812+
$manager->persist($book);
813+
$manager->flush();
814+
815+
$client = self::createClient();
816+
$sessionId = $this->initializeMcpSession($client);
817+
818+
$res = $client->request('POST', '/mcp', [
819+
'headers' => [
820+
'Accept' => 'application/json, text/event-stream',
821+
'Content-Type' => 'application/json',
822+
'mcp-session-id' => $sessionId,
823+
],
824+
'json' => [
825+
'jsonrpc' => '2.0',
826+
'id' => 2,
827+
'method' => 'tools/call',
828+
'params' => [
829+
'name' => 'list_books_dto',
830+
'arguments' => [
831+
'search' => '',
832+
],
833+
],
834+
],
835+
]);
836+
837+
self::assertResponseIsSuccessful();
838+
$result = $res->toArray()['result'] ?? null;
839+
self::assertIsArray($result);
840+
self::assertArrayHasKey('content', $result);
841+
$content = $result['content'][0]['text'] ?? null;
842+
self::assertNotNull($content, 'No text content in result');
843+
self::assertStringContainsString('Raiders of the Lost Ark', $content);
844+
self::assertStringContainsString('1-528491', $content);
845+
846+
$structuredContent = $result['structuredContent'] ?? null;
847+
$this->assertIsArray($structuredContent);
848+
849+
$actualBook = $structuredContent;
850+
851+
// when api_platform.use_symfony_listeners is true, the result is formatted as JSON-LD
852+
if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) {
853+
self::assertArrayHasKey('@context', $structuredContent);
854+
$context = $structuredContent['@context'];
855+
self::assertArrayHasKeyAndValue('@vocab', 'http://localhost/docs.jsonld#', $context);
856+
self::assertArrayHasKeyAndValue('hydra', 'http://www.w3.org/ns/hydra/core#', $context);
857+
}
858+
859+
self::assertArrayHasKeyAndValue('id', 528491, $actualBook);
860+
self::assertArrayHasKeyAndValue('name', 'Raiders of the Lost Ark', $actualBook);
861+
self::assertArrayHasKeyAndValue('isbn', '1-528491', $actualBook);
862+
self::assertArrayNotHasKey('status', $actualBook);
863+
}
864+
788865
private static function assertArrayHasKeyAndValue(string $key, mixed $value, array $data): void
789866
{
790867
self::assertArrayHasKey($key, $data);

0 commit comments

Comments
 (0)