Skip to content

Commit 13f91e9

Browse files
committed
feat: mcp bundle tool integration
1 parent f5f72c4 commit 13f91e9

File tree

32 files changed

+2830
-24
lines changed

32 files changed

+2830
-24
lines changed

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
"jangregor/phpstan-prophecy": "^2.1.11",
145145
"justinrainbow/json-schema": "^6.5.2",
146146
"laravel/framework": "^11.0 || ^12.0",
147+
"mcp/sdk": "^0.3.0",
147148
"orchestra/testbench": "^9.1",
148149
"phpspec/prophecy-phpunit": "^2.2",
149150
"phpstan/extension-installer": "^1.1",
@@ -178,8 +179,10 @@
178179
"symfony/intl": "^6.4 || ^7.0 || ^8.0",
179180
"symfony/json-streamer": "^7.4 || ^8.0",
180181
"symfony/maker-bundle": "^1.24",
182+
"symfony/mcp-bundle": "^0.2.0",
181183
"symfony/mercure-bundle": "*",
182184
"symfony/messenger": "^6.4 || ^7.0 || ^8.0",
185+
"symfony/monolog-bundle": "^4.0",
183186
"symfony/object-mapper": "^7.4 || ^8.0",
184187
"symfony/routing": "^6.4 || ^7.0 || ^8.0",
185188
"symfony/security-bundle": "^6.4 || ^7.0 || ^8.0",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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\Mcp\Capability\Registry;
15+
16+
use ApiPlatform\JsonSchema\Schema;
17+
use ApiPlatform\JsonSchema\SchemaFactory;
18+
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
19+
use ApiPlatform\Metadata\McpResource;
20+
use ApiPlatform\Metadata\McpTool;
21+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
22+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
23+
use Mcp\Capability\Registry\Loader\LoaderInterface;
24+
use Mcp\Capability\RegistryInterface;
25+
use Mcp\Schema\Annotations;
26+
use Mcp\Schema\Resource;
27+
use Mcp\Schema\Tool;
28+
use Mcp\Schema\ToolAnnotations;
29+
30+
final class Loader implements LoaderInterface
31+
{
32+
public const HANDLER = 'api_platform.mcp.handler';
33+
34+
public function __construct(
35+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
36+
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollection,
37+
private readonly SchemaFactoryInterface $schemaFactory,
38+
) {
39+
}
40+
41+
public function load(RegistryInterface $registry): void
42+
{
43+
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
44+
$metadata = $this->resourceMetadataCollection->create($resourceClass);
45+
46+
foreach ($metadata as $resource) {
47+
foreach ($resource->getMcp() ?? [] as $mcp) {
48+
if ($mcp instanceof McpTool) {
49+
$inputClass = $mcp->getInput()['class'] ?? $mcp->getClass();
50+
$schema = $this->schemaFactory->buildSchema($inputClass, 'json', Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]);
51+
$outputSchema = $this->schemaFactory->buildSchema($inputClass, 'json', Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]);
52+
$registry->registerTool(
53+
new Tool(
54+
name: $mcp->getName(),
55+
inputSchema: $schema->getDefinitions()[$schema->getRootDefinitionKey()]->getArrayCopy(),
56+
description: $mcp->getDescription(),
57+
annotations: $mcp->getAnnotations() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null,
58+
icons: $mcp->getIcons(),
59+
meta: $mcp->getMeta(),
60+
outputSchema: $outputSchema->getDefinitions()[$schema->getRootDefinitionKey()]->getArrayCopy(),
61+
),
62+
self::HANDLER,
63+
true
64+
);
65+
}
66+
67+
if ($mcp instanceof McpResource) {
68+
$registry->registerResource(
69+
new Resource(
70+
uri: $mcp->getUri(),
71+
name: $mcp->getName(),
72+
description: $mcp->getDescription(),
73+
mimeType: $mcp->getMimeType(),
74+
annotations: $mcp->getAnnotations() ? Annotations::fromArray($mcp->getAnnotations()) : null,
75+
size: $mcp->getSize(),
76+
icons: $mcp->getIcons(),
77+
meta: $mcp->getMeta()
78+
),
79+
self::HANDLER,
80+
true
81+
);
82+
}
83+
}
84+
}
85+
}
86+
}
87+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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\Mcp\Metadata\Operation\Factory;
15+
16+
use ApiPlatform\Metadata\Exception\RuntimeException;
17+
use ApiPlatform\Metadata\HttpOperation;
18+
use ApiPlatform\Metadata\McpResource;
19+
use ApiPlatform\Metadata\McpTool;
20+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
21+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
22+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
23+
24+
final class OperationMetadataFactory implements OperationMetadataFactoryInterface
25+
{
26+
public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory)
27+
{
28+
}
29+
30+
/**
31+
* @throws RuntimeException
32+
*
33+
* @return HttpOperation
34+
*/
35+
public function create(string $operationName, array $context = []): \ApiPlatform\Metadata\Operation
36+
{
37+
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
38+
foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) {
39+
if (null === $mcp = $resource->getMcp()) {
40+
continue;
41+
}
42+
43+
foreach ($mcp as $operation) {
44+
if (!($operation instanceof McpTool || $operation instanceof McpResource)) {
45+
continue;
46+
}
47+
48+
if ($operation->getName() === $operationName) {
49+
return $operation;
50+
}
51+
52+
if ($operation instanceof McpResource && $operation->getUri() === $operationName) {
53+
return $operation;
54+
}
55+
}
56+
}
57+
}
58+
59+
throw new RuntimeException(\sprintf('MCP operation "%s" not found.', $operationName));
60+
}
61+
}

src/Mcp/Routing/IriConverter.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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\Mcp\Routing;
15+
16+
use ApiPlatform\Metadata\IriConverterInterface;
17+
use ApiPlatform\Metadata\McpResource;
18+
use ApiPlatform\Metadata\McpTool;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\UrlGeneratorInterface;
21+
22+
final class IriConverter implements IriConverterInterface
23+
{
24+
public function __construct(private readonly IriConverterInterface $inner)
25+
{
26+
}
27+
28+
public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object
29+
{
30+
return $this->inner->getResourceFromIri($iri, $context, $operation);
31+
}
32+
33+
public function getIriFromResource(object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): ?string
34+
{
35+
if (($operation instanceof McpTool || $operation instanceof McpResource) && !isset($context['item_uri_template'])) {
36+
return null;
37+
}
38+
39+
return $this->inner->getIriFromResource($resource, $referenceType, $operation, $context);
40+
}
41+
}

src/Mcp/Server/Handler.php

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
/*
15+
* This file is part of the official PHP MCP SDK.
16+
*
17+
* A collaboration between Symfony and the PHP Foundation.
18+
*
19+
* For the full copyright and license information, please view the LICENSE
20+
* file that was distributed with this source code.
21+
*/
22+
23+
namespace ApiPlatform\Mcp\Server;
24+
25+
use ApiPlatform\Metadata\HttpOperation;
26+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
27+
use ApiPlatform\State\ProcessorInterface;
28+
use ApiPlatform\State\ProviderInterface;
29+
use Mcp\Schema\JsonRpc\Error;
30+
use Mcp\Schema\JsonRpc\Request;
31+
use Mcp\Schema\JsonRpc\Response;
32+
use Mcp\Schema\Request\CallToolRequest;
33+
use Mcp\Schema\Request\ReadResourceRequest;
34+
use Mcp\Schema\Result\CallToolResult;
35+
use Mcp\Schema\Result\ReadResourceResult;
36+
use Mcp\Server\Handler\Request\RequestHandlerInterface;
37+
use Mcp\Server\Session\SessionInterface;
38+
use Psr\Log\LoggerInterface;
39+
use Psr\Log\NullLogger;
40+
use Symfony\Component\HttpFoundation\RequestStack;
41+
42+
/**
43+
* @implements RequestHandlerInterface<CallToolResult|ReadResourceResult>
44+
*/
45+
final class Handler implements RequestHandlerInterface
46+
{
47+
public function __construct(
48+
private readonly OperationMetadataFactoryInterface $operationMetadataFactory,
49+
private readonly ProviderInterface $provider,
50+
private readonly ProcessorInterface $processor,
51+
private readonly RequestStack $requestStack,
52+
private readonly LoggerInterface $logger = new NullLogger(),
53+
) {
54+
}
55+
56+
public function supports(Request $request): bool
57+
{
58+
return $request instanceof CallToolRequest || $request instanceof ReadResourceRequest;
59+
}
60+
61+
/**
62+
* @return Response<CallToolResult|ReadResourceResult>|Error
63+
*/
64+
public function handle(Request $request, SessionInterface $session): Response|Error
65+
{
66+
$isResource = $request instanceof ReadResourceRequest;
67+
68+
if ($isResource) {
69+
$operationNameOrUri = $request->uri;
70+
$arguments = [];
71+
$this->logger->debug('Reading resource', ['uri' => $operationNameOrUri]);
72+
} else {
73+
\assert($request instanceof CallToolRequest);
74+
$operationNameOrUri = $request->name;
75+
$arguments = $request->arguments ?? [];
76+
$this->logger->debug('Executing tool', ['name' => $operationNameOrUri, 'arguments' => $arguments]);
77+
}
78+
79+
/** @var HttpOperation $operation */
80+
$operation = $this->operationMetadataFactory->create($operationNameOrUri);
81+
82+
$uriVariables = [];
83+
if (!$isResource) {
84+
foreach ($operation->getUriVariables() ?? [] as $key => $link) {
85+
if (isset($arguments[$key])) {
86+
$uriVariables[$key] = $arguments[$key];
87+
}
88+
}
89+
}
90+
91+
$context = [
92+
'request' => ($httpRequest = $this->requestStack->getCurrentRequest()),
93+
'mcp_request' => $request,
94+
'uri_variables' => $uriVariables,
95+
'resource_class' => $operation->getClass(),
96+
];
97+
98+
if (!$isResource) {
99+
$context['mcp_data'] = $arguments;
100+
}
101+
102+
if (null === $operation->canValidate()) {
103+
$operation = $operation->withValidate(false);
104+
}
105+
106+
if (null === $operation->canRead()) {
107+
$operation = $operation->withRead(true);
108+
}
109+
110+
if (null === $operation->getProvider()) {
111+
$operation = $operation->withProvider('api_platform.mcp.state.tool_provider');
112+
}
113+
114+
if (null === $operation->canDeserialize()) {
115+
$operation = $operation->withDeserialize(false);
116+
}
117+
118+
$body = $this->provider->provide($operation, $uriVariables, $context);
119+
120+
if (!$isResource) {
121+
$context['previous_data'] = $httpRequest->attributes->get('previous_data');
122+
$context['data'] = $httpRequest->attributes->get('data');
123+
$context['read_data'] = $httpRequest->attributes->get('read_data');
124+
$context['mapped_data'] = $httpRequest->attributes->get('mapped_data');
125+
}
126+
127+
if (null === $operation->canWrite()) {
128+
$operation = $operation->withWrite(true);
129+
}
130+
131+
if (null === $operation->canSerialize()) {
132+
$operation = $operation->withSerialize(false);
133+
}
134+
135+
return $this->processor->process($body, $operation, $uriVariables, $context);
136+
}
137+
}

0 commit comments

Comments
 (0)