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..9c31073bb35 --- /dev/null +++ b/src/Mcp/JsonSchema/SchemaFactory.php @@ -0,0 +1,152 @@ + + * + * 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 and allOf into a flat schema node. + */ + public static function resolveNode(array|\ArrayObject $node, array $definitions): array + { + if ($node instanceof \ArrayObject) { + $node = $node->getArrayCopy(); + } + + if (isset($node['$ref'])) { + $refKey = str_replace('#/definitions/', '', $node['$ref']); + if (isset($definitions[$refKey])) { + return self::resolveNode($definitions[$refKey], $definitions); + } + + return ['type' => 'object']; + } + + if (isset($node['allOf'])) { + $merged = ['type' => 'object', 'properties' => [], 'required' => []]; + foreach ($node['allOf'] as $entry) { + $resolved = self::resolveNode($entry, $definitions); + if (isset($resolved['properties'])) { + $merged['properties'] = array_merge($merged['properties'], $resolved['properties']); + } + if (isset($resolved['required'])) { + $merged['required'] = array_merge($merged['required'], $resolved['required']); + } + } + + if ([] === $merged['required']) { + unset($merged['required']); + } + if ([] === $merged['properties']) { + unset($merged['properties']); + } + if (isset($node['description'])) { + $merged['description'] = $node['description']; + } + + return self::resolveProperties($merged, $definitions); + } + + if (!isset($node['type'])) { + $node['type'] = 'object'; + } + + return self::resolveProperties($node, $definitions); + } + + /** + * Recursively resolve $ref inside property schemas and array items. + */ + private static function resolveProperties(array $node, array $definitions): array + { + if (isset($node['properties']) && \is_array($node['properties'])) { + foreach ($node['properties'] as $propName => $propSchema) { + $node['properties'][$propName] = self::resolvePropertyNode($propSchema, $definitions); + } + } + + return $node; + } + + private static function resolvePropertyNode(array|\ArrayObject $node, array $definitions): array + { + if ($node instanceof \ArrayObject) { + $node = $node->getArrayCopy(); + } + + if (isset($node['$ref'])) { + $refKey = str_replace('#/definitions/', '', $node['$ref']); + if (isset($definitions[$refKey])) { + return self::resolveNode($definitions[$refKey], $definitions); + } + + return ['type' => 'object']; + } + + if (isset($node['items'])) { + $node['items'] = self::resolvePropertyNode($node['items'], $definitions); + } + + if (isset($node['properties']) && \is_array($node['properties'])) { + foreach ($node['properties'] as $propName => $propSchema) { + $node['properties'][$propName] = self::resolvePropertyNode($propSchema, $definitions); + } + } + + 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..c8111cbd32a --- /dev/null +++ b/src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php @@ -0,0 +1,199 @@ + + * + * 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 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/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index a2e1d698cf4..da1a6515303 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -206,6 +206,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'); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 45fd8bebd0c..6868d4d786c 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -717,6 +717,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/tests/Functional/McpTest.php b/tests/Functional/McpTest.php index 46ed75109ae..b08458c0047 100644 --- a/tests/Functional/McpTest.php +++ b/tests/Functional/McpTest.php @@ -444,44 +444,31 @@ public function testToolsList(): void ], $listBooks); self::assertArrayHasKeyAndValue('description', 'List Books', $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::assertArrayNotHasKey('$schema', $outputSchema); + self::assertArrayNotHasKey('definitions', $outputSchema); + self::assertArrayNotHasKey('allOf', $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::assertArrayHasKeyAndValue('allOf', [ - ['$ref' => '#/definitions/HydraCollectionBaseSchema'], - [ - 'type' => 'object', - 'required' => ['hydra:member'], - 'properties' => [ - 'hydra:member' => [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/definitions/McpBook.jsonld', - ], - ], - ], - ], - ], $outputSchema); + // Collection schema: hydra:member contains flattened item schemas + self::assertArrayHasKey('properties', $outputSchema); + self::assertArrayHasKey('hydra:member', $outputSchema['properties']); + $hydraMember = $outputSchema['properties']['hydra:member']; + self::assertArrayHasKeyAndValue('type', 'array', $hydraMember); + + // Items are inlined (no $ref) + self::assertArrayHasKey('items', $hydraMember); + self::assertArrayNotHasKey('$ref', $hydraMember['items']); + self::assertArrayHasKeyAndValue('type', 'object', $hydraMember['items']); + self::assertArrayHasKey('properties', $hydraMember['items']); + $itemProps = $hydraMember['items']['properties']; + self::assertArrayHasKey('id', $itemProps); + self::assertArrayHasKey('title', $itemProps); + self::assertArrayHasKey('isbn', $itemProps); + self::assertArrayHasKey('status', $itemProps); + + self::assertSame(['hydra:member'], $outputSchema['required']); $listBooksDto = array_filter($tools, static function (array $input) { return 'list_books_dto' === $input['name']; @@ -499,20 +486,15 @@ public function testToolsList(): void ], $listBooksDto); self::assertArrayHasKeyAndValue('description', 'List Books and return a DTO', $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'], - ], - ], $definitions); + self::assertArrayNotHasKey('$schema', $outputSchema); + self::assertArrayNotHasKey('definitions', $outputSchema); + self::assertArrayHasKeyAndValue('type', 'object', $outputSchema); + self::assertArrayHasKey('properties', $outputSchema); + self::assertArrayHasKey('id', $outputSchema['properties']); + self::assertArrayHasKey('name', $outputSchema['properties']); + self::assertArrayHasKey('isbn', $outputSchema['properties']); } public function testMcpToolAttribute(): void @@ -799,14 +781,11 @@ public function testMcpListBooks(): 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', '/contexts/McpBook', $structuredContent); - self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent); - $members = $structuredContent['hydra:member']; - } else { - $members = $structuredContent; - } + // 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::assertArrayHasKeyAndValue('@context', '/contexts/McpBook', $structuredContent); + self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent); + $members = $structuredContent['hydra:member']; $this->assertCount(1, $members, json_encode($members, \JSON_PRETTY_PRINT)); $actualBook = array_first($members); @@ -877,18 +856,17 @@ 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); - } + // MCP Handler overrides Accept to match the operation's output format (jsonld by default), + // so the response is always formatted as JSON-LD. + 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);