Skip to content

Commit a5761cc

Browse files
alexisLefebvreclaudesoyuka
authored
fix(jsonschema): don't require @id in single-item MCP output schema (#8343)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: soyuka <soyuka@users.noreply.github.com>
1 parent a1abcbc commit a5761cc

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/Hydra/Tests/JsonSchema/SchemaFactoryTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,33 @@ public function testSchemaTypeBuildSchema(): void
161161
$this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']);
162162
}
163163

164+
// gen_id=false output schema must not require `@id` (e.g. an operation whose serializer omits the IRI).
165+
public function testGenIdFalseOutputSchemaDoesNotRequireId(): void
166+
{
167+
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new Get(), null, ['gen_id' => false]);
168+
169+
$definitions = $resultSchema->getDefinitions();
170+
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
171+
172+
$this->assertSame(['$ref' => '#/definitions/HydraItemBaseSchemaWithoutId'], $definitions[$rootDefinitionKey]['allOf'][0]);
173+
$this->assertArrayHasKey('HydraItemBaseSchemaWithoutId', $definitions);
174+
$this->assertSame(['@type'], $definitions['HydraItemBaseSchemaWithoutId']['required']);
175+
$this->assertArrayNotHasKey('HydraItemBaseSchema', $definitions);
176+
}
177+
178+
// Default (gen_id left to its true default): the output schema keeps `@id` required.
179+
public function testOutputSchemaRequiresIdByDefault(): void
180+
{
181+
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new Get());
182+
183+
$definitions = $resultSchema->getDefinitions();
184+
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
185+
186+
$this->assertSame(['$ref' => '#/definitions/HydraItemBaseSchema'], $definitions[$rootDefinitionKey]['allOf'][0]);
187+
$this->assertSame(['@id', '@type'], $definitions['HydraItemBaseSchema']['required']);
188+
$this->assertArrayNotHasKey('HydraItemBaseSchemaWithoutId', $definitions);
189+
}
190+
164191
public function testSchemaTypeBuildSchemaWithoutPrefix(): void
165192
{
166193
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new GetCollection(), null, [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false]);

src/Mcp/JsonSchema/SchemaFactory.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515

1616
use ApiPlatform\JsonSchema\Schema;
1717
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
18+
use ApiPlatform\Metadata\CollectionOperationInterface;
19+
use ApiPlatform\Metadata\McpResource;
20+
use ApiPlatform\Metadata\McpTool;
1821
use ApiPlatform\Metadata\Operation;
1922

2023
/**
@@ -32,6 +35,16 @@ public function __construct(
3235

3336
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
3437
{
38+
// A single-item MCP operation has no routed item URI (Mcp\Routing\IriConverter returns null),
39+
// so the serializer omits `@id`: mirror that here so the advertised output schema doesn't require it.
40+
if (Schema::TYPE_OUTPUT === $type
41+
&& ($operation instanceof McpTool || $operation instanceof McpResource)
42+
&& !$operation instanceof CollectionOperationInterface
43+
) {
44+
$serializerContext ??= [];
45+
$serializerContext['gen_id'] = false;
46+
}
47+
3548
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
3649

3750
$definitions = [];

src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
use ApiPlatform\JsonSchema\Schema;
1717
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
1818
use ApiPlatform\Mcp\JsonSchema\SchemaFactory;
19+
use ApiPlatform\Metadata\McpResource;
20+
use ApiPlatform\Metadata\McpTool;
21+
use ApiPlatform\Metadata\McpToolCollection;
1922
use PHPUnit\Framework\TestCase;
2023

2124
class SchemaFactoryTest extends TestCase
@@ -422,4 +425,68 @@ public function testRefInsideAnyOfIsResolved(): void
422425
$this->assertSame(['type' => 'null'], $related['anyOf'][1]);
423426
$this->assertArrayNotHasKey('type', $related);
424427
}
428+
429+
/**
430+
* A single-item MCP operation has no routed item URI (Mcp\Routing\IriConverter returns null),
431+
* so the output schema must advertise `gen_id` as false to drop the required `@id`.
432+
*/
433+
public function testSingleItemMcpOperationForcesGenIdFalse(): void
434+
{
435+
foreach ([new McpTool(class: \stdClass::class), new McpResource(uri: 'app://dummy', class: \stdClass::class)] as $operation) {
436+
$captured = null;
437+
$inner = $this->createMock(SchemaFactoryInterface::class);
438+
$inner->method('buildSchema')->willReturnCallback(function (...$args) use (&$captured) {
439+
$captured = $args[5] ?? null;
440+
441+
return $this->emptyObjectSchema();
442+
});
443+
444+
$factory = new SchemaFactory($inner);
445+
$factory->buildSchema('App\\Dummy', 'jsonld', Schema::TYPE_OUTPUT, $operation);
446+
447+
$this->assertFalse($captured['gen_id'] ?? null, $operation::class.' must force gen_id to false');
448+
}
449+
}
450+
451+
public function testMcpToolCollectionDoesNotForceGenIdFalse(): void
452+
{
453+
$captured = 'untouched';
454+
$inner = $this->createMock(SchemaFactoryInterface::class);
455+
$inner->method('buildSchema')->willReturnCallback(function (...$args) use (&$captured) {
456+
$captured = $args[5] ?? null;
457+
458+
return $this->emptyObjectSchema();
459+
});
460+
461+
$factory = new SchemaFactory($inner);
462+
$factory->buildSchema('App\\Dummy', 'jsonld', Schema::TYPE_OUTPUT, new McpToolCollection(class: \stdClass::class));
463+
464+
$this->assertNull($captured);
465+
}
466+
467+
public function testInputSchemaDoesNotForceGenIdFalse(): void
468+
{
469+
$captured = 'untouched';
470+
$inner = $this->createMock(SchemaFactoryInterface::class);
471+
$inner->method('buildSchema')->willReturnCallback(function (...$args) use (&$captured) {
472+
$captured = $args[5] ?? null;
473+
474+
return $this->emptyObjectSchema();
475+
});
476+
477+
$factory = new SchemaFactory($inner);
478+
$factory->buildSchema('App\\Dummy', 'json', Schema::TYPE_INPUT, new McpTool(class: \stdClass::class));
479+
480+
$this->assertNull($captured);
481+
}
482+
483+
private function emptyObjectSchema(): Schema
484+
{
485+
$schema = new Schema(Schema::VERSION_JSON_SCHEMA);
486+
unset($schema['$schema']);
487+
$schema->getDefinitions()['Dummy'] = new \ArrayObject(['type' => 'object']);
488+
$schema['$ref'] = '#/definitions/Dummy';
489+
490+
return $schema;
491+
}
425492
}

0 commit comments

Comments
 (0)