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 @@ -178,8 +178,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
4 changes: 4 additions & 0 deletions src/Hydra/Serializer/DocumentationNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ private function getHydraOperations(bool $collection, ?ResourceMetadataCollectio
$hydraOperations = [];
foreach ($resourceMetadataCollection as $resourceMetadata) {
foreach ($resourceMetadata->getOperations() as $operation) {
if (!$operation instanceof HttpOperation) {
continue;
}

if (true === $operation->getHideHydraOperation()) {
continue;
}
Expand Down
4 changes: 4 additions & 0 deletions src/JsonSchema/ResourceMetadataTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ private function findOperationForType(ResourceMetadataCollection $resourceMetada
// Find the operation and use the first one that matches criterias
foreach ($resourceMetadataCollection as $resourceMetadata) {
foreach ($resourceMetadata->getOperations() ?? [] as $op) {
if (!$op instanceof HttpOperation) {
continue;
}

if (!$lookForCollection && $op instanceof CollectionOperationInterface) {
continue;
}
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 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]);
$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()
),
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;
Copy link
Contributor

Choose a reason for hiding this comment

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

As tell-dont-ask implies we expect creation of the operation, edge cases could lead to clear exceptions on how to resolve those. What about dropping nullability on the return typehint, and throw LogicException here?

Copy link
Member Author

Choose a reason for hiding this comment

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

}
}
40 changes: 40 additions & 0 deletions src/Mcp/Routing/IriConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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;

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

if (!$operation instanceof HttpOperation) {
throw new RuntimeException(\sprintf('Operation "%s" must be an instance of HttpOperation.', $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);
}
}
Loading
Loading