Add mcp tools for projects

This commit is contained in:
Jan Böhmer 2026-07-25 21:30:41 +02:00
parent e323ed9803
commit 8b484d5776
3 changed files with 101 additions and 7 deletions

View file

@ -31,13 +31,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\DeviceRepository; use App\Repository\Parts\DeviceRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use App\Validator\Constraints\UniqueObjectCollection; use App\Validator\Constraints\UniqueObjectCollection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\ProjectAttachment;
@ -75,6 +82,28 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
], ],
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['project:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], denormalizationContext: ['groups' => ['project:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
mcp: [
'list_projects' => new McpToolCollection(
title: 'List/search projects',
description: 'List all projects, optionally filtered by a keyword matched against the name and comment. Each entry includes its full hierarchical path, and results are sorted by that path so parents are immediately followed by their own children, making it easy to derive the tree structure from the flat list. Use get_project_details for a specific project to retrieve its BOM entries, status and other details.',
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
output: StructuralElementOverview::class,
normalizationContext: ['groups' => ['mcp_structural_overview:read']],
input: StructuralElementSearchInput::class,
security: 'is_granted("@projects.read")',
processor: ListStructuralElementsProcessor::class,
),
'get_project_details' => new McpTool(
title: 'Get project details by ID',
description: 'Get detailed information about a specific project by its database ID, including its BOM entries, status, description and associated build part.',
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
normalizationContext: ['groups' => ['project:read', 'api:basic:read', 'mcp_project_details:read']],
input: ElementByIdInput::class,
security: 'is_granted("@projects.read")',
validate: true,
processor: GetStructuralElementDetailsProcessor::class,
),
],
)] )]
#[ApiFilter(PropertyFilter::class)] #[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
@ -98,7 +127,7 @@ class Project extends AbstractStructuralDBElement
* @var Collection<int, ProjectBOMEntry> * @var Collection<int, ProjectBOMEntry>
*/ */
#[Assert\Valid] #[Assert\Valid]
#[Groups(['extended', 'full', 'import'])] #[Groups(['extended', 'full', 'import', 'mcp_project_details:read'])]
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectBOMEntry::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectBOMEntry::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'])] #[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'])]
#[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'])] #[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'])]

View file

@ -83,14 +83,14 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
#[Assert\Positive] #[Assert\Positive]
#[ORM\Column(name: 'quantity', type: Types::FLOAT)] #[ORM\Column(name: 'quantity', type: Types::FLOAT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])] #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
protected float $quantity = 1.0; protected float $quantity = 1.0;
/** /**
* @var string A comma separated list of the names, where this parts should be placed * @var string A comma separated list of the names, where this parts should be placed
*/ */
#[ORM\Column(name: 'mountnames', type: Types::TEXT)] #[ORM\Column(name: 'mountnames', type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])] #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
protected string $mountnames = ''; protected string $mountnames = '';
/** /**
@ -98,14 +98,14 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/ */
#[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed')] #[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed')]
#[ORM\Column(type: Types::STRING, nullable: true)] #[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])] #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full', 'mcp_project_details:read'])]
protected ?string $name = null; protected ?string $name = null;
/** /**
* @var string An optional comment for this BOM entry * @var string An optional comment for this BOM entry
*/ */
#[ORM\Column(type: Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])] #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full', 'mcp_project_details:read'])]
protected string $comment = ''; protected string $comment = '';
/** /**
@ -121,7 +121,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/ */
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'project_bom_entries')] #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'project_bom_entries')]
#[ORM\JoinColumn(name: 'id_part')] #[ORM\JoinColumn(name: 'id_part')]
#[Groups(['bom_entry:read', 'bom_entry:write', 'full'])] #[Groups(['bom_entry:read', 'bom_entry:write', 'full', 'mcp_project_details:read'])]
protected ?Part $part = null; protected ?Part $part = null;
/** /**
@ -129,7 +129,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/ */
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])] #[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)] #[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])] #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full', 'mcp_project_details:read'])]
protected ?BigDecimal $price = null; protected ?BigDecimal $price = null;
/** /**

View file

@ -34,7 +34,9 @@ use App\Entity\Parts\Part;
use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartCustomState;
use App\Entity\Parts\StorageLocation; use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier; use App\Entity\Parts\Supplier;
use App\Entity\ProjectSystem\Project;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Mcp\DTO\ElementByIdInput; use App\Mcp\DTO\ElementByIdInput;
use App\Mcp\DTO\StructuralElementOverview; use App\Mcp\DTO\StructuralElementOverview;
use App\Mcp\DTO\StructuralElementSearchInput; use App\Mcp\DTO\StructuralElementSearchInput;
@ -201,6 +203,68 @@ class StructuralElementProcessorsTest extends WebTestCase
$this->getProcessor->process(new ElementByIdInput($node1->getID()), $this->getOperationForClass(Category::class)); $this->getProcessor->process(new ElementByIdInput($node1->getID()), $this->getOperationForClass(Category::class));
} }
/**
* get_project_details is the only get_X_details tool whose entity (Project) has a substantial nested
* collection (bom_entries) worth exposing. Since bom_entries -> part is a relation to the (very large)
* Part entity, the mcp_project_details:read group deliberately does NOT pull in the "full"/"part:read"
* groups on Part, so that each bom entry's part reference stays a lean id+name reference instead of
* exploding into the part's whole object graph (category, storage locations, lots, ...).
*/
public function testGetProjectDetailsSerializesBomEntriesWithLeanPartReference(): void
{
$project = $this->em->getRepository(Project::class)->findOneBy(['name' => 'Node 1']);
self::assertNotNull($project);
$part = $this->em->getRepository(Part::class)->findOneBy([]);
self::assertNotNull($part);
$bomEntry = (new ProjectBOMEntry())->setPart($part)->setQuantity(3)->setMountnames('R1,R2,R3');
$project->addBomEntry($bomEntry);
$this->em->persist($bomEntry);
$this->em->flush();
$resourceMetadataCollectionFactory = self::getContainer()->get(ResourceMetadataCollectionFactoryInterface::class);
$metadata = $resourceMetadataCollectionFactory->create(Project::class);
$operation = null;
foreach ($metadata as $resource) {
foreach ($resource->getMcp() ?? [] as $mcp) {
if ($mcp instanceof McpTool && $mcp->getName() === 'get_project_details') {
$operation = $mcp;
}
}
}
self::assertNotNull($operation);
$result = $this->getProcessor->process(new ElementByIdInput($project->getID()), $operation);
$serializer = self::getContainer()->get(SerializerInterface::class);
$json = $serializer->serialize($result, 'jsonld', [
'groups' => $operation->getNormalizationContext()['groups'] ?? null,
'resource_class' => Project::class,
'operation' => $operation,
]);
$decoded = json_decode($json, true, flags: \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('bom_entries', $decoded);
$entry = null;
foreach ($decoded['bom_entries'] as $candidate) {
if ($candidate['id'] === $bomEntry->getId()) {
$entry = $candidate;
}
}
self::assertNotNull($entry, 'Newly added bom entry not found in serialized output');
self::assertEquals(3.0, $entry['quantity']);
self::assertSame('R1,R2,R3', $entry['mountnames']);
self::assertArrayHasKey('part', $entry);
self::assertSame($part->getID(), $entry['part']['id']);
self::assertSame($part->getName(), $entry['part']['name']);
//The part reference must stay lean (id+name only) - it must NOT explode into the part's full object graph
self::assertArrayNotHasKey('category', $entry['part']);
self::assertArrayNotHasKey('partLots', $entry['part']);
}
/** /**
* Regression test: a list_X tool's output must actually serialize id+name through the real * 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 * pipeline (JSON-LD normalizer with the operation's own normalizationContext), not just when the
@ -257,5 +321,6 @@ class StructuralElementProcessorsTest extends WebTestCase
yield 'suppliers' => [Supplier::class, 'list_suppliers']; yield 'suppliers' => [Supplier::class, 'list_suppliers'];
yield 'measurement_units' => [MeasurementUnit::class, 'list_measurement_units']; yield 'measurement_units' => [MeasurementUnit::class, 'list_measurement_units'];
yield 'part_custom_states' => [PartCustomState::class, 'list_part_custom_states']; yield 'part_custom_states' => [PartCustomState::class, 'list_part_custom_states'];
yield 'projects' => [Project::class, 'list_projects'];
} }
} }