Skip to content

Commit 73a57b3

Browse files
fix(jsonschema): don't require @id in single-item MCP output schema
IriConverter returns a null IRI for a non-collection McpTool/McpResource, so the serializer omits @id but the schema still required it — strict MCP clients rejected the structuredContent. Kept required for McpToolCollection members, which do get an IRI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7bc11b2 commit 73a57b3

2 files changed

Lines changed: 38 additions & 4 deletions

File tree

src/Hydra/JsonSchema/SchemaFactory.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
2323
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
2424
use ApiPlatform\JsonSchema\SchemaUriPrefixTrait;
25+
use ApiPlatform\Metadata\CollectionOperationInterface;
26+
use ApiPlatform\Metadata\McpResource;
27+
use ApiPlatform\Metadata\McpTool;
2528
use ApiPlatform\Metadata\Operation;
2629
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2730

@@ -133,7 +136,7 @@ public function buildSchema(string $className, string $format = 'jsonld', string
133136

134137
if (!$collectionKey) {
135138
$definitionName = $schema->getRootDefinitionKey() ?? $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext);
136-
$this->decorateItemDefinition($definitionName, $definitions, $prefix, $type, $serializerContext);
139+
$this->decorateItemDefinition($definitionName, $definitions, $prefix, $type, $serializerContext, $operation);
137140

138141
if (isset($definitions[$definitionName])) {
139142
$currentDefinitions = $schema->getDefinitions();
@@ -254,7 +257,7 @@ public function buildSchema(string $className, string $format = 'jsonld', string
254257
unset($schema['items']);
255258

256259
if (isset($definitions[$collectionKey])) {
257-
$this->decorateItemDefinition($collectionKey, $definitions, $prefix, $type, $serializerContext);
260+
$this->decorateItemDefinition($collectionKey, $definitions, $prefix, $type, $serializerContext, $operation);
258261
}
259262

260263
return $schema;
@@ -267,13 +270,16 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
267270
}
268271
}
269272

270-
private function decorateItemDefinition(string $definitionName, \ArrayObject $definitions, string $prefix, string $type, ?array $serializerContext): void
273+
private function decorateItemDefinition(string $definitionName, \ArrayObject $definitions, string $prefix, string $type, ?array $serializerContext, ?Operation $operation = null): void
271274
{
272275
if (!isset($definitions[$definitionName])) {
273276
return;
274277
}
275278

276-
$hasNoId = Schema::TYPE_OUTPUT === $type && false === ($serializerContext['gen_id'] ?? true);
279+
// A single-item MCP operation has no routed item URI: the serializer omits `@id`, so don't require it.
280+
$isMcpItem = ($operation instanceof McpTool || $operation instanceof McpResource) && !$operation instanceof CollectionOperationInterface;
281+
282+
$hasNoId = Schema::TYPE_OUTPUT === $type && (false === ($serializerContext['gen_id'] ?? true) || $isMcpItem);
277283
$baseName = self::ITEM_BASE_SCHEMA_NAME;
278284
if ($hasNoId) {
279285
$baseName = self::ITEM_WITHOUT_ID_BASE_SCHEMA_NAME;

src/Hydra/Tests/JsonSchema/SchemaFactoryTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
use ApiPlatform\Metadata\ApiResource;
2424
use ApiPlatform\Metadata\Get;
2525
use ApiPlatform\Metadata\GetCollection;
26+
use ApiPlatform\Metadata\McpTool;
27+
use ApiPlatform\Metadata\McpToolCollection;
2628
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2729
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2830
use ApiPlatform\Metadata\Property\PropertyNameCollection;
@@ -161,6 +163,32 @@ public function testSchemaTypeBuildSchema(): void
161163
$this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']);
162164
}
163165

166+
// Single-item McpTool: serializer omits `@id`, so the output schema must not require it.
167+
public function testSingleItemMcpToolOutputSchemaDoesNotRequireId(): void
168+
{
169+
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new McpTool(class: Dummy::class));
170+
171+
$definitions = $resultSchema->getDefinitions();
172+
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
173+
174+
$this->assertSame(['$ref' => '#/definitions/HydraItemBaseSchemaWithoutId'], $definitions[$rootDefinitionKey]['allOf'][0]);
175+
$this->assertArrayHasKey('HydraItemBaseSchemaWithoutId', $definitions);
176+
$this->assertSame(['@type'], $definitions['HydraItemBaseSchemaWithoutId']['required']);
177+
$this->assertArrayNotHasKey('HydraItemBaseSchema', $definitions);
178+
}
179+
180+
// McpToolCollection: members get `@id` (member normalization sets item_uri_template), so it stays required.
181+
public function testMcpToolCollectionOutputSchemaStillRequiresIdOnMembers(): void
182+
{
183+
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new McpToolCollection(class: Dummy::class));
184+
185+
$definitions = $resultSchema->getDefinitions();
186+
187+
$this->assertSame(['$ref' => '#/definitions/HydraItemBaseSchema'], $definitions['Dummy.jsonld']['allOf'][0]);
188+
$this->assertSame(['@id', '@type'], $definitions['HydraItemBaseSchema']['required']);
189+
$this->assertArrayNotHasKey('HydraItemBaseSchemaWithoutId', $definitions);
190+
}
191+
164192
public function testSchemaTypeBuildSchemaWithoutPrefix(): void
165193
{
166194
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new GetCollection(), null, [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false]);

0 commit comments

Comments
 (0)