Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@
"symfony/intl": "^6.4 || ^7.0 || ^8.0",
"symfony/json-streamer": "^7.4 || ^8.0",
"symfony/maker-bundle": "^1.24",
"symfony/mcp-bundle": "dev-main",
"symfony/mercure-bundle": "*",
"symfony/messenger": "^6.4 || ^7.0 || ^8.0",
"symfony/monolog-bundle": "^4.0",
"symfony/object-mapper": "^7.4 || ^8.0",
"symfony/routing": "^6.4 || ^7.0 || ^8.0",
"symfony/security-bundle": "^6.4 || ^7.0 || ^8.0",
Expand Down
85 changes: 85 additions & 0 deletions src/Mcp/Capability/Registry/Loader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Mcp\Capability\Registry;

use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactory;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\McpResource;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
use ApiPlatform\Symfony\Controller\McpController;
use Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Schema\Annotations;
use Mcp\Schema\Resource;
use Mcp\Schema\Tool;

final class Loader implements LoaderInterface
{
public const HANDLER = 'api_platform.mcp.handler';

public function __construct(
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollection,
private readonly SchemaFactoryInterface $schemaFactory,
) {
}

public function load(RegistryInterface $registry): void
{
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
$metadata = $this->resourceMetadataCollection->create($resourceClass);

foreach ($metadata as $resource) {
foreach ($resource->getMcp() ?? [] as $mcp) {
if ($mcp instanceof McpTool) {
$inputClass = $mcp->getInput()['class'] ?? $mcp->getClass();
$schema = $this->schemaFactory->buildSchema($inputClass, 'json', Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]);
$registry->registerTool(
new Tool(
name: $mcp->getName(),
inputSchema: $schema->getDefinitions()[$schema->getRootDefinitionKey()]->getArrayCopy(),
description: $mcp->getDescription(),
annotations: $mcp->getAnnotations() ? Annotations::fromArray($mcp->getAnnotations()) : null,
icons: $mcp->getIcons(),
meta: $mcp->getMeta()
),
self::HANDLER,
true
);
}

if ($mcp instanceof McpResource) {
$registry->registerResource(
new Resource(
uri: $mcp->getUri(),
name: $mcp->getName(),
description: $mcp->getDescription(),
mimeType: $mcp->getMimeType(),
annotations: $mcp->getAnnotations() ? Annotations::fromArray($mcp->getAnnotations()) : null,
size: $mcp->getSize(),
icons: $mcp->getIcons(),
meta: $mcp->getMeta()
),
self::HANDLER,
true
);
}
}
}
}
}
}
46 changes: 46 additions & 0 deletions src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Mcp\Metadata\Operation\Factory;

use ApiPlatform\Metadata\McpResource;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;

final class OperationMetadataFactory implements OperationMetadataFactoryInterface
{
public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory)
{
}

public function create(string $operationName, array $context = []): ?\ApiPlatform\Metadata\Operation
{
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) {
if (null === $mcp = $resource->getMcp()) {
continue;
}

foreach ($mcp as $operation) {
if (($operation instanceof McpTool || $operation instanceof McpResource) && $operation->getName() === $operationName) {
return $operation;
}
}
}
}

return null;
}
}
29 changes: 29 additions & 0 deletions src/Mcp/Routing/IriConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace ApiPlatform\Mcp\Routing;

use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\McpResource;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\Operation;

final class IriConverter implements IriConverterInterface
{
public function __construct(private readonly IriConverterInterface $inner)
{
}

public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object
{
return $this->inner->getResourceFromIri($iri, $context, $operation);
}

public function getIriFromResource(object|string $resource, int $referenceType = 1, ?Operation $operation = null, array $context = []): ?string
{
if (($operation instanceof McpTool || $operation instanceof McpResource) && !isset($context['item_uri_template'])) {
return null;
}

return $this->inner->getIriFromResource($resource, $referenceType, $operation, $context);
}
}
115 changes: 115 additions & 0 deletions src/Mcp/Server/Handler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace ApiPlatform\Mcp\Server;

use ApiPlatform\Mcp\State\ToolProvider;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Session\SessionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\RequestStack;

/**
* @implements RequestHandlerInterface<CallToolResult>
*/
final class Handler implements RequestHandlerInterface
{
public function __construct(
private readonly OperationMetadataFactoryInterface $operationMetadataFactory,
private readonly ProviderInterface $provider,
private readonly ProcessorInterface $processor,
private readonly RequestStack $requestStack,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}

public function supports(Request $request): bool
{
return $request instanceof CallToolRequest;
}

/**
* @return Response<CallToolResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
\assert($request instanceof CallToolRequest);

$toolName = $request->name;
$arguments = $request->arguments ?? [];

$this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]);

$operation = $this->operationMetadataFactory->create($toolName);

$uriVariables = [];
foreach ($operation->getUriVariables() ?? [] as $key => $link) {
if (isset($arguments[$key])) {
$uriVariables[$key] = $arguments[$key];
}
}

$context = [
'request' => ($httpRequest = $this->requestStack->getCurrentRequest()),
'mcp_request' => $request,
'uri_variables' => $uriVariables,
'resource_class' => $operation->getClass(),
'mcp_data' => $arguments,
];

if (null === $operation->canValidate()) {
$operation = $operation->withValidate(false);
}

if (null === $operation->canRead()) {
$operation = $operation->withRead(true);
}

if (null === $operation->getProvider()) {
$operation = $operation->withProvider('api_platform.mcp.state.tool_provider');
}

if (null === $operation->canDeserialize()) {
$operation = $operation->withDeserialize(false);
}

$body = $this->provider->provide($operation, $uriVariables, $context);

$context['previous_data'] = $httpRequest->attributes->get('previous_data');
$context['data'] = $httpRequest->attributes->get('data');
$context['read_data'] = $httpRequest->attributes->get('read_data');
$context['mapped_data'] = $httpRequest->attributes->get('mapped_data');

if (null === $operation->canWrite()) {
$operation = $operation->withWrite(true);
}

if (null === $operation->canSerialize()) {
$operation = $operation->withSerialize(false);
}

return $this->processor->process($body, $operation, $uriVariables, $context);
}
}
71 changes: 71 additions & 0 deletions src/Mcp/State/StructuredContentProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Mcp\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\SerializerContextBuilderInterface;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Result\CallToolResult;
use Symfony\Component\Serializer\Encoder\ContextAwareEncoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;

final class StructuredContentProcessor implements ProcessorInterface
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly SerializerContextBuilderInterface $serializerContextBuilder,
public readonly ProcessorInterface $decorated,
) {
}

public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
if (
!$this->serializer instanceof NormalizerInterface
|| !$this->serializer instanceof EncoderInterface
|| !isset($context['mcp_request'])
|| !($request = $context['request'])
) {
return $this->decorated->process($data, $operation, $uriVariables, $context);
}

if ($data instanceof CallToolResult) {
return new Response($context['mcp_request']->getId(), $data);
}

$context['original_data'] = $data;
$class = $operation->getClass();
$serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [
'resource_class' => $class,
'operation' => $operation,
]);

$serializerContext['uri_variables'] = $uriVariables;

$structuredContent = $this->serializer->normalize($data, $format = $request->getRequestFormat(), $serializerContext);

return new Response(
$context['mcp_request']->getId(),
new CallToolResult(
[new TextContent($this->serializer->encode($structuredContent, $format, $serializerContext))],
false,
$structuredContent,
),
);
}
}
28 changes: 28 additions & 0 deletions src/Mcp/State/ToolProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace ApiPlatform\Mcp\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

/**
* @implements ProviderInterface<object>
*/
final class ToolProvider implements ProviderInterface
{
public function __construct(private readonly ObjectMapperInterface $objectMapper)
{
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
if (!isset($context['mcp_request'])) {
return null;
}

$data = (object) $context['mcp_data'];
$class = $operation->getInput()['class'] ?? $operation->getClass();
return $this->objectMapper->map($data, $class);
}
}
Loading
Loading