Added MCP tools to list footprints, storelocations, etc. via MCP

This commit is contained in:
Jan Böhmer 2026-07-22 23:47:05 +02:00
parent 3056809afd
commit f14418d889
14 changed files with 770 additions and 0 deletions

View file

@ -33,6 +33,8 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\McpToolCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Operation;
@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Entity\EDA\EDACategoryInfo; 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\Repository\Parts\CategoryRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\CategoryAttachment; 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'], 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'], 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(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,6 +33,8 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\McpToolCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Operation;
@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Entity\EDA\EDAFootprintInfo; 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\Repository\Parts\FootprintRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Base\AbstractStructuralDBElement;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\FootprintAttachment; 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'], 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'], 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(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\McpToolCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment; 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\Repository\Parts\ManufacturerRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Base\AbstractStructuralDBElement;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\ManufacturerAttachment; 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'], 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'], 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(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\McpToolCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment; 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\Repository\Parts\MeasurementUnitRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Base\AbstractStructuralDBElement;
use Doctrine\Common\Collections\ArrayCollection; 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'], 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'], 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(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])]

View file

@ -33,6 +33,8 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\McpToolCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\Filter\PropertyFilter;
@ -40,7 +42,12 @@ use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Base\AbstractPartsContainingDBElement; use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\PartCustomStateParameter; 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\Repository\Parts\PartCustomStateRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria; 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'], 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'], 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(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name"])] #[ApiFilter(LikeFilter::class, properties: ["name"])]

View file

@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\McpToolCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment; 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\Repository\Parts\StorelocationRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\StorageLocationAttachment; 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'], 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'], 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(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,13 +33,20 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\McpTool;
use ApiPlatform\Metadata\McpToolCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment; 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\Repository\Parts\SupplierRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Orderdetail;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\SupplierAttachment; 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'], 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'], 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(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -0,0 +1,47 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,
) {
}
}

View file

@ -0,0 +1,36 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,
) {
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,
];
}
}

View file

@ -0,0 +1,70 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
}
}

View file

@ -0,0 +1,78 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
);
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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')));
}
}

View file

@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<string, array{0: class-string, 1: string}>
*/
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'];
}
}