diff --git a/composer.json b/composer.json index 553ed57cbc..b1ee829d00 100644 --- a/composer.json +++ b/composer.json @@ -182,8 +182,10 @@ "symfony/intl": "^6.4 || ^7.0 || ^8.0", "symfony/json-streamer": "^7.4 || ^8.0", "symfony/maker-bundle": "^1.24", + "symfony/mcp-bundle": "dev-main", "symfony/mercure-bundle": "*", "symfony/messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/monolog-bundle": "^4.0", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/routing": "^6.4 || ^7.0 || ^8.0", "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Mcp/Capability/Registry/Loader.php b/src/Mcp/Capability/Registry/Loader.php new file mode 100644 index 0000000000..d7457710a8 --- /dev/null +++ b/src/Mcp/Capability/Registry/Loader.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\Capability\Registry; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactory; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Symfony\Controller\McpController; +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Annotations; +use Mcp\Schema\Resource; +use Mcp\Schema\Tool; + +final class Loader implements LoaderInterface +{ + public const HANDLER = 'api_platform.mcp.handler'; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollection, + private readonly SchemaFactoryInterface $schemaFactory, + ) { + } + + public function load(RegistryInterface $registry): void + { + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $metadata = $this->resourceMetadataCollection->create($resourceClass); + + foreach ($metadata as $resource) { + foreach ($resource->getMcp() ?? [] as $mcp) { + if ($mcp instanceof McpTool) { + $inputClass = $mcp->getInput()['class'] ?? $mcp->getClass(); + $schema = $this->schemaFactory->buildSchema($inputClass, 'json', Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); + $registry->registerTool( + new Tool( + name: $mcp->getName(), + inputSchema: $schema->getDefinitions()[$schema->getRootDefinitionKey()]->getArrayCopy(), + description: $mcp->getDescription(), + annotations: $mcp->getAnnotations() ? Annotations::fromArray($mcp->getAnnotations()) : null, + icons: $mcp->getIcons(), + meta: $mcp->getMeta() + ), + self::HANDLER, + true + ); + } + + if ($mcp instanceof McpResource) { + $registry->registerResource( + new Resource( + uri: $mcp->getUri(), + name: $mcp->getName(), + description: $mcp->getDescription(), + mimeType: $mcp->getMimeType(), + annotations: $mcp->getAnnotations() ? Annotations::fromArray($mcp->getAnnotations()) : null, + size: $mcp->getSize(), + icons: $mcp->getIcons(), + meta: $mcp->getMeta() + ), + self::HANDLER, + true + ); + } + } + } + } + } +} diff --git a/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php new file mode 100644 index 0000000000..d516db7fb9 --- /dev/null +++ b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\Metadata\Operation\Factory; + +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; + +final class OperationMetadataFactory implements OperationMetadataFactoryInterface +{ + public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory) + { + } + + public function create(string $operationName, array $context = []): ?\ApiPlatform\Metadata\Operation + { + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) { + if (null === $mcp = $resource->getMcp()) { + continue; + } + + foreach ($mcp as $operation) { + if (($operation instanceof McpTool || $operation instanceof McpResource) && $operation->getName() === $operationName) { + return $operation; + } + } + } + } + + return null; + } +} diff --git a/src/Mcp/Routing/IriConverter.php b/src/Mcp/Routing/IriConverter.php new file mode 100644 index 0000000000..df47895e5d --- /dev/null +++ b/src/Mcp/Routing/IriConverter.php @@ -0,0 +1,29 @@ +inner->getResourceFromIri($iri, $context, $operation); + } + + public function getIriFromResource(object|string $resource, int $referenceType = 1, ?Operation $operation = null, array $context = []): ?string + { + if (($operation instanceof McpTool || $operation instanceof McpResource) && !isset($context['item_uri_template'])) { + return null; + } + + return $this->inner->getIriFromResource($resource, $referenceType, $operation, $context); + } +} diff --git a/src/Mcp/Server/Handler.php b/src/Mcp/Server/Handler.php new file mode 100644 index 0000000000..9dcc1ac339 --- /dev/null +++ b/src/Mcp/Server/Handler.php @@ -0,0 +1,115 @@ + + */ +final class Handler implements RequestHandlerInterface +{ + public function __construct( + private readonly OperationMetadataFactoryInterface $operationMetadataFactory, + private readonly ProviderInterface $provider, + private readonly ProcessorInterface $processor, + private readonly RequestStack $requestStack, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof CallToolRequest; + } + + /** + * @return Response|Error + */ + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof CallToolRequest); + + $toolName = $request->name; + $arguments = $request->arguments ?? []; + + $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); + + $operation = $this->operationMetadataFactory->create($toolName); + + $uriVariables = []; + foreach ($operation->getUriVariables() ?? [] as $key => $link) { + if (isset($arguments[$key])) { + $uriVariables[$key] = $arguments[$key]; + } + } + + $context = [ + 'request' => ($httpRequest = $this->requestStack->getCurrentRequest()), + 'mcp_request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + 'mcp_data' => $arguments, + ]; + + if (null === $operation->canValidate()) { + $operation = $operation->withValidate(false); + } + + if (null === $operation->canRead()) { + $operation = $operation->withRead(true); + } + + if (null === $operation->getProvider()) { + $operation = $operation->withProvider('api_platform.mcp.state.tool_provider'); + } + + if (null === $operation->canDeserialize()) { + $operation = $operation->withDeserialize(false); + } + + $body = $this->provider->provide($operation, $uriVariables, $context); + + $context['previous_data'] = $httpRequest->attributes->get('previous_data'); + $context['data'] = $httpRequest->attributes->get('data'); + $context['read_data'] = $httpRequest->attributes->get('read_data'); + $context['mapped_data'] = $httpRequest->attributes->get('mapped_data'); + + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(true); + } + + if (null === $operation->canSerialize()) { + $operation = $operation->withSerialize(false); + } + + return $this->processor->process($body, $operation, $uriVariables, $context); + } +} diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php new file mode 100644 index 0000000000..c5b7a78096 --- /dev/null +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Result\CallToolResult; +use Symfony\Component\Serializer\Encoder\ContextAwareEncoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +final class StructuredContentProcessor implements ProcessorInterface +{ + public function __construct( + private readonly SerializerInterface $serializer, + private readonly SerializerContextBuilderInterface $serializerContextBuilder, + public readonly ProcessorInterface $decorated, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ( + !$this->serializer instanceof NormalizerInterface + || !$this->serializer instanceof EncoderInterface + || !isset($context['mcp_request']) + || !($request = $context['request']) + ) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + if ($data instanceof CallToolResult) { + return new Response($context['mcp_request']->getId(), $data); + } + + $context['original_data'] = $data; + $class = $operation->getClass(); + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ + 'resource_class' => $class, + 'operation' => $operation, + ]); + + $serializerContext['uri_variables'] = $uriVariables; + + $structuredContent = $this->serializer->normalize($data, $format = $request->getRequestFormat(), $serializerContext); + + return new Response( + $context['mcp_request']->getId(), + new CallToolResult( + [new TextContent($this->serializer->encode($structuredContent, $format, $serializerContext))], + false, + $structuredContent, + ), + ); + } +} diff --git a/src/Mcp/State/ToolProvider.php b/src/Mcp/State/ToolProvider.php new file mode 100644 index 0000000000..285c78b8b2 --- /dev/null +++ b/src/Mcp/State/ToolProvider.php @@ -0,0 +1,28 @@ + + */ +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 28580ea7c3..0eba76e3ed 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> */ @@ -1033,6 +1034,19 @@ class: $class, /** * @return Operations|null */ + public function getMcp(): ?array + { + return $this->mcp; + } + + public function withMcp(array $mcp): static + { + $self = clone $this; + $self->mcp = $mcp; + + return $self; + } + public function getOperations(): ?Operations { return $this->operations; diff --git a/src/Metadata/McpResource.php b/src/Metadata/McpResource.php new file mode 100644 index 0000000000..fb6a7c911c --- /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 0000000000..f92f906696 --- /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/Metadata.php b/src/Metadata/Metadata.php index 009612c990..b2171c534e 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -83,6 +83,7 @@ public function __construct( protected ?bool $jsonStream = null, protected ?bool $map = null, protected array $extraProperties = [], + ) { if (\is_array($parameters) && $parameters) { $parameters = new Parameters($parameters); @@ -91,6 +92,10 @@ public function __construct( $this->parameters = $parameters; } + + + + public function canMap(): ?bool { return $this->map; diff --git a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php index 7561858e79..914f22662b 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/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 644d18c389..f1ff76c2cf 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -33,9 +33,25 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\AsOperationMutator; use ApiPlatform\Metadata\AsResourceMutator; +use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\FilterInterface; +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\Tests\Fixtures\Metadata\Get; use ApiPlatform\Metadata\UriVariableTransformerInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\OpenApi\Model\Tag; @@ -70,6 +86,7 @@ use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Yaml\Yaml; +use Symfony\AI\McpBundle\McpBundle; use Twig\Environment; /** @@ -180,6 +197,11 @@ public function load(array $configs, ContainerBuilder $container): void if (class_exists(ObjectMapper::class)) { $loader->load('state/object_mapper.php'); } + + if (($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class)) { + $loader->load('mcp.php'); + } + $container->registerForAutoconfiguration(FilterInterface::class) ->addTag('api_platform.filter'); $container->registerForAutoconfiguration(ProviderInterface::class) @@ -190,11 +212,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 @@ -221,6 +239,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 acd75f2d93..a0192379bf 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -171,6 +171,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addElasticsearchSection($rootNode); $this->addOpenApiSection($rootNode); $this->addMakerSection($rootNode); + $this->addMcpSection($rootNode); $this->addExceptionToStatusSection($rootNode); @@ -665,6 +666,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.php b/src/Symfony/Bundle/Resources/config/mcp.php new file mode 100644 index 0000000000..558493f9f3 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/mcp.php @@ -0,0 +1,69 @@ + + * + * 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\Server\Handler; +use ApiPlatform\Mcp\State\StructuredContentProcessor; +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.handler', Handler::class) + ->args([ + service('api_platform.mcp.metadata.operation.mcp_factory'), + service('api_platform.state_provider.main'), + service('api_platform.state_processor.main'), + service('request_stack'), + service('monolog.logger.mcp'), + ]) + ->tag('mcp.request_handler'); + + $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'), + ]); + + $services->set('api_platform.mcp.state_processor.structured_content', StructuredContentProcessor::class) + ->decorate('api_platform.state_processor.main', null, 200) + ->args([ + service('api_platform.serializer'), + service('api_platform.serializer.context_builder'), + service('api_platform.mcp.state_processor.structured_content.inner'), + ]); +}; diff --git a/tests/Fixtures/TestBundle/ApiResource/McpDummy.php b/tests/Fixtures/TestBundle/ApiResource/McpDummy.php new file mode 100644 index 0000000000..2cfd6959b3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpDummy.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\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/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 2a74b0d9b1..31e154f872 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 eb58c616ae..75bd00ab2e 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -92,6 +92,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 diff --git a/tests/Fixtures/app/config/routing_test.php b/tests/Fixtures/app/config/routing_test.php index 4d74d8b24e..087fefdc8e 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 0000000000..736e96085e --- /dev/null +++ b/tests/Functional/McpTest.php @@ -0,0 +1,123 @@ + + * + * 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\SetupClassResourcesTrait; + +class McpTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [McpDummy::class]; + } + + public function testGetMcpOperation(): void + { + $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', + ], + ]); + + self::assertEquals( + [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'tools' => [ + [ + 'name' => 'mcp_dummy_tool', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'integer', + ], + 'name' => [ + 'type' => 'string', + ], + ], + ], + ], + ], + ], + ], + $res->toArray() + ); + + 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', + ], + ], + ], + ]); + + dd($res->toArray()); + self::assertResponseIsSuccessful(); + } +}