From 52942777044358349d3ca4254494039b15cd7680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 10 May 2026 16:21:34 +0200 Subject: [PATCH] Fixed get part details tool --- src/Entity/Parts/Part.php | 8 +- src/Mcp/JsonSchema/FixedSchemaFactory.php | 162 ++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/Mcp/JsonSchema/FixedSchemaFactory.php diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 97c051fb..bbc0f8d1 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -86,7 +86,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')] #[ORM\Index(columns: ['name'], name: 'parts_idx_name')] #[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')] -#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')] +#[ORM\Index(name: 'parts_idx_gtin', columns: ['gtin'])] #[ApiResource( operations: [ new Get(normalizationContext: [ @@ -122,8 +122,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; title: 'Get part details by ID', description: 'Get detailed information about a specific part by its database ID', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], - normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read']], + normalizationContext: [ + 'groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'], + 'item_uri_template' => '/api/parts/{id}', + ], input: ElementByIdInput::class, + validate: true, processor: GetPartByIdProcessor::class ), ], diff --git a/src/Mcp/JsonSchema/FixedSchemaFactory.php b/src/Mcp/JsonSchema/FixedSchemaFactory.php new file mode 100644 index 00000000..c851c394 --- /dev/null +++ b/src/Mcp/JsonSchema/FixedSchemaFactory.php @@ -0,0 +1,162 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\DependencyInjection\Attribute\AsAlias; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; + +/** + * Overwrite the default JSON Schema factory to resolve $ref and allOf into a flat schema. + * This is a workaround until https://github.com/api-platform/core/pull/7962 is merged + */ +#[AsAlias('api_platform.mcp.json_schema.schema_factory')] +readonly class FixedSchemaFactory 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, allOf, and nested structures into a flat schema node. + * + * @param array $resolving Tracks the current $ref resolution chain to detect circular references + */ + public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array + { + if ($node instanceof \ArrayObject) { + $node = $node->getArrayCopy(); + } + + if (isset($node['$ref'])) { + $refKey = str_replace('#/definitions/', '', $node['$ref']); + if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) { + return ['type' => 'object']; + } + $resolving[$refKey] = true; + $resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving); + unset($resolving[$refKey]); + + return $resolved; + } + + if (isset($node['allOf'])) { + $merged = ['type' => 'object', 'properties' => []]; + $requiredSets = []; + foreach ($node['allOf'] as $entry) { + $resolved = self::resolveNode($entry, $definitions, $resolving); + if (isset($resolved['properties'])) { + foreach ($resolved['properties'] as $k => $v) { + $merged['properties'][$k] = $v; + } + } + if (isset($resolved['required'])) { + $requiredSets[] = $resolved['required']; + } + } + + if ($requiredSets) { + $merged['required'] = array_merge(...$requiredSets); + } + if ([] === $merged['properties']) { + unset($merged['properties']); + } + if (isset($node['description'])) { + $merged['description'] = $node['description']; + } + + return self::resolveDeep($merged, $definitions, $resolving); + } + + // oneOf/anyOf nodes must not receive a type fallback — their type is expressed + // through the sub-schemas. Adding 'type: object' here would break schemas like + // HydraItemBaseSchema's @context, which is oneOf: [string, object]. + if (isset($node['oneOf']) || isset($node['anyOf'])) { + return self::resolveDeep($node, $definitions, $resolving); + } + + if (!isset($node['type'])) { + $node['type'] = 'object'; + } + + return self::resolveDeep($node, $definitions, $resolving); + } + + /** + * Recursively resolve nested properties and array items. + */ + private static function resolveDeep(array $node, array $definitions, array &$resolving): array + { + if (isset($node['items'])) { + $node['items'] = self::resolveNode( + $node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'], + $definitions, + $resolving, + ); + } + + if (isset($node['properties']) && \is_array($node['properties'])) { + foreach ($node['properties'] as $propName => $propSchema) { + $node['properties'][$propName] = self::resolveNode( + $propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema, + $definitions, + $resolving, + ); + } + } + + return $node; + } +}