Skip to content
Merged
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
63 changes: 30 additions & 33 deletions src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ public function load(array $configs, ContainerBuilder $container): void
$patchFormats['jsonapi'] = ['application/vnd.api+json'];
}

// McpToolProvider requires Symfony's object_mapper service; mirror FrameworkBundle's gate so we don't try to wire it when object-mapper is dev-only.
$mcpEnabled = ($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class) && ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle']);
// kernel listeners never run for a JSON-RPC tool call, so MCP needs its own provider chain
$mcpProviderChain = $mcpEnabled && $config['use_symfony_listeners'];

$this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats, $docsFormats);
$this->registerMetadataConfiguration($container, $config, $loader);
$this->registerOAuthConfiguration($container, $config);
Expand All @@ -193,12 +198,12 @@ public function load(array $configs, ContainerBuilder $container): void
$this->registerDoctrineOrmConfiguration($container, $config, $loader);
$this->registerDoctrineMongoDbOdmConfiguration($container, $config, $loader);
$this->registerHttpCacheConfiguration($container, $config, $loader);
$this->registerValidatorConfiguration($container, $config, $loader);
$this->registerValidatorConfiguration($container, $config, $loader, $mcpProviderChain);
$this->registerDataCollectorConfiguration($container, $config, $loader);
$this->registerMercureConfiguration($container, $config, $loader);
$this->registerMessengerConfiguration($container, $config, $loader);
$this->registerElasticsearchConfiguration($container, $config, $loader);
$this->registerSecurityConfiguration($container, $config, $loader);
$this->registerSecurityConfiguration($container, $config, $loader, $mcpProviderChain);
$this->registerMakerConfiguration($container, $config, $loader);
$this->registerArgumentResolverConfiguration($loader);
$this->registerLinkSecurityConfiguration($loader, $config);
Expand All @@ -214,34 +219,9 @@ public function load(array $configs, ContainerBuilder $container): void

$container->setParameter('api_platform.mcp.format', $config['mcp']['format'] ?? null);

// McpToolProvider requires Symfony's object_mapper service; mirror FrameworkBundle's gate so we don't try to wire it when object-mapper is dev-only.
if (($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class) && ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle'])) {
if ($mcpEnabled) {
$loader->load('mcp/mcp.php');

if ($config['use_symfony_listeners']) {
// In this mode the state pipeline is driven by kernel listeners, which never run for
// a JSON-RPC tool call, so MCP needs its own provider chain to keep enforcing
// security, parameters and validation.
$loader->load('mcp/events.php');

/** @var string[] $bundles */
$bundles = $container->getParameter('kernel.bundles');
$hasValidator = interface_exists(ValidatorInterface::class);

if ($hasValidator) {
$loader->load('mcp/validator.php');
}

if (isset($bundles['SecurityBundle'])) {
$loader->load('mcp/security.php');

if ($hasValidator) {
$loader->load('mcp/security_validator.php');
}
}
} else {
$loader->load('mcp/state.php');
}
$loader->load($mcpProviderChain ? 'mcp/events.php' : 'mcp/state.php');
}

$container->registerForAutoconfiguration(FilterInterface::class)
Expand Down Expand Up @@ -956,9 +936,9 @@ private function getFormats(array $configFormats): array
return $formats;
}

private function registerValidatorConfiguration(ContainerBuilder $container, array $config, PhpFileLoader $loader): void
private function registerValidatorConfiguration(ContainerBuilder $container, array $config, PhpFileLoader $loader, bool $mcpProviderChain): void
{
if (interface_exists(ValidatorInterface::class)) {
if ($this->isValidatorAvailable()) {
$loader->load('metadata/validator.php');
$loader->load('validator/validator.php');

Expand All @@ -968,6 +948,10 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr

$loader->load($config['use_symfony_listeners'] ? 'validator/events.php' : 'validator/state.php');

if ($mcpProviderChain) {
$loader->load('mcp/validator.php');
}

$container->registerForAutoconfiguration(ValidationGroupsGeneratorInterface::class)
->addTag('api_platform.validation_groups_generator');
$container->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class)
Expand Down Expand Up @@ -1067,7 +1051,7 @@ private function registerElasticsearchConfiguration(ContainerBuilder $container,
$loader->load('elasticsearch.php');
}

private function registerSecurityConfiguration(ContainerBuilder $container, array $config, PhpFileLoader $loader): void
private function registerSecurityConfiguration(ContainerBuilder $container, array $config, PhpFileLoader $loader, bool $mcpProviderChain): void
{
/** @var string[] $bundles */
$bundles = $container->getParameter('kernel.bundles');
Expand All @@ -1080,15 +1064,28 @@ private function registerSecurityConfiguration(ContainerBuilder $container, arra

$loader->load('state/security.php');

if (interface_exists(ValidatorInterface::class)) {
if ($mcpProviderChain) {
$loader->load('mcp/security.php');
}

if ($this->isValidatorAvailable()) {
$loader->load('state/security_validator.php');

if ($mcpProviderChain) {
$loader->load('mcp/security_validator.php');
}
}

if ($this->isConfigEnabled($container, $config['graphql'])) {
$loader->load('graphql/security.php');
}
}

private function isValidatorAvailable(): bool
{
return interface_exists(ValidatorInterface::class);
}

private function registerOpenApiConfiguration(ContainerBuilder $container, array $config, PhpFileLoader $loader): void
{
$container->setParameter('api_platform.openapi.termsOfService', $config['openapi']['termsOfService']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,21 @@ class ApiPlatformExtensionTest extends TestCase
private ContainerBuilder $container;

protected function setUp(): void
{
$this->container = $this->createContainer([
'DoctrineBundle' => DoctrineBundle::class,
'SecurityBundle' => SecurityBundle::class,
'TwigBundle' => TwigBundle::class,
]);
}

/**
* @param array<string, class-string> $bundles
*/
private function createContainer(array $bundles): ContainerBuilder
{
$containerParameterBag = new ParameterBag([
'kernel.bundles' => [
'DoctrineBundle' => DoctrineBundle::class,
'SecurityBundle' => SecurityBundle::class,
'TwigBundle' => TwigBundle::class,
],
'kernel.bundles' => $bundles,
'kernel.bundles_metadata' => [
'TestBundle' => [
'parent' => null,
Expand All @@ -137,7 +145,7 @@ protected function setUp(): void
'kernel.environment' => 'test',
]);

$this->container = new ContainerBuilder($containerParameterBag);
return new ContainerBuilder($containerParameterBag);
}

private function assertContainerHas(array $services, array $aliases = []): void
Expand Down Expand Up @@ -370,6 +378,55 @@ public function testEventListenersConfiguration(): void
$this->container->hasParameter('api_platform.swagger.http_auth');
}

public function testMcpProviderChainIsSecuredAndValidatedWithSecurityBundle(): void
{
$config = self::DEFAULT_CONFIG;
$config['api_platform']['use_symfony_listeners'] = true;
(new ApiPlatformExtension())->load($config, $this->container);

$this->assertContainerHasService('api_platform.mcp.handler');

foreach ([
'api_platform.mcp.state_provider.access_checker',
'api_platform.mcp.state_provider.access_checker.pre_read',
'api_platform.mcp.state_provider.access_checker.post_deserialize',
'api_platform.mcp.state_provider.access_checker.post_validate',
'api_platform.mcp.state_provider.security_parameter',
'api_platform.mcp.state_provider.validate',
'api_platform.mcp.state_provider.parameter_validator',
] as $service) {
$this->assertContainerHasService($service);
}
}

public function testMcpProviderChainIsNotSecuredWithoutSecurityBundle(): void
{
$this->container = $this->createContainer([
'DoctrineBundle' => DoctrineBundle::class,
'TwigBundle' => TwigBundle::class,
]);

$config = self::DEFAULT_CONFIG;
$config['api_platform']['use_symfony_listeners'] = true;
(new ApiPlatformExtension())->load($config, $this->container);

$this->assertContainerHasService('api_platform.mcp.handler');
$this->assertNotContainerHasService('api_platform.state_provider.access_checker');

foreach ([
'api_platform.mcp.state_provider.access_checker',
'api_platform.mcp.state_provider.access_checker.pre_read',
'api_platform.mcp.state_provider.access_checker.post_deserialize',
'api_platform.mcp.state_provider.access_checker.post_validate',
'api_platform.mcp.state_provider.security_parameter',
] as $service) {
$this->assertNotContainerHasService($service);
}

$this->assertContainerHasService('api_platform.mcp.state_provider.validate');
$this->assertContainerHasService('api_platform.mcp.state_provider.parameter_validator');
}

public function testItRegistersMetadataConfiguration(): void
{
$config = self::DEFAULT_CONFIG;
Expand Down
Loading