Skip to content
Closed
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
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,10 @@
"symfony/intl": "^6.4 || ^7.0",
"symfony/json-streamer": "7.4.x-dev",
"symfony/maker-bundle": "^1.24",
"symfony/mcp-bundle": "dev-main",
"symfony/mercure-bundle": "*",
"symfony/messenger": "^6.4 || ^7.0",
"symfony/monolog-bundle": "4.x-dev",
"symfony/object-mapper": "7.4.x-dev",
"symfony/routing": "^6.4 || ^7.0",
"symfony/security-bundle": "^6.4 || ^7.0",
Expand Down Expand Up @@ -218,5 +220,6 @@
"type": "library",
"repositories": [
{"type": "vcs", "url": "https://github.com/soyuka/phpunit"}
]
],
"minimum-stability": "dev"
}
29 changes: 29 additions & 0 deletions src/Mcp/Factory/McpCapabilityFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Factory;

/**
* Creates MCP capability definitions from various sources.
*
* @internal
*/
interface McpCapabilityFactoryInterface
{
/**
* Creates and yields MCP capability definitions.
*
* @return \Generator<array{type: string, definition: array}>
*/
public function create(): \Generator;
}
79 changes: 79 additions & 0 deletions src/Mcp/Factory/McpDocumentationFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Factory;

use Symfony\Component\Routing\RequestContext;

/**
* Creates MCP Resource definitions for API Platform's documentation endpoints.
*
* @internal
*/
final readonly class McpDocumentationFactory implements McpCapabilityFactoryInterface
{
public function __construct(
private RequestContext $requestContext,
) {
}

/**
* @return \Generator<array{type: string, definition: array}>
*/
public function create(): \Generator
{
$prefix = \sprintf('%s://%s/', $this->requestContext->getScheme(), $this->requestContext->getHost());

// API Documentation Resources
yield [
'type' => 'resource',
'definition' => [
'uri' => $prefix.'docs.jsonopenapi',
'name' => 'openapi_spec',
'description' => 'The OpenAPI specification for this API.',
'mimeType' => 'application/vnd.openapi+json',
],
];
yield [
'type' => 'resource',
'definition' => [
'uri' => $prefix.'docs.jsonld',
'name' => 'hydra_docs',
'description' => 'The Hydra documentation for this API.',
'mimeType' => 'application/ld+json',
],
];

// Entrypoint Resource
yield [
'type' => 'resource',
'definition' => [
'uri' => $prefix.'entrypoint',
'name' => 'api_entrypoint',
'description' => 'The main entrypoint for the API.',
'mimeType' => 'application/ld+json',
],
];

// JSON-LD Context Resource Template
yield [
'type' => 'resource_template',
'definition' => [
'uriTemplate' => $prefix.'contexts/{shortName}',
'name' => 'jsonld_context',
'description' => 'The JSON-LD context for a given resource short name.',
'mimeType' => 'application/ld+json',
],
];
}
}
158 changes: 158 additions & 0 deletions src/Mcp/Factory/McpOperationFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Factory;

use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
use Symfony\Component\Routing\RequestContext;

/**
* Creates MCP capability definitions from API Platform operations.
*
* @internal
*/
final readonly class McpOperationFactory implements McpCapabilityFactoryInterface
{
public function __construct(
private ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
private ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
private RequestContext $requestContext,
private SchemaFactoryInterface $schemaFactory,
) {
}

/**
* Creates and yields MCP capability definitions.
*
* @return \Generator<array{type: string, definition: array}>
*/
public function create(): \Generator
{
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
$resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);
foreach ($resourceMetadataCollection as $resource) {
foreach ($resource->getOperations() as $operation) {
// TODO: A dedicated `$operation->getMcp() === false` property would be the ideal way to control inclusion.
if (!$operation instanceof HttpOperation) {
continue;
}

$mcpName = $operation->getExtraProperties()['mcp_name'] ?? null;
if (!$mcpName) {
continue;
}

// TBD: To support multiple formats, we could iterate over `getOutputFormats`
// and yield a tool for each, suffixing the name with the format,
// e.g., "api_books_get_collection_jsonld", "api_books_get_collection_json".
// The handler would then parse this name to set the correct `Accept` header.
$method = strtoupper($operation->getMethod());

if (!$operation->getUriTemplate()) {
continue;
}

$mimeType = current($operation->getInputFormats())[0] ?? 'application/json';

if ('GET' === $method) {
$uri = \sprintf('%s://%s/%s', $this->requestContext->getScheme(), $this->requestContext->getHost(), ltrim(str_replace('{._format}', '', $operation->getUriTemplate()), '/'));

if (!$operation->getUriVariables()) {
yield [
'type' => 'resource',
'definition' => [
'uri' => $uri,
'name' => $mcpName,
'description' => $operation->getDescription(),
'mimeType' => $mimeType,
],
];

continue;
}

yield [
'type' => 'resource_template',
'definition' => [
'uriTemplate' => $uri,
'name' => $mcpName,
'description' => $operation->getDescription(),
'mimeType' => $mimeType,
],
];
continue;
}

yield [
'type' => 'tool',
'definition' => [
'name' => $mcpName,
'description' => $operation->getDescription(),
'inputSchema' => $this->buildInputSchema($operation),
],
];
}
}
}
}

private function buildInputSchema(HttpOperation $operation): ?array
{
$schema = [
'type' => 'object',
'properties' => [],
'required' => [],
];

// 1. Add properties from the request body for relevant methods
if (\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)) {
$bodySchema = $this->schemaFactory->buildSchema($operation->getClass(), 'json', Schema::TYPE_INPUT, $operation);
$rootDefinitionKey = $bodySchema->getRootDefinitionKey();

if (null !== $rootDefinitionKey && isset($bodySchema->getDefinitions()[$rootDefinitionKey])) {
$bodyDefinition = $bodySchema->getDefinitions()[$rootDefinitionKey]->getArrayCopy();
if (isset($bodyDefinition['properties'])) {
$schema['properties'] = array_merge($schema['properties'], $bodyDefinition['properties']);
}
if (isset($bodyDefinition['required'])) {
$schema['required'] = array_merge($schema['required'], $bodyDefinition['required']);
}
}
}

// 2. Add properties from URI variables
foreach ($operation->getUriVariables() as $parameterName => $uriVariable) {
$schema['properties'][$parameterName] = $uriVariable->getSchema() ?? ['type' => 'string'];
if ($uriVariable->getRequired() ?? true) {
$schema['required'][] = $parameterName;
}
}

if (empty($schema['properties'])) {
return null;
}

if (empty($schema['required'])) {
unset($schema['required']);
} else {
// Ensure unique values
$schema['required'] = array_values(array_unique($schema['required']));
}

return $schema;
}
}
50 changes: 50 additions & 0 deletions src/Mcp/Metadata/Factory/Operation/McpOperationMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Factory\Operation;

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

/**
* Finds an operation by its sanitized MCP name.
*
* @internal
*/
final readonly class McpOperationMetadataFactory implements OperationMetadataFactoryInterface
{
public function __construct(
private ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
) {
}

public function create(string $mcpName, array $context = []): ?Operation
{
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
foreach ($resourceMetadataCollection as $resource) {
foreach ($resource->getOperations() as $operation) {
$candidateMcpName = $operation->getExtraProperties()['mcp_name'] ?? null;
if ($candidateMcpName === $mcpName) {
return $operation;
}
}
}
}

return null;
}
}
Loading
Loading