From 13f91e94d0b155c8f09fc8b74b6cef82d66542ba Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 7 Dec 2025 15:47:25 +0100 Subject: [PATCH] feat: mcp bundle tool integration --- composer.json | 3 + src/Mcp/Capability/Registry/Loader.php | 87 ++ .../Factory/OperationMetadataFactory.php | 61 ++ src/Mcp/Routing/IriConverter.php | 41 + src/Mcp/Server/Handler.php | 137 +++ src/Mcp/State/StructuredContentProcessor.php | 75 ++ src/Mcp/State/ToolProvider.php | 40 + src/Metadata/ApiResource.php | 36 +- src/Metadata/McpResource.php | 343 ++++++++ src/Metadata/McpTool.php | 298 +++++++ .../MetadataCollectionFactoryTrait.php | 37 +- .../Extractor/Adapter/XmlResourceAdapter.php | 25 + .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ApiPlatformExtension.php | 54 +- .../DependencyInjection/Configuration.php | 11 + .../Bundle/Resources/config/mcp/events.php | 44 + .../Bundle/Resources/config/mcp/mcp.php | 49 ++ .../Bundle/Resources/config/mcp/state.php | 44 + .../TestBundle/ApiResource/McpDummy.php | 62 ++ .../ApiResource/McpResourceExample.php | 67 ++ .../ApiResource/McpToolAttribute.php | 63 ++ .../ApiResource/McpWithCustomResult.php | 68 ++ .../ApiResource/McpWithMarkdown.php | 72 ++ .../ApiResource/McpWithValidation.php | 79 ++ .../Fixtures/TestBundle/Document/McpBook.php | 98 +++ tests/Fixtures/TestBundle/Entity/McpBook.php | 100 +++ .../Normalizer/McpMarkdownNormalizer.php | 48 ++ tests/Fixtures/app/AppKernel.php | 7 + tests/Fixtures/app/config/config_common.yml | 15 + tests/Fixtures/app/config/routing_test.php | 5 +- tests/Functional/McpTest.php | 781 ++++++++++++++++++ .../DependencyInjection/ConfigurationTest.php | 3 + 32 files changed, 2830 insertions(+), 24 deletions(-) create mode 100644 src/Mcp/Capability/Registry/Loader.php create mode 100644 src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php create mode 100644 src/Mcp/Routing/IriConverter.php create mode 100644 src/Mcp/Server/Handler.php create mode 100644 src/Mcp/State/StructuredContentProcessor.php create mode 100644 src/Mcp/State/ToolProvider.php create mode 100644 src/Metadata/McpResource.php create mode 100644 src/Metadata/McpTool.php create mode 100644 src/Symfony/Bundle/Resources/config/mcp/events.php create mode 100644 src/Symfony/Bundle/Resources/config/mcp/mcp.php create mode 100644 src/Symfony/Bundle/Resources/config/mcp/state.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpResourceExample.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpToolAttribute.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpWithCustomResult.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpWithMarkdown.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpWithValidation.php create mode 100644 tests/Fixtures/TestBundle/Document/McpBook.php create mode 100644 tests/Fixtures/TestBundle/Entity/McpBook.php create mode 100644 tests/Fixtures/TestBundle/Serializer/Normalizer/McpMarkdownNormalizer.php create mode 100644 tests/Functional/McpTest.php diff --git a/composer.json b/composer.json index 04dda4477c6..652c2192406 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -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": "^0.2.0", "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", diff --git a/src/Mcp/Capability/Registry/Loader.php b/src/Mcp/Capability/Registry/Loader.php new file mode 100644 index 00000000000..619b64560be --- /dev/null +++ b/src/Mcp/Capability/Registry/Loader.php @@ -0,0 +1,87 @@ + + * + * 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()[$schema->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 + ); + } + } + } + } + } +} diff --git a/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php new file mode 100644 index 00000000000..642483e2dbc --- /dev/null +++ b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php @@ -0,0 +1,61 @@ + + * + * 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\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 = []): \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)) { + 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)); + } +} diff --git a/src/Mcp/Routing/IriConverter.php b/src/Mcp/Routing/IriConverter.php new file mode 100644 index 00000000000..1ffb0ff8739 --- /dev/null +++ b/src/Mcp/Routing/IriConverter.php @@ -0,0 +1,41 @@ + + * + * 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); + } +} diff --git a/src/Mcp/Server/Handler.php b/src/Mcp/Server/Handler.php new file mode 100644 index 00000000000..b163498f0b6 --- /dev/null +++ b/src/Mcp/Server/Handler.php @@ -0,0 +1,137 @@ + + * + * 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 + */ +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|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); + } +} diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php new file mode 100644 index 00000000000..9920d44d385 --- /dev/null +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -0,0 +1,75 @@ + + * + * 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 Mcp\Schema\Result\ReadResourceResult; +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); + } + + $result = $this->decorated->process($data, $operation, $uriVariables, $context); + + if ($result instanceof CallToolResult || $result instanceof ReadResourceResult) { + return new Response($context['mcp_request']->getId(), $result); + } + + $context['original_data'] = $result; + $class = $operation->getClass(); + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ + 'resource_class' => $class, + 'operation' => $operation, + ]); + + $serializerContext['uri_variables'] = $uriVariables; + + $format = $request->getRequestFormat('') ?: 'json'; + $structuredContent = $this->serializer->normalize($result, $format, $serializerContext); + $textContent = $this->serializer->encode($structuredContent, $format, $serializerContext); + + return new Response( + $context['mcp_request']->getId(), + new CallToolResult( + [new TextContent($textContent)], + false, + \is_array($structuredContent) ? $structuredContent : null, + ), + ); + } +} diff --git a/src/Mcp/State/ToolProvider.php b/src/Mcp/State/ToolProvider.php new file mode 100644 index 00000000000..20f4adc15bf --- /dev/null +++ b/src/Mcp/State/ToolProvider.php @@ -0,0 +1,40 @@ + + * + * 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\ProviderInterface; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +/** + * @implements ProviderInterface + */ +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); + } +} diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 28580ea7c3c..64ba76920d4 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -40,16 +40,16 @@ class ApiResource extends Metadata protected ?Operations $operations; /** - * @param list|array|Operations|null $operations Operations is a list of HttpOperation - * @param array|array|string[]|string|null $uriVariables - * @param class-string $class - * @param array $headers - * @param string|callable|null $provider - * @param string|callable|null $processor - * @param mixed|null $mercure - * @param mixed|null $messenger - * @param mixed|null $input - * @param mixed|null $output + * @param array|array|Operations|null $operations Operations is a list of HttpOperation + * @param array|array|string[]|string|null $uriVariables + * @param array $headers + * @param string|callable|null $provider + * @param string|callable|null $processor + * @param mixed|null $mercure + * @param mixed|null $messenger + * @param mixed|null $input + * @param mixed|null $output + * @param array|null $mcp A list of Mcp resources or tools */ public function __construct( /** @@ -972,6 +972,7 @@ public function __construct( protected ?bool $jsonStream = null, protected array $extraProperties = [], ?bool $map = null, + protected ?array $mcp = null, ) { parent::__construct( shortName: $shortName, @@ -1018,7 +1019,7 @@ class: $class, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, extraProperties: $extraProperties, - map: $map + map: $map, ); /* @var Operations $operations> */ @@ -1030,6 +1031,19 @@ class: $class, } } + public function getMcp(): ?array + { + return $this->mcp; + } + + public function withMcp(array $mcp): static + { + $self = clone $this; + $self->mcp = $mcp; + + return $self; + } + /** * @return Operations|null */ diff --git a/src/Metadata/McpResource.php b/src/Metadata/McpResource.php new file mode 100644 index 00000000000..64e791f9e9c --- /dev/null +++ b/src/Metadata/McpResource.php @@ -0,0 +1,343 @@ + + * + * 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\Metadata; + +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\OpenApi\Attributes\Webhook; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\State\OptionsInterface; +use Symfony\Component\WebLink\Link as WebLink; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class McpResource extends HttpOperation +{ + /** + * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. + * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. + * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. + * @param ?string $mimeType the MIME type, if known and constant for this resource + * @param ?int $size the size in bytes, if known and constant + * @param mixed|null $annotations optional annotations describing the resource + * @param array|null $icons Optional list of icon URLs representing the resource + * @param array|null $meta Optional metadata + * @param string[]|null $types the RDF types of this property + * @param array|string|null $formats {@see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation} + * @param array|string|null $inputFormats {@see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation} + * @param array|string|null $outputFormats {@see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation} + * @param array|string[]|string|null $uriVariables {@see https://api-platform.com/docs/core/subresources/} + * @param string|null $routePrefix {@see https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations} + * @param string|null $sunset {@see https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed} + * @param string|int|null $status {@see https://api-platform.com/docs/core/operations/#configuring-operations} + * @param array{ + * max_age?: int, + * vary?: string|string[], + * public?: bool, + * shared_max_age?: int, + * stale_while_revalidate?: int, + * stale_if_error?: int, + * must_revalidate?: bool, + * proxy_revalidate?: bool, + * no_cache?: bool, + * no_store?: bool, + * no_transform?: bool, + * immutable?: bool, + * }|null $cacheHeaders {@see https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers} + * @param array|null $headers + * @param list|null $paginationViaCursor {@see https://api-platform.com/docs/core/pagination/#cursor-based-pagination} + * @param array|null $normalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} + * @param array|null $denormalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} + * @param array|null $hydraContext {@see https://api-platform.com/docs/core/extending-jsonld-context/#hydra} + * @param array{ + * class?: string|null, + * name?: string, + * }|string|false|null $input {@see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation} + * @param array{ + * class?: string|null, + * name?: string, + * }|string|false|null $output {@see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation} + * @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure} + * @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus} + * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} + * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} + * @param WebLink[]|null $links + * @param array>|null $errors + */ + public function __construct( + protected string $uri, + ?string $name = null, + ?string $description = null, + protected ?string $mimeType = null, + protected ?int $size = null, + protected mixed $annotations = null, + protected ?array $icons = null, + protected ?array $meta = null, + + string $method = self::METHOD_GET, + ?string $uriTemplate = null, + ?array $types = null, + $formats = null, + $inputFormats = null, + $outputFormats = null, + $uriVariables = null, + ?string $routePrefix = null, + ?string $routeName = null, + ?array $defaults = null, + ?array $requirements = null, + ?array $options = null, + ?bool $stateless = null, + ?string $sunset = null, + ?string $acceptPatch = null, + $status = null, + ?string $host = null, + ?array $schemes = null, + ?string $condition = null, + ?string $controller = null, + ?array $headers = null, + ?array $cacheHeaders = null, + ?array $paginationViaCursor = null, + ?array $hydraContext = null, + bool|OpenApiOperation|Webhook|null $openapi = null, + ?array $exceptionToStatus = null, + ?array $links = null, + ?array $errors = null, + ?bool $strictQueryParameterValidation = null, + ?bool $hideHydraOperation = null, + + ?string $shortName = null, + ?string $class = null, + ?bool $paginationEnabled = null, + ?string $paginationType = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?bool $paginationFetchJoinCollection = null, + ?bool $paginationUseOutputWalkers = null, + ?array $order = null, + ?array $normalizationContext = null, + ?array $denormalizationContext = null, + ?bool $collectDenormalizationErrors = null, + string|\Stringable|null $security = null, + ?string $securityMessage = null, + string|\Stringable|null $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + string|\Stringable|null $securityPostValidation = null, + ?string $securityPostValidationMessage = null, + ?string $deprecationReason = null, + ?array $filters = null, + ?array $validationContext = null, + $input = null, + $output = null, + $mercure = null, + $messenger = null, + ?int $urlGenerationStrategy = null, + ?bool $read = null, + ?bool $deserialize = null, + ?bool $validate = null, + ?bool $write = null, + ?bool $serialize = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?int $priority = null, + $provider = null, + $processor = null, + ?OptionsInterface $stateOptions = null, + ?Parameters $parameters = null, + array|string|null $rules = null, + ?string $policy = null, + array|string|null $middleware = null, + ?bool $queryParameterValidationEnabled = null, + ?bool $jsonStream = null, + array $extraProperties = [], + ?bool $map = null, + ) { + parent::__construct( + method: $method, + uriTemplate: $uriTemplate, + types: $types, + formats: $formats, + inputFormats: $inputFormats, + outputFormats: $outputFormats, + uriVariables: $uriVariables, + routePrefix: $routePrefix, + routeName: $routeName, + defaults: $defaults, + requirements: $requirements, + options: $options, + stateless: $stateless, + sunset: $sunset, + acceptPatch: $acceptPatch, + status: $status, + host: $host, + schemes: $schemes, + condition: $condition, + controller: $controller, + headers: $headers, + cacheHeaders: $cacheHeaders, + paginationViaCursor: $paginationViaCursor, + hydraContext: $hydraContext, + openapi: $openapi, + exceptionToStatus: $exceptionToStatus, + links: $links, + errors: $errors, + strictQueryParameterValidation: $strictQueryParameterValidation, + hideHydraOperation: $hideHydraOperation, + shortName: $shortName, + class: $class, + paginationEnabled: $paginationEnabled, + paginationType: $paginationType, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + order: $order, + description: $description, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + deprecationReason: $deprecationReason, + filters: $filters, + validationContext: $validationContext, + input: $input, + output: $output, + mercure: $mercure, + messenger: $messenger, + urlGenerationStrategy: $urlGenerationStrategy, + read: $read, + deserialize: $deserialize, + validate: $validate, + write: $write, + serialize: $serialize, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + priority: $priority, + name: $name, + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + parameters: $parameters, + rules: $rules, + policy: $policy, + middleware: $middleware, + queryParameterValidationEnabled: $queryParameterValidationEnabled, + jsonStream: $jsonStream, + extraProperties: $extraProperties, + map: $map, + ); + } + + public function getUri(): string + { + return $this->uri; + } + + public function withUri(string $uri): static + { + $self = clone $this; + $self->uri = $uri; + + return $self; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function withMimeType(?string $mimeType): static + { + $self = clone $this; + $self->mimeType = $mimeType; + + return $self; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function withSize(?int $size): static + { + $self = clone $this; + $self->size = $size; + + return $self; + } + + public function getAnnotations(): mixed + { + return $this->annotations; + } + + public function withAnnotations(mixed $annotations): static + { + $self = clone $this; + $self->annotations = $annotations; + + return $self; + } + + public function getIcons(): ?array + { + return $this->icons; + } + + public function withIcons(?array $icons): static + { + $self = clone $this; + $self->icons = $icons; + + return $self; + } + + public function getMeta(): ?array + { + return $this->meta; + } + + public function withMeta(?array $meta): static + { + $self = clone $this; + $self->meta = $meta; + + return $self; + } +} diff --git a/src/Metadata/McpTool.php b/src/Metadata/McpTool.php new file mode 100644 index 00000000000..72d8a205b5b --- /dev/null +++ b/src/Metadata/McpTool.php @@ -0,0 +1,298 @@ + + * + * 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\Metadata; + +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\OpenApi\Attributes\Webhook; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\State\OptionsInterface; +use Symfony\Component\WebLink\Link as WebLink; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class McpTool extends HttpOperation +{ + /** + * @param string|null $name The name of the tool (defaults to the method name) + * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) + * @param mixed|null $annotations Optional annotations describing tool behavior + * @param array|null $icons Optional list of icon URLs representing the tool + * @param array|null $meta Optional metadata + * @param string[]|null $types the RDF types of this property + * @param array|string|null $formats {@see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation} + * @param array|string|null $inputFormats {@see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation} + * @param array|string|null $outputFormats {@see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation} + * @param array|string[]|string|null $uriVariables {@see https://api-platform.com/docs/core/subresources/} + * @param string|null $routePrefix {@see https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations} + * @param string|null $sunset {@see https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed} + * @param string|int|null $status {@see https://api-platform.com/docs/core/operations/#configuring-operations} + * @param array{ + * max_age?: int, + * vary?: string|string[], + * public?: bool, + * shared_max_age?: int, + * stale_while_revalidate?: int, + * stale_if_error?: int, + * must_revalidate?: bool, + * proxy_revalidate?: bool, + * no_cache?: bool, + * no_store?: bool, + * no_transform?: bool, + * immutable?: bool, + * }|null $cacheHeaders {@see https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers} + * @param array|null $headers + * @param list|null $paginationViaCursor {@see https://api-platform.com/docs/core/pagination/#cursor-based-pagination} + * @param array|null $normalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} + * @param array|null $denormalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} + * @param array|null $hydraContext {@see https://api-platform.com/docs/core/extending-jsonld-context/#hydra} + * @param array{ + * class?: string|null, + * name?: string, + * }|string|false|null $input {@see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation} + * @param array{ + * class?: string|null, + * name?: string, + * }|string|false|null $output {@see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation} + * @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure} + * @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus} + * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} + * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} + * @param WebLink[]|null $links + * @param array>|null $errors + */ + public function __construct( + ?string $name = null, + ?string $description = null, + protected mixed $annotations = null, + protected ?array $icons = null, + protected ?array $meta = null, + + string $method = self::METHOD_GET, + ?string $uriTemplate = null, + ?array $types = null, + $formats = null, + $inputFormats = null, + $outputFormats = null, + $uriVariables = null, + ?string $routePrefix = null, + ?string $routeName = null, + ?array $defaults = null, + ?array $requirements = null, + ?array $options = null, + ?bool $stateless = null, + ?string $sunset = null, + ?string $acceptPatch = null, + $status = null, + ?string $host = null, + ?array $schemes = null, + ?string $condition = null, + ?string $controller = null, + ?array $headers = null, + ?array $cacheHeaders = null, + ?array $paginationViaCursor = null, + ?array $hydraContext = null, + bool|OpenApiOperation|Webhook|null $openapi = null, + ?array $exceptionToStatus = null, + ?array $links = null, + ?array $errors = null, + ?bool $strictQueryParameterValidation = null, + ?bool $hideHydraOperation = null, + + ?string $shortName = null, + ?string $class = null, + ?bool $paginationEnabled = null, + ?string $paginationType = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?bool $paginationFetchJoinCollection = null, + ?bool $paginationUseOutputWalkers = null, + ?array $order = null, + ?array $normalizationContext = null, + ?array $denormalizationContext = null, + ?bool $collectDenormalizationErrors = null, + string|\Stringable|null $security = null, + ?string $securityMessage = null, + string|\Stringable|null $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + string|\Stringable|null $securityPostValidation = null, + ?string $securityPostValidationMessage = null, + ?string $deprecationReason = null, + ?array $filters = null, + ?array $validationContext = null, + $input = null, + $output = null, + $mercure = null, + $messenger = null, + ?int $urlGenerationStrategy = null, + ?bool $read = null, + ?bool $deserialize = null, + ?bool $validate = null, + ?bool $write = null, + ?bool $serialize = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?int $priority = null, + $provider = null, + $processor = null, + ?OptionsInterface $stateOptions = null, + ?Parameters $parameters = null, + array|string|null $rules = null, + ?string $policy = null, + array|string|null $middleware = null, + ?bool $queryParameterValidationEnabled = null, + ?bool $jsonStream = null, + array $extraProperties = [], + ?bool $map = null, + ) { + parent::__construct( + method: $method, + uriTemplate: $uriTemplate, + types: $types, + formats: $formats, + inputFormats: $inputFormats, + outputFormats: $outputFormats, + uriVariables: $uriVariables, + routePrefix: $routePrefix, + routeName: $routeName, + defaults: $defaults, + requirements: $requirements, + options: $options, + stateless: $stateless, + sunset: $sunset, + acceptPatch: $acceptPatch, + status: $status, + host: $host, + schemes: $schemes, + condition: $condition, + controller: $controller, + headers: $headers, + cacheHeaders: $cacheHeaders, + paginationViaCursor: $paginationViaCursor, + hydraContext: $hydraContext, + openapi: $openapi, + exceptionToStatus: $exceptionToStatus, + links: $links, + errors: $errors, + strictQueryParameterValidation: $strictQueryParameterValidation, + hideHydraOperation: $hideHydraOperation, + shortName: $shortName, + class: $class, + paginationEnabled: $paginationEnabled, + paginationType: $paginationType, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + order: $order, + description: $description, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + deprecationReason: $deprecationReason, + filters: $filters, + validationContext: $validationContext, + input: $input, + output: $output, + mercure: $mercure, + messenger: $messenger, + urlGenerationStrategy: $urlGenerationStrategy, + read: $read, + deserialize: $deserialize, + validate: $validate, + write: $write, + serialize: $serialize, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + priority: $priority, + name: $name, + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + parameters: $parameters, + rules: $rules, + policy: $policy, + middleware: $middleware, + queryParameterValidationEnabled: $queryParameterValidationEnabled, + jsonStream: $jsonStream, + extraProperties: $extraProperties, + map: $map, + ); + } + + public function getAnnotations(): mixed + { + return $this->annotations; + } + + public function withAnnotations(mixed $annotations): static + { + $self = clone $this; + $self->annotations = $annotations; + + return $self; + } + + public function getIcons(): ?array + { + return $this->icons; + } + + public function withIcons(?array $icons): static + { + $self = clone $this; + $self->icons = $icons; + + return $self; + } + + public function getMeta(): ?array + { + return $this->meta; + } + + public function withMeta(?array $meta): static + { + $self = clone $this; + $self->meta = $meta; + + return $self; + } +} diff --git a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php index 7561858e799..914f22662b3 100644 --- a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php +++ b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php @@ -17,6 +17,8 @@ use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; use ApiPlatform\Metadata\Metadata; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Parameter; @@ -46,7 +48,7 @@ public function __construct(private readonly ?ResourceMetadataCollectionFactoryI private function isResourceMetadata(string $name): bool { - return is_a($name, ApiResource::class, true) || is_subclass_of($name, HttpOperation::class) || is_subclass_of($name, GraphQlOperation::class) || is_a($name, Parameter::class, true); + return is_a($name, ApiResource::class, true) || is_subclass_of($name, HttpOperation::class) || is_subclass_of($name, GraphQlOperation::class) || is_a($name, Parameter::class, true) || is_a($name, McpTool::class, true) || is_a($name, McpResource::class, true); } /** @@ -90,11 +92,21 @@ private function buildResourceOperations(array $metadataCollection, string $reso if ($operations) { $resource = $resource->withOperations(new Operations($operations)); } - $resources[++$index] = $resource; - continue; - } - if (!is_subclass_of($metadata, HttpOperation::class) && !is_subclass_of($metadata, GraphQlOperation::class)) { + if ($mcp = $resource->getMcp()) { + $processedMcp = []; + foreach ($mcp as $key => $mcpOperation) { + if (null === $mcpOperation->getName()) { + $mcpOperation = $mcpOperation->withName($key); + } + + [, $mcpOperation] = $this->getOperationWithDefaults($resource, $mcpOperation); + $processedMcp[$key] = $mcpOperation; + } + $resource = $resource->withMcp($processedMcp); + } + + $resources[++$index] = $resource; continue; } @@ -106,6 +118,21 @@ private function buildResourceOperations(array $metadataCollection, string $reso continue; } + if ($metadata instanceof McpTool || $metadata instanceof McpResource) { + if (-1 === $index) { + $resources[++$index] = $this->getResourceWithDefaults($resourceClass, $shortName, new ApiResource()); + } + [$key, $operation] = $this->getOperationWithDefaults($resources[$index], $metadata); + $mcp = $resources[$index]->getMcp() ?? []; + $mcp[$key] = $operation; + $resources[$index] = $resources[$index]->withMcp($mcp); + continue; + } + + if (!is_subclass_of($metadata, HttpOperation::class) && !is_subclass_of($metadata, GraphQlOperation::class)) { + continue; + } + if (-1 === $index || $this->hasSameOperation($resources[$index], $metadata::class, $metadata)) { $resources[++$index] = $this->getResourceWithDefaults($resourceClass, $shortName, new ApiResource()); } diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index cf1f5738640..e1d9c0f8bfc 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -441,6 +441,31 @@ private function buildGraphQlOperations(\SimpleXMLElement $resource, array $valu } } + private function buildMcp(\SimpleXMLElement $resource, ?array $values): void + { + if (!$values) { + return; + } + + $node = $resource->addChild('mcpOperations'); + foreach ($values as $value) { + $child = $node->addChild('mcpOperation'); + foreach ($value as $index => $data) { + if (method_exists($this, 'build'.ucfirst($index))) { + $this->{'build'.ucfirst($index)}($child, $data); + continue; + } + + if (\is_string($data) || null === $data || is_numeric($data) || \is_bool($data)) { + $child->addAttribute($index, $this->parse($data)); + continue; + } + + throw new \LogicException(\sprintf('Cannot adapt mcpOperation attribute or child "%s". Please create a "%s" method in %s.', $index, 'build'.ucfirst($index), self::class)); + } + } + } + private function buildOperations(\SimpleXMLElement $resource, array $values): void { $node = $resource->addChild('operations'); diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 4ba7ce8cd19..30c16895a07 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -344,3 +344,4 @@ resources: another_custom_property: 'Lorem ipsum': 'Dolor sit amet' map: null + mcp: null diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 5155dfb665a..3e790206e5d 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -33,8 +33,24 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\AsOperationMutator; use ApiPlatform\Metadata\AsResourceMutator; +use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\DeleteMutation; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\HeaderParameter; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\OperationMutatorInterface; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\ResourceMutatorInterface; use ApiPlatform\Metadata\UriVariableTransformerInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; @@ -51,6 +67,7 @@ use Doctrine\Persistence\ManagerRegistry; use PHPStan\PhpDocParser\Parser\PhpDocParser; use Ramsey\Uuid\Uuid; +use Symfony\AI\McpBundle\McpBundle; use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand; use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; use Symfony\Component\Config\FileLocator; @@ -183,6 +200,12 @@ public function load(array $configs, ContainerBuilder $container): void if (class_exists(ObjectMapper::class) && class_exists(TranslationExtractCommand::class)) { $loader->load('state/object_mapper.php'); } + + if (($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class)) { + $loader->load('mcp/mcp.php'); + $loader->load($config['use_symfony_listeners'] ? 'mcp/events.php' : 'mcp/state.php'); + } + $container->registerForAutoconfiguration(FilterInterface::class) ->addTag('api_platform.filter'); $container->registerForAutoconfiguration(ProviderInterface::class) @@ -193,11 +216,7 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('api_platform.uri_variables.transformer'); $container->registerForAutoconfiguration(ParameterProviderInterface::class) ->addTag('api_platform.parameter_provider'); - $container->registerAttributeForAutoconfiguration(ApiResource::class, static function (ChildDefinition $definition): void { - $definition->setAbstract(true) - ->addTag('api_platform.resource') - ->addTag('container.excluded', ['source' => 'by #[ApiResource] attribute']); - }); + $container->registerAttributeForAutoconfiguration( AsResourceMutator::class, static function (ChildDefinition $definition, AsResourceMutator $attribute, \ReflectionClass $reflector): void { // @phpstan-ignore-line @@ -224,6 +243,31 @@ static function (ChildDefinition $definition, AsOperationMutator $attribute, \Re }, ); + foreach ([ + McpTool::class, + McpResource::class, + Patch::class, + Delete::class, + DeleteMutation::class, + Subscription::class, + Query::class, + Get::class, + QueryParameter::class, + Mutation::class, + QueryCollection::class, + NotExposed::class, + HeaderParameter::class, + Post::class, + GetCollection::class, + Put::class, + ApiResource::class, + ] as $class) { + $container->registerAttributeForAutoconfiguration($class, static function (ChildDefinition $definition): void { + $definition + ->addTag('api_platform.resource'); + }); + } + if (!$container->has('api_platform.state.item_provider')) { $container->setAlias('api_platform.state.item_provider', 'api_platform.state_provider.object'); } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 5c2c06e96f6..a3ecfc14078 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -175,6 +175,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addElasticsearchSection($rootNode); $this->addOpenApiSection($rootNode); $this->addMakerSection($rootNode); + $this->addMcpSection($rootNode); $this->addExceptionToStatusSection($rootNode); @@ -669,6 +670,16 @@ private function addMakerSection(ArrayNodeDefinition $rootNode): void ->end(); } + private function addMcpSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('mcp') + ->canBeDisabled() + ->end() + ->end(); + } + private function defineDefault(ArrayNodeDefinition $defaultsNode, \ReflectionClass $reflectionClass, CamelCaseToSnakeCaseNameConverter $nameConverter): void { foreach ($reflectionClass->getConstructor()->getParameters() as $parameter) { diff --git a/src/Symfony/Bundle/Resources/config/mcp/events.php b/src/Symfony/Bundle/Resources/config/mcp/events.php new file mode 100644 index 00000000000..c694a6d04ae --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/mcp/events.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\Mcp\Server\Handler; +use ApiPlatform\Mcp\State\StructuredContentProcessor; + +return function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.mcp.state_processor.write', 'ApiPlatform\State\Processor\WriteProcessor') + ->args([ + null, + service('api_platform.state_processor.locator'), + ]); + + $services->set('api_platform.mcp.state_processor', StructuredContentProcessor::class) + ->args([ + service('api_platform.serializer'), + service('api_platform.serializer.context_builder'), + service('api_platform.mcp.state_processor.write'), + ]); + + $services->set('api_platform.mcp.handler', Handler::class) + ->args([ + service('api_platform.mcp.metadata.operation.mcp_factory'), + service('api_platform.state_provider.locator'), + service('api_platform.mcp.state_processor'), + service('request_stack'), + service('monolog.logger.mcp'), + ]) + ->tag('mcp.request_handler'); +}; diff --git a/src/Symfony/Bundle/Resources/config/mcp/mcp.php b/src/Symfony/Bundle/Resources/config/mcp/mcp.php new file mode 100644 index 00000000000..4749320d9e6 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/mcp/mcp.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory; +use ApiPlatform\Mcp\Routing\IriConverter; +use ApiPlatform\Mcp\State\ToolProvider; + +return function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.mcp.loader', Loader::class) + ->args([ + service('api_platform.metadata.resource.name_collection_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.json_schema.schema_factory'), + ]) + ->tag('mcp.loader'); + + $services->set('api_platform.mcp.iri_converter', IriConverter::class) + ->decorate('api_platform.iri_converter', null, 300) + ->args([ + service('api_platform.mcp.iri_converter.inner'), + ]); + + $services->set('api_platform.mcp.state.tool_provider', ToolProvider::class) + ->args([ + service('object_mapper'), + ]) + ->tag('api_platform.state_provider'); + + $services->set('api_platform.mcp.metadata.operation.mcp_factory', OperationMetadataFactory::class) + ->args([ + service('api_platform.metadata.resource.name_collection_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), + ]); +}; diff --git a/src/Symfony/Bundle/Resources/config/mcp/state.php b/src/Symfony/Bundle/Resources/config/mcp/state.php new file mode 100644 index 00000000000..9e03a1f91e6 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/mcp/state.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\Mcp\Server\Handler; +use ApiPlatform\Mcp\State\StructuredContentProcessor; + +return function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.mcp.state_processor.write', 'ApiPlatform\State\Processor\WriteProcessor') + ->args([ + null, + service('api_platform.state_processor.locator'), + ]); + + $services->set('api_platform.mcp.state_processor', StructuredContentProcessor::class) + ->args([ + service('api_platform.serializer'), + service('api_platform.serializer.context_builder'), + service('api_platform.mcp.state_processor.write'), + ]); + + $services->set('api_platform.mcp.handler', Handler::class) + ->args([ + service('api_platform.mcp.metadata.operation.mcp_factory'), + service('api_platform.state_provider.main'), + service('api_platform.mcp.state_processor'), + service('request_stack'), + service('monolog.logger.mcp'), + ]) + ->tag('mcp.request_handler'); +}; diff --git a/tests/Fixtures/TestBundle/ApiResource/McpDummy.php b/tests/Fixtures/TestBundle/ApiResource/McpDummy.php new file mode 100644 index 00000000000..4363d29eb38 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpDummy.php @@ -0,0 +1,62 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; + +#[ApiResource( + shortName: 'McpDummy', + operations: [], + mcp: [ + 'mcp_dummy_tool' => new McpTool( + processor: [self::class, 'process'] + ), + ] +)] +class McpDummy +{ + public function __construct( + private int $id, + private string $name, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function setId(int $id): void + { + $this->id = $id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public static function process($data): mixed + { + $data->setName('processed'); + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/McpResourceExample.php b/tests/Fixtures/TestBundle/ApiResource/McpResourceExample.php new file mode 100644 index 00000000000..350ade2f72d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpResourceExample.php @@ -0,0 +1,67 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpResource; + +#[ApiResource( + shortName: 'McpResourceExample', + operations: [], + mcp: [ + 'resource_doc' => new McpResource( + uri: 'resource://api-platform/documentation', + name: 'API-Platform-Documentation', + description: 'Official API Platform documentation', + mimeType: 'text/markdown', + provider: [self::class, 'provide'] + ), + ] +)] +class McpResourceExample +{ + public function __construct( + private string $content, + private string $uri, + ) { + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function getUri(): string + { + return $this->uri; + } + + public function setUri(string $uri): void + { + $this->uri = $uri; + } + + public static function provide(): self + { + return new self( + '# API Platform Documentation\n\nThis is a sample documentation resource.', + 'resource://api-platform/documentation' + ); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/McpToolAttribute.php b/tests/Fixtures/TestBundle/ApiResource/McpToolAttribute.php new file mode 100644 index 00000000000..2d908f13c28 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpToolAttribute.php @@ -0,0 +1,63 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; + +#[ApiResource( + shortName: 'McpToolAttribute', + operations: [] +)] +#[McpTool( + name: 'process_message', + description: 'Process a message with priority', + processor: [McpToolAttribute::class, 'process'] +)] +class McpToolAttribute +{ + public function __construct( + private string $message, + private int $priority = 1, + ) { + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): void + { + $this->priority = $priority; + } + + public static function process($data): mixed + { + $data->setMessage('Processed: '.$data->getMessage()); + $data->setPriority($data->getPriority() + 10); + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/McpWithCustomResult.php b/tests/Fixtures/TestBundle/ApiResource/McpWithCustomResult.php new file mode 100644 index 00000000000..c38daa29a3f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpWithCustomResult.php @@ -0,0 +1,68 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Result\CallToolResult; + +#[ApiResource( + shortName: 'McpWithCustomResult', + operations: [], + mcp: [ + 'custom_result' => new McpTool( + processor: [self::class, 'process'] + ), + ] +)] +class McpWithCustomResult +{ + public function __construct( + private string $text, + private bool $includeMetadata = false, + ) { + } + + public function getText(): string + { + return $this->text; + } + + public function setText(string $text): void + { + $this->text = $text; + } + + public function isIncludeMetadata(): bool + { + return $this->includeMetadata; + } + + public function setIncludeMetadata(bool $includeMetadata): void + { + $this->includeMetadata = $includeMetadata; + } + + public static function process($data): CallToolResult + { + $metadata = $data->isIncludeMetadata() ? ['processed' => true, 'timestamp' => time()] : null; + + return new CallToolResult( + [new TextContent('Custom result: '.$data->getText())], + false, + $metadata + ); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/McpWithMarkdown.php b/tests/Fixtures/TestBundle/ApiResource/McpWithMarkdown.php new file mode 100644 index 00000000000..14dfc7e705a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpWithMarkdown.php @@ -0,0 +1,72 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; + +#[ApiResource( + shortName: 'McpWithMarkdown', + operations: [], + mcp: [ + 'generate_markdown' => new McpTool( + description: 'Generate markdown documentation', + processor: [self::class, 'process'] + ), + ] +)] +class McpWithMarkdown +{ + public function __construct( + private string $title, + private string $content, + private bool $includeCodeBlock = false, + ) { + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function isIncludeCodeBlock(): bool + { + return $this->includeCodeBlock; + } + + public function setIncludeCodeBlock(bool $includeCodeBlock): void + { + $this->includeCodeBlock = $includeCodeBlock; + } + + public static function process($data): self + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/McpWithValidation.php b/tests/Fixtures/TestBundle/ApiResource/McpWithValidation.php new file mode 100644 index 00000000000..bdd05393a95 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpWithValidation.php @@ -0,0 +1,79 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'McpWithValidation', + operations: [], + mcp: [ + 'validate_input' => new McpTool( + processor: [self::class, 'process'] + ), + ] +)] +class McpWithValidation +{ + public function __construct( + #[Assert\NotBlank] + #[Assert\Length(min: 3, max: 50)] + private ?string $name = null, + #[Assert\NotNull] + #[Assert\Email] + private ?string $email = null, + #[Assert\Positive] + private ?int $age = null, + ) { + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): void + { + $this->email = $email; + } + + public function getAge(): ?int + { + return $this->age; + } + + public function setAge(?int $age): void + { + $this->age = $age; + } + + public static function process($data): mixed + { + $data->setName('Valid: '.$data->getName()); + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/Document/McpBook.php b/tests/Fixtures/TestBundle/Document/McpBook.php new file mode 100644 index 00000000000..857b6bb9bd3 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/McpBook.php @@ -0,0 +1,98 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource( + shortName: 'McpBook', + operations: [], + mcp: [ + 'get_book_info' => new McpTool( + provider: [self::class, 'provide'] + ), + 'update_book_status' => new McpTool( + processor: [self::class, 'process'] + ), + ] +)] +#[ODM\Document] +class McpBook +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field] + private string $title; + + #[ODM\Field] + private string $isbn; + + #[ODM\Field(nullable: true)] + private ?string $status = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getIsbn(): string + { + return $this->isbn; + } + + public function setIsbn(string $isbn): void + { + $this->isbn = $isbn; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(?string $status): void + { + $this->status = $status; + } + + public static function provide(): self + { + $book = new self(); + $book->setTitle('API Platform Guide'); + $book->setIsbn('978-1234567890'); + $book->setStatus('available'); + + return $book; + } + + public static function process($data): mixed + { + $data->setStatus('updated'); + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/McpBook.php b/tests/Fixtures/TestBundle/Entity/McpBook.php new file mode 100644 index 00000000000..fd8109d0c10 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/McpBook.php @@ -0,0 +1,100 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource( + shortName: 'McpBook', + operations: [], + mcp: [ + 'get_book_info' => new McpTool( + provider: [self::class, 'provide'] + ), + 'update_book_status' => new McpTool( + processor: [self::class, 'process'] + ), + ] +)] +#[ORM\Entity] +class McpBook +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + private string $title; + + #[ORM\Column] + private string $isbn; + + #[ORM\Column(nullable: true)] + private ?string $status = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getIsbn(): string + { + return $this->isbn; + } + + public function setIsbn(string $isbn): void + { + $this->isbn = $isbn; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(?string $status): void + { + $this->status = $status; + } + + public static function provide(): self + { + $book = new self(); + $book->setTitle('API Platform Guide'); + $book->setIsbn('978-1234567890'); + $book->setStatus('available'); + + return $book; + } + + public static function process($data): mixed + { + $data->setStatus('updated'); + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/Serializer/Normalizer/McpMarkdownNormalizer.php b/tests/Fixtures/TestBundle/Serializer/Normalizer/McpMarkdownNormalizer.php new file mode 100644 index 00000000000..63ca4c2a8ba --- /dev/null +++ b/tests/Fixtures/TestBundle/Serializer/Normalizer/McpMarkdownNormalizer.php @@ -0,0 +1,48 @@ + + * + * 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\Tests\Fixtures\TestBundle\Serializer\Normalizer; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpWithMarkdown; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class McpMarkdownNormalizer implements NormalizerInterface +{ + public function normalize(mixed $object, ?string $format = null, array $context = []): string + { + \assert($object instanceof McpWithMarkdown); + + $markdown = "# {$object->getTitle()}\n\n"; + $markdown .= $object->getContent(); + + if ($object->isIncludeCodeBlock()) { + $markdown .= "\n\n```php\n"; + $markdown .= "echo 'Hello, World!';\n"; + $markdown .= '```'; + } + + return $markdown; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof McpWithMarkdown; + } + + public function getSupportedTypes(?string $format): array + { + return [ + McpWithMarkdown::class => true, + ]; + } +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index a4e7a595086..a066be30f64 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -28,10 +28,12 @@ use Doctrine\Bundle\MongoDBBundle\Command\TailCursorDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; +use Symfony\AI\McpBundle\McpBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\MakerBundle\MakerBundle; use Symfony\Bundle\MercureBundle\MercureBundle; +use Symfony\Bundle\MonologBundle\MonologBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; @@ -85,6 +87,11 @@ public function registerBundles(): array $bundles[] = new DoctrineMongoDBBundle(); } + if (class_exists(McpBundle::class)) { + $bundles[] = new MonologBundle(); + $bundles[] = new McpBundle(); + } + $bundles[] = new TestBundle(); return $bundles; diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index e6d7667d55e..488b4d51cdb 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -94,6 +94,17 @@ api_platform: mercure: include_type: true +mcp: + client_transports: + http: true + stdio: false + http: + path: '/mcp' + session: + store: 'file' + directory: '%kernel.cache_dir%/mcp' + ttl: 3600 + services: test.client: class: ApiPlatform\Tests\Fixtures\TestBundle\BrowserKit\Client @@ -429,6 +440,10 @@ services: tags: - name: 'serializer.normalizer' + ApiPlatform\Tests\Fixtures\TestBundle\Serializer\Normalizer\McpMarkdownNormalizer: + tags: + - name: 'serializer.normalizer' + ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\AuthorItemProvider: tags: - name: 'api_platform.state_provider' diff --git a/tests/Fixtures/app/config/routing_test.php b/tests/Fixtures/app/config/routing_test.php index 4d74d8b24ee..087fefdc8eb 100644 --- a/tests/Fixtures/app/config/routing_test.php +++ b/tests/Fixtures/app/config/routing_test.php @@ -18,16 +18,15 @@ $routes->import('routing_common.yml'); $routes->import('@TestBundle/Controller/Orm', 'attribute'); + $routes->import('.', 'mcp'); + if (class_exists(WebProfilerBundle::class)) { - // 2. Resolve the actual directory of the bundle $reflection = new ReflectionClass(WebProfilerBundle::class); $bundleDir = dirname($reflection->getFileName()); - // 3. Check if the PHP config exists on the filesystem $usePhp = file_exists($bundleDir.'/Resources/config/routing/wdt.php'); $ext = $usePhp ? 'php' : 'xml'; - // 4. Import dynamically based on the extension found $routes->import("@WebProfilerBundle/Resources/config/routing/wdt.$ext") ->prefix('/_wdt'); diff --git a/tests/Functional/McpTest.php b/tests/Functional/McpTest.php new file mode 100644 index 00000000000..1b8506e6d45 --- /dev/null +++ b/tests/Functional/McpTest.php @@ -0,0 +1,781 @@ + + * + * 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\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpResourceExample; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpToolAttribute; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpWithCustomResult; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpWithMarkdown; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpWithValidation; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\McpBook as McpBookDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\McpBook; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\AI\McpBundle\McpBundle; + +class McpTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + McpDummy::class, + McpToolAttribute::class, + McpBook::class, + McpBookDocument::class, + McpWithCustomResult::class, + McpWithValidation::class, + McpWithMarkdown::class, + McpResourceExample::class, + ]; + } + + private function isPsr17FactoryAvailable(): bool + { + try { + if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { + return false; + } + + \Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory(); + + return true; + } catch (\Throwable) { + return false; + } + } + + public function testGetMcpOperation(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], + ]); + + $data = $res->toArray(); + self::assertArrayHasKey('result', $data); + self::assertArrayHasKey('tools', $data['result']); + + $tools = $data['result']['tools']; + $dummyTool = null; + foreach ($tools as $tool) { + if ('mcp_dummy_tool' === $tool['name']) { + $dummyTool = $tool; + break; + } + } + + self::assertNotNull($dummyTool); + self::assertEquals('mcp_dummy_tool', $dummyTool['name']); + self::assertArrayHasKey('inputSchema', $dummyTool); + self::assertEquals('object', $dummyTool['inputSchema']['type']); + self::assertArrayHasKey('properties', $dummyTool['inputSchema']); + self::assertArrayHasKey('id', $dummyTool['inputSchema']['properties']); + self::assertArrayHasKey('name', $dummyTool['inputSchema']['properties']); + + self::assertResponseIsSuccessful(); + + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'mcp_dummy_tool', + 'arguments' => [ + 'id' => 1, + 'name' => 'test', + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + } + + public function testMcpJsonSchemaValidation(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + + // Test with invalid type (string instead of integer for id) + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'mcp_dummy_tool', + 'arguments' => [ + 'id' => 'not-an-integer', + 'name' => 'test', + ], + ], + ], + ]); + + // The MCP SDK should validate against the JSON Schema + // If validation fails, we might get an error or the value might be coerced + $result = $res->toArray(false); + + // Check if we got a response (SDK might validate or coerce types) + self::assertArrayHasKey('jsonrpc', $result); + + // If there's an error, it should be about invalid input/type + if (isset($result['error'])) { + $errorMessage = strtolower($result['error']['message'] ?? ''); + self::assertTrue( + str_contains($errorMessage, 'invalid') + || str_contains($errorMessage, 'type') + || str_contains($errorMessage, 'must be of type'), + 'Error message should indicate type/validation issue: '.$errorMessage + ); + } + } + + public function testMcpToolAttribute(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], + ]); + + $data = $res->toArray(); + $tools = $data['result']['tools']; + $processMessageTool = null; + foreach ($tools as $tool) { + if ('process_message' === $tool['name']) { + $processMessageTool = $tool; + break; + } + } + + self::assertNotNull($processMessageTool); + self::assertEquals('process_message', $processMessageTool['name']); + self::assertEquals('Process a message with priority', $processMessageTool['description'] ?? null); + self::assertArrayHasKey('inputSchema', $processMessageTool); + self::assertEquals('object', $processMessageTool['inputSchema']['type']); + + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'process_message', + 'arguments' => [ + 'message' => 'Hello World', + 'priority' => 5, + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(); + self::assertArrayHasKey('result', $result); + } + + public function testMcpWithEntity(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_book_info', + 'arguments' => [], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(false); + self::assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + self::assertNotNull($content); + self::assertStringContainsString('API Platform Guide', $content); + self::assertStringContainsString('978-1234567890', $content); + } + + public function testMcpCustomCallToolResult(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'custom_result', + 'arguments' => [ + 'text' => 'Test content', + 'includeMetadata' => false, + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(); + self::assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + self::assertEquals('Custom result: Test content', $content); + self::assertNull($result['result']['_meta'] ?? null); + + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'custom_result', + 'arguments' => [ + 'text' => 'Test with metadata', + 'includeMetadata' => true, + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(); + self::assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + self::assertEquals('Custom result: Test with metadata', $content); + // Check if metadata exists in result, might be under different key + $hasMeta = isset($result['result']['_meta']) || isset($result['result']['meta']) || isset($result['result']['structuredContent']); + self::assertTrue($hasMeta, 'No metadata found in: '.json_encode(array_keys($result['result']))); + } + + public function testMcpValidation(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'validate_input', + 'arguments' => [ + 'name' => 'ab', + 'email' => 'invalid-email', + 'age' => -5, + ], + ], + ], + ]); + + $result = $res->toArray(false); + if (422 === $res->getStatusCode()) { + self::assertArrayHasKey('error', $result); + } else { + self::assertResponseIsSuccessful(); + } + + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'validate_input', + 'arguments' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(); + self::assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + self::assertNotNull($content); + self::assertStringContainsString('Valid: John Doe', $content); + } + + public function testMcpResource(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'resources/list', + ], + ]); + + self::assertResponseIsSuccessful(); + $data = $res->toArray(); + $resources = $data['result']['resources'] ?? []; + $docResource = null; + foreach ($resources as $resource) { + if (str_contains($resource['name'] ?? '', 'Documentation') || 'resource_doc' === ($resource['name'] ?? '')) { + $docResource = $resource; + break; + } + } + + self::assertNotNull($docResource, 'Could not find documentation resource in: '.json_encode(array_column($resources, 'name'))); + self::assertEquals('resource://api-platform/documentation', $docResource['uri']); + self::assertStringContainsString('Documentation', $docResource['name'] ?? ''); + self::assertNotEmpty($docResource['description'] ?? ''); + self::assertEquals('text/markdown', $docResource['mimeType'] ?? null); + + // Test resources/read + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'resource://api-platform/documentation', + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(false); + self::assertArrayHasKey('result', $result); + $contents = $result['result']['contents'] ?? $result['result']['content'] ?? []; + self::assertNotEmpty($contents, 'No contents in result'); + $content = $contents[0]['text'] ?? null; + self::assertNotNull($content); + self::assertStringContainsString('API Platform', $content); + } + + public function testMcpMarkdownContent(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $client = self::createClient(); + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + $sessionId = $res->getHeaders()['mcp-session-id'][0]; + + // Test markdown without code block + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'generate_markdown', + 'arguments' => [ + 'title' => 'API Platform Guide', + 'content' => 'This is a comprehensive guide to using API Platform.', + 'includeCodeBlock' => false, + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(); + self::assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + self::assertNotNull($content, 'No text content in result'); + self::assertStringContainsString('# API Platform Guide', $content); + self::assertStringContainsString('This is a comprehensive guide to using API Platform.', $content); + self::assertStringNotContainsString('```', $content); + + // Test markdown with code block + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'generate_markdown', + 'arguments' => [ + 'title' => 'Code Example', + 'content' => 'Here is how to use the feature:', + 'includeCodeBlock' => true, + ], + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $result = $res->toArray(); + self::assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + self::assertNotNull($content); + self::assertStringContainsString('# Code Example', $content); + self::assertStringContainsString('Here is how to use the feature:', $content); + self::assertStringContainsString('```php', $content); + self::assertStringContainsString("echo 'Hello, World!';", $content); + self::assertStringContainsString('```', $content); + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 7c8725b4a2d..06d12ac8563 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -239,6 +239,9 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'hydra_prefix' => null, ], 'enable_phpdoc_parser' => true, + 'mcp' => [ + 'enabled' => true, + ], ], $config); }