From f14418d889006343e777133bb941ce7204bdb9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Wed, 22 Jul 2026 23:47:05 +0200 Subject: [PATCH] Added MCP tools to list footprints, storelocations, etc. via MCP --- src/Entity/Parts/Category.php | 29 +++ src/Entity/Parts/Footprint.php | 29 +++ src/Entity/Parts/Manufacturer.php | 29 +++ src/Entity/Parts/MeasurementUnit.php | 29 +++ src/Entity/Parts/PartCustomState.php | 29 +++ src/Entity/Parts/StorageLocation.php | 29 +++ src/Entity/Parts/Supplier.php | 29 +++ src/Mcp/DTO/StructuralElementOverview.php | 47 ++++ src/Mcp/DTO/StructuralElementSearchInput.php | 36 +++ .../StructuralElementOverviewNormalizer.php | 68 ++++++ .../GetStructuralElementDetailsProcessor.php | 70 ++++++ .../Mcp/ListStructuralElementsProcessor.php | 78 +++++++ ...tructuralElementOverviewNormalizerTest.php | 52 +++++ .../Mcp/StructuralElementProcessorsTest.php | 216 ++++++++++++++++++ 14 files changed, 770 insertions(+) create mode 100644 src/Mcp/DTO/StructuralElementOverview.php create mode 100644 src/Mcp/DTO/StructuralElementSearchInput.php create mode 100644 src/Serializer/StructuralElementOverviewNormalizer.php create mode 100644 src/State/Mcp/GetStructuralElementDetailsProcessor.php create mode 100644 src/State/Mcp/ListStructuralElementsProcessor.php create mode 100644 tests/Serializer/StructuralElementOverviewNormalizerTest.php create mode 100644 tests/State/Mcp/StructuralElementProcessorsTest.php diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index 80fcc43c..cd2933fa 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -33,6 +33,8 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; @@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Attachments\Attachment; use App\Entity\EDA\EDACategoryInfo; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; use App\Repository\Parts\CategoryRepository; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; use Doctrine\DBAL\Types\Types; use Doctrine\Common\Collections\ArrayCollection; use App\Entity\Attachments\CategoryAttachment; @@ -77,6 +84,28 @@ use Symfony\Component\Validator\Constraints as Assert; ], normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['category:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'list_categories' => new McpToolCollection( + title: 'List/search categories', + description: 'List all part categories, optionally filtered by a keyword matched against the name and comment. Categories are used to group parts by their function.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + output: StructuralElementOverview::class, + normalizationContext: ['groups' => ['mcp_structural_overview:read']], + input: StructuralElementSearchInput::class, + security: 'is_granted("@categories.read")', + processor: ListStructuralElementsProcessor::class, + ), + 'get_category_details' => new McpTool( + title: 'Get category details by ID', + description: 'Get detailed information about a specific part category by its database ID.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['category:read', 'api:basic:read']], + input: ElementByIdInput::class, + security: 'is_granted("@categories.read")', + validate: true, + processor: GetStructuralElementDetailsProcessor::class, + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] diff --git a/src/Entity/Parts/Footprint.php b/src/Entity/Parts/Footprint.php index 2027a310..6e38465e 100644 --- a/src/Entity/Parts/Footprint.php +++ b/src/Entity/Parts/Footprint.php @@ -33,6 +33,8 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; @@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Attachments\Attachment; use App\Entity\EDA\EDAFootprintInfo; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; use App\Repository\Parts\FootprintRepository; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; use App\Entity\Base\AbstractStructuralDBElement; use Doctrine\Common\Collections\ArrayCollection; use App\Entity\Attachments\FootprintAttachment; @@ -76,6 +83,28 @@ use Symfony\Component\Validator\Constraints as Assert; ], normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['footprint:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'list_footprints' => new McpToolCollection( + title: 'List/search footprints', + description: 'List all footprints, optionally filtered by a keyword matched against the name and comment. Footprints describe the physical package/shape of a part.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + output: StructuralElementOverview::class, + normalizationContext: ['groups' => ['mcp_structural_overview:read']], + input: StructuralElementSearchInput::class, + security: 'is_granted("@footprints.read")', + processor: ListStructuralElementsProcessor::class, + ), + 'get_footprint_details' => new McpTool( + title: 'Get footprint details by ID', + description: 'Get detailed information about a specific footprint by its database ID.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['footprint:read', 'api:basic:read']], + input: ElementByIdInput::class, + security: 'is_granted("@footprints.read")', + validate: true, + processor: GetStructuralElementDetailsProcessor::class, + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] diff --git a/src/Entity/Parts/Manufacturer.php b/src/Entity/Parts/Manufacturer.php index 76526c31..ef491277 100644 --- a/src/Entity/Parts/Manufacturer.php +++ b/src/Entity/Parts/Manufacturer.php @@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Attachments\Attachment; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; use App\Repository\Parts\ManufacturerRepository; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; use App\Entity\Base\AbstractStructuralDBElement; use Doctrine\Common\Collections\ArrayCollection; use App\Entity\Attachments\ManufacturerAttachment; @@ -75,6 +82,28 @@ use Symfony\Component\Validator\Constraints as Assert; ], normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['manufacturer:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'list_manufacturers' => new McpToolCollection( + title: 'List/search manufacturers', + description: 'List all manufacturers, optionally filtered by a keyword matched against the name and comment.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + output: StructuralElementOverview::class, + normalizationContext: ['groups' => ['mcp_structural_overview:read']], + input: StructuralElementSearchInput::class, + security: 'is_granted("@manufacturers.read")', + processor: ListStructuralElementsProcessor::class, + ), + 'get_manufacturer_details' => new McpTool( + title: 'Get manufacturer details by ID', + description: 'Get detailed information about a specific manufacturer by its database ID.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read']], + input: ElementByIdInput::class, + security: 'is_granted("@manufacturers.read")', + validate: true, + processor: GetStructuralElementDetailsProcessor::class, + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] diff --git a/src/Entity/Parts/MeasurementUnit.php b/src/Entity/Parts/MeasurementUnit.php index e775b65b..bd8ffd84 100644 --- a/src/Entity/Parts/MeasurementUnit.php +++ b/src/Entity/Parts/MeasurementUnit.php @@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Attachments\Attachment; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; use App\Repository\Parts\MeasurementUnitRepository; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; use Doctrine\DBAL\Types\Types; use App\Entity\Base\AbstractStructuralDBElement; use Doctrine\Common\Collections\ArrayCollection; @@ -80,6 +87,28 @@ use Symfony\Component\Validator\Constraints\Length; ], normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['measurement_unit:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'list_measurement_units' => new McpToolCollection( + title: 'List/search measurement units', + description: 'List all measurement units, optionally filtered by a keyword matched against the name and comment. Measurement units describe how the amount of a part is measured (e.g. pieces, meters, grams).', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + output: StructuralElementOverview::class, + normalizationContext: ['groups' => ['mcp_structural_overview:read']], + input: StructuralElementSearchInput::class, + security: 'is_granted("@measurement_units.read")', + processor: ListStructuralElementsProcessor::class, + ), + 'get_measurement_unit_details' => new McpTool( + title: 'Get measurement unit details by ID', + description: 'Get detailed information about a specific measurement unit by its database ID.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read']], + input: ElementByIdInput::class, + security: 'is_granted("@measurement_units.read")', + validate: true, + processor: GetStructuralElementDetailsProcessor::class, + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])] diff --git a/src/Entity/Parts/PartCustomState.php b/src/Entity/Parts/PartCustomState.php index 136ff984..e9e1fa87 100644 --- a/src/Entity/Parts/PartCustomState.php +++ b/src/Entity/Parts/PartCustomState.php @@ -33,6 +33,8 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Serializer\Filter\PropertyFilter; @@ -40,7 +42,12 @@ use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Base\AbstractPartsContainingDBElement; use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Parameters\PartCustomStateParameter; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; use App\Repository\Parts\PartCustomStateRepository; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; @@ -67,6 +74,28 @@ use Symfony\Component\Validator\Constraints as Assert; ], normalizationContext: ['groups' => ['part_custom_state:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['part_custom_state:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'list_part_custom_states' => new McpToolCollection( + title: 'List/search part custom states', + description: 'List all part custom states, optionally filtered by a keyword matched against the name and comment. Custom states are used to mark parts with a custom status (e.g. "obsolete", "needs review").', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + output: StructuralElementOverview::class, + normalizationContext: ['groups' => ['mcp_structural_overview:read']], + input: StructuralElementSearchInput::class, + security: 'is_granted("@part_custom_states.read")', + processor: ListStructuralElementsProcessor::class, + ), + 'get_part_custom_state_details' => new McpTool( + title: 'Get part custom state details by ID', + description: 'Get detailed information about a specific part custom state by its database ID.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['part_custom_state:read', 'api:basic:read']], + input: ElementByIdInput::class, + security: 'is_granted("@part_custom_states.read")', + validate: true, + processor: GetStructuralElementDetailsProcessor::class, + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(LikeFilter::class, properties: ["name"])] diff --git a/src/Entity/Parts/StorageLocation.php b/src/Entity/Parts/StorageLocation.php index 9571da6e..3e8cc611 100644 --- a/src/Entity/Parts/StorageLocation.php +++ b/src/Entity/Parts/StorageLocation.php @@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Attachments\Attachment; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; use App\Repository\Parts\StorelocationRepository; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; use Doctrine\DBAL\Types\Types; use Doctrine\Common\Collections\ArrayCollection; use App\Entity\Attachments\StorageLocationAttachment; @@ -76,6 +83,28 @@ use Symfony\Component\Validator\Constraints as Assert; ], normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['location:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'list_storage_locations' => new McpToolCollection( + title: 'List/search storage locations', + description: 'List all storage locations, optionally filtered by a keyword matched against the name and comment. Storage locations describe where parts are physically stored.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + output: StructuralElementOverview::class, + normalizationContext: ['groups' => ['mcp_structural_overview:read']], + input: StructuralElementSearchInput::class, + security: 'is_granted("@storelocations.read")', + processor: ListStructuralElementsProcessor::class, + ), + 'get_storage_location_details' => new McpTool( + title: 'Get storage location details by ID', + description: 'Get detailed information about a specific storage location by its database ID.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['location:read', 'api:basic:read']], + input: ElementByIdInput::class, + security: 'is_granted("@storelocations.read")', + validate: true, + processor: GetStructuralElementDetailsProcessor::class, + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] diff --git a/src/Entity/Parts/Supplier.php b/src/Entity/Parts/Supplier.php index 75cf62d1..11dcf980 100644 --- a/src/Entity/Parts/Supplier.php +++ b/src/Entity/Parts/Supplier.php @@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\LikeFilter; use App\Entity\Attachments\Attachment; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; use App\Repository\Parts\SupplierRepository; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; use App\Entity\PriceInformations\Orderdetail; use Doctrine\Common\Collections\ArrayCollection; use App\Entity\Attachments\SupplierAttachment; @@ -80,6 +87,28 @@ use Symfony\Component\Validator\Constraints as Assert; ], normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['supplier:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'list_suppliers' => new McpToolCollection( + title: 'List/search suppliers', + description: 'List all suppliers, optionally filtered by a keyword matched against the name and comment.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + output: StructuralElementOverview::class, + normalizationContext: ['groups' => ['mcp_structural_overview:read']], + input: StructuralElementSearchInput::class, + security: 'is_granted("@suppliers.read")', + processor: ListStructuralElementsProcessor::class, + ), + 'get_supplier_details' => new McpTool( + title: 'Get supplier details by ID', + description: 'Get detailed information about a specific supplier by its database ID.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read']], + input: ElementByIdInput::class, + security: 'is_granted("@suppliers.read")', + validate: true, + processor: GetStructuralElementDetailsProcessor::class, + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] diff --git a/src/Mcp/DTO/StructuralElementOverview.php b/src/Mcp/DTO/StructuralElementOverview.php new file mode 100644 index 00000000..aa96de38 --- /dev/null +++ b/src/Mcp/DTO/StructuralElementOverview.php @@ -0,0 +1,47 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * A lean overview projection of a structural "master data" element (category, footprint, manufacturer, ...), + * used by the list_X MCP tools. Use the corresponding get_X_details tool with the id to fetch full details. + * + * The properties are tagged with the 'mcp_structural_overview:read' group (and the list_X mcp tools set + * this as their normalizationContext), because otherwise the operation would silently fall back to the + * backing entity's own (unrelated) normalization groups, which don't match these plain properties and + * would filter them out entirely. + */ +readonly class StructuralElementOverview +{ + public function __construct( + /** @var int The database ID of the element, to be used with the corresponding get_X_details tool */ + #[Groups(['mcp_structural_overview:read'])] + public int $id, + /** @var string The name of the element */ + #[Groups(['mcp_structural_overview:read'])] + public string $name, + ) { + } +} diff --git a/src/Mcp/DTO/StructuralElementSearchInput.php b/src/Mcp/DTO/StructuralElementSearchInput.php new file mode 100644 index 00000000..22a164e2 --- /dev/null +++ b/src/Mcp/DTO/StructuralElementSearchInput.php @@ -0,0 +1,36 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +/** + * Generic input for listing/searching one of the structural "master data" entities (categories, footprints, + * manufacturers, storage locations, suppliers, measurement units, part custom states). + */ +readonly class StructuralElementSearchInput +{ + public function __construct( + /** @var string|null Optional keyword to filter by name or comment (case-insensitive substring match). If omitted, all elements are returned. */ + public ?string $keyword = null, + ) { + } +} diff --git a/src/Serializer/StructuralElementOverviewNormalizer.php b/src/Serializer/StructuralElementOverviewNormalizer.php new file mode 100644 index 00000000..4205593f --- /dev/null +++ b/src/Serializer/StructuralElementOverviewNormalizer.php @@ -0,0 +1,68 @@ +. + */ + +namespace App\Serializer; + +use App\Mcp\DTO\StructuralElementOverview; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes StructuralElementOverview as a plain {id, name} object, without the JSON-LD "@id"/"@type" + * metadata that ApiPlatform\JsonLd\Serializer\ItemNormalizer would otherwise add (as a blank/genid node, + * since this DTO is intentionally not an API resource). That metadata is useless clutter for the list_X + * MCP tools, whose whole point is a lean id+name overview. + * + * @see \App\Tests\Serializer\StructuralElementOverviewNormalizerTest + */ +class StructuralElementOverviewNormalizer implements NormalizerInterface +{ + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof StructuralElementOverview; + } + + /** + * @return array{id: int, name: string} + */ + public function normalize($object, ?string $format = null, array $context = []): array + { + if (!$object instanceof StructuralElementOverview) { + throw new \InvalidArgumentException('This normalizer only supports StructuralElementOverview objects!'); + } + + return [ + 'id' => $object->id, + 'name' => $object->name, + ]; + } + + /** + * @return bool[] + */ + public function getSupportedTypes(?string $format): array + { + return [ + StructuralElementOverview::class => true, + ]; + } +} diff --git a/src/State/Mcp/GetStructuralElementDetailsProcessor.php b/src/State/Mcp/GetStructuralElementDetailsProcessor.php new file mode 100644 index 00000000..27cab45a --- /dev/null +++ b/src/State/Mcp/GetStructuralElementDetailsProcessor.php @@ -0,0 +1,70 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Mcp\DTO\ElementByIdInput; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; + +/** + * Generic get-by-id processor shared by all structural "master data" entities (categories, footprints, + * manufacturers, storage locations, suppliers, measurement units, part custom states). The concrete entity + * class is determined from the operation, so this single processor can be reused for all of them. + */ +class GetStructuralElementDetailsProcessor implements ProcessorInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbstractStructuralDBElement + { + if (!$data instanceof ElementByIdInput) { + throw new \InvalidArgumentException('Expected ElementByIdInput'); + } + + $class = $operation->getClass(); + if (!is_a($class, AbstractStructuralDBElement::class, true)) { + throw new \LogicException(sprintf('%s can only be used for resources extending %s', self::class, AbstractStructuralDBElement::class)); + } + + $element = $this->entityManager->find($class, $data->id); + + if (!$element instanceof AbstractStructuralDBElement) { + throw new NotFoundHttpException(sprintf('%s with id %d not found', (new \ReflectionClass($class))->getShortName(), $data->id)); + } + + if (!$this->authorizationChecker->isGranted('read', $element)) { + throw new AccessDeniedException(sprintf('Access denied to %s with id %d', (new \ReflectionClass($class))->getShortName(), $data->id)); + } + + return $element; + } +} diff --git a/src/State/Mcp/ListStructuralElementsProcessor.php b/src/State/Mcp/ListStructuralElementsProcessor.php new file mode 100644 index 00000000..a3936b59 --- /dev/null +++ b/src/State/Mcp/ListStructuralElementsProcessor.php @@ -0,0 +1,78 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; +use Doctrine\ORM\EntityManagerInterface; + +/** + * Generic list/search processor shared by all structural "master data" entities (categories, footprints, + * manufacturers, storage locations, suppliers, measurement units, part custom states). The concrete entity + * class is determined from the operation, so this single processor can be reused for all of them. + * Returns a lean id+name overview only; use the corresponding get_X_details tool for full details. + */ +class ListStructuralElementsProcessor implements ProcessorInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + ) { + } + + /** + * @return StructuralElementOverview[] + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$data instanceof StructuralElementSearchInput) { + throw new \InvalidArgumentException('Expected StructuralElementSearchInput'); + } + + $class = $operation->getClass(); + if (!is_a($class, AbstractStructuralDBElement::class, true)) { + throw new \LogicException(sprintf('%s can only be used for resources extending %s', self::class, AbstractStructuralDBElement::class)); + } + + $qb = $this->entityManager->getRepository($class)->createQueryBuilder('element'); + $qb->select('element.id AS id', 'element.name AS name'); + + if ($data->keyword !== null && $data->keyword !== '') { + //Escape % and _ characters in the keyword, like PartSearchFilter does + $keyword = str_replace(['%', '_'], ['\%', '\_'], $data->keyword); + $qb->andWhere('ILIKE(element.name, :keyword) = TRUE OR ILIKE(element.comment, :keyword) = TRUE') + ->setParameter('keyword', '%'.$keyword.'%'); + } + + $qb->orderBy('element.name', 'ASC'); + + $rows = $qb->getQuery()->getArrayResult(); + + return array_map( + static fn (array $row): StructuralElementOverview => new StructuralElementOverview(id: $row['id'], name: $row['name']), + $rows + ); + } +} diff --git a/tests/Serializer/StructuralElementOverviewNormalizerTest.php b/tests/Serializer/StructuralElementOverviewNormalizerTest.php new file mode 100644 index 00000000..d657114e --- /dev/null +++ b/tests/Serializer/StructuralElementOverviewNormalizerTest.php @@ -0,0 +1,52 @@ +. + */ + +namespace App\Tests\Serializer; + +use App\Mcp\DTO\StructuralElementOverview; +use App\Serializer\StructuralElementOverviewNormalizer; +use PHPUnit\Framework\TestCase; + +final class StructuralElementOverviewNormalizerTest extends TestCase +{ + private StructuralElementOverviewNormalizer $service; + + protected function setUp(): void + { + $this->service = new StructuralElementOverviewNormalizer(); + } + + public function testNormalize(): void + { + $overview = new StructuralElementOverview(id: 42, name: 'Test Node'); + + //Must be a plain array with exactly id+name, no JSON-LD "@id"/"@type" clutter + $this->assertSame(['id' => 42, 'name' => 'Test Node'], $this->service->normalize($overview)); + } + + public function testSupportsNormalization(): void + { + $this->assertFalse($this->service->supportsNormalization(new \stdClass())); + $this->assertTrue($this->service->supportsNormalization(new StructuralElementOverview(id: 1, name: 'X'))); + } +} diff --git a/tests/State/Mcp/StructuralElementProcessorsTest.php b/tests/State/Mcp/StructuralElementProcessorsTest.php new file mode 100644 index 00000000..d4fd8122 --- /dev/null +++ b/tests/State/Mcp/StructuralElementProcessorsTest.php @@ -0,0 +1,216 @@ +. + */ + +namespace App\Tests\State\Mcp; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartCustomState; +use App\Entity\Parts\StorageLocation; +use App\Entity\Parts\Supplier; +use App\Entity\UserSystem\User; +use App\Mcp\DTO\ElementByIdInput; +use App\Mcp\DTO\StructuralElementOverview; +use App\Mcp\DTO\StructuralElementSearchInput; +use App\State\Mcp\GetStructuralElementDetailsProcessor; +use App\State\Mcp\ListStructuralElementsProcessor; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Serializer\SerializerInterface; + +class StructuralElementProcessorsTest extends WebTestCase +{ + private KernelBrowser $client; + private EntityManagerInterface $em; + private ListStructuralElementsProcessor $listProcessor; + private GetStructuralElementDetailsProcessor $getProcessor; + + protected function setUp(): void + { + $this->client = static::createClient(); + + $userRepository = self::getContainer()->get('doctrine')->getRepository(User::class); + $admin = $userRepository->findOneBy(['name' => 'admin']); + $this->client->loginUser($admin); + + $this->em = self::getContainer()->get(EntityManagerInterface::class); + $this->listProcessor = self::getContainer()->get(ListStructuralElementsProcessor::class); + $this->getProcessor = self::getContainer()->get(GetStructuralElementDetailsProcessor::class); + } + + private function getOperationForClass(string $class): Get + { + return (new Get())->withClass($class); + } + + public function testListReturnsAllElementsWithoutKeyword(): void + { + $result = $this->listProcessor->process(new StructuralElementSearchInput(), $this->getOperationForClass(Category::class)); + + //DataStructureFixtures creates 7 category nodes ("Node 1" .. "Node 1.1.1") + self::assertGreaterThanOrEqual(7, count($result)); + foreach ($result as $overview) { + self::assertInstanceOf(StructuralElementOverview::class, $overview); + //The list tool is a lean id+name overview only + self::assertIsInt($overview->id); + self::assertIsString($overview->name); + } + } + + public function testListFiltersByKeyword(): void + { + $result = $this->listProcessor->process( + new StructuralElementSearchInput(keyword: 'Node 3'), + $this->getOperationForClass(Category::class) + ); + + self::assertCount(1, $result); + self::assertSame('Node 3', $result[0]->name); + + $node3 = $this->em->getRepository(Category::class)->findOneBy(['name' => 'Node 3']); + self::assertSame($node3->getID(), $result[0]->id); + } + + public function testListIsGenericAcrossEntityClasses(): void + { + $result = $this->listProcessor->process( + new StructuralElementSearchInput(keyword: 'Node 3'), + $this->getOperationForClass(Footprint::class) + ); + + self::assertCount(1, $result); + self::assertInstanceOf(StructuralElementOverview::class, $result[0]); + self::assertSame('Node 3', $result[0]->name); + } + + public function testListRejectsNonStructuralResourceClass(): void + { + $this->expectException(\LogicException::class); + $this->listProcessor->process(new StructuralElementSearchInput(), $this->getOperationForClass(Part::class)); + } + + public function testGetDetailsReturnsElement(): void + { + $node1 = $this->em->getRepository(Category::class)->findOneBy(['name' => 'Node 1']); + self::assertNotNull($node1); + + $result = $this->getProcessor->process(new ElementByIdInput($node1->getID()), $this->getOperationForClass(Category::class)); + + self::assertSame($node1->getID(), $result->getID()); + self::assertSame('Node 1', $result->getName()); + } + + public function testGetDetailsThrowsNotFoundForUnknownId(): void + { + $this->expectException(NotFoundHttpException::class); + $this->getProcessor->process(new ElementByIdInput(999999999), $this->getOperationForClass(Category::class)); + } + + public function testGetDetailsRejectsNonStructuralResourceClass(): void + { + $part = $this->em->getRepository(Part::class)->findOneBy([]); + self::assertNotNull($part); + + $this->expectException(\LogicException::class); + $this->getProcessor->process(new ElementByIdInput($part->getID()), $this->getOperationForClass(Part::class)); + } + + public function testGetDetailsDeniesAccessForUserWithoutPermission(): void + { + $userRepository = self::getContainer()->get('doctrine')->getRepository(User::class); + $noread = $userRepository->findOneBy(['name' => 'noread']); + $this->client->loginUser($noread); + + $node1 = $this->em->getRepository(Category::class)->findOneBy(['name' => 'Node 1']); + + $this->expectException(AccessDeniedException::class); + $this->getProcessor->process(new ElementByIdInput($node1->getID()), $this->getOperationForClass(Category::class)); + } + + /** + * Regression test: a list_X tool's output must actually serialize id+name through the real + * pipeline (JSON-LD normalizer with the operation's own normalizationContext), not just when the + * processor is called directly. Previously, list_X operations had no normalizationContext of their + * own, so they silently inherited the backing entity's REST "Read" groups (e.g. "category:read"), + * which don't match StructuralElementOverview's plain properties, and every field was serialized + * away, leaving an empty object. + * + */ + #[DataProvider('provideListToolClasses')] + public function testListToolOutputActuallySerializesIdAndName(string $entityClass, string $toolName): void + { + $resourceMetadataCollectionFactory = self::getContainer()->get(ResourceMetadataCollectionFactoryInterface::class); + $metadata = $resourceMetadataCollectionFactory->create($entityClass); + + $operation = null; + foreach ($metadata as $resource) { + foreach ($resource->getMcp() ?? [] as $mcp) { + if ($mcp instanceof McpTool && $mcp->getName() === $toolName) { + $operation = $mcp; + } + } + } + self::assertNotNull($operation, sprintf('mcp tool "%s" not found on %s', $toolName, $entityClass)); + + $serializer = self::getContainer()->get(SerializerInterface::class); + $overview = new StructuralElementOverview(id: 123, name: 'Regression test node'); + + $result = $serializer->serialize([$overview], 'jsonld', [ + 'groups' => $operation->getNormalizationContext()['groups'] ?? null, + 'resource_class' => $entityClass, + 'operation' => $operation, + ]); + + $decoded = json_decode($result, true, flags: \JSON_THROW_ON_ERROR); + self::assertArrayHasKey('hydra:member', $decoded); + self::assertSame(123, $decoded['hydra:member'][0]['id'] ?? null); + self::assertSame('Regression test node', $decoded['hydra:member'][0]['name'] ?? null); + //The list_X tools are a lean id+name overview - no JSON-LD "@id"/"@type" clutter per item + self::assertArrayNotHasKey('@id', $decoded['hydra:member'][0]); + self::assertArrayNotHasKey('@type', $decoded['hydra:member'][0]); + } + + /** + * @return iterable + */ + public static function provideListToolClasses(): iterable + { + yield 'categories' => [Category::class, 'list_categories']; + yield 'footprints' => [Footprint::class, 'list_footprints']; + yield 'manufacturers' => [Manufacturer::class, 'list_manufacturers']; + yield 'storage_locations' => [StorageLocation::class, 'list_storage_locations']; + yield 'suppliers' => [Supplier::class, 'list_suppliers']; + yield 'measurement_units' => [MeasurementUnit::class, 'list_measurement_units']; + yield 'part_custom_states' => [PartCustomState::class, 'list_part_custom_states']; + } +}