diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 893f5406b66..f4152857848 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -108,6 +108,7 @@ use ApiPlatform\Laravel\State\SwaggerUiProvider; use ApiPlatform\Laravel\State\ValidateProvider; use ApiPlatform\Mcp\Capability\Registry\Loader as McpLoader; +use ApiPlatform\Mcp\JsonSchema\SchemaFactory as McpSchemaFactory; use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory as McpOperationMetadataFactory; use ApiPlatform\Mcp\Routing\IriConverter as McpIriConverter; use ApiPlatform\Mcp\Server\Handler; @@ -405,7 +406,7 @@ public function register(): void /** @var ConfigRepository */ $config = $app['config']; - return new SwaggerUiProvider($app->make(ReadProvider::class), $app->make(OpenApiFactoryInterface::class), $config->get('api-platform.swagger_ui.enabled', false)); + return new SwaggerUiProvider($app->make(ReadProvider::class), $app->make(OpenApiFactoryInterface::class), $config->get('api-platform.swagger_ui.enabled', false), $config->get('api-platform.scalar.enabled', false)); }); $this->app->singleton(DeserializeProvider::class, static function (Application $app) { @@ -746,6 +747,8 @@ public function register(): void oauthClientId: $config->get('api-platform.swagger_ui.oauth.clientId'), oauthClientSecret: $config->get('api-platform.swagger_ui.oauth.clientSecret'), oauthPkce: $config->get('api-platform.swagger_ui.oauth.pkce', false), + scalarEnabled: $config->get('api-platform.scalar.enabled', false), + scalarExtraConfiguration: $config->get('api-platform.scalar.extra_configuration', []), ); }); @@ -759,7 +762,7 @@ public function register(): void /** @var ConfigRepository */ $config = $app['config']; - return new DocumentationController($app->make(ResourceNameCollectionFactoryInterface::class), $config->get('api-platform.title') ?? '', $config->get('api-platform.description') ?? '', $config->get('api-platform.version') ?? '', $app->make(OpenApiFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $app->make(Negotiator::class), $config->get('api-platform.docs_formats'), $config->get('api-platform.swagger_ui.enabled', false)); + return new DocumentationController($app->make(ResourceNameCollectionFactoryInterface::class), $config->get('api-platform.title') ?? '', $config->get('api-platform.description') ?? '', $config->get('api-platform.version') ?? '', $app->make(OpenApiFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $app->make(Negotiator::class), $config->get('api-platform.docs_formats'), $config->get('api-platform.swagger_ui.enabled', false), $config->get('api-platform.scalar.enabled', false)); }); $this->app->singleton(EntrypointController::class, static function (Application $app) { @@ -1083,11 +1086,17 @@ private function registerMcp(): void ); }); + $this->app->singleton(McpSchemaFactory::class, static function (Application $app) { + return new McpSchemaFactory( + $app->make(SchemaFactory::class) + ); + }); + $this->app->singleton(McpLoader::class, static function (Application $app) { return new McpLoader( $app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(SchemaFactoryInterface::class) + $app->make(McpSchemaFactory::class) ); }); $this->app->tag(McpLoader::class, 'mcp.loader'); diff --git a/src/Laravel/Controller/DocumentationController.php b/src/Laravel/Controller/DocumentationController.php index 0b3b1809b74..33f44b22e59 100644 --- a/src/Laravel/Controller/DocumentationController.php +++ b/src/Laravel/Controller/DocumentationController.php @@ -53,6 +53,7 @@ public function __construct( ?Negotiator $negotiator = null, private readonly array $documentationFormats = [OpenApiNormalizer::JSON_FORMAT => ['application/vnd.openapi+json'], OpenApiNormalizer::FORMAT => ['application/json']], private readonly bool $swaggerUiEnabled = true, + private readonly bool $scalarEnabled = true, ) { $this->negotiator = $negotiator ?? new Negotiator(); } @@ -94,7 +95,7 @@ class: OpenApi::class, outputFormats: $this->documentationFormats ); - if ('html' === $format && $this->swaggerUiEnabled) { + if ('html' === $format && ($this->swaggerUiEnabled || $this->scalarEnabled)) { $operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true); } diff --git a/src/Laravel/State/SwaggerUiProcessor.php b/src/Laravel/State/SwaggerUiProcessor.php index 6a90a23fdc9..2818517d2ca 100644 --- a/src/Laravel/State/SwaggerUiProcessor.php +++ b/src/Laravel/State/SwaggerUiProcessor.php @@ -34,6 +34,7 @@ final class SwaggerUiProcessor implements ProcessorInterface /** * @param array $formats + * @param array $scalarExtraConfiguration */ public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -43,6 +44,8 @@ public function __construct( private readonly ?string $oauthClientId = null, private readonly ?string $oauthClientSecret = null, private readonly bool $oauthPkce = false, + private readonly bool $scalarEnabled = false, + private readonly array $scalarExtraConfiguration = [], ) { } @@ -92,7 +95,9 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable $status = $requestedOperation->getStatus() ?? $status; } - return new Response(view('api-platform::swagger-ui', $swaggerContext + ['swagger_data' => $swaggerData]), 200); + $swaggerData['scalarExtraConfiguration'] = $this->scalarExtraConfiguration; + + return new Response(view('api-platform::swagger-ui', $swaggerContext + ['swagger_data' => $swaggerData, 'scalar_enabled' => $this->scalarEnabled]), 200); } /** diff --git a/src/Laravel/State/SwaggerUiProvider.php b/src/Laravel/State/SwaggerUiProvider.php index a465e5c1748..dd8d0fbae76 100644 --- a/src/Laravel/State/SwaggerUiProvider.php +++ b/src/Laravel/State/SwaggerUiProvider.php @@ -38,6 +38,7 @@ public function __construct( private readonly ProviderInterface $decorated, private readonly OpenApiFactoryInterface $openApiFactory, private readonly bool $swaggerUiEnabled = true, + private readonly bool $scalarEnabled = false, ) { } @@ -52,7 +53,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c !($operation instanceof HttpOperation) || !($request = $context['request'] ?? null) || 'html' !== $request->getRequestFormat() - || !$this->swaggerUiEnabled + || (!$this->swaggerUiEnabled && !$this->scalarEnabled) || true === ($operation->getExtraProperties()['_api_disable_swagger_provider'] ?? false) ) { return $this->decorated->provide($operation, $uriVariables, $context); diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 81411333ba8..e5117655726 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -97,6 +97,11 @@ AuthorizationException::class => 403, ], + 'scalar' => [ + 'enabled' => true, + 'extra_configuration' => [], + ], + 'swagger_ui' => [ 'enabled' => true, // 'apiKeys' => [ diff --git a/src/Laravel/resources/views/swagger-ui.blade.php b/src/Laravel/resources/views/swagger-ui.blade.php index 4a9436c6e0c..4fec36e76fc 100644 --- a/src/Laravel/resources/views/swagger-ui.blade.php +++ b/src/Laravel/resources/views/swagger-ui.blade.php @@ -213,8 +213,13 @@ @endif
- - - + @if (($scalar_enabled ?? false) && request()->query('ui') === 'scalar') + + + @else + + + + @endif diff --git a/src/Mcp/.gitignore b/src/Mcp/.gitignore new file mode 100644 index 00000000000..8e6e8828bfd --- /dev/null +++ b/src/Mcp/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.cache diff --git a/src/Mcp/Capability/Registry/Loader.php b/src/Mcp/Capability/Registry/Loader.php index eb7e32c784c..32bff5b1089 100644 --- a/src/Mcp/Capability/Registry/Loader.php +++ b/src/Mcp/Capability/Registry/Loader.php @@ -50,22 +50,25 @@ public function load(RegistryInterface $registry): void foreach ($resource->getMcp() ?? [] as $mcp) { if ($mcp instanceof McpTool) { $inputClass = $mcp->getInput()['class'] ?? $mcp->getClass(); - $inputFormat = array_first($mcp->getInputFormats() ?? ['json']); + $inputFormat = array_key_first($mcp->getInputFormats() ?? ['json' => ['application/json']]); $inputSchema = $this->schemaFactory->buildSchema($inputClass, $inputFormat, Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); - $outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass(); - $outputFormat = array_first($mcp->getOutputFormats() ?? ['jsonld']); - $outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); + $outputSchema = null; + if (false !== $mcp->getStructuredContent()) { + $outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass(); + $outputFormat = array_key_first($mcp->getOutputFormats() ?? ['json' => ['application/json']]); + $outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true])->getArrayCopy(); + } $registry->registerTool( new Tool( name: $mcp->getName(), - inputSchema: $inputSchema->getDefinitions()[$inputSchema->getRootDefinitionKey()]->getArrayCopy(), + inputSchema: $inputSchema->getArrayCopy(), description: $mcp->getDescription(), annotations: $mcp->getAnnotations() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null, icons: $mcp->getIcons(), meta: $mcp->getMeta(), - outputSchema: $outputSchema->getArrayCopy(), + outputSchema: $outputSchema, ), self::HANDLER, true, diff --git a/src/Mcp/JsonSchema/SchemaFactory.php b/src/Mcp/JsonSchema/SchemaFactory.php new file mode 100644 index 00000000000..7ccc3860ad9 --- /dev/null +++ b/src/Mcp/JsonSchema/SchemaFactory.php @@ -0,0 +1,144 @@ + + * + * 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\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Operation; + +/** + * Wraps a SchemaFactoryInterface and flattens the resulting schema + * into a MCP-compliant structure: no $ref, no allOf, no definitions. + * + * @experimental + */ +final class SchemaFactory implements SchemaFactoryInterface +{ + public function __construct( + private readonly SchemaFactoryInterface $decorated, + ) { + } + + public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema + { + $schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + + $definitions = []; + foreach ($schema->getDefinitions() as $key => $definition) { + $definitions[$key] = $definition instanceof \ArrayObject ? $definition->getArrayCopy() : (array) $definition; + } + + $rootKey = $schema->getRootDefinitionKey(); + if (null !== $rootKey) { + $root = $definitions[$rootKey] ?? []; + } else { + // Collection schemas (and others) put allOf/type directly on the root + $root = $schema->getArrayCopy(false); + } + + $flat = self::resolveNode($root, $definitions); + + $flatSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($flatSchema['$schema']); + foreach ($flat as $key => $value) { + $flatSchema[$key] = $value; + } + + return $flatSchema; + } + + /** + * Recursively resolve $ref, allOf, and nested structures into a flat schema node. + * + * @param array $resolving Tracks the current $ref resolution chain to detect circular references + */ + public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array + { + if ($node instanceof \ArrayObject) { + $node = $node->getArrayCopy(); + } + + if (isset($node['$ref'])) { + $refKey = str_replace('#/definitions/', '', $node['$ref']); + if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) { + return ['type' => 'object']; + } + $resolving[$refKey] = true; + $resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving); + unset($resolving[$refKey]); + + return $resolved; + } + + if (isset($node['allOf'])) { + $merged = ['type' => 'object', 'properties' => []]; + $requiredSets = []; + foreach ($node['allOf'] as $entry) { + $resolved = self::resolveNode($entry, $definitions, $resolving); + if (isset($resolved['properties'])) { + foreach ($resolved['properties'] as $k => $v) { + $merged['properties'][$k] = $v; + } + } + if (isset($resolved['required'])) { + $requiredSets[] = $resolved['required']; + } + } + + if ($requiredSets) { + $merged['required'] = array_merge(...$requiredSets); + } + if ([] === $merged['properties']) { + unset($merged['properties']); + } + if (isset($node['description'])) { + $merged['description'] = $node['description']; + } + + return self::resolveDeep($merged, $definitions, $resolving); + } + + if (!isset($node['type'])) { + $node['type'] = 'object'; + } + + return self::resolveDeep($node, $definitions, $resolving); + } + + /** + * Recursively resolve nested properties and array items. + */ + private static function resolveDeep(array $node, array $definitions, array &$resolving): array + { + if (isset($node['items'])) { + $node['items'] = self::resolveNode( + $node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'], + $definitions, + $resolving, + ); + } + + if (isset($node['properties']) && \is_array($node['properties'])) { + foreach ($node['properties'] as $propName => $propSchema) { + $node['properties'][$propName] = self::resolveNode( + $propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema, + $definitions, + $resolving, + ); + } + } + + return $node; + } +} diff --git a/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php index c4e1d4d4f1f..ce353489f54 100644 --- a/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php +++ b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Mcp\Metadata\Operation\Factory; -use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\McpResource; use ApiPlatform\Metadata\McpTool; @@ -32,10 +31,7 @@ public function __construct( ) { } - /** - * @throws RuntimeException - */ - public function create(string $operationName, array $context = []): HttpOperation + public function create(string $operationName, array $context = []): ?HttpOperation { foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) { @@ -55,6 +51,6 @@ public function create(string $operationName, array $context = []): HttpOperatio } } - throw new RuntimeException(\sprintf('MCP operation "%s" not found.', $operationName)); + return null; } } diff --git a/src/Mcp/Server/Handler.php b/src/Mcp/Server/Handler.php index c5c7982ce5c..6a09b1dc16b 100644 --- a/src/Mcp/Server/Handler.php +++ b/src/Mcp/Server/Handler.php @@ -49,7 +49,15 @@ public function __construct( public function supports(Request $request): bool { - return $request instanceof CallToolRequest || $request instanceof ReadResourceRequest; + if ($request instanceof CallToolRequest) { + return null !== $this->operationMetadataFactory->create($request->name); + } + + if ($request instanceof ReadResourceRequest) { + return null !== $this->operationMetadataFactory->create($request->uri); + } + + return false; } /** @@ -70,9 +78,13 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->debug('Executing tool', ['name' => $operationNameOrUri, 'arguments' => $arguments]); } - /** @var HttpOperation $operation */ + /** @var HttpOperation|null $operation */ $operation = $this->operationMetadataFactory->create($operationNameOrUri); + if (null === $operation) { + return Error::forMethodNotFound(\sprintf('MCP operation "%s" not found.', $operationNameOrUri), $request->getId()); + } + $uriVariables = []; if (!$isResource) { foreach ($operation->getUriVariables() ?? [] as $key => $link) { @@ -83,7 +95,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er } $context = [ - 'request' => ($httpRequest = $this->requestStack->getCurrentRequest()), + 'request' => $this->requestStack->getCurrentRequest(), 'mcp_request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => $operation->getClass(), @@ -93,6 +105,15 @@ public function handle(Request $request, SessionInterface $session): Response|Er $context['mcp_data'] = $arguments; } + $operation = $operation->withExtraProperties( + array_merge($operation->getExtraProperties(), ['_api_disable_swagger_provider' => true]) + ); + + // MCP has its own transport (JSON-RPC) — HTTP content negotiation is irrelevant. + if (null === $operation->canNegotiateContent()) { + $operation = $operation->withContentNegotiation(false); + } + if (null === $operation->canValidate()) { $operation = $operation->withValidate(false); } @@ -111,7 +132,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er $body = $this->provider->provide($operation, $uriVariables, $context); - if (!$isResource) { + if (!$isResource && null !== ($httpRequest = $context['request'] ?? null)) { $context['previous_data'] = $httpRequest->attributes->get('previous_data'); $context['data'] = $httpRequest->attributes->get('data'); $context['read_data'] = $httpRequest->attributes->get('read_data'); diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php index b4fac25d2fa..e375c03e2cd 100644 --- a/src/Mcp/State/StructuredContentProcessor.php +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -40,12 +40,7 @@ public function __construct( 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']) - ) { + if (!isset($context['mcp_request'])) { return $this->decorated->process($data, $operation, $uriVariables, $context); } @@ -55,12 +50,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return new Response($context['mcp_request']->getId(), $result); } + $request = $context['request'] ?? null; $context['original_data'] = $result; $class = $operation->getClass(); $includeStructuredContent = $operation instanceof McpTool || $operation instanceof McpResource ? $operation->getStructuredContent() ?? true : false; $structuredContent = null; - if ($includeStructuredContent) { + if ($includeStructuredContent && $request && $this->serializer instanceof NormalizerInterface && $this->serializer instanceof EncoderInterface) { $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $class, 'operation' => $operation, diff --git a/src/Mcp/Tests/Capability/Registry/LoaderTest.php b/src/Mcp/Tests/Capability/Registry/LoaderTest.php new file mode 100644 index 00000000000..a5318628cd3 --- /dev/null +++ b/src/Mcp/Tests/Capability/Registry/LoaderTest.php @@ -0,0 +1,183 @@ + + * + * 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\Tests\Capability\Registry; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Tool; +use PHPUnit\Framework\TestCase; + +class LoaderTest extends TestCase +{ + public function testToolRegistrationWithFlatSchema(): void + { + $inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($inputSchema['$schema']); + $inputSchema['type'] = 'object'; + $inputSchema['properties'] = ['name' => ['type' => 'string']]; + + $outputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($outputSchema['$schema']); + $outputSchema['type'] = 'object'; + $outputSchema['properties'] = ['id' => ['type' => 'integer'], 'name' => ['type' => 'string']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturnOnConsecutiveCalls($inputSchema, $outputSchema); + + $mcpTool = new McpTool( + name: 'createDummy', + description: 'Creates a dummy', + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['createDummy' => $mcpTool]); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerTool') + ->with( + $this->callback(function (Tool $tool): bool { + $this->assertSame('createDummy', $tool->name); + $this->assertSame('Creates a dummy', $tool->description); + $this->assertSame(['type' => 'object', 'properties' => ['name' => ['type' => 'string']]], $tool->inputSchema); + $this->assertSame(['type' => 'object', 'properties' => ['id' => ['type' => 'integer'], 'name' => ['type' => 'string']]], $tool->outputSchema); + + return true; + }), + Loader::HANDLER, + true, + ); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } + + public function testStructuredContentFalseSkipsOutputSchema(): void + { + $inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($inputSchema['$schema']); + $inputSchema['type'] = 'object'; + $inputSchema['properties'] = ['query' => ['type' => 'string']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($inputSchema); + + $mcpTool = new McpTool( + name: 'search', + description: 'Search things', + structuredContent: false, + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['search' => $mcpTool]); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerTool') + ->with( + $this->callback(function (Tool $tool): bool { + $this->assertSame('search', $tool->name); + $this->assertNull($tool->outputSchema); + + return true; + }), + Loader::HANDLER, + true, + ); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } + + public function testResourceRegistration(): void + { + $mcpResource = new McpResource( + uri: 'dummy://docs', + name: 'docs', + description: 'Documentation resource', + mimeType: 'text/plain', + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['docs' => $mcpResource]); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerResource') + ->with( + $this->callback(function ($resource): bool { + $this->assertSame('dummy://docs', $resource->uri); + $this->assertSame('docs', $resource->name); + $this->assertSame('Documentation resource', $resource->description); + $this->assertSame('text/plain', $resource->mimeType); + + return true; + }), + Loader::HANDLER, + true, + ); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } + + public function testEmptyMcpIsSkipped(): void + { + $resource = new ApiResource(class: \stdClass::class); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->never())->method('registerTool'); + $registry->expects($this->never())->method('registerResource'); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } +} diff --git a/src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php b/src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php new file mode 100644 index 00000000000..66c9e00b35e --- /dev/null +++ b/src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php @@ -0,0 +1,305 @@ + + * + * 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\Tests\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\JsonSchema\SchemaFactory; +use PHPUnit\Framework\TestCase; + +class SchemaFactoryTest extends TestCase +{ + public function testFlatSchemaPassesThrough(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Dummy'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Dummy'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['name' => ['type' => 'string']], $arr['properties']); + $this->assertArrayNotHasKey('$ref', $arr); + $this->assertArrayNotHasKey('definitions', $arr); + $this->assertArrayNotHasKey('$schema', $arr); + } + + public function testRefIsResolved(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Wrapper'] = new \ArrayObject([ + '$ref' => '#/definitions/Actual', + ]); + $definitions['Actual'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Wrapper'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['id' => ['type' => 'integer']], $arr['properties']); + $this->assertArrayNotHasKey('$ref', $arr); + $this->assertArrayNotHasKey('definitions', $arr); + } + + public function testAllOfIsMerged(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'description' => 'A dummy resource', + 'allOf' => [ + ['$ref' => '#/definitions/Part1'], + ['$ref' => '#/definitions/Part2'], + ], + ]); + $definitions['Part1'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + 'required' => ['name'], + ]); + $definitions['Part2'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'email' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'jsonld'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame('A dummy resource', $arr['description']); + $this->assertArrayHasKey('name', $arr['properties']); + $this->assertArrayHasKey('email', $arr['properties']); + $this->assertSame(['name'], $arr['required']); + $this->assertArrayNotHasKey('allOf', $arr); + $this->assertArrayNotHasKey('$ref', $arr); + $this->assertArrayNotHasKey('definitions', $arr); + } + + public function testMissingTypeGetsObjectAdded(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['NoType'] = new \ArrayObject([ + 'properties' => [ + 'foo' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/NoType'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['foo' => ['type' => 'string']], $arr['properties']); + } + + public function testNestedRefInsideAllOf(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'allOf' => [ + ['$ref' => '#/definitions/Middle'], + ], + ]); + $definitions['Middle'] = new \ArrayObject([ + '$ref' => '#/definitions/Leaf', + ]); + $definitions['Leaf'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'deep' => ['type' => 'boolean'], + ], + 'required' => ['deep'], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['deep' => ['type' => 'boolean']], $arr['properties']); + $this->assertSame(['deep'], $arr['required']); + } + + public function testCircularRefFallsBackToObject(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['A'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'b' => ['$ref' => '#/definitions/B'], + ], + ]); + $definitions['B'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'a' => ['$ref' => '#/definitions/A'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/A'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + // A.b resolves B, B.a resolves A again, then A.b hits the cycle and breaks + $this->assertSame(['type' => 'object'], $arr['properties']['b']['properties']['a']['properties']['b']); + } + + public function testAllOfInsidePropertyIsResolved(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'nested' => [ + 'allOf' => [ + ['$ref' => '#/definitions/PartA'], + ['$ref' => '#/definitions/PartB'], + ], + ], + ], + ]); + $definitions['PartA'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'x' => ['type' => 'integer'], + ], + ]); + $definitions['PartB'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'y' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertArrayHasKey('x', $arr['properties']['nested']['properties']); + $this->assertArrayHasKey('y', $arr['properties']['nested']['properties']); + $this->assertArrayNotHasKey('allOf', $arr['properties']['nested']); + } + + public function testSameRefUsedTwiceIsResolvedBothTimes(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'address' => ['$ref' => '#/definitions/Address'], + 'billingAddress' => ['$ref' => '#/definitions/Address'], + ], + ]); + $definitions['Address'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'street' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + // Both properties should be fully resolved (not circular-ref fallback) + $this->assertSame(['street' => ['type' => 'string']], $arr['properties']['address']['properties']); + $this->assertSame(['street' => ['type' => 'string']], $arr['properties']['billingAddress']['properties']); + } + + public function testUnresolvableRefFallsBackToObject(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + '$ref' => '#/definitions/DoesNotExist', + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame(['type' => 'object'], $arr); + } +} diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 10bcc257331..bdbcf0af8a6 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -33,6 +33,9 @@ "mcp/sdk": "^0.4.0", "symfony/polyfill-php85": "^1.32" }, + "require-dev": { + "phpunit/phpunit": "^12.2" + }, "autoload": { "psr-4": { "ApiPlatform\\Mcp\\": "" diff --git a/src/Mcp/phpunit.xml.dist b/src/Mcp/phpunit.xml.dist new file mode 100644 index 00000000000..79772319f23 --- /dev/null +++ b/src/Mcp/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + + + + ./Tests/ + + + + + trigger_deprecation + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 5f459c0e35e..b4e55ef6765 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -168,6 +169,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index abeb8ed7a58..dabe1b854d5 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -163,6 +164,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 0139b825611..4babd54eb27 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -168,6 +169,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 74886491a23..27df4b9ad41 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -169,6 +170,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 3f5e0daaeb4..58d4cf98c7f 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -207,6 +207,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -265,6 +266,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/McpResource.php b/src/Metadata/McpResource.php index 0e01513ec44..c36342c1e6b 100644 --- a/src/Metadata/McpResource.php +++ b/src/Metadata/McpResource.php @@ -168,6 +168,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -250,6 +251,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/McpTool.php b/src/Metadata/McpTool.php index 87f79928c45..3a1d12c44bb 100644 --- a/src/Metadata/McpTool.php +++ b/src/Metadata/McpTool.php @@ -162,6 +162,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -244,6 +245,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index c7afb4941f0..e106aa23b4e 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -99,6 +99,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -175,6 +176,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 359f583d163..cbd53751e59 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -792,6 +792,7 @@ public function __construct( protected ?bool $validate = null, protected ?bool $write = null, protected ?bool $serialize = null, + protected ?bool $contentNegotiation = null, protected ?bool $fetchPartial = null, protected ?bool $forceEager = null, /** @@ -936,6 +937,19 @@ public function withSerialize(bool $serialize = true): static return $self; } + public function canNegotiateContent(): ?bool + { + return $this->contentNegotiation; + } + + public function withContentNegotiation(bool $contentNegotiation = true): static + { + $self = clone $this; + $self->contentNegotiation = $contentNegotiation; + + return $self; + } + public function getPriority(): ?int { return $this->priority; diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index b81814350d7..13d7dc442a0 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -169,6 +170,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 208366234ef..419512a851d 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -170,6 +171,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 5fbfbfd49f6..3ea21ffeadd 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -170,6 +171,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php index df787da2b4e..965d615d2e7 100644 --- a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php @@ -18,6 +18,8 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -39,6 +41,7 @@ public function __construct( private readonly array $formats, private readonly array $patchFormats, private readonly ?array $errorFormats = null, + private readonly ?string $mcpFormat = null, ) { } @@ -63,6 +66,22 @@ public function create(string $resourceClass): ResourceMetadataCollection } $resourceMetadataCollection[$index] = $resourceMetadataCollection[$index]->withOperations($this->normalize($resourceInputFormats, $resourceOutputFormats, $resourceMetadata->getOperations())); + + // Apply MCP-specific format to MCP operations + if (null !== $this->mcpFormat && null !== ($mcp = $resourceMetadata->getMcp())) { + if (!isset($this->formats[$this->mcpFormat])) { + throw new InvalidArgumentException(\sprintf('The MCP format "%s" is not configured in api_platform.formats. Available formats: %s.', $this->mcpFormat, implode(', ', array_keys($this->formats)))); + } + $mcpFormats = [$this->mcpFormat => $this->formats[$this->mcpFormat]]; + $newMcp = []; + foreach ($mcp as $key => $operation) { + if (($operation instanceof McpTool || $operation instanceof McpResource) && null === $operation->getFormats() && null === $operation->getInputFormats() && null === $operation->getOutputFormats()) { + $operation = $operation->withInputFormats($mcpFormats)->withOutputFormats($mcpFormats); + } + $newMcp[$key] = $operation; + } + $resourceMetadataCollection[$index] = $resourceMetadataCollection[$index]->withMcp($newMcp); + } } return $resourceMetadataCollection; diff --git a/src/State/Provider/ContentNegotiationProvider.php b/src/State/Provider/ContentNegotiationProvider.php index 09b693ed8a7..42e81252282 100644 --- a/src/State/Provider/ContentNegotiationProvider.php +++ b/src/State/Provider/ContentNegotiationProvider.php @@ -40,7 +40,7 @@ public function __construct(private readonly ?ProviderInterface $decorated = nul public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { + if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation || false === $operation->canNegotiateContent()) { return $this->decorated?->provide($operation, $uriVariables, $context); } diff --git a/src/Symfony/Action/DocumentationAction.php b/src/Symfony/Action/DocumentationAction.php index 891d5d83d4c..50981993b69 100644 --- a/src/Symfony/Action/DocumentationAction.php +++ b/src/Symfony/Action/DocumentationAction.php @@ -52,6 +52,7 @@ public function __construct( private readonly bool $swaggerUiEnabled = true, private readonly bool $docsEnabled = true, private readonly bool $reDocEnabled = true, + private readonly bool $scalarEnabled = true, ) { $this->negotiator = $negotiator ?? new Negotiator(); } @@ -91,8 +92,8 @@ public function __invoke(?Request $request = null) */ private function getOpenApiDocumentation(array $context, string $format, Request $request): OpenApi|Response { - if ('html' === $format && !$this->swaggerUiEnabled && !$this->reDocEnabled) { - throw new NotFoundHttpException('Swagger UI and ReDoc are disabled.'); + if ('html' === $format && !$this->swaggerUiEnabled && !$this->reDocEnabled && !$this->scalarEnabled) { + throw new NotFoundHttpException('Swagger UI, ReDoc and Scalar are disabled.'); } if ($this->provider && $this->processor) { @@ -105,7 +106,7 @@ class: OpenApi::class, outputFormats: $this->documentationFormats ); - if ('html' === $format && ($this->swaggerUiEnabled || $this->reDocEnabled)) { + if ('html' === $format && ($this->swaggerUiEnabled || $this->reDocEnabled || $this->scalarEnabled)) { $operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true); } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index a2e1d698cf4..08f2464bd4a 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -151,6 +151,7 @@ public function load(array $configs, ContainerBuilder $container): void // to prevent HTML documentation from being served on resource endpoints. $config['enable_swagger_ui'] = false; $config['enable_re_doc'] = false; + $config['enable_scalar'] = false; } $jsonSchemaFormats = $config['jsonschema_formats']; @@ -206,6 +207,8 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load($config['use_symfony_listeners'] ? 'symfony/object_mapper.php' : 'state/object_mapper_processor.php'); } + $container->setParameter('api_platform.mcp.format', $config['mcp']['format'] ?? null); + 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'); @@ -647,6 +650,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array if (!$config['enable_swagger']) { $container->setParameter('api_platform.enable_swagger_ui', false); $container->setParameter('api_platform.enable_re_doc', false); + $container->setParameter('api_platform.enable_scalar', false); return; } @@ -657,7 +661,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('openapi/yaml.php'); } - if ($config['enable_swagger_ui'] || $config['enable_re_doc']) { + if ($config['enable_swagger_ui'] || $config['enable_re_doc'] || $config['enable_scalar']) { $loader->load('swagger_ui.php'); if ($config['use_symfony_listeners']) { @@ -667,13 +671,14 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('state/swagger_ui.php'); } - if (!$config['enable_swagger_ui'] && !$config['enable_re_doc']) { + if (!$config['enable_swagger_ui'] && !$config['enable_re_doc'] && !$config['enable_scalar']) { // Remove the listener but keep the controller to allow customizing the path of the UI $container->removeDefinition('api_platform.swagger.listener.ui'); } $container->setParameter('api_platform.enable_swagger_ui', $config['enable_swagger_ui']); $container->setParameter('api_platform.enable_re_doc', $config['enable_re_doc']); + $container->setParameter('api_platform.enable_scalar', $config['enable_scalar']); $container->setParameter('api_platform.swagger.api_keys', $config['swagger']['api_keys']); $container->setParameter('api_platform.swagger.persist_authorization', $config['swagger']['persist_authorization']); $container->setParameter('api_platform.swagger.http_auth', $config['swagger']['http_auth']); @@ -681,6 +686,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array throw new RuntimeException('You can not set "swagger_ui_extra_configuration" twice - in "openapi" and "swagger" section.'); } $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?: $config['swagger']['swagger_ui_extra_configuration']); + $container->setParameter('api_platform.scalar.extra_configuration', $config['openapi']['scalar_extra_configuration']); } private function registerJsonApiConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 45fd8bebd0c..a6f6ba16765 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -124,6 +124,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_json_streamer')->defaultValue(class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))->info('Enable json streamer.')->end() ->booleanNode('enable_swagger_ui')->defaultValue(class_exists(TwigBundle::class))->info('Enable Swagger UI')->end() ->booleanNode('enable_re_doc')->defaultValue(class_exists(TwigBundle::class))->info('Enable ReDoc')->end() + ->booleanNode('enable_scalar')->defaultValue(class_exists(TwigBundle::class))->info('Enable Scalar API Reference')->end() ->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end() ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() @@ -590,6 +591,14 @@ private function addOpenApiSection(ArrayNodeDefinition $rootNode): void ->end() ->info('To pass extra configuration to Swagger UI, like docExpansion or filter.') ->end() + ->variableNode('scalar_extra_configuration') + ->defaultValue([]) + ->validate() + ->ifTrue(static fn ($v): bool => false === \is_array($v)) + ->thenInvalid('The scalar_extra_configuration parameter must be an array.') + ->end() + ->info('To pass extra configuration to Scalar API Reference, like theme or darkMode.') + ->end() ->booleanNode('overrideResponses')->defaultTrue()->info('Whether API Platform adds automatic responses to the OpenAPI documentation.')->end() ->scalarNode('error_resource_class')->defaultNull()->info('The class used to represent errors in the OpenAPI documentation.')->end() ->scalarNode('validation_error_resource_class')->defaultNull()->info('The class used to represent validation errors in the OpenAPI documentation.')->end() @@ -717,6 +726,12 @@ private function addMcpSection(ArrayNodeDefinition $rootNode): void ->children() ->arrayNode('mcp') ->canBeDisabled() + ->children() + ->scalarNode('format') + ->defaultValue('jsonld') + ->info('The serialization format used for MCP tool input/output. Must be a format registered in api_platform.formats (e.g. "jsonld", "json", "jsonapi").') + ->end() + ->end() ->end() ->end(); } diff --git a/src/Symfony/Bundle/Resources/config/mcp/mcp.php b/src/Symfony/Bundle/Resources/config/mcp/mcp.php index 8ffdffaea8c..97e042661b3 100644 --- a/src/Symfony/Bundle/Resources/config/mcp/mcp.php +++ b/src/Symfony/Bundle/Resources/config/mcp/mcp.php @@ -14,6 +14,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Mcp\JsonSchema\SchemaFactory; use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory; use ApiPlatform\Mcp\Routing\IriConverter; use ApiPlatform\Mcp\State\ToolProvider; @@ -21,11 +22,16 @@ return static function (ContainerConfigurator $container) { $services = $container->services(); + $services->set('api_platform.mcp.json_schema.schema_factory', SchemaFactory::class) + ->args([ + service('api_platform.json_schema.schema_factory'), + ]); + $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'), + service('api_platform.mcp.json_schema.schema_factory'), ]) ->tag('mcp.loader'); diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.php b/src/Symfony/Bundle/Resources/config/metadata/resource.php index 0e0b3c088d4..eb11e227e64 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.php +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.php @@ -132,6 +132,7 @@ '%api_platform.formats%', '%api_platform.patch_formats%', '%api_platform.error_formats%', + '%api_platform.mcp.format%', ]); $services->set('api_platform.metadata.resource.metadata_collection_factory.filters', FiltersResourceMetadataCollectionFactory::class) diff --git a/src/Symfony/Bundle/Resources/config/swagger_ui.php b/src/Symfony/Bundle/Resources/config/swagger_ui.php index 4d4b756af6e..312e12227af 100644 --- a/src/Symfony/Bundle/Resources/config/swagger_ui.php +++ b/src/Symfony/Bundle/Resources/config/swagger_ui.php @@ -28,6 +28,8 @@ '%api_platform.graphql.graphiql.enabled%', '%api_platform.asset_package%', '%api_platform.swagger_ui.extra_configuration%', + '%api_platform.enable_scalar%', + '%api_platform.scalar.extra_configuration%', ]); $services->set('api_platform.swagger_ui.processor', SwaggerUiProcessor::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 203afa69245..2ac24e613e0 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -54,5 +54,6 @@ '%api_platform.enable_swagger_ui%', '%api_platform.enable_docs%', '%api_platform.enable_re_doc%', + '%api_platform.enable_scalar%', ]); }; diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 12488624e08..c8c2c833e70 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -202,6 +202,7 @@ '%api_platform.enable_swagger_ui%', '%api_platform.enable_docs%', '%api_platform.enable_re_doc%', + '%api_platform.enable_scalar%', ]); $services->set('api_platform.action.placeholder', PlaceholderAction::class) diff --git a/src/Symfony/Bundle/Resources/public/init-scalar-ui.js b/src/Symfony/Bundle/Resources/public/init-scalar-ui.js new file mode 100644 index 00000000000..ba8c2091117 --- /dev/null +++ b/src/Symfony/Bundle/Resources/public/init-scalar-ui.js @@ -0,0 +1,12 @@ +'use strict'; + +window.onload = function() { + var data = JSON.parse(document.getElementById('swagger-data').innerText); + + var config = Object.assign({ + content: data.spec, + theme: 'default', + }, data.scalarExtraConfiguration || {}); + + Scalar.createApiReference('#swagger-ui', config); +}; diff --git a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 3405232fbaa..52dfca324ee 100644 --- a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -3,17 +3,23 @@ {% block head_metas %} + {% endblock %} {% block title %} {% if title %}{{ title }} - {% endif %}API Platform {% endblock %} + {% set active_ui = app.request.query.get('ui', 'swagger_ui') %} + {% set is_scalar = (scalarEnabled and not swaggerUiEnabled and not reDocEnabled) or (scalarEnabled and 'scalar' == active_ui) %} + {% block stylesheet %} - - - - + {% if not is_scalar %} + + + + + {% endif %} {% endblock %} {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html', assetPackage)) })} %} @@ -25,6 +31,7 @@ +{% if not is_scalar %} @@ -69,9 +76,11 @@
{% endif %} +{% endif %}
+{% if not is_scalar %}
@@ -81,16 +90,20 @@ {% endfor %}
Other API docs: - {% set active_ui = app.request.query.get('ui', 'swagger_ui') %} {% if swaggerUiEnabled and active_ui != 'swagger_ui' %}Swagger UI{% endif %} {% if reDocEnabled and active_ui != 're_doc' %}ReDoc{% endif %} + {% if scalarEnabled and active_ui != 'scalar' %}Scalar{% endif %} {% if not graphQlEnabled or graphiQlEnabled %}GraphiQL{% endif %}
+{% endif %} {% block javascript %} - {% if (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} + {% if is_scalar %} + + + {% elseif (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} {% else %} @@ -98,7 +111,9 @@ {% endif %} - + {% if not is_scalar %} + + {% endif %} {% endblock %} diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php index e0240c9c717..1c5e29db84f 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php @@ -18,7 +18,7 @@ final class SwaggerUiContext /** * @param string|null $assetPackage */ - public function __construct(private readonly bool $swaggerUiEnabled = false, private readonly bool $showWebby = true, private readonly bool $reDocEnabled = false, private readonly bool $graphQlEnabled = false, private readonly bool $graphiQlEnabled = false, private $assetPackage = null, private readonly array $extraConfiguration = []) + public function __construct(private readonly bool $swaggerUiEnabled = false, private readonly bool $showWebby = true, private readonly bool $reDocEnabled = false, private readonly bool $graphQlEnabled = false, private readonly bool $graphiQlEnabled = false, private $assetPackage = null, private readonly array $extraConfiguration = [], private readonly bool $scalarEnabled = false, private readonly array $scalarExtraConfiguration = []) { } @@ -56,4 +56,14 @@ public function getExtraConfiguration(): array { return $this->extraConfiguration; } + + public function isScalarEnabled(): bool + { + return $this->scalarEnabled; + } + + public function getScalarExtraConfiguration(): array + { + return $this->scalarExtraConfiguration; + } } diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php index 844f64d9c5c..eba9d89fed8 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php @@ -52,6 +52,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'showWebby' => $this->swaggerUiContext->isWebbyShown(), 'swaggerUiEnabled' => $this->swaggerUiContext->isSwaggerUiEnabled(), 'reDocEnabled' => $this->swaggerUiContext->isRedocEnabled(), + 'scalarEnabled' => $this->swaggerUiContext->isScalarEnabled(), 'graphQlEnabled' => $this->swaggerUiContext->isGraphQlEnabled(), 'graphiQlEnabled' => $this->swaggerUiContext->isGraphiQlEnabled(), 'assetPackage' => $this->swaggerUiContext->getAssetPackage(), @@ -75,6 +76,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'pkce' => $this->oauthPkce, ], 'extraConfiguration' => $this->swaggerUiContext->getExtraConfiguration(), + 'scalarExtraConfiguration' => $this->swaggerUiContext->getScalarExtraConfiguration(), ]; $status = 200; diff --git a/src/Symfony/Tests/Action/DocumentationActionTest.php b/src/Symfony/Tests/Action/DocumentationActionTest.php index 2595e33b232..802f4ceb3e9 100644 --- a/src/Symfony/Tests/Action/DocumentationActionTest.php +++ b/src/Symfony/Tests/Action/DocumentationActionTest.php @@ -37,10 +37,10 @@ class DocumentationActionTest extends TestCase { use ProphecyTrait; - public function testHtmlFormatWhenSwaggerUiAndReDocDisabledThrows404(): void + public function testHtmlFormatWhenSwaggerUiAndReDocAndScalarDisabledThrows404(): void { $this->expectException(NotFoundHttpException::class); - $this->expectExceptionMessage('Swagger UI and ReDoc are disabled.'); + $this->expectExceptionMessage('Swagger UI, ReDoc and Scalar are disabled.'); $request = new Request(); $request->attributes->set('_format', 'html'); @@ -57,6 +57,7 @@ public function testHtmlFormatWhenSwaggerUiAndReDocDisabledThrows404(): void ], swaggerUiEnabled: false, reDocEnabled: false, + scalarEnabled: false, ); $documentation($request); diff --git a/tests/Functional/DocumentationActionTest.php b/tests/Functional/DocumentationActionTest.php index b31dca0c763..69d8ba90fde 100644 --- a/tests/Functional/DocumentationActionTest.php +++ b/tests/Functional/DocumentationActionTest.php @@ -24,18 +24,19 @@ class DocumentationActionAppKernel extends \AppKernel { public static bool $swaggerUiEnabled = true; public static bool $reDocEnabled = true; + public static bool $scalarEnabled = true; public static bool $docsEnabled = true; public function getCacheDir(): string { - $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$docsEnabled ? '' : '_no_docs'); + $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$scalarEnabled ? '_scalar' : '_no_scalar').(self::$docsEnabled ? '' : '_no_docs'); return parent::getCacheDir().'/'.$suffix; } public function getLogDir(): string { - $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$docsEnabled ? '' : '_no_docs'); + $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$scalarEnabled ? '_scalar' : '_no_scalar').(self::$docsEnabled ? '' : '_no_docs'); return parent::getLogDir().'/'.$suffix; } @@ -48,6 +49,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $container->loadFromExtension('api_platform', [ 'enable_swagger_ui' => DocumentationActionAppKernel::$swaggerUiEnabled, 'enable_re_doc' => DocumentationActionAppKernel::$reDocEnabled, + 'enable_scalar' => DocumentationActionAppKernel::$scalarEnabled, 'enable_docs' => DocumentationActionAppKernel::$docsEnabled, ]); }); @@ -63,32 +65,36 @@ protected static function getKernelClass(): string return DocumentationActionAppKernel::class; } - public function testHtmlDocumentationIsNotAccessibleWhenSwaggerUiAndReDocAreDisabled(): void + public function testHtmlDocumentationIsNotAccessibleWhenSwaggerUiAndReDocAndScalarAreDisabled(): void { DocumentationActionAppKernel::$swaggerUiEnabled = false; DocumentationActionAppKernel::$reDocEnabled = false; + DocumentationActionAppKernel::$scalarEnabled = false; $client = self::createClient(); $container = static::getContainer(); $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertFalse($container->getParameter('api_platform.enable_scalar')); $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); $this->assertResponseStatusCodeSame(404); - $this->assertStringContainsString('Swagger UI and ReDoc are disabled.', $client->getResponse()->getContent(false)); + $this->assertStringContainsString('Swagger UI, ReDoc and Scalar are disabled.', $client->getResponse()->getContent(false)); } public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsDisabled(): void { DocumentationActionAppKernel::$swaggerUiEnabled = false; DocumentationActionAppKernel::$reDocEnabled = false; + DocumentationActionAppKernel::$scalarEnabled = false; $client = self::createClient(); $container = static::getContainer(); $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertFalse($container->getParameter('api_platform.enable_scalar')); $client->request('GET', '/docs.jsonopenapi', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); $this->assertResponseIsSuccessful(); @@ -161,10 +167,47 @@ public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsEnabled(): void $this->assertJsonContains(['info' => ['title' => 'My Dummy API']]); } - public function testEnableDocsFalseDisablesSwaggerUiAndReDoc(): void + public function testHtmlDocumentationIsAccessibleWhenOnlyScalarIsEnabled(): void + { + DocumentationActionAppKernel::$swaggerUiEnabled = false; + DocumentationActionAppKernel::$reDocEnabled = false; + DocumentationActionAppKernel::$scalarEnabled = true; + + $client = self::createClient(); + + $container = static::getContainer(); + $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); + $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertTrue($container->getParameter('api_platform.enable_scalar')); + + $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); + $this->assertResponseIsSuccessful(); + $content = $client->getResponse()->getContent(); + $this->assertStringContainsString('cdn.jsdelivr.net/npm/@scalar/api-reference', $content); + $this->assertStringContainsString('init-scalar-ui.js', $content); + } + + public function testScalarUiIsAccessibleWithUiQueryParameter(): void + { + DocumentationActionAppKernel::$swaggerUiEnabled = true; + DocumentationActionAppKernel::$reDocEnabled = true; + DocumentationActionAppKernel::$scalarEnabled = true; + + $client = self::createClient(); + + $client->request('GET', '/docs?ui=scalar', ['headers' => ['Accept' => 'text/html']]); + $this->assertResponseIsSuccessful(); + $content = $client->getResponse()->getContent(); + $this->assertStringContainsString('cdn.jsdelivr.net/npm/@scalar/api-reference', $content); + $this->assertStringContainsString('init-scalar-ui.js', $content); + $this->assertStringNotContainsString('swagger-ui-bundle.js', $content); + } + + public function testEnableDocsFalseDisablesSwaggerUiAndReDocAndScalar(): void { DocumentationActionAppKernel::$swaggerUiEnabled = true; DocumentationActionAppKernel::$reDocEnabled = true; + DocumentationActionAppKernel::$scalarEnabled = true; DocumentationActionAppKernel::$docsEnabled = false; $client = self::createClient(); @@ -174,6 +217,7 @@ public function testEnableDocsFalseDisablesSwaggerUiAndReDoc(): void // enable_docs: false acts as a master switch, forcing these to false $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertFalse($container->getParameter('api_platform.enable_scalar')); $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); $this->assertResponseStatusCodeSame(404); diff --git a/tests/Functional/McpTest.php b/tests/Functional/McpTest.php index 46ed75109ae..5cff5220061 100644 --- a/tests/Functional/McpTest.php +++ b/tests/Functional/McpTest.php @@ -436,52 +436,49 @@ public function testToolsList(): void $listBooks = array_first($listBooks); - self::assertArrayHasKeyAndValue('inputSchema', [ - 'type' => 'object', - 'properties' => [ - 'search' => ['type' => 'string'], + self::assertArraySubset( + subset: [ + 'description' => 'List Books', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'search' => ['type' => 'string'], + ], + ], + 'outputSchema' => [ + 'type' => 'object', + ], ], - ], $listBooks); - self::assertArrayHasKeyAndValue('description', 'List Books', $listBooks); + array: $listBooks, + ); + // Output schemas are flattened for MCP compliance: no $ref, no allOf, no definitions $outputSchema = $listBooks['outputSchema']; - self::assertArrayHasKeyAndValue('$schema', 'http://json-schema.org/draft-07/schema#', $outputSchema); - self::assertArrayHasKeyAndValue('type', 'object', $outputSchema); - - self::assertArrayHasKey('definitions', $outputSchema); - $definitions = $outputSchema['definitions']; - self::assertArrayHasKey('McpBook.jsonld', $definitions); - $McpBookJsonLd = $definitions['McpBook.jsonld']; - self::assertArrayHasKeyAndValue('allOf', [ - [ - '$ref' => '#/definitions/HydraItemBaseSchema', - ], - [ - 'type' => 'object', - 'properties' => [ - 'id' => ['readOnly' => true, 'type' => 'integer'], - 'title' => ['type' => 'string'], - 'isbn' => ['type' => 'string'], - 'status' => ['type' => ['string', 'null']], - ], - ], - ], $McpBookJsonLd); + self::assertArrayNotHasKey('$schema', $outputSchema); + self::assertArrayNotHasKey('definitions', $outputSchema); + self::assertArrayNotHasKey('allOf', $outputSchema); - self::assertArrayHasKeyAndValue('allOf', [ - ['$ref' => '#/definitions/HydraCollectionBaseSchema'], - [ - 'type' => 'object', - 'required' => ['hydra:member'], + // Collection schema: hydra:member contains flattened item schemas + self::assertArraySubset( + subset: [ 'properties' => [ 'hydra:member' => [ 'type' => 'array', 'items' => [ - '$ref' => '#/definitions/McpBook.jsonld', + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'isbn' => ['type' => 'string'], + 'status' => ['type' => ['string', 'null']], + ], ], ], ], + 'required' => ['hydra:member'], ], - ], $outputSchema); + array: $outputSchema, + ); $listBooksDto = array_filter($tools, static function (array $input) { return 'list_books_dto' === $input['name']; @@ -491,28 +488,29 @@ public function testToolsList(): void $listBooksDto = array_first($listBooksDto); - self::assertArrayHasKeyAndValue('inputSchema', [ - 'type' => 'object', - 'properties' => [ - 'search' => ['type' => 'string'], + self::assertArraySubset( + subset: [ + 'description' => 'List Books and return a DTO', ], - ], $listBooksDto); - self::assertArrayHasKeyAndValue('description', 'List Books and return a DTO', $listBooksDto); + array: $listBooksDto, + ); + // DTO output schema is also flattened $outputSchema = $listBooksDto['outputSchema']; - self::assertArrayHasKeyAndValue('$schema', 'http://json-schema.org/draft-07/schema#', $outputSchema); - self::assertArrayNotHasKey('type', $outputSchema); - - self::assertArrayHasKey('definitions', $outputSchema); - $definitions = $outputSchema['definitions']; - self::assertArrayHasKeyAndValue('McpBookOutputDto.jsonld', [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string'], - 'isbn' => ['type' => 'string'], + self::assertArrayNotHasKey('$schema', $outputSchema); + self::assertArrayNotHasKey('definitions', $outputSchema); + + self::assertArraySubset( + subset: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'isbn' => ['type' => 'string'], + ], ], - ], $definitions); + array: $outputSchema, + ); } public function testMcpToolAttribute(): void @@ -796,25 +794,24 @@ public function testMcpListBooks(): void self::assertStringContainsString('API Platform Guide for MCP', $content); self::assertStringContainsString('1-528491', $content); - $structuredContent = $result['structuredContent'] ?? null; - $this->assertIsArray($structuredContent); - - // when api_platform.use_symfony_listeners is true, the result is formatted as JSON-LD - if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) { - self::assertArrayHasKeyAndValue('@context', '/contexts/McpBook', $structuredContent); - self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent); - $members = $structuredContent['hydra:member']; - } else { - $members = $structuredContent; - } - - $this->assertCount(1, $members, json_encode($members, \JSON_PRETTY_PRINT)); - $actualBook = array_first($members); - - self::assertArrayHasKeyAndValue('id', 1, $actualBook); - self::assertArrayHasKeyAndValue('title', 'API Platform Guide for MCP', $actualBook); - self::assertArrayHasKeyAndValue('isbn', '1-528491', $actualBook); - self::assertArrayHasKeyAndValue('status', 'available', $actualBook); + // MCP Handler overrides Accept to match the operation's output format (jsonld by default), + // so the response is always formatted as JSON-LD regardless of use_symfony_listeners. + self::assertJsonContains([ + 'result' => [ + 'content' => [ + ], + 'structuredContent' => [ + '@context' => '/contexts/McpBook', + 'hydra:totalItems' => 1, + 'hydra:member' => [[ + 'id' => 1, + 'title' => 'API Platform Guide for MCP', + 'isbn' => '1-528491', + 'status' => 'available', + ]], + ], + ], + ]); } public function testMcpListBooksDto(): void @@ -877,28 +874,29 @@ public function testMcpListBooksDto(): void $structuredContent = $result['structuredContent'] ?? null; $this->assertIsArray($structuredContent); - // when api_platform.use_symfony_listeners is true, the result is formatted as JSON-LD - if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) { - self::assertArrayHasKeyAndValue('@context', [ - '@vocab' => 'http://localhost/docs.jsonld#', - 'hydra' => 'http://www.w3.org/ns/hydra/core#', - 'id' => 'McpBookOutputDto/id', - 'name' => 'McpBookOutputDto/name', - 'isbn' => 'McpBookOutputDto/isbn', - ], $structuredContent); - self::assertArrayHasKey('@id', $structuredContent); - self::assertArrayHasKeyAndValue('@type', 'McpBookOutputDto', $structuredContent); - } - - self::assertArrayHasKeyAndValue('id', 1, $structuredContent); - self::assertArrayHasKeyAndValue('name', 'API Platform Guide for MCP', $structuredContent); - self::assertArrayHasKeyAndValue('isbn', '1-528491', $structuredContent); - self::assertArrayNotHasKey('status', $structuredContent); - } + // MCP Handler overrides Accept to match the operation's output format (jsonld by default), + // so the response is always formatted as JSON-LD. + self::assertArraySubset( + subset: [ + '@context' => [ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'id' => 'McpBookOutputDto/id', + 'name' => 'McpBookOutputDto/name', + 'isbn' => 'McpBookOutputDto/isbn', + ], + '@type' => 'McpBookOutputDto', + 'id' => '1', + 'name' => 'API Platform Guide for MCP', + 'isbn' => '1-528491', + ], + array: $structuredContent, + ); - private static function assertArrayHasKeyAndValue(string $key, mixed $value, array $data): void - { - self::assertArrayHasKey($key, $data, json_encode($data, \JSON_PRETTY_PRINT)); - self::assertSame($value, $data[$key]); + // check only the beginning of the string, the end is random + self::assertArrayHasKey('@id', $structuredContent); + self::assertStringStartsWith('/.well-known/genid/', $structuredContent['@id']); + + self::assertArrayNotHasKey('status', $structuredContent); } } diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index def95beb140..056ab68d16a 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -231,6 +231,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'tags' => [], 'error_resource_class' => null, 'validation_error_resource_class' => null, + 'scalar_extra_configuration' => [], ], 'maker' => [ 'enabled' => true, @@ -246,10 +247,12 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enable_phpdoc_parser' => true, 'mcp' => [ 'enabled' => true, + 'format' => 'jsonld', ], 'jsonapi' => [ 'use_iri_as_id' => true, ], + 'enable_scalar' => true, ], $config); }