Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"jangregor/phpstan-prophecy": "^2.1.11",
"justinrainbow/json-schema": "^6.5.2",
"laravel/framework": "^11.0 || ^12.0",
"mcp/sdk": "^0.3.0",
"orchestra/testbench": "^9.1",
"phpspec/prophecy-phpunit": "^2.2",
"phpstan/extension-installer": "^1.1",
Expand Down Expand Up @@ -178,8 +179,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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is available tags now
https://github.com/symfony/mcp-bundle/tags

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes but symfony/mcp-bundle@fa41067 not released yet :/

"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
87 changes: 87 additions & 0 deletions src/Mcp/Capability/Registry/Loader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?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 Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Schema\Annotations;
use Mcp\Schema\Resource;
use Mcp\Schema\Tool;
use Mcp\Schema\ToolAnnotations;

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]);
$outputSchema = $this->schemaFactory->buildSchema($inputClass, 'json', Schema::TYPE_OUTPUT, $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() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null,
icons: $mcp->getIcons(),
meta: $mcp->getMeta(),
outputSchema: $outputSchema->getDefinitions()[$outputSchema->getRootDefinitionKey()]->getArrayCopy(),
),
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
);
}
}
}
}
}
}
64 changes: 64 additions & 0 deletions src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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\Exception\RuntimeException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\McpResource;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\Operation;
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,
) {
}

/**
* @throws RuntimeException
*
* @return HttpOperation
*/
public function create(string $operationName, array $context = []): 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)) {
continue;
}

if ($operation->getName() === $operationName) {
return $operation;
}

if ($operation instanceof McpResource && $operation->getUri() === $operationName) {
return $operation;
}
}
}
}

throw new RuntimeException(\sprintf('MCP operation "%s" not found.', $operationName));
}
}
41 changes: 41 additions & 0 deletions src/Mcp/Routing/IriConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?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\Routing;

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

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 = UrlGeneratorInterface::ABS_PATH, ?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);
}
}
137 changes: 137 additions & 0 deletions src/Mcp/Server/Handler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?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);

/*
* 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\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\ReadResourceResult;
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|ReadResourceResult>
*/
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 || $request instanceof ReadResourceRequest;
}

/**
* @return Response<CallToolResult|ReadResourceResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
$isResource = $request instanceof ReadResourceRequest;

if ($isResource) {
$operationNameOrUri = $request->uri;
$arguments = [];
$this->logger->debug('Reading resource', ['uri' => $operationNameOrUri]);
} else {
\assert($request instanceof CallToolRequest);
$operationNameOrUri = $request->name;
$arguments = $request->arguments ?? [];
$this->logger->debug('Executing tool', ['name' => $operationNameOrUri, 'arguments' => $arguments]);
}

/** @var HttpOperation $operation */
$operation = $this->operationMetadataFactory->create($operationNameOrUri);

$uriVariables = [];
if (!$isResource) {
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(),
];

if (!$isResource) {
$context['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);

if (!$isResource) {
$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);
}
}
Loading
Loading