mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-27 03:31:35 +00:00
Add mcp tools for projects
This commit is contained in:
parent
e323ed9803
commit
8b484d5776
3 changed files with 101 additions and 7 deletions
|
|
@ -31,13 +31,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\DeviceRepository;
|
||||
use App\State\Mcp\GetStructuralElementDetailsProcessor;
|
||||
use App\State\Mcp\ListStructuralElementsProcessor;
|
||||
use App\Validator\Constraints\UniqueObjectCollection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
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'],
|
||||
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(LikeFilter::class, properties: ["name", "comment"])]
|
||||
|
|
@ -98,7 +127,7 @@ class Project extends AbstractStructuralDBElement
|
|||
* @var Collection<int, ProjectBOMEntry>
|
||||
*/
|
||||
#[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)]
|
||||
#[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'])]
|
||||
#[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'])]
|
||||
|
|
|
|||
|
|
@ -83,14 +83,14 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
|
|||
|
||||
#[Assert\Positive]
|
||||
#[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;
|
||||
|
||||
/**
|
||||
* @var string A comma separated list of the names, where this parts should be placed
|
||||
*/
|
||||
#[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 = '';
|
||||
|
||||
/**
|
||||
|
|
@ -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')]
|
||||
#[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;
|
||||
|
||||
/**
|
||||
* @var string An optional comment for this BOM entry
|
||||
*/
|
||||
#[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 = '';
|
||||
|
||||
/**
|
||||
|
|
@ -121,7 +121,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
|
|||
*/
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'project_bom_entries')]
|
||||
#[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;
|
||||
|
||||
/**
|
||||
|
|
@ -129,7 +129,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
|
|||
*/
|
||||
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
|
||||
#[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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ use App\Entity\Parts\Part;
|
|||
use App\Entity\Parts\PartCustomState;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Mcp\DTO\StructuralElementOverview;
|
||||
use App\Mcp\DTO\StructuralElementSearchInput;
|
||||
|
|
@ -201,6 +203,68 @@ class StructuralElementProcessorsTest extends WebTestCase
|
|||
$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
|
||||
* 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 'measurement_units' => [MeasurementUnit::class, 'list_measurement_units'];
|
||||
yield 'part_custom_states' => [PartCustomState::class, 'list_part_custom_states'];
|
||||
yield 'projects' => [Project::class, 'list_projects'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue