Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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', []),
);
});

Expand All @@ -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) {
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion src/Laravel/Controller/DocumentationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
}

Expand Down
7 changes: 6 additions & 1 deletion src/Laravel/State/SwaggerUiProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class SwaggerUiProcessor implements ProcessorInterface

/**
* @param array<string, string[]> $formats
* @param array<string, mixed> $scalarExtraConfiguration
*/
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
Expand All @@ -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 = [],
) {
}

Expand Down Expand Up @@ -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);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/Laravel/State/SwaggerUiProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/Laravel/config/api-platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@
AuthorizationException::class => 403,
],

'scalar' => [
'enabled' => true,
'extra_configuration' => [],
],

'swagger_ui' => [
'enabled' => true,
// 'apiKeys' => [
Expand Down
11 changes: 8 additions & 3 deletions src/Laravel/resources/views/swagger-ui.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,13 @@
@endif

<div id="swagger-ui" class="api-platform"></div>
<script src="/vendor/api-platform/swagger-ui/swagger-ui-bundle.js"></script>
<script src="/vendor/api-platform/swagger-ui/swagger-ui-standalone-preset.js"></script>
<script src="/vendor/api-platform/init-swagger-ui.js"></script>
@if (($scalar_enabled ?? false) && request()->query('ui') === 'scalar')
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
<script src="/vendor/api-platform/init-scalar-ui.js"></script>
@else
<script src="/vendor/api-platform/swagger-ui/swagger-ui-bundle.js"></script>
<script src="/vendor/api-platform/swagger-ui/swagger-ui-standalone-preset.js"></script>
<script src="/vendor/api-platform/init-swagger-ui.js"></script>
@endif
</body>
</html>
3 changes: 3 additions & 0 deletions src/Mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/composer.lock
/vendor
/.phpunit.cache
15 changes: 9 additions & 6 deletions src/Mcp/Capability/Registry/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
144 changes: 144 additions & 0 deletions src/Mcp/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Mcp\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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -55,6 +51,6 @@ public function create(string $operationName, array $context = []): HttpOperatio
}
}

throw new RuntimeException(\sprintf('MCP operation "%s" not found.', $operationName));
return null;
}
}
Loading
Loading