From 11347c23f0429932c69df0e8eced3afa555f4429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 10 May 2026 12:28:25 +0200 Subject: [PATCH] Added basic MCP tools to search for parts and get part details --- src/Entity/Parts/Part.php | 23 ++++++++ src/Mcp/DTO/ElementByIdInput.php | 35 +++++++++++ src/State/Mcp/GetPartByIdProcessor.php | 60 +++++++++++++++++++ src/State/Mcp/SearchPartsProcessor.php | 80 ++++++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 src/Mcp/DTO/ElementByIdInput.php create mode 100644 src/State/Mcp/GetPartByIdProcessor.php create mode 100644 src/State/Mcp/SearchPartsProcessor.php diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 5ac81b60..97c051fb 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -32,6 +32,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; @@ -39,6 +41,7 @@ use App\ApiPlatform\Filter\EntityFilter; use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\PartStoragelocationFilter; use App\ApiPlatform\Filter\TagFilter; +use App\DataTables\Filters\PartSearchFilter; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Attachments\PartAttachment; @@ -55,7 +58,10 @@ use App\Entity\Parts\PartTraits\ManufacturerTrait; use App\Entity\Parts\PartTraits\OrderTrait; use App\Entity\Parts\PartTraits\ProjectTrait; use App\EntityListeners\TreeCacheInvalidationListener; +use App\Mcp\DTO\ElementByIdInput; use App\Repository\PartRepository; +use App\State\Mcp\GetPartByIdProcessor; +use App\State\Mcp\SearchPartsProcessor; use App\Validator\Constraints\UniqueObjectCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -104,6 +110,23 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; ], normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'search_parts' => new McpToolCollection( + title: "Search parts by keyword", + description: 'Search for parts', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + input: PartSearchFilter::class, + processor: SearchPartsProcessor::class, + ), + 'get_part_details' => new McpTool( + 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']], + input: ElementByIdInput::class, + processor: GetPartByIdProcessor::class + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit", "partCustomState"])] diff --git a/src/Mcp/DTO/ElementByIdInput.php b/src/Mcp/DTO/ElementByIdInput.php new file mode 100644 index 00000000..bade3af2 --- /dev/null +++ b/src/Mcp/DTO/ElementByIdInput.php @@ -0,0 +1,35 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +use Symfony\Component\Validator\Constraints as Assert; + +readonly class ElementByIdInput +{ + public function __construct( + #[Assert\NotNull] + #[Assert\Positive] + public int $id, + ) { + } +} diff --git a/src/State/Mcp/GetPartByIdProcessor.php b/src/State/Mcp/GetPartByIdProcessor.php new file mode 100644 index 00000000..b147ef68 --- /dev/null +++ b/src/State/Mcp/GetPartByIdProcessor.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Entity\Parts\Part; +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; + +class GetPartByIdProcessor implements ProcessorInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof ElementByIdInput) { + throw new \InvalidArgumentException('Expected PartByIdInput'); + } + + $part = $this->entityManager->find(Part::class, $data->id); + + if (!$part instanceof Part) { + throw new NotFoundHttpException(sprintf('Part with id %d not found', $data->id)); + } + + if (!$this->authorizationChecker->isGranted('read', $part)) { + throw new AccessDeniedException(sprintf('Access denied to part with id %d', $data->id)); + } + + return $part; + } +} diff --git a/src/State/Mcp/SearchPartsProcessor.php b/src/State/Mcp/SearchPartsProcessor.php new file mode 100644 index 00000000..8d922394 --- /dev/null +++ b/src/State/Mcp/SearchPartsProcessor.php @@ -0,0 +1,80 @@ +. + */ + +declare(strict_types=1); + + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\DataTables\Filters\PartSearchFilter; +use App\Entity\Parts\Part; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; + +class SearchPartsProcessor implements ProcessorInterface +{ + + public function __construct( + private readonly EntityManagerInterface $entityManager, + ) { + + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof PartSearchFilter) { + return []; + } + + $qb = $this->entityManager->getRepository(Part::class)->createQueryBuilder('part'); + + $data->apply($qb); + $this->addJoins($qb); + + $qb->addGroupBy('part'); + + return $qb->getQuery()->getResult(); + } + + private function addJoins(QueryBuilder $qb): void + { + $dql = $qb->getDQL(); + + if (str_contains($dql, '_category')) { + $qb->leftJoin('part.category', '_category'); + } + if (str_contains($dql, '_storelocations')) { + $qb->leftJoin('part.partLots', '_partLots'); + $qb->leftJoin('_partLots.storage_location', '_storelocations'); + } + if (str_contains($dql, '_orderdetails') || str_contains($dql, '_suppliers')) { + $qb->leftJoin('part.orderdetails', '_orderdetails'); + $qb->leftJoin('_orderdetails.supplier', '_suppliers'); + } + if (str_contains($dql, '_manufacturer')) { + $qb->leftJoin('part.manufacturer', '_manufacturer'); + } + if (str_contains($dql, '_footprint')) { + $qb->leftJoin('part.footprint', '_footprint'); + } + } +}