Skip to content

Commit f5a6db7

Browse files
authored
Merge c0836f7 into 4a72073
2 parents 4a72073 + c0836f7 commit f5a6db7

File tree

20 files changed

+1411
-24
lines changed

20 files changed

+1411
-24
lines changed

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,10 @@
182182
"symfony/intl": "^6.4 || ^7.0 || ^8.0",
183183
"symfony/json-streamer": "^7.4 || ^8.0",
184184
"symfony/maker-bundle": "^1.24",
185+
"symfony/mcp-bundle": "dev-main",
185186
"symfony/mercure-bundle": "*",
186187
"symfony/messenger": "^6.4 || ^7.0 || ^8.0",
188+
"symfony/monolog-bundle": "^4.0",
187189
"symfony/object-mapper": "^7.4 || ^8.0",
188190
"symfony/routing": "^6.4 || ^7.0 || ^8.0",
189191
"symfony/security-bundle": "^6.4 || ^7.0 || ^8.0",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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\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 ApiPlatform\Symfony\Controller\McpController;
24+
use Mcp\Capability\Registry\Loader\LoaderInterface;
25+
use Mcp\Capability\RegistryInterface;
26+
use Mcp\Schema\Annotations;
27+
use Mcp\Schema\Resource;
28+
use Mcp\Schema\Tool;
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+
$registry->registerTool(
52+
new Tool(
53+
name: $mcp->getName(),
54+
inputSchema: $schema->getDefinitions()[$schema->getRootDefinitionKey()]->getArrayCopy(),
55+
description: $mcp->getDescription(),
56+
annotations: $mcp->getAnnotations() ? Annotations::fromArray($mcp->getAnnotations()) : null,
57+
icons: $mcp->getIcons(),
58+
meta: $mcp->getMeta()
59+
),
60+
self::HANDLER,
61+
true
62+
);
63+
}
64+
65+
if ($mcp instanceof McpResource) {
66+
$registry->registerResource(
67+
new Resource(
68+
uri: $mcp->getUri(),
69+
name: $mcp->getName(),
70+
description: $mcp->getDescription(),
71+
mimeType: $mcp->getMimeType(),
72+
annotations: $mcp->getAnnotations() ? Annotations::fromArray($mcp->getAnnotations()) : null,
73+
size: $mcp->getSize(),
74+
icons: $mcp->getIcons(),
75+
meta: $mcp->getMeta()
76+
),
77+
self::HANDLER,
78+
true
79+
);
80+
}
81+
}
82+
}
83+
}
84+
}
85+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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\Mcp\Metadata\Operation\Factory;
15+
16+
use ApiPlatform\Metadata\McpResource;
17+
use ApiPlatform\Metadata\McpTool;
18+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
20+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
21+
22+
final class OperationMetadataFactory implements OperationMetadataFactoryInterface
23+
{
24+
public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory)
25+
{
26+
}
27+
28+
public function create(string $operationName, array $context = []): ?\ApiPlatform\Metadata\Operation
29+
{
30+
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
31+
foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) {
32+
if (null === $mcp = $resource->getMcp()) {
33+
continue;
34+
}
35+
36+
foreach ($mcp as $operation) {
37+
if (($operation instanceof McpTool || $operation instanceof McpResource) && $operation->getName() === $operationName) {
38+
return $operation;
39+
}
40+
}
41+
}
42+
}
43+
44+
return null;
45+
}
46+
}

src/Mcp/Routing/IriConverter.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace ApiPlatform\Mcp\Routing;
4+
5+
use ApiPlatform\Metadata\IriConverterInterface;
6+
use ApiPlatform\Metadata\McpResource;
7+
use ApiPlatform\Metadata\McpTool;
8+
use ApiPlatform\Metadata\Operation;
9+
10+
final class IriConverter implements IriConverterInterface
11+
{
12+
public function __construct(private readonly IriConverterInterface $inner)
13+
{
14+
}
15+
16+
public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object
17+
{
18+
return $this->inner->getResourceFromIri($iri, $context, $operation);
19+
}
20+
21+
public function getIriFromResource(object|string $resource, int $referenceType = 1, ?Operation $operation = null, array $context = []): ?string
22+
{
23+
if (($operation instanceof McpTool || $operation instanceof McpResource) && !isset($context['item_uri_template'])) {
24+
return null;
25+
}
26+
27+
return $this->inner->getIriFromResource($resource, $referenceType, $operation, $context);
28+
}
29+
}

src/Mcp/Server/Handler.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
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+
namespace ApiPlatform\Mcp\Server;
13+
14+
use ApiPlatform\Mcp\State\ToolProvider;
15+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
16+
use ApiPlatform\State\ProcessorInterface;
17+
use ApiPlatform\State\ProviderInterface;
18+
use Mcp\Capability\Registry\ReferenceHandlerInterface;
19+
use Mcp\Capability\RegistryInterface;
20+
use Mcp\Exception\ToolCallException;
21+
use Mcp\Exception\ToolNotFoundException;
22+
use Mcp\Schema\Content\TextContent;
23+
use Mcp\Schema\JsonRpc\Error;
24+
use Mcp\Schema\JsonRpc\Request;
25+
use Mcp\Schema\JsonRpc\Response;
26+
use Mcp\Schema\Request\CallToolRequest;
27+
use Mcp\Schema\Result\CallToolResult;
28+
use Mcp\Server\Handler\Request\RequestHandlerInterface;
29+
use Mcp\Server\Session\SessionInterface;
30+
use Psr\Log\LoggerInterface;
31+
use Psr\Log\NullLogger;
32+
use Symfony\Component\HttpFoundation\RequestStack;
33+
34+
/**
35+
* @implements RequestHandlerInterface<CallToolResult>
36+
*/
37+
final class Handler implements RequestHandlerInterface
38+
{
39+
public function __construct(
40+
private readonly OperationMetadataFactoryInterface $operationMetadataFactory,
41+
private readonly ProviderInterface $provider,
42+
private readonly ProcessorInterface $processor,
43+
private readonly RequestStack $requestStack,
44+
private readonly LoggerInterface $logger = new NullLogger(),
45+
) {
46+
}
47+
48+
public function supports(Request $request): bool
49+
{
50+
return $request instanceof CallToolRequest;
51+
}
52+
53+
/**
54+
* @return Response<CallToolResult>|Error
55+
*/
56+
public function handle(Request $request, SessionInterface $session): Response|Error
57+
{
58+
\assert($request instanceof CallToolRequest);
59+
60+
$toolName = $request->name;
61+
$arguments = $request->arguments ?? [];
62+
63+
$this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]);
64+
65+
$operation = $this->operationMetadataFactory->create($toolName);
66+
67+
$uriVariables = [];
68+
foreach ($operation->getUriVariables() ?? [] as $key => $link) {
69+
if (isset($arguments[$key])) {
70+
$uriVariables[$key] = $arguments[$key];
71+
}
72+
}
73+
74+
$context = [
75+
'request' => ($httpRequest = $this->requestStack->getCurrentRequest()),
76+
'mcp_request' => $request,
77+
'uri_variables' => $uriVariables,
78+
'resource_class' => $operation->getClass(),
79+
'mcp_data' => $arguments,
80+
];
81+
82+
if (null === $operation->canValidate()) {
83+
$operation = $operation->withValidate(false);
84+
}
85+
86+
if (null === $operation->canRead()) {
87+
$operation = $operation->withRead(true);
88+
}
89+
90+
if (null === $operation->getProvider()) {
91+
$operation = $operation->withProvider('api_platform.mcp.state.tool_provider');
92+
}
93+
94+
if (null === $operation->canDeserialize()) {
95+
$operation = $operation->withDeserialize(false);
96+
}
97+
98+
$body = $this->provider->provide($operation, $uriVariables, $context);
99+
100+
$context['previous_data'] = $httpRequest->attributes->get('previous_data');
101+
$context['data'] = $httpRequest->attributes->get('data');
102+
$context['read_data'] = $httpRequest->attributes->get('read_data');
103+
$context['mapped_data'] = $httpRequest->attributes->get('mapped_data');
104+
105+
if (null === $operation->canWrite()) {
106+
$operation = $operation->withWrite(true);
107+
}
108+
109+
if (null === $operation->canSerialize()) {
110+
$operation = $operation->withSerialize(false);
111+
}
112+
113+
return $this->processor->process($body, $operation, $uriVariables, $context);
114+
}
115+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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\Mcp\State;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\State\ProcessorInterface;
18+
use ApiPlatform\State\SerializerContextBuilderInterface;
19+
use Mcp\Schema\Content\TextContent;
20+
use Mcp\Schema\JsonRpc\Response;
21+
use Mcp\Schema\Result\CallToolResult;
22+
use Symfony\Component\Serializer\Encoder\ContextAwareEncoderInterface;
23+
use Symfony\Component\Serializer\Encoder\EncoderInterface;
24+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
25+
use Symfony\Component\Serializer\SerializerInterface;
26+
27+
final class StructuredContentProcessor implements ProcessorInterface
28+
{
29+
public function __construct(
30+
private readonly SerializerInterface $serializer,
31+
private readonly SerializerContextBuilderInterface $serializerContextBuilder,
32+
public readonly ProcessorInterface $decorated,
33+
) {
34+
}
35+
36+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
37+
{
38+
if (
39+
!$this->serializer instanceof NormalizerInterface
40+
|| !$this->serializer instanceof EncoderInterface
41+
|| !isset($context['mcp_request'])
42+
|| !($request = $context['request'])
43+
) {
44+
return $this->decorated->process($data, $operation, $uriVariables, $context);
45+
}
46+
47+
if ($data instanceof CallToolResult) {
48+
return new Response($context['mcp_request']->getId(), $data);
49+
}
50+
51+
$context['original_data'] = $data;
52+
$class = $operation->getClass();
53+
$serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [
54+
'resource_class' => $class,
55+
'operation' => $operation,
56+
]);
57+
58+
$serializerContext['uri_variables'] = $uriVariables;
59+
60+
$structuredContent = $this->serializer->normalize($data, $format = $request->getRequestFormat(), $serializerContext);
61+
62+
return new Response(
63+
$context['mcp_request']->getId(),
64+
new CallToolResult(
65+
[new TextContent($this->serializer->encode($structuredContent, $format, $serializerContext))],
66+
false,
67+
$structuredContent,
68+
),
69+
);
70+
}
71+
}

src/Mcp/State/ToolProvider.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace ApiPlatform\Mcp\State;
4+
5+
use ApiPlatform\Metadata\Operation;
6+
use ApiPlatform\State\ProviderInterface;
7+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
8+
9+
/**
10+
* @implements ProviderInterface<object>
11+
*/
12+
final class ToolProvider implements ProviderInterface
13+
{
14+
public function __construct(private readonly ObjectMapperInterface $objectMapper)
15+
{
16+
}
17+
18+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
19+
{
20+
if (!isset($context['mcp_request'])) {
21+
return null;
22+
}
23+
24+
$data = (object) $context['mcp_data'];
25+
$class = $operation->getInput()['class'] ?? $operation->getClass();
26+
return $this->objectMapper->map($data, $class);
27+
}
28+
}

0 commit comments

Comments
 (0)