diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 889f7703b49..f3b4f0f33a4 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -85,6 +85,7 @@ use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory; use ApiPlatform\Laravel\Eloquent\Metadata\IdentifiersExtractor as EloquentIdentifiersExtractor; +use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint; use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver; use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor; @@ -241,10 +242,38 @@ public function register(): void ); }); - $this->app->singleton(ModelMetadata::class, static function () { + // Serve the Eloquent model metadata from a dumped file so the app can boot without a live + // database. Skipped when APP_DEBUG is true so local development always introspects fresh. + // The dump holds only attribute/relation arrays (plain scalars and class-strings), so it is + // read back with allowed_classes disabled. The dump command gets a live, unseeded instance + // (contextual binding below) so it never reads back its own dump. + $this->app->singleton(ModelMetadata::class, static function (Application $app) { + /** @var ConfigRepository $config */ + $config = $app['config']; + $dumpPath = $config->get('api-platform.metadata_dump'); + + if (true !== $config->get('app.debug') && \is_string($dumpPath) && is_file($dumpPath)) { + $contents = file_get_contents($dumpPath); + if (false !== $contents) { + $data = @unserialize($contents, ['allowed_classes' => false]); + if (\is_array($data) && \is_array($data['attributes'] ?? null) && \is_array($data['relations'] ?? null)) { + $fingerprint = MetadataDumpFingerprint::fromMigrations($app->databasePath('migrations')); + if (($data['fingerprint'] ?? null) !== $fingerprint) { + $app['log']->warning('The API Platform metadata dump is stale: migrations have changed since it was generated. Re-run "php artisan api-platform:metadata:dump".'); + } + + return new ModelMetadata(attributes: $data['attributes'], relations: $data['relations']); + } + } + } + return new ModelMetadata(); }); + $this->app->when(Console\DumpMetadataCommand::class) + ->needs(ModelMetadata::class) + ->give(static fn () => new ModelMetadata()); + $this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class); $this->app->singleton(ClassMetadataFactory::class, static function (Application $app) { /** @var ConfigRepository */ @@ -1110,6 +1139,7 @@ public function register(): void if ($this->app->runningInConsole()) { $this->commands([ Console\InstallCommand::class, + Console\DumpMetadataCommand::class, Console\Maker\MakeStateProcessorCommand::class, Console\Maker\MakeStateProviderCommand::class, Console\Maker\MakeFilterCommand::class, diff --git a/src/Laravel/Console/DumpMetadataCommand.php b/src/Laravel/Console/DumpMetadataCommand.php new file mode 100644 index 00000000000..e8ba05aafdd --- /dev/null +++ b/src/Laravel/Console/DumpMetadataCommand.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Console; + +use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Console\Attribute\AsCommand; + +#[AsCommand(name: 'api-platform:metadata:dump')] +final class DumpMetadataCommand extends Command +{ + /** + * @var string + */ + protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}'; + + /** + * @var string + */ + protected $description = 'Dump the Eloquent model metadata to a file so the app can boot without hitting the database'; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ModelMetadata $modelMetadata, + ) { + parent::__construct(); + } + + public function handle(): int + { + $path = $this->option('path'); + if (null === $path || '' === $path) { + $path = config('api-platform.metadata_dump'); + } + + if (!\is_string($path) || '' === $path) { + $this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.'); + + return self::FAILURE; + } + + // This command is bound to a live ModelMetadata (contextual binding) so introspection reads + // the database rather than a previously dumped, possibly stale, cache. + $attributes = []; + $relations = []; + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + try { + $model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor(); + } catch (\ReflectionException) { + continue; + } + + if (!$model instanceof Model) { + continue; + } + + $attributes[$resourceClass] = $this->modelMetadata->getAttributes($model); + $relations[$resourceClass] = $this->modelMetadata->getRelations($model); + } + + $directory = \dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) { + $this->error(\sprintf('Unable to create directory "%s".', $directory)); + + return self::FAILURE; + } + + // Write to a temporary file then rename so a concurrent boot never reads a half-written dump. + $payload = serialize([ + 'fingerprint' => MetadataDumpFingerprint::fromMigrations($this->laravel->databasePath('migrations')), + 'attributes' => $attributes, + 'relations' => $relations, + ]); + $temporaryPath = $path.'.'.getmypid().'.tmp'; + if (false === file_put_contents($temporaryPath, $payload) || !rename($temporaryPath, $path)) { + @unlink($temporaryPath); + $this->error(\sprintf('Unable to write the metadata dump to "%s".', $path)); + + return self::FAILURE; + } + + $this->info(\sprintf('Dumped metadata for %d model(s) to "%s".', \count($attributes), $path)); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.php b/src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.php new file mode 100644 index 00000000000..2be611c2ad4 --- /dev/null +++ b/src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.php @@ -0,0 +1,39 @@ + + * + * 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\Laravel\Eloquent\Metadata; + +/** + * Fingerprints the migration files so a stale metadata dump (migrations changed since it was + * generated) can be detected at boot without reading the database, which would defeat the dump. + * + * It hashes file names, modification times and sizes only: a manual schema change made outside the + * migration files is not detected. + * + * @internal + */ +final class MetadataDumpFingerprint +{ + public static function fromMigrations(string $migrationsPath): string + { + $files = glob($migrationsPath.'/*.php') ?: []; + sort($files); + + $hash = hash_init('xxh128'); + foreach ($files as $file) { + hash_update($hash, $file.'|'.filemtime($file).'|'.filesize($file)); + } + + return hash_final($hash); + } +} diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index a3d23d4f5b7..ae89498df60 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -27,16 +27,6 @@ */ final class ModelMetadata { - /** - * @var array> - */ - private $attributesLocalCache = []; - - /** - * @var array> - */ - private $relationsLocalCache = []; - /** * The methods that can be called in a model to indicate a relation. * @@ -56,8 +46,15 @@ final class ModelMetadata 'morphedByMany', ]; - public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter()) - { + /** + * @param array> $attributes seeds the attribute cache, e.g. from a dump produced by api-platform:metadata:dump, so the app can boot without a database + * @param array> $relations seeds the relation cache for the same reason + */ + public function __construct( + private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter(), + private array $attributes = [], + private array $relations = [], + ) { } /** @@ -67,8 +64,8 @@ public function __construct(private NameConverterInterface $relationNameConverte */ public function getAttributes(Model $model): array { - if (isset($this->attributesLocalCache[$model::class])) { - return $this->attributesLocalCache[$model::class]; + if (isset($this->attributes[$model::class])) { + return $this->attributes[$model::class]; } $connection = $model->getConnection(); @@ -112,7 +109,7 @@ public function getAttributes(Model $model): array return $result; } - return $this->attributesLocalCache[$model::class] = $result; + return $this->attributes[$model::class] = $result; } /** @@ -194,8 +191,8 @@ private function getVirtualAttributes(Model $model, array $columns): array */ public function getRelations(Model $model): array { - if (isset($this->relationsLocalCache[$model::class])) { - return $this->relationsLocalCache[$model::class]; + if (isset($this->relations[$model::class])) { + return $this->relations[$model::class]; } $relations = []; @@ -254,7 +251,7 @@ public function getRelations(Model $model): array ]; } - return $this->relationsLocalCache[$model::class] = $relations; + return $this->relations[$model::class] = $relations; } /** diff --git a/src/Laravel/Tests/Console/DumpMetadataCommandTest.php b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php new file mode 100644 index 00000000000..1506bc75c39 --- /dev/null +++ b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php @@ -0,0 +1,163 @@ + + * + * 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\Laravel\Tests\Console; + +use ApiPlatform\Laravel\Console\DumpMetadataCommand; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Illuminate\Console\Command; +use Illuminate\Testing\PendingCommand; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Author; +use Workbench\App\Models\Book; + +class DumpMetadataCommandTest extends TestCase +{ + use WithWorkbench; + + private string $dumpPath; + + protected function setUp(): void + { + parent::setUp(); + + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_dump_cmd_').'.meta'; + @unlink($this->dumpPath); + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + + parent::tearDown(); + } + + public function testItDumpsTheSeededModelMetadataToTheGivenFile(): void + { + $bookAttributes = ['name' => ['name' => 'name', 'type' => 'string']]; + $bookRelations = ['author' => ['name' => 'author', 'related' => Author::class]]; + $authorAttributes = ['id' => ['name' => 'id', 'type' => 'integer']]; + $authorRelations = []; + + $this->seedCommandModelMetadata( + attributes: [Book::class => $bookAttributes, Author::class => $authorAttributes], + relations: [Book::class => $bookRelations, Author::class => $authorRelations], + ); + $this->stubResourceClasses([Book::class, Author::class]); + + $this->runDump() + ->expectsOutputToContain('Dumped metadata for 2 model(s)') + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($this->dumpPath); + + $dumped = $this->readDump(); + + $this->assertSame(['fingerprint', 'attributes', 'relations'], array_keys($dumped)); + $this->assertIsString($dumped['fingerprint']); + $this->assertSame($bookAttributes, $dumped['attributes'][Book::class]); + $this->assertSame($authorAttributes, $dumped['attributes'][Author::class]); + $this->assertSame($bookRelations, $dumped['relations'][Book::class]); + $this->assertSame($authorRelations, $dumped['relations'][Author::class]); + } + + public function testItSkipsResourceClassesThatAreNotEloquentModels(): void + { + $bookAttributes = ['name' => ['name' => 'name', 'type' => 'string']]; + + $this->seedCommandModelMetadata( + attributes: [Book::class => $bookAttributes], + relations: [Book::class => []], + ); + $this->stubResourceClasses([Book::class, 'App\\Resource\\NotAModel', \DateTimeImmutable::class]); + + $this->runDump() + ->expectsOutputToContain('Dumped metadata for 1 model(s)') + ->assertExitCode(Command::SUCCESS); + + $dumped = $this->readDump(); + + $this->assertSame([Book::class], array_keys($dumped['attributes'])); + $this->assertSame([Book::class], array_keys($dumped['relations'])); + } + + public function testItFailsWhenNoPathIsResolvable(): void + { + $this->app['config']->set('api-platform.metadata_dump', null); + $this->stubResourceClasses([]); + + $command = $this->artisan('api-platform:metadata:dump'); + if (!$command instanceof PendingCommand) { + $this->fail('artisan() did not return a PendingCommand.'); + } + + $command + ->expectsOutputToContain('No dump path configured') + ->assertExitCode(Command::FAILURE); + } + + /** + * @param array> $attributes + * @param array> $relations + */ + private function seedCommandModelMetadata(array $attributes, array $relations): void + { + $this->app->when(DumpMetadataCommand::class) + ->needs(ModelMetadata::class) + ->give(static fn (): ModelMetadata => new ModelMetadata(attributes: $attributes, relations: $relations)); + } + + /** + * @param list $classes + */ + private function stubResourceClasses(array $classes): void + { + $nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $nameFactory->method('create')->willReturn(new ResourceNameCollection($classes)); + + $this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory); + } + + private function runDump(): PendingCommand + { + $command = $this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath]); + if (!$command instanceof PendingCommand) { + $this->fail('artisan() did not return a PendingCommand.'); + } + + return $command; + } + + /** + * @return array{fingerprint: string, attributes: array, relations: array} + */ + private function readDump(): array + { + $contents = file_get_contents($this->dumpPath); + if (false === $contents) { + $this->fail(\sprintf('Unable to read the dump file "%s".', $this->dumpPath)); + } + + $dumped = unserialize($contents, ['allowed_classes' => false]); + if (!\is_array($dumped)) { + $this->fail('The dump file did not contain an array.'); + } + + return $dumped; + } +} diff --git a/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php new file mode 100644 index 00000000000..8267d1f92e2 --- /dev/null +++ b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php @@ -0,0 +1,113 @@ + + * + * 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\Laravel\Tests\Metadata; + +use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use Illuminate\Support\Facades\Log; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Book; + +class DumpedMetadataBootTest extends TestCase +{ + use WithWorkbench; + + private const CANNED_ATTRIBUTES = [ + 'name' => ['name' => 'name', 'type' => 'string', 'nullable' => false], + ]; + + private const CANNED_RELATIONS = [ + 'author' => ['name' => 'author', 'method_name' => 'author', 'related' => 'Workbench\\App\\Models\\Author'], + ]; + + private string $dumpPath; + + protected function setUp(): void + { + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_boot_dump_').'.meta'; + + file_put_contents($this->dumpPath, serialize([ + 'attributes' => [Book::class => self::CANNED_ATTRIBUTES], + 'relations' => [Book::class => self::CANNED_RELATIONS], + ])); + + parent::setUp(); + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + + parent::tearDown(); + } + + protected function defineEnvironment($app): void + { + $app['config']->set('app.debug', false); + $app['config']->set('api-platform.metadata_dump', $this->dumpPath); + } + + public function testItServesModelMetadataFromTheDumpWithoutHittingTheDatabase(): void + { + $modelMetadata = $this->app->make(ModelMetadata::class); + + $book = (new \ReflectionClass(Book::class))->newInstanceWithoutConstructor(); + + $this->assertSame(self::CANNED_ATTRIBUTES, $modelMetadata->getAttributes($book)); + $this->assertSame(self::CANNED_RELATIONS, $modelMetadata->getRelations($book)); + } + + public function testItWarnsWhenTheDumpFingerprintIsStale(): void + { + // The canned dump written in setUp() carries no fingerprint, so it never matches the + // current migrations and must be reported as stale. + Log::spy(); + $this->app->forgetInstance(ModelMetadata::class); + + $this->app->make(ModelMetadata::class); + + Log::shouldHaveReceived('warning')->withArgs(static fn (string $message): bool => str_contains($message, 'stale'))->once(); + } + + public function testItDoesNotWarnWhenTheFingerprintMatches(): void + { + file_put_contents($this->dumpPath, serialize([ + 'fingerprint' => MetadataDumpFingerprint::fromMigrations($this->app->databasePath('migrations')), + 'attributes' => [Book::class => self::CANNED_ATTRIBUTES], + 'relations' => [Book::class => self::CANNED_RELATIONS], + ])); + + Log::spy(); + $this->app->forgetInstance(ModelMetadata::class); + + $this->app->make(ModelMetadata::class); + + Log::shouldNotHaveReceived('warning'); + } + + public function testItIsNotSeededWhenDebugIsEnabled(): void + { + $this->app['config']->set('app.debug', true); + $this->app->forgetInstance(ModelMetadata::class); + + $modelMetadata = $this->app->make(ModelMetadata::class); + + $reflection = new \ReflectionProperty(ModelMetadata::class, 'attributes'); + + $this->assertSame([], $reflection->getValue($modelMetadata), 'A debug boot must return an unseeded ModelMetadata that introspects the database.'); + } +} diff --git a/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php b/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php new file mode 100644 index 00000000000..d5083e2b73b --- /dev/null +++ b/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php @@ -0,0 +1,96 @@ + + * + * 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\Laravel\Tests\Unit\Metadata; + +use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint; +use PHPUnit\Framework\TestCase; + +class MetadataDumpFingerprintTest extends TestCase +{ + private string $migrationsPath; + + protected function setUp(): void + { + $this->migrationsPath = sys_get_temp_dir().'/apip_fingerprint_'.getmypid(); + if (!is_dir($this->migrationsPath)) { + mkdir($this->migrationsPath, 0o755, true); + } + + foreach (glob($this->migrationsPath.'/*.php') ?: [] as $file) { + unlink($file); + } + } + + protected function tearDown(): void + { + foreach (glob($this->migrationsPath.'/*.php') ?: [] as $file) { + unlink($file); + } + + if (is_dir($this->migrationsPath)) { + rmdir($this->migrationsPath); + } + } + + public function testItIsStableForUnchangedMigrations(): void + { + $this->writeMigration('2024_01_01_000000_create_books_table.php', 1_700_000_000); + + $first = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + $second = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + + $this->assertSame($first, $second); + } + + public function testItChangesWhenAMigrationIsAdded(): void + { + $this->writeMigration('2024_01_01_000000_create_books_table.php', 1_700_000_000); + $before = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + + $this->writeMigration('2024_02_02_000000_create_authors_table.php', 1_700_000_100); + + $this->assertNotSame($before, MetadataDumpFingerprint::fromMigrations($this->migrationsPath)); + } + + public function testItChangesWhenAMigrationIsTouched(): void + { + $file = $this->writeMigration('2024_01_01_000000_create_books_table.php', 1_700_000_000); + $before = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + + touch($file, 1_700_999_999); + // The first fingerprint call already stat()ed the file, so its mtime is cached for this + // process; clear it so the second call reads the value set by touch(). + clearstatcache(true, $file); + + $this->assertNotSame($before, MetadataDumpFingerprint::fromMigrations($this->migrationsPath)); + } + + public function testItIsEmptyHashForNoMigrations(): void + { + $this->assertNotSame('', MetadataDumpFingerprint::fromMigrations($this->migrationsPath)); + $this->assertSame( + MetadataDumpFingerprint::fromMigrations($this->migrationsPath), + MetadataDumpFingerprint::fromMigrations($this->migrationsPath.'/missing'), + ); + } + + private function writeMigration(string $name, int $mtime): string + { + $path = $this->migrationsPath.'/'.$name; + file_put_contents($path, ' + * + * 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\Laravel\Tests\Unit\Metadata; + +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use PHPUnit\Framework\TestCase; +use Workbench\App\Models\Book; + +class ModelMetadataTest extends TestCase +{ + public function testSeededAttributesAreReturnedWithoutDatabaseIntrospection(): void + { + $attributes = ['name' => ['name' => 'name', 'type' => 'string']]; + + $modelMetadata = new ModelMetadata(attributes: [Book::class => $attributes]); + + $book = (new \ReflectionClass(Book::class))->newInstanceWithoutConstructor(); + + $this->assertSame($attributes, $modelMetadata->getAttributes($book)); + } + + public function testSeededRelationsAreReturnedWithoutDatabaseIntrospection(): void + { + $relations = ['author' => ['name' => 'author', 'method_name' => 'author', 'related' => 'Workbench\\App\\Models\\Author']]; + + $modelMetadata = new ModelMetadata(relations: [Book::class => $relations]); + + $book = (new \ReflectionClass(Book::class))->newInstanceWithoutConstructor(); + + $this->assertSame($relations, $modelMetadata->getRelations($book)); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 38e0de76e9f..15f23e34cc3 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,6 +178,14 @@ // we recommend using "file" or "acpu" 'cache' => 'file', + // Path to an Eloquent model metadata file produced by `php artisan api-platform:metadata:dump`. + // When set (and APP_DEBUG is false), the model attributes and relations are read from this file + // at boot instead of being introspected from the database, allowing the app to boot without a + // live DB (e.g. during `docker build`, `composer install`, or static analysis in CI). Commit the + // file to VCS or bake it into your image, and re-run the command when your models change. Leave + // null to disable. + 'metadata_dump' => null, + // MCP (Model Context Protocol) configuration 'mcp' => [ 'enabled' => true,