Compare commits

...

5 commits

Author SHA1 Message Date
Jan Böhmer
5294277704 Fixed get part details tool 2026-05-10 16:21:34 +02:00
Jan Böhmer
3f10a1235e updated dependencies 2026-05-10 13:44:49 +02:00
Jan Böhmer
11347c23f0 Added basic MCP tools to search for parts and get part details 2026-05-10 12:28:25 +02:00
Jan Böhmer
dc5b818400 Fixed mcp compatibility 2026-05-06 23:37:38 +02:00
Jan Böhmer
d453c48c55 Added dependencies and basic structures 2026-05-06 22:58:38 +02:00
13 changed files with 2677 additions and 1598 deletions

View file

@ -15,7 +15,9 @@
"amphp/http-client": "^5.1",
"api-platform/doctrine-orm": "^4.1",
"api-platform/json-api": "^4.0.0",
"api-platform/mcp": "4.3.x-dev",
"api-platform/symfony": "^4.0.0",
"api-platform/metadata": "4.3.x-dev",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "^0.14.8",
"brick/schema": "^0.2.0",
@ -40,6 +42,7 @@
"league/html-to-markdown": "^5.0.1",
"liip/imagine-bundle": "^2.2",
"maennchen/zipstream-php": "2.1",
"mcp/sdk": "v0.5.0 as 0.4.0",
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
"nelexa/zip": "^4.0",
"nelmio/cors-bundle": "^2.3",
@ -73,6 +76,7 @@
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mcp-bundle": "^0.8.0",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",

963
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -34,4 +34,5 @@ return [
Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
];

View file

@ -38,3 +38,7 @@ api_platform:
serializer:
# Change this to false later, to remove the hydra prefix on the API
hydra_prefix: true
mcp:
enabled: true # default: true
format: jsonld # default: 'jsonld'

10
config/packages/mcp.yaml Normal file
View file

@ -0,0 +1,10 @@
mcp:
client_transports:
http: true
stdio: false
http:
path: "/mcp"
session:
store: "file"
directory: "%kernel.cache_dir%/mcp"
ttl: 3600

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,11 @@ csp_report:
methods: [POST]
defaults: { _controller: nelmio_security.csp_reporter_controller::indexAction }
mcp:
resource: .
type: mcp
# Must be last as it matches everything
redirector:
path: /{url}
@ -26,3 +31,4 @@ redirector:
controller: App\Controller\RedirectController::addLocalePart
# Dont match localized routes (no redirection loop, if no root with that name exists) or API prefixed routes
condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')"

View file

@ -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;
@ -80,7 +86,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
#[ORM\Index(name: 'parts_idx_gtin', columns: ['gtin'])]
#[ApiResource(
operations: [
new Get(normalizationContext: [
@ -104,6 +110,27 @@ 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'],
'item_uri_template' => '/api/parts/{id}',
],
input: ElementByIdInput::class,
validate: true,
processor: GetPartByIdProcessor::class
),
],
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit", "partCustomState"])]

View file

@ -0,0 +1,35 @@
<?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\Validator\Constraints as Assert;
readonly class ElementByIdInput
{
public function __construct(
#[Assert\NotNull]
#[Assert\Positive]
public int $id,
) {
}
}

View file

@ -0,0 +1,162 @@
<?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\JsonSchema;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
/**
* Overwrite the default JSON Schema factory to resolve $ref and allOf into a flat schema.
* This is a workaround until https://github.com/api-platform/core/pull/7962 is merged
*/
#[AsAlias('api_platform.mcp.json_schema.schema_factory')]
readonly class FixedSchemaFactory implements SchemaFactoryInterface
{
public function __construct(
private readonly SchemaFactoryInterface $decorated,
) {
}
public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
{
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
$definitions = [];
foreach ($schema->getDefinitions() as $key => $definition) {
$definitions[$key] = $definition instanceof \ArrayObject ? $definition->getArrayCopy() : (array) $definition;
}
$rootKey = $schema->getRootDefinitionKey();
if (null !== $rootKey) {
$root = $definitions[$rootKey] ?? [];
} else {
// Collection schemas (and others) put allOf/type directly on the root
$root = $schema->getArrayCopy(false);
}
$flat = self::resolveNode($root, $definitions);
$flatSchema = new Schema(Schema::VERSION_JSON_SCHEMA);
unset($flatSchema['$schema']);
foreach ($flat as $key => $value) {
$flatSchema[$key] = $value;
}
return $flatSchema;
}
/**
* Recursively resolve $ref, allOf, and nested structures into a flat schema node.
*
* @param array $resolving Tracks the current $ref resolution chain to detect circular references
*/
public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array
{
if ($node instanceof \ArrayObject) {
$node = $node->getArrayCopy();
}
if (isset($node['$ref'])) {
$refKey = str_replace('#/definitions/', '', $node['$ref']);
if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) {
return ['type' => 'object'];
}
$resolving[$refKey] = true;
$resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving);
unset($resolving[$refKey]);
return $resolved;
}
if (isset($node['allOf'])) {
$merged = ['type' => 'object', 'properties' => []];
$requiredSets = [];
foreach ($node['allOf'] as $entry) {
$resolved = self::resolveNode($entry, $definitions, $resolving);
if (isset($resolved['properties'])) {
foreach ($resolved['properties'] as $k => $v) {
$merged['properties'][$k] = $v;
}
}
if (isset($resolved['required'])) {
$requiredSets[] = $resolved['required'];
}
}
if ($requiredSets) {
$merged['required'] = array_merge(...$requiredSets);
}
if ([] === $merged['properties']) {
unset($merged['properties']);
}
if (isset($node['description'])) {
$merged['description'] = $node['description'];
}
return self::resolveDeep($merged, $definitions, $resolving);
}
// oneOf/anyOf nodes must not receive a type fallback — their type is expressed
// through the sub-schemas. Adding 'type: object' here would break schemas like
// HydraItemBaseSchema's @context, which is oneOf: [string, object].
if (isset($node['oneOf']) || isset($node['anyOf'])) {
return self::resolveDeep($node, $definitions, $resolving);
}
if (!isset($node['type'])) {
$node['type'] = 'object';
}
return self::resolveDeep($node, $definitions, $resolving);
}
/**
* Recursively resolve nested properties and array items.
*/
private static function resolveDeep(array $node, array $definitions, array &$resolving): array
{
if (isset($node['items'])) {
$node['items'] = self::resolveNode(
$node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'],
$definitions,
$resolving,
);
}
if (isset($node['properties']) && \is_array($node['properties'])) {
foreach ($node['properties'] as $propName => $propSchema) {
$node['properties'][$propName] = self::resolveNode(
$propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema,
$definitions,
$resolving,
);
}
}
return $node;
}
}

View file

@ -0,0 +1,60 @@
<?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\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;
}
}

View file

@ -0,0 +1,80 @@
<?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\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');
}
}
}

View file

@ -585,6 +585,9 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/mcp-bundle": {
"version": "v0.8.0"
},
"symfony/mime": {
"version": "v4.3.1"
},