Compare commits

..

11 commits

Author SHA1 Message Date
Jan Böhmer
661cc5a052 Added documentation on MCP server capabilities
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-07-25 22:06:25 +02:00
Jan Böhmer
16ceccb083 Show MCP endpoint url in api panel in user settings 2026-07-25 21:48:29 +02:00
Jan Böhmer
9e404e3a93 Updated symfony AI dependencies 2026-07-25 21:40:30 +02:00
Jan Böhmer
32669b2cc0 Require authentication for accessing MCP tools 2026-07-25 21:35:27 +02:00
Jan Böhmer
8b484d5776 Add mcp tools for projects 2026-07-25 21:30:41 +02:00
Jan Böhmer
e323ed9803 Return full path and sort structures by full pathes in list tools 2026-07-25 21:03:05 +02:00
Jan Böhmer
f14418d889 Added MCP tools to list footprints, storelocations, etc. via MCP 2026-07-22 23:47:05 +02:00
Jan Böhmer
3056809afd Update dependencies 2026-07-22 21:29:17 +02:00
Jan Böhmer
8e098ca1c0 Merge branch 'master' into mcp 2026-07-22 21:28:04 +02:00
Jan Böhmer
a0435a56bd Bumped version to 2.13.4
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-07-22 00:37:26 +02:00
Jan Böhmer
3de2c91d93 Mention in docs and messages, that the env in docker-compose must not be quoted
Fixed issue #1453
2026-07-22 00:29:09 +02:00
32 changed files with 1431 additions and 192 deletions

1
.env
View file

@ -8,6 +8,7 @@ APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
# Change this and uncomment the following line to set the trusted hosts for your Part-DB installation, aka under which
# domain names it is reachable. This is makes things more secure, because it can prevent certain header attacks.
# You have to escape dots in the domain name with a backslash
# IMPORTANT: In .env files (like this one), the value must be wrapped in single quotes as shown below.
#TRUSTED_HOSTS='^(your-domain\.invalid)$'
# You can also allow multiple domain names, e.g. for testing or development purposes, with an | between the domain names.

View file

@ -1 +1 @@
2.13.3
2.13.4

View file

@ -42,7 +42,7 @@
"league/html-to-markdown": "^5.0.1",
"liip/imagine-bundle": "^2.2",
"maennchen/zipstream-php": "2.1",
"mcp/sdk": "v0.6.0",
"mcp/sdk": "v0.7.0 as v0.6.0",
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
"nelexa/zip": "^4.0",
"nelmio/cors-bundle": "^2.3",
@ -60,10 +60,10 @@
"scheb/2fa-trusted-device": "^v7.11.0",
"shivas/versioning-bundle": "^4.0",
"spatie/db-dumper": "^3.3.1",
"symfony/ai-bundle": "^0.11.0",
"symfony/ai-lm-studio-platform": "^v0.11.0",
"symfony/ai-ollama-platform": "^0.11.0",
"symfony/ai-open-router-platform": "^0.11.0",
"symfony/ai-bundle": "^0.12.0",
"symfony/ai-lm-studio-platform": "^v0.12.0",
"symfony/ai-ollama-platform": "^0.12.0",
"symfony/ai-open-router-platform": "^0.12.0",
"symfony/apache-pack": "^1.0",
"symfony/asset": "7.4.*",
"symfony/console": "7.4.*",
@ -78,7 +78,7 @@
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mcp-bundle": "^v0.11.0",
"symfony/mcp-bundle": "^v0.12.0",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",

327
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d6329811b6e5a29717a34b5084129bf3",
"content-hash": "df7fa4865832ec3d95601bb711eb96d6",
"packages": [
{
"name": "amphp/amp",
@ -3269,16 +3269,16 @@
},
{
"name": "doctrine/dbal",
"version": "4.4.3",
"version": "4.4.4",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "61e730f1658814821a85f2402c945f3883407dec"
"reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec",
"reference": "61e730f1658814821a85f2402c945f3883407dec",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce",
"reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce",
"shasum": ""
},
"require": {
@ -3355,7 +3355,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/4.4.3"
"source": "https://github.com/doctrine/dbal/tree/4.4.4"
},
"funding": [
{
@ -3371,7 +3371,7 @@
"type": "tidelift"
}
],
"time": "2026-03-20T08:52:12+00:00"
"time": "2026-07-21T14:34:40+00:00"
},
{
"name": "doctrine/deprecations",
@ -3423,16 +3423,16 @@
},
{
"name": "doctrine/doctrine-bundle",
"version": "2.18.3",
"version": "2.19.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineBundle.git",
"reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a"
"reference": "07b90f707b82981097731c419f546e7ba97fba3c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/241d61f6bbc77275d5a9f95d514241c058bf2d0a",
"reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/07b90f707b82981097731c419f546e7ba97fba3c",
"reference": "07b90f707b82981097731c419f546e7ba97fba3c",
"shasum": ""
},
"require": {
@ -3454,7 +3454,7 @@
"doctrine/cache": "< 1.11",
"doctrine/orm": "<2.17 || >=4.0",
"symfony/var-exporter": "< 6.4.1 || 7.0.0",
"twig/twig": "<2.13 || >=3.0 <3.0.4"
"twig/twig": "<2.13 || >=3.0 <3.0.4 || >=5"
},
"require-dev": {
"doctrine/annotations": "^1 || ^2",
@ -3468,10 +3468,12 @@
"phpunit/phpunit": "^10.5.53 || ^12.3.10",
"psr/log": "^1.1.4 || ^2.0 || ^3.0",
"symfony/doctrine-messenger": "^6.4 || ^7.0",
"symfony/event-dispatcher": "^6.4 || ^7.0",
"symfony/expression-language": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0",
"symfony/messenger": "^6.4 || ^7.0",
"symfony/property-info": "^6.4 || ^7.0",
"symfony/runtime": "^6.4 || ^7.0",
"symfony/security-bundle": "^6.4 || ^7.0",
"symfony/stopwatch": "^6.4 || ^7.0",
"symfony/string": "^6.4 || ^7.0",
@ -3480,7 +3482,7 @@
"symfony/var-exporter": "^6.4.1 || ^7.0.1",
"symfony/web-profiler-bundle": "^6.4 || ^7.0",
"symfony/yaml": "^6.4 || ^7.0",
"twig/twig": "^2.14.7 || ^3.0.4"
"twig/twig": "^2.14.7 || ^3.0.4 || ^4"
},
"suggest": {
"doctrine/orm": "The Doctrine ORM integration is optional in the bundle.",
@ -3525,7 +3527,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.3"
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.0"
},
"funding": [
{
@ -3541,7 +3543,7 @@
"type": "tidelift"
}
],
"time": "2026-06-08T08:22:50+00:00"
"time": "2026-07-23T14:52:05+00:00"
},
{
"name": "doctrine/doctrine-migrations-bundle",
@ -7153,16 +7155,16 @@
},
{
"name": "mcp/sdk",
"version": "v0.6.0",
"version": "v0.7.0",
"source": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/php-sdk.git",
"reference": "433c84b58af346dd32f15f9909679e96a46ebe23"
"reference": "a6f415578fa789783d010274d11c922e97659a8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/433c84b58af346dd32f15f9909679e96a46ebe23",
"reference": "433c84b58af346dd32f15f9909679e96a46ebe23",
"url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/a6f415578fa789783d010274d11c922e97659a8a",
"reference": "a6f415578fa789783d010274d11c922e97659a8a",
"shasum": ""
},
"require": {
@ -7231,9 +7233,9 @@
"description": "Model Context Protocol SDK for Client and Server applications in PHP",
"support": {
"issues": "https://github.com/modelcontextprotocol/php-sdk/issues",
"source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.6.0"
"source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.7.0"
},
"time": "2026-06-02T15:47:04+00:00"
"time": "2026-07-14T22:58:00+00:00"
},
{
"name": "mf2/mf2",
@ -11199,21 +11201,21 @@
},
{
"name": "symfony/ai-bundle",
"version": "v0.11.1",
"version": "v0.12.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ai-bundle.git",
"reference": "a66502ab5ff676be4f318a273eab17ab934c661a"
"reference": "df2a6e8c3cf79a93dc8d9d9efd516ea981973420"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ai-bundle/zipball/a66502ab5ff676be4f318a273eab17ab934c661a",
"reference": "a66502ab5ff676be4f318a273eab17ab934c661a",
"url": "https://api.github.com/repos/symfony/ai-bundle/zipball/df2a6e8c3cf79a93dc8d9d9efd516ea981973420",
"reference": "df2a6e8c3cf79a93dc8d9d9efd516ea981973420",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/ai-platform": "^0.11",
"symfony/ai-platform": "^0.12",
"symfony/clock": "^7.3|^8.0",
"symfony/config": "^7.3|^8.0",
"symfony/console": "^7.3|^8.0",
@ -11228,76 +11230,76 @@
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.53",
"symfony/ai-agent": "^0.11",
"symfony/ai-ai-ml-api-platform": "^0.11",
"symfony/ai-albert-platform": "^0.11",
"symfony/ai-amazee-ai-platform": "^0.11",
"symfony/ai-anthropic-platform": "^0.11",
"symfony/ai-azure-platform": "^0.11",
"symfony/ai-azure-search-store": "^0.11",
"symfony/ai-bedrock-platform": "^0.11",
"symfony/ai-cache-message-store": "^0.11",
"symfony/ai-cache-platform": "^0.11",
"symfony/ai-cache-store": "^0.11",
"symfony/ai-cartesia-platform": "^0.11",
"symfony/ai-cerebras-platform": "^0.11",
"symfony/ai-chat": "^0.11",
"symfony/ai-chroma-db-store": "^0.11",
"symfony/ai-click-house-store": "^0.11",
"symfony/ai-cloudflare-message-store": "^0.11",
"symfony/ai-cloudflare-store": "^0.11",
"symfony/ai-cohere-platform": "^0.11",
"symfony/ai-decart-platform": "^0.11",
"symfony/ai-deep-seek-platform": "^0.11",
"symfony/ai-deepgram-platform": "^0.11",
"symfony/ai-docker-model-runner-platform": "^0.11",
"symfony/ai-doctrine-message-store": "^0.11",
"symfony/ai-elasticsearch-store": "^0.11",
"symfony/ai-eleven-labs-platform": "^0.11",
"symfony/ai-failover-platform": "^0.11",
"symfony/ai-gemini-platform": "^0.11",
"symfony/ai-generic-platform": "^0.11",
"symfony/ai-hugging-face-platform": "^0.11",
"symfony/ai-lm-studio-platform": "^0.11",
"symfony/ai-manticore-search-store": "^0.11",
"symfony/ai-maria-db-store": "^0.11",
"symfony/ai-meilisearch-message-store": "^0.11",
"symfony/ai-meilisearch-store": "^0.11",
"symfony/ai-meta-platform": "^0.11",
"symfony/ai-milvus-store": "^0.11",
"symfony/ai-mini-max-platform": "^0.11",
"symfony/ai-mistral-platform": "^0.11",
"symfony/ai-mongo-db-message-store": "^0.11",
"symfony/ai-mongo-db-store": "^0.11",
"symfony/ai-neo4j-store": "^0.11",
"symfony/ai-ollama-platform": "^0.11",
"symfony/ai-open-ai-platform": "^0.11",
"symfony/ai-open-responses-platform": "^0.11",
"symfony/ai-open-router-platform": "^0.11",
"symfony/ai-open-search-store": "^0.11",
"symfony/ai-ovh-platform": "^0.11",
"symfony/ai-perplexity-platform": "^0.11",
"symfony/ai-pinecone-store": "^0.11",
"symfony/ai-pogocache-message-store": "^0.11",
"symfony/ai-postgres-store": "^0.11",
"symfony/ai-qdrant-store": "^0.11",
"symfony/ai-redis-message-store": "^0.11",
"symfony/ai-redis-store": "^0.11",
"symfony/ai-replicate-platform": "^0.11",
"symfony/ai-s3vectors-store": "^0.11",
"symfony/ai-scaleway-platform": "^0.11",
"symfony/ai-session-message-store": "^0.11",
"symfony/ai-sqlite-store": "^0.11",
"symfony/ai-store": "^0.11",
"symfony/ai-supabase-store": "^0.11",
"symfony/ai-surreal-db-message-store": "^0.11",
"symfony/ai-surreal-db-store": "^0.11",
"symfony/ai-transformers-php-platform": "^0.11",
"symfony/ai-typesense-store": "^0.11",
"symfony/ai-vektor-store": "^0.11",
"symfony/ai-vertex-ai-platform": "^0.11",
"symfony/ai-voyage-platform": "^0.11",
"symfony/ai-weaviate-store": "^0.11",
"symfony/ai-agent": "^0.12",
"symfony/ai-ai-ml-api-platform": "^0.12",
"symfony/ai-albert-platform": "^0.12",
"symfony/ai-amazee-ai-platform": "^0.12",
"symfony/ai-anthropic-platform": "^0.12",
"symfony/ai-azure-platform": "^0.12",
"symfony/ai-azure-search-store": "^0.12",
"symfony/ai-bedrock-platform": "^0.12",
"symfony/ai-cache-message-store": "^0.12",
"symfony/ai-cache-platform": "^0.12",
"symfony/ai-cache-store": "^0.12",
"symfony/ai-cartesia-platform": "^0.12",
"symfony/ai-cerebras-platform": "^0.12",
"symfony/ai-chat": "^0.12",
"symfony/ai-chroma-db-store": "^0.12",
"symfony/ai-click-house-store": "^0.12",
"symfony/ai-cloudflare-message-store": "^0.12",
"symfony/ai-cloudflare-store": "^0.12",
"symfony/ai-cohere-platform": "^0.12",
"symfony/ai-decart-platform": "^0.12",
"symfony/ai-deep-seek-platform": "^0.12",
"symfony/ai-deepgram-platform": "^0.12",
"symfony/ai-docker-model-runner-platform": "^0.12",
"symfony/ai-doctrine-message-store": "^0.12",
"symfony/ai-elasticsearch-store": "^0.12",
"symfony/ai-eleven-labs-platform": "^0.12",
"symfony/ai-failover-platform": "^0.12",
"symfony/ai-gemini-platform": "^0.12",
"symfony/ai-generic-platform": "^0.12",
"symfony/ai-hugging-face-platform": "^0.12",
"symfony/ai-lm-studio-platform": "^0.12",
"symfony/ai-manticore-search-store": "^0.12",
"symfony/ai-maria-db-store": "^0.12",
"symfony/ai-meilisearch-message-store": "^0.12",
"symfony/ai-meilisearch-store": "^0.12",
"symfony/ai-meta-platform": "^0.12",
"symfony/ai-milvus-store": "^0.12",
"symfony/ai-mini-max-platform": "^0.12",
"symfony/ai-mistral-platform": "^0.12",
"symfony/ai-mongo-db-message-store": "^0.12",
"symfony/ai-mongo-db-store": "^0.12",
"symfony/ai-neo4j-store": "^0.12",
"symfony/ai-ollama-platform": "^0.12",
"symfony/ai-open-ai-platform": "^0.12",
"symfony/ai-open-responses-platform": "^0.12",
"symfony/ai-open-router-platform": "^0.12",
"symfony/ai-open-search-store": "^0.12",
"symfony/ai-ovh-platform": "^0.12",
"symfony/ai-perplexity-platform": "^0.12",
"symfony/ai-pinecone-store": "^0.12",
"symfony/ai-pogocache-message-store": "^0.12",
"symfony/ai-postgres-store": "^0.12",
"symfony/ai-qdrant-store": "^0.12",
"symfony/ai-redis-message-store": "^0.12",
"symfony/ai-redis-store": "^0.12",
"symfony/ai-replicate-platform": "^0.12",
"symfony/ai-s3vectors-store": "^0.12",
"symfony/ai-scaleway-platform": "^0.12",
"symfony/ai-session-message-store": "^0.12",
"symfony/ai-sqlite-store": "^0.12",
"symfony/ai-store": "^0.12",
"symfony/ai-supabase-store": "^0.12",
"symfony/ai-surreal-db-message-store": "^0.12",
"symfony/ai-surreal-db-store": "^0.12",
"symfony/ai-transformers-php-platform": "^0.12",
"symfony/ai-typesense-store": "^0.12",
"symfony/ai-vektor-store": "^0.12",
"symfony/ai-vertex-ai-platform": "^0.12",
"symfony/ai-voyage-platform": "^0.12",
"symfony/ai-weaviate-store": "^0.12",
"symfony/expression-language": "^7.3|^8.0",
"symfony/security-core": "^7.3|^8.0",
"symfony/translation": "^7.3|^8.0",
@ -11335,7 +11337,7 @@
],
"description": "Integration bundle for Symfony AI components",
"support": {
"source": "https://github.com/symfony/ai-bundle/tree/v0.11.1"
"source": "https://github.com/symfony/ai-bundle/tree/v0.12.0"
},
"funding": [
{
@ -11355,25 +11357,25 @@
"type": "tidelift"
}
],
"time": "2026-07-16T11:42:34+00:00"
"time": "2026-07-21T23:38:06+00:00"
},
{
"name": "symfony/ai-generic-platform",
"version": "v0.11.0",
"version": "v0.12.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ai-generic-platform.git",
"reference": "b56ea991a4232ea16204960ab8dc8a1da44cb320"
"reference": "df2cc5cebb6d4196b67a9d73ef630b552af581c7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ai-generic-platform/zipball/b56ea991a4232ea16204960ab8dc8a1da44cb320",
"reference": "b56ea991a4232ea16204960ab8dc8a1da44cb320",
"url": "https://api.github.com/repos/symfony/ai-generic-platform/zipball/df2cc5cebb6d4196b67a9d73ef630b552af581c7",
"reference": "df2cc5cebb6d4196b67a9d73ef630b552af581c7",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/ai-platform": "^0.11",
"symfony/ai-platform": "^0.12",
"symfony/http-client": "^7.3|^8.0"
},
"require-dev": {
@ -11420,7 +11422,7 @@
"platform"
],
"support": {
"source": "https://github.com/symfony/ai-generic-platform/tree/v0.11.0"
"source": "https://github.com/symfony/ai-generic-platform/tree/v0.12.0"
},
"funding": [
{
@ -11440,26 +11442,26 @@
"type": "tidelift"
}
],
"time": "2026-07-15T00:58:20+00:00"
"time": "2026-07-21T23:38:06+00:00"
},
{
"name": "symfony/ai-lm-studio-platform",
"version": "v0.11.0",
"version": "v0.12.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ai-lm-studio-platform.git",
"reference": "b9725715569184ed80ebecb01a17893660cc550b"
"reference": "7594a3a305d8ea5c1cfe7493bb2352e7fdf5afed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ai-lm-studio-platform/zipball/b9725715569184ed80ebecb01a17893660cc550b",
"reference": "b9725715569184ed80ebecb01a17893660cc550b",
"url": "https://api.github.com/repos/symfony/ai-lm-studio-platform/zipball/7594a3a305d8ea5c1cfe7493bb2352e7fdf5afed",
"reference": "7594a3a305d8ea5c1cfe7493bb2352e7fdf5afed",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/ai-generic-platform": "^0.11",
"symfony/ai-platform": "^0.11",
"symfony/ai-generic-platform": "^0.12",
"symfony/ai-platform": "^0.12",
"symfony/http-client": "^7.3|^8.0"
},
"require-dev": {
@ -11507,7 +11509,7 @@
"platform"
],
"support": {
"source": "https://github.com/symfony/ai-lm-studio-platform/tree/v0.11.0"
"source": "https://github.com/symfony/ai-lm-studio-platform/tree/v0.12.0"
},
"funding": [
{
@ -11527,25 +11529,25 @@
"type": "tidelift"
}
],
"time": "2026-07-15T00:58:20+00:00"
"time": "2026-07-21T23:38:06+00:00"
},
{
"name": "symfony/ai-ollama-platform",
"version": "v0.11.0",
"version": "v0.12.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ai-ollama-platform.git",
"reference": "b42c359a7f414053d1b30f70e6508528853a8704"
"reference": "17b050999d417e47e28f76f7054164b3c69e1f70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ai-ollama-platform/zipball/b42c359a7f414053d1b30f70e6508528853a8704",
"reference": "b42c359a7f414053d1b30f70e6508528853a8704",
"url": "https://api.github.com/repos/symfony/ai-ollama-platform/zipball/17b050999d417e47e28f76f7054164b3c69e1f70",
"reference": "17b050999d417e47e28f76f7054164b3c69e1f70",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/ai-platform": "^0.11",
"symfony/ai-platform": "^0.12",
"symfony/http-client": "^7.3|^8.0"
},
"require-dev": {
@ -11593,7 +11595,7 @@
"platform"
],
"support": {
"source": "https://github.com/symfony/ai-ollama-platform/tree/v0.11.0"
"source": "https://github.com/symfony/ai-ollama-platform/tree/v0.12.0"
},
"funding": [
{
@ -11613,26 +11615,26 @@
"type": "tidelift"
}
],
"time": "2026-07-15T00:58:20+00:00"
"time": "2026-07-21T23:38:06+00:00"
},
{
"name": "symfony/ai-open-router-platform",
"version": "v0.11.0",
"version": "v0.12.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ai-open-router-platform.git",
"reference": "5ad625058227bf628ca0f504ba035599a1e8c91d"
"reference": "6a424947c2583b55d8886ec29e65d0455c01b4b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ai-open-router-platform/zipball/5ad625058227bf628ca0f504ba035599a1e8c91d",
"reference": "5ad625058227bf628ca0f504ba035599a1e8c91d",
"url": "https://api.github.com/repos/symfony/ai-open-router-platform/zipball/6a424947c2583b55d8886ec29e65d0455c01b4b3",
"reference": "6a424947c2583b55d8886ec29e65d0455c01b4b3",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/ai-generic-platform": "^0.11",
"symfony/ai-platform": "^0.11",
"symfony/ai-generic-platform": "^0.12",
"symfony/ai-platform": "^0.12",
"symfony/http-client": "^7.3|^8.0"
},
"require-dev": {
@ -11680,7 +11682,7 @@
"platform"
],
"support": {
"source": "https://github.com/symfony/ai-open-router-platform/tree/v0.11.0"
"source": "https://github.com/symfony/ai-open-router-platform/tree/v0.12.0"
},
"funding": [
{
@ -11700,20 +11702,20 @@
"type": "tidelift"
}
],
"time": "2026-07-15T00:58:20+00:00"
"time": "2026-07-21T23:38:06+00:00"
},
{
"name": "symfony/ai-platform",
"version": "v0.11.0",
"version": "v0.12.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ai-platform.git",
"reference": "dce4285eaf891cbdcb9526e576e3df6f8220fd1d"
"reference": "af04cc80651271a35d38f20018603a9d837c5fda"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ai-platform/zipball/dce4285eaf891cbdcb9526e576e3df6f8220fd1d",
"reference": "dce4285eaf891cbdcb9526e576e3df6f8220fd1d",
"url": "https://api.github.com/repos/symfony/ai-platform/zipball/af04cc80651271a35d38f20018603a9d837c5fda",
"reference": "af04cc80651271a35d38f20018603a9d837c5fda",
"shasum": ""
},
"require": {
@ -11812,7 +11814,7 @@
"voyage"
],
"support": {
"source": "https://github.com/symfony/ai-platform/tree/v0.11.0"
"source": "https://github.com/symfony/ai-platform/tree/v0.12.0"
},
"funding": [
{
@ -11832,7 +11834,7 @@
"type": "tidelift"
}
],
"time": "2026-07-15T01:10:14+00:00"
"time": "2026-07-21T01:26:51+00:00"
},
{
"name": "symfony/apache-pack",
@ -14280,20 +14282,20 @@
},
{
"name": "symfony/mcp-bundle",
"version": "v0.11.0",
"version": "v0.12.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/mcp-bundle.git",
"reference": "f973cf4f146674d362da3258ba35629ad447ecb1"
"reference": "7ffd7bfab9d315a52a8224e35b32bd0db1765d44"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/f973cf4f146674d362da3258ba35629ad447ecb1",
"reference": "f973cf4f146674d362da3258ba35629ad447ecb1",
"url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/7ffd7bfab9d315a52a8224e35b32bd0db1765d44",
"reference": "7ffd7bfab9d315a52a8224e35b32bd0db1765d44",
"shasum": ""
},
"require": {
"mcp/sdk": "^0.6|^0.7",
"mcp/sdk": "^0.7",
"php-http/discovery": "^1.20",
"symfony/config": "^7.3|^8.0",
"symfony/console": "^7.3|^8.0",
@ -14343,7 +14345,7 @@
],
"description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)",
"support": {
"source": "https://github.com/symfony/mcp-bundle/tree/v0.11.0"
"source": "https://github.com/symfony/mcp-bundle/tree/v0.12.0"
},
"funding": [
{
@ -14363,7 +14365,7 @@
"type": "tidelift"
}
],
"time": "2026-07-15T00:43:00+00:00"
"time": "2026-07-21T01:04:45+00:00"
},
{
"name": "symfony/mime",
@ -18187,16 +18189,16 @@
},
{
"name": "symplify/easy-coding-standard",
"version": "13.2.13",
"version": "13.2.17",
"source": {
"type": "git",
"url": "https://github.com/ecsphp/ecs.git",
"reference": "6d28a6dfef3e18ddc013ba68b121487cad2cf14c"
"reference": "f5f8131520fb7d3efcf348b72205372ea619fe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ecsphp/ecs/zipball/6d28a6dfef3e18ddc013ba68b121487cad2cf14c",
"reference": "6d28a6dfef3e18ddc013ba68b121487cad2cf14c",
"url": "https://api.github.com/repos/ecsphp/ecs/zipball/f5f8131520fb7d3efcf348b72205372ea619fe0e",
"reference": "f5f8131520fb7d3efcf348b72205372ea619fe0e",
"shasum": ""
},
"require": {
@ -18231,9 +18233,9 @@
"static analysis"
],
"support": {
"source": "https://github.com/ecsphp/ecs/tree/13.2.13"
"source": "https://github.com/ecsphp/ecs/tree/13.2.17"
},
"time": "2026-07-20T11:00:52+00:00"
"time": "2026-07-22T20:02:43+00:00"
},
{
"name": "tecnickcom/tc-lib-barcode",
@ -20982,12 +20984,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "4fac0729c45b6dba8d6171a94eccb1d5d9514bf9"
"reference": "e7d396822cefa7207fd352f61f68660811e108a4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/4fac0729c45b6dba8d6171a94eccb1d5d9514bf9",
"reference": "4fac0729c45b6dba8d6171a94eccb1d5d9514bf9",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/e7d396822cefa7207fd352f61f68660811e108a4",
"reference": "e7d396822cefa7207fd352f61f68660811e108a4",
"shasum": ""
},
"conflict": {
@ -21124,7 +21126,7 @@
"codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5",
"commerceteam/commerce": ">=0.9.6,<0.9.9",
"components/jquery": ">=1.0.3,<3.5",
"composer/composer": "<2.2.28|>=2.3,<2.9.8",
"composer/composer": "<2.2.29|>=2.3,<2.10.2",
"concrete5/concrete5": "<9.5.2",
"concrete5/core": "<8.5.8|>=9,<9.1",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
@ -21184,7 +21186,7 @@
"doctrine/mongodb-odm-bundle": "<3.0.1",
"doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4",
"dolibarr/dolibarr": "<=23.0.2",
"dompdf/dompdf": "<2.0.4",
"dompdf/dompdf": "<3.1.6",
"doublethreedigital/guest-entries": "<3.1.2",
"dreamfactory/df-core": "<1.0.4",
"drupal-pattern-lab/unified-twig-extensions": "<=0.1",
@ -21339,10 +21341,10 @@
"gregwar/rst": "<1.0.3",
"grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<7.12.1",
"guzzlehttp/guzzle": "<7.15.1",
"guzzlehttp/guzzle-services": "<1.5.4",
"guzzlehttp/oauth-subscriber": "<0.8.1",
"guzzlehttp/psr7": "<2.12.1",
"guzzlehttp/psr7": "<2.12.3",
"haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2",
"handcraftedinthealps/goodby-csv": "<1.4.3",
"harvesthq/chosen": "<1.8.7",
@ -21617,7 +21619,7 @@
"personnummer/personnummer": "<3.0.2",
"ph7software/ph7builder": "<=17.9.1",
"phanan/koel": "<=9.7",
"pheditor/pheditor": "<2.0.6",
"pheditor/pheditor": "<2.0.8",
"phenx/php-svg-lib": "<0.5.2",
"php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5",
"php-mod/curl": "<2.3.2",
@ -21633,7 +21635,7 @@
"phpoffice/common": "<0.2.9",
"phpoffice/math": "<=0.2",
"phpoffice/phpexcel": "<=1.8.2",
"phpoffice/phpspreadsheet": "<=1.30.4|>=2,<=2.1.15|>=2.2,<=2.4.4|>=3,<=3.10.4|>=4,<=5.6",
"phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8",
"phppgadmin/phppgadmin": "<=7.13",
"phpseclib/phpseclib": "<=2.0.54|>=3,<=3.0.53",
"phpservermon/phpservermon": "<3.6",
@ -21660,7 +21662,7 @@
"pocketmine/pocketmine-mp": "<5.42.1",
"pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1",
"pontedilana/php-weasyprint": "<=2.5.1",
"poweradmin/poweradmin": "<4.2.4|>=4.3,<4.3.3",
"poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4",
"pressbooks/pressbooks": "<5.18",
"prestashop/autoupgrade": ">=4,<4.10.1",
"prestashop/blockreassurance": "<=5.1.3",
@ -22096,7 +22098,7 @@
"type": "tidelift"
}
],
"time": "2026-07-17T19:07:03+00:00"
"time": "2026-07-24T23:00:42+00:00"
},
{
"name": "sebastian/cli-parser",
@ -23609,7 +23611,14 @@
"time": "2025-11-17T20:03:58+00:00"
}
],
"aliases": [],
"aliases": [
{
"package": "mcp/sdk",
"version": "0.7.0.0",
"alias": "v0.6.0",
"alias_normalized": "0.6.0.0"
}
],
"minimum-stability": "stable",
"stability-flags": {
"roave/security-advisories": 20

View file

@ -73,3 +73,6 @@ security:
- { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
# Restrict access to KICAD to users, which has API access permission
- { path: "^/kicad-api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
# Restrict MCP access to users, which has the MCP access permission
- { path: "^/mcp", allow_if: 'is_granted("@api.use_mcp") and is_authenticated()' }

View file

@ -381,7 +381,12 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
access_api:
label: "perm.api.access_api"
apiTokenRole: ROLE_API_READ_ONLY
use_mcp:
label: "perm.api.use_mcp"
alsoSet: [ 'access_api' ]
apiTokenRole: ROLE_API_READ_ONLY
manage_tokens:
label: "perm.api.manage_tokens"
alsoSet: ['access_api']
apiTokenRole: ROLE_API_FULL

View file

@ -3215,10 +3215,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* stdio?: bool|Param, // Default: false
* http?: bool|Param, // Default: false
* },
* discovery?: array{
* scan_dirs?: list<scalar|Param|null>,
* exclude_dirs?: list<scalar|Param|null>,
* },
* apps?: array{ // MCP Apps support (interactive HTML UI resources). Apps are registered with the #[AsMcpApp] attribute.
* enabled?: bool|Param|null, // Default: null
* },

216
docs/api/mcp.md Normal file
View file

@ -0,0 +1,216 @@
---
title: MCP Server
layout: default
parent: API
nav_order: 3
---
# MCP Server
{: .new }
> This feature was added recently and might still change in future versions.
Part-DB ships a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server, which allows AI assistants and
agents (like Claude, ChatGPT, or AI-powered coding tools) to directly interact with your Part-DB inventory: they can
search for parts, look up categories, footprints, manufacturers, storage locations, suppliers, and projects, and even
query external info providers like Digikey, Mouser or LCSC, all using natural language, without you having to write
any code against the [REST API]({% link api/intro.md %}).
MCP is a standardized, widely supported protocol, so once your Part-DB MCP endpoint is set up, you can connect it to
many different AI clients and applications.
{: .warning }
> The MCP integration is currently **read-only**: an AI assistant can look up data, but it can not create, change or
> delete anything in your Part-DB instance via MCP.
> Still, giving an AI assistant access to your inventory means it can read everything the connected user account is
> allowed to see, so only connect trusted AI clients and keep your API token secret, just like you would for the
> [REST API]({% link api/authentication.md %}).
## Enabling the MCP server
The MCP server is disabled by default and has to be enabled by an administrator first:
1. Open the system settings and go to the **AI** tab.
2. In the **MCP (Model Context Protocol) Server** section, enable the **Enable MCP endpoint** checkbox.
This can also be controlled via the `MCP_ENABLED` environment variable.
Once enabled, the MCP server is reachable under the `/mcp` path of your Part-DB instance (e.g.
`https://your-part-db.local/mcp`). Unlike most other Part-DB pages, this path is **not** locale-prefixed (so it is
`/mcp`, not `/en/mcp`).
## Permissions
Users which should be allowed to use the MCP tools additionally need the **Use MCP tools (for AI agents)** permission
(under the **API** permission group). Granting it automatically also grants the base **Access API** permission.
Like the REST API, authentication against the MCP endpoint is done using an [API token]({% link
api/authentication.md %}). A token with the **Read-Only** scope is enough to use all currently available MCP tools,
as they only read data.
## Connecting an AI client
To connect an AI client to Part-DB, you need two things:
* The **MCP endpoint URL**, e.g. `https://your-part-db.local/mcp`. Once you have the required permission, you can
also find it on the **API** panel of your user settings page, under "MCP endpoint", together with a
copy-to-clipboard button.
* An **API token**. Create one on the same **API** panel of your user settings page (see
[Authentication]({% link api/authentication.md %}) for details about tokens and scopes). A token with the
**Read-Only** scope is sufficient.
The client has to send this token as a bearer token in the `Authorization` header of every request:
`Authorization: Bearer tcp_<your-token>`. How exactly you configure this depends on the AI client you use; some
examples for common clients are shown below.
{: .note }
> Part-DB's MCP server only supports the **Streamable HTTP** transport (no stdio, no plain SSE). Most modern MCP
> clients support this transport directly. Clients that only support local, stdio-based MCP servers can be bridged to
> a remote HTTP server with a small proxy tool like [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), as shown
> in the Claude Desktop example below.
MCP client configuration formats change frequently, so if the examples below don't quite match what you see in your
client, check the client's own documentation for how to add a remote MCP server with a custom `Authorization` header.
### Claude Code
Add the server with the Claude Code CLI:
```bash
claude mcp add part-db https://your-part-db.local/mcp \
--transport http \
--header "Authorization: Bearer tcp_<your-token>"
```
### Claude Desktop
Claude Desktop currently only launches local (stdio) MCP servers directly from its config file, so a remote server
like Part-DB's has to be bridged with the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) proxy. Open
Claude Desktop's configuration file (**Settings → Developer → Edit Config**) and add:
```json
{
"mcpServers": {
"part-db": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://your-part-db.local/mcp",
"--header",
"Authorization: Bearer tcp_<your-token>"
]
}
}
}
```
### Google Antigravity
Open **Manage MCP Servers** and add the server via its JSON configuration:
```json
{
"mcpServers": {
"part-db": {
"serverUrl": "https://your-part-db.local/mcp",
"headers": {
"Authorization": "Bearer tcp_<your-token>"
}
}
}
}
```
### Cursor
Add the following to your `.cursor/mcp.json` (project-specific) or global Cursor MCP settings:
```json
{
"mcpServers": {
"part-db": {
"url": "https://your-part-db.local/mcp",
"headers": {
"Authorization": "Bearer tcp_<your-token>"
}
}
}
}
```
### VS Code (MCP support / GitHub Copilot)
Add the following to your `.vscode/mcp.json` (or use the **MCP: Add Server** command from the command palette):
```json
{
"servers": {
"part-db": {
"type": "http",
"url": "https://your-part-db.local/mcp",
"headers": {
"Authorization": "Bearer tcp_<your-token>"
}
}
}
}
```
### Other clients
Any MCP client that supports the Streamable HTTP transport with custom headers can connect to Part-DB, you generally
just need to provide:
* **URL**: `https://your-part-db.local/mcp`
* **Transport**: Streamable HTTP
* **Header**: `Authorization: Bearer tcp_<your-token>`
## Available tools
The following MCP tools are currently available. All of them are read-only.
### Parts
* **search_parts** Search for parts by a keyword, with toggles to control which fields are searched (name,
description, comment, tags, storage location, supplier order number, MPN, IPN, supplier, manufacturer, footprint,
category, database ID), and an optional regex mode.
* **get_part_details** Get full details about a specific part by its database ID, including stock, prices,
order details, attachments, parameters and EDA info.
### Master data
Categories, footprints, manufacturers, storage locations, measurement units, suppliers and part custom states all
expose the same pair of tools:
* **list_categories** / **get_category_details**
* **list_footprints** / **get_footprint_details**
* **list_manufacturers** / **get_manufacturer_details**
* **list_storage_locations** / **get_storage_location_details**
* **list_measurement_units** / **get_measurement_unit_details**
* **list_suppliers** / **get_supplier_details**
* **list_part_custom_states** / **get_part_custom_state_details**
Each `list_*` tool accepts an optional `keyword`, matched against the name and comment. Without a keyword, all
elements are returned in hierarchical tree order; with a keyword, matching results are sorted by their full path, so
parent/child relationships can still be derived from the flat list. Each `get_*_details` tool takes the element's
database `id` and returns its full details.
### Projects
* **list_projects** / **get_project_details** Same behavior as the master data tools above.
`get_project_details` additionally returns the project's BOM entries, status, description and associated build
part.
### Info Provider System
These tools query external part information providers (e.g. Digikey, Mouser, LCSC), see the
[Information provider system]({% link usage/information_provider_system.md %}) page for background:
* **list_info_providers** List the info providers that are currently active and can be used with the two tools
below.
* **search_info_providers** Search one or more external info providers (or the configured default providers, if
none are specified) for parts matching a keyword.
* **get_info_provider_part_details** Get full details (datasheets, images, parameters, prices, ...) for a specific
search result, identified by the `provider_key` and `provider_id` returned by `search_info_providers`.

View file

@ -265,10 +265,16 @@ See the [information providers]({% link usage/information_provider_system.md %})
should be accessible. If accessed via a hostname not matching the regex, an error page will be shown instead. By default this
is empty, meaning Part-DB accepts requests for any host name, which is not recommended for production use.
For example, if Part-DB should only be reachable under `part-db.example.invalid`, set:
For example, if Part-DB should only be reachable under `part-db.example.invalid`, set the following in your
`.env.local` file (the value must be wrapped in single quotes here):
```
TRUSTED_HOSTS='^(part-db\.example\.invalid)$'
```
If you set `TRUSTED_HOSTS` as an environment variable in your `docker-compose.yaml` instead, the value must
**not** be quoted:
```
TRUSTED_HOSTS=^(part-db\.example\.invalid)$
```
You can specify multiple host names separated by `|`, e.g. `^(localhost|part-db\.example\.invalid)$`.
Part-DB displays a warning on the homepage (visible to administrators only) as long as this value is not set.
* `DEMO_MODE` (env only): Set Part-DB into demo mode, which forbids users to change their passwords and settings. Used for the demo

View file

@ -87,6 +87,7 @@ services:
# To prevent HTTP Host header attacks, set this to a regex matching all host names Part-DB should be reachable under.
# By default (unset) Part-DB accepts requests for any host name, which is not recommended for production use.
# IMPORTANT: Unlike in .env files, the value here must NOT be wrapped in quotes.
# - TRUSTED_HOSTS=^(part-db\.example\.invalid)$
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:
@ -101,7 +102,8 @@ services:
openssl rand -hex 32
```
6. Uncomment and set the `TRUSTED_HOSTS` variable to a regex matching the host name(s) Part-DB should be reachable under
(e.g. `^(part-db\.example\.invalid)$`), to prevent HTTP Host header attacks. See [Configuration]({% link configuration.md %})
(e.g. `^(part-db\.example\.invalid)$`), to prevent HTTP Host header attacks. Note that, unlike in `.env` files, the
value must **not** be quoted in `docker-compose.yaml`. See [Configuration]({% link configuration.md %})
for more information.
7. Inside the folder, run
@ -181,6 +183,7 @@ services:
# To prevent HTTP Host header attacks, set this to a regex matching all host names Part-DB should be reachable under.
# By default (unset) Part-DB accepts requests for any host name, which is not recommended for production use.
# IMPORTANT: Unlike in .env files, the value here must NOT be wrapped in quotes.
# - TRUSTED_HOSTS=^(part-db\.example\.invalid)$
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:

View file

@ -24,12 +24,18 @@ the reverse proxy).
You should also set the `TRUSTED_HOSTS` environment variable to a regex matching the host name(s) Part-DB is reachable
under via the reverse proxy, to prevent `HTTP Host header attacks`. For example, if Part-DB is reachable via
`part-db.example.invalid`, set:
`part-db.example.invalid`, set the following in your `.env.local` file (the value must be wrapped in single quotes here):
```
TRUSTED_HOSTS='^(part-db\.example\.invalid)$'
```
If you set `TRUSTED_HOSTS` in your `docker-compose.yaml` instead, the value must **not** be quoted:
```
TRUSTED_HOSTS=^(part-db\.example\.invalid)$
```
See the [configuration options](../configuration.md) page for more information about `TRUSTED_HOSTS`.
## Part-DB in a subpath via reverse proxy

View file

@ -33,6 +33,8 @@ 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;
@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
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\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\DBAL\Types\Types;
use Doctrine\Common\Collections\ArrayCollection;
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'],
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. 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.',
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(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,6 +33,8 @@ 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;
@ -40,7 +42,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
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\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use App\Entity\Base\AbstractStructuralDBElement;
use Doctrine\Common\Collections\ArrayCollection;
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'],
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. 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.',
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(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,13 +33,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\ManufacturerRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use App\Entity\Base\AbstractStructuralDBElement;
use Doctrine\Common\Collections\ArrayCollection;
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'],
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. 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.',
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(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,13 +33,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\MeasurementUnitRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractStructuralDBElement;
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'],
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). 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.',
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(LikeFilter::class, properties: ["name", "comment", "unit"])]

View file

@ -33,6 +33,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;
@ -40,7 +42,12 @@ use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
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\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
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'],
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"). 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.',
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(LikeFilter::class, properties: ["name"])]

View file

@ -33,13 +33,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\StorelocationRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\DBAL\Types\Types;
use Doctrine\Common\Collections\ArrayCollection;
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'],
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. 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.',
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(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -33,13 +33,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\SupplierRepository;
use App\State\Mcp\GetStructuralElementDetailsProcessor;
use App\State\Mcp\ListStructuralElementsProcessor;
use App\Entity\PriceInformations\Orderdetail;
use Doctrine\Common\Collections\ArrayCollection;
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'],
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. 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.',
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(LikeFilter::class, properties: ["name", "comment"])]

View file

@ -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'])]

View file

@ -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;
/**

View file

@ -0,0 +1,50 @@
<?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,
/** @var string The full path of the element (including the names of all its parent elements, e.g. "Parent → Child") */
#[Groups(['mcp_structural_overview:read'])]
public string $full_path,
) {
}
}

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,69 @@
<?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, full_path} 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 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, full_path: 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,
'full_path' => $object->full_path,
];
}
/**
* @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,98 @@
<?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 App\Services\Trees\NodesListBuilder;
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+full_path overview only; use the corresponding get_X_details tool for full details.
*/
readonly class ListStructuralElementsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private NodesListBuilder $nodesListBuilder,
) {
}
/**
* @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));
}
if ($data->keyword === null || $data->keyword === '') {
//Without a filter, use the (cached) NodesListBuilder, which already returns the elements in
//correct hierarchical tree order (parents immediately followed by their own children), instead
//of fetching everything and re-sorting it ourselves.
/** @var AbstractStructuralDBElement[] $elements */
$elements = $this->nodesListBuilder->typeToNodesList($class);
return array_map($this->toOverview(...), $elements);
}
$qb = $this->entityManager->getRepository($class)->createQueryBuilder('element');
//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.'%');
/** @var AbstractStructuralDBElement[] $elements */
$elements = $qb->getQuery()->getResult();
$overviews = array_map($this->toOverview(...), $elements);
//Sort by full path (rather than the DB query), so that parents are always immediately followed by
//their own children, allowing clients to derive the hierarchical structure from a flat list.
usort($overviews, static fn (StructuralElementOverview $a, StructuralElementOverview $b): int => strnatcasecmp($a->full_path, $b->full_path));
return $overviews;
}
private function toOverview(AbstractStructuralDBElement $element): StructuralElementOverview
{
return new StructuralElementOverview(
id: $element->getID(),
name: $element->getName(),
full_path: $element->getFullPath(),
);
}
}

View file

@ -23,12 +23,16 @@
<p>Try following things:</p>
<ul>
<li>Ensure that you are using the correct URL to access Part-DB.</li>
<li>Set the <code>TRUSTED_HOSTS</code> environment variable in your <code>.env.local</code> file (or your docker-compose environment) to a regular expression matching all host names Part-DB should be reachable under, e.g.
{% if seen_host %}
<code>TRUSTED_HOSTS='^({{ seen_host|replace({'.': '\\.'}) }})$'</code> (if <code>{{ seen_host }}</code> is a host name you trust)
{% else %}
<code>TRUSTED_HOSTS='^(localhost|example\.com)$'</code>
{% endif %}
<li>Set the <code>TRUSTED_HOSTS</code> environment variable to a regular expression matching all host names Part-DB should be reachable under. Note that the required quoting differs depending on where you set it:
<ul>
{% if seen_host %}
<li>In your <code>.env.local</code> file, the value must be wrapped in single quotes: <code>TRUSTED_HOSTS='^({{ seen_host|replace({'.': '\\.'}) }})$'</code> (if <code>{{ seen_host }}</code> is a host name you trust)</li>
<li>In <code>docker-compose.yaml</code>, the value must NOT be quoted: <code>TRUSTED_HOSTS=^({{ seen_host|replace({'.': '\\.'}) }})$</code> (if <code>{{ seen_host }}</code> is a host name you trust)</li>
{% else %}
<li>In your <code>.env.local</code> file, the value must be wrapped in single quotes: <code>TRUSTED_HOSTS='^(localhost|example\.com)$'</code></li>
<li>In <code>docker-compose.yaml</code>, the value must NOT be quoted: <code>TRUSTED_HOSTS=^(localhost|example\.com)$</code></li>
{% endif %}
</ul>
</li>
<li>If Part-DB is running behind a reverse proxy, also make sure that <code>TRUSTED_PROXIES</code> is configured correctly, so the original host name is forwarded properly.</li>
<li>Run <kbd>php bin/console cache:clear</kbd> after changing the configuration.</li>

View file

@ -98,9 +98,12 @@
{% if trusted_hosts_unconfigured and is_granted('@system.server_infos') %}
<div class="alert alert-warning" role="alert">
<h5><i class="fa-solid fa-triangle-exclamation fa-fw"></i> {% trans %}system.trusted_hosts.unconfigured.title{% endtrans %}</h5>
<p class="mb-1">{% trans %}system.trusted_hosts.unconfigured.message{% endtrans %}</p>
<p class="mb-0">{% trans %}system.trusted_hosts.unconfigured.suggestion{% endtrans %}
<br><code>TRUSTED_HOSTS='^({{ app.request.host|replace({'.': '\\.'}) }})$'</code></p>
<p class="mb-1">{% trans %}system.trusted_hosts.unconfigured.message{% endtrans %}
{% trans %}system.trusted_hosts.unconfigured.suggestion{% endtrans %}</p>
<p class="mb-0">{% trans %}system.trusted_hosts.unconfigured.env_example{% endtrans %}
<code>TRUSTED_HOSTS='^({{ app.request.host|replace({'.': '\\.'}) }})$'</code></p>
<p class="mb-0">{% trans %}system.trusted_hosts.unconfigured.docker_example{% endtrans %}
<code>TRUSTED_HOSTS=^({{ app.request.host|replace({'.': '\\.'}) }})$</code></p>
<small>{% trans %}update_manager.new_version_available.only_administrators_can_see{% endtrans %}</small>
</div>
{% endif %}

View file

@ -94,6 +94,21 @@
</div>
</div>
{% if is_granted("@api.use_mcp") %}
<div class="row">
<div class="col-sm-2">
<b>{% trans %}api.api_endpoints.mcp_url{% endtrans %}:</b>
</div>
<div class="col-sm-10">
<button class="btn btn-outline-dark d-print-none btn-sm" data-clipboard-text="{{ absolute_url('/mcp') }}">
<i class="fas fa-copy"></i>
</button>
<span>{{ absolute_url('/mcp') }}</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>

View file

@ -0,0 +1,55 @@
<?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', full_path: 'Parent → Test Node');
//Must be a plain array with exactly id+name+full_path, no JSON-LD "@id"/"@type" clutter
$this->assertSame(
['id' => 42, 'name' => 'Test Node', 'full_path' => 'Parent → 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', full_path: 'X')));
}
}

View file

@ -0,0 +1,326 @@
<?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\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;
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+full_path overview only
self::assertIsInt($overview->id);
self::assertIsString($overview->name);
self::assertIsString($overview->full_path);
}
}
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);
//Node 3 is a root node, so its full path is just its own name
self::assertSame('Node 3', $result[0]->full_path);
$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 testListIncludesFullPathForNestedElements(): void
{
$result = $this->listProcessor->process(
new StructuralElementSearchInput(keyword: 'Node 1.1.1'),
$this->getOperationForClass(Category::class)
);
self::assertCount(1, $result);
self::assertSame('Node 1.1.1', $result[0]->name);
self::assertSame('Node 1 → Node 1.1 → Node 1.1.1', $result[0]->full_path);
}
public function testListIsSortedByFullPathSoParentsPrecedeTheirChildren(): void
{
$result = $this->listProcessor->process(new StructuralElementSearchInput(), $this->getOperationForClass(Category::class));
$fullPaths = array_map(static fn (StructuralElementOverview $overview): string => $overview->full_path, $result);
//The list must be sorted by full_path itself, not e.g. by name
$sorted = $fullPaths;
sort($sorted, SORT_NATURAL | SORT_FLAG_CASE);
self::assertSame($sorted, $fullPaths);
//Since parent paths are always a prefix of their children's paths, sorting by full_path means
//"Node 1" is immediately followed by all of its descendants ("Node 1 → ...") before any sibling
//root node that sorts after "Node 1" (e.g. "Node 2") appears.
$node1Index = array_search('Node 1', $fullPaths, true);
$node11Index = array_search('Node 1 → Node 1.1', $fullPaths, true);
$node111Index = array_search('Node 1 → Node 1.1 → Node 1.1.1', $fullPaths, true);
$node2Index = array_search('Node 2', $fullPaths, true);
self::assertNotFalse($node1Index);
self::assertNotFalse($node11Index);
self::assertNotFalse($node111Index);
self::assertNotFalse($node2Index);
self::assertLessThan($node11Index, $node1Index);
self::assertLessThan($node111Index, $node11Index);
self::assertLessThan($node2Index, $node111Index);
}
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));
}
/**
* 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
* 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', full_path: 'Parent → 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);
self::assertSame('Parent → Regression test node', $decoded['hydra:member'][0]['full_path'] ?? null);
//The list_X tools are a lean 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'];
yield 'projects' => [Project::class, 'list_projects'];
}
}

View file

@ -13728,7 +13728,19 @@ Buerklin-API-Authentication-Server:
<unit id="qZ_kgWT" name="system.trusted_hosts.unconfigured.suggestion">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.suggestion</source>
<target>Legen Sie einen regulären Ausdruck fest, der alle Hostnamen abdeckt, unter denen Part-DB erreichbar sein soll, z. B. basierend auf dem Hostnamen, den Sie für den Zugriff auf diese Seite verwendet haben. Dies ist möglich in Ihrer Datei &lt;code&gt;.env.local&lt;/code&gt; oder &lt;code&gt;docker-compose.yaml&lt;/code&gt;:</target>
<target>Legen Sie einen regulären Ausdruck fest, der alle Hostnamen abdeckt, unter denen Part-DB erreichbar sein soll, z. B. basierend auf dem Hostnamen, den Sie für den Zugriff auf diese Seite verwendet haben. Beachten Sie, dass die erforderliche Anführungszeichen-Schreibweise davon abhängt, wo Sie den Wert setzen:</target>
</segment>
</unit>
<unit id="ie4thSr" name="system.trusted_hosts.unconfigured.env_example">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.env_example</source>
<target>In Ihrer Datei &lt;code&gt;.env.local&lt;/code&gt; muss der Wert in einfache Anführungszeichen eingeschlossen werden:</target>
</segment>
</unit>
<unit id="ni3hSTN" name="system.trusted_hosts.unconfigured.docker_example">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.docker_example</source>
<target>In &lt;code&gt;docker-compose.yaml&lt;/code&gt; darf der Wert NICHT in Anführungszeichen stehen:</target>
</segment>
</unit>
<unit id="cEwxoSj" name="info_providers.provider_key">

View file

@ -13730,7 +13730,19 @@ Buerklin-API Authentication server:
<unit id="qZ_kgWT" name="system.trusted_hosts.unconfigured.suggestion">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.suggestion</source>
<target>Set it to a regular expression matching all host names Part-DB should be reachable under in your &lt;code&gt;.env.local&lt;/code&gt; or &lt;code&gt;docker-compose.yaml&lt;/code&gt; file, e.g. based on the host name you used to access this page:</target>
<target>Set it to a regular expression matching all host names Part-DB should be reachable under, e.g. based on the host name you used to access this page. Note that the required quoting differs depending on where you set it:</target>
</segment>
</unit>
<unit id="ie4thSr" name="system.trusted_hosts.unconfigured.env_example">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.env_example</source>
<target>In your &lt;code&gt;.env.local&lt;/code&gt; file, the value must be wrapped in single quotes:</target>
</segment>
</unit>
<unit id="ni3hSTN" name="system.trusted_hosts.unconfigured.docker_example">
<segment state="translated">
<source>system.trusted_hosts.unconfigured.docker_example</source>
<target>In &lt;code&gt;docker-compose.yaml&lt;/code&gt;, the value must NOT be quoted:</target>
</segment>
</unit>
<unit id="cEwxoSj" name="info_providers.provider_key">
@ -13781,5 +13793,17 @@ Buerklin-API Authentication server:
<target>Warning: Changing values here can break the info retrieval mechanism! You should use the "update from info provider" functionality whenever possible.</target>
</segment>
</unit>
<unit id="bNrxe9i" name="perm.api.use_mcp">
<segment>
<source>perm.api.use_mcp</source>
<target>Use MCP tools (for AI agents)</target>
</segment>
</unit>
<unit id="ced_8yl" name="api.api_endpoints.mcp_url">
<segment>
<source>api.api_endpoints.mcp_url</source>
<target>MCP endpoint</target>
</segment>
</unit>
</file>
</xliff>