diff --git a/.env b/.env index 557ed6e8..c175012b 100644 --- a/.env +++ b/.env @@ -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. diff --git a/VERSION b/VERSION index a1a4224d..965a689e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.13.3 +2.13.4 diff --git a/composer.json b/composer.json index d3b97c04..bd3eb9f1 100644 --- a/composer.json +++ b/composer.json @@ -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.*", diff --git a/composer.lock b/composer.lock index 7bd338b4..eae1db09 100644 --- a/composer.lock +++ b/composer.lock @@ -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 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index e7a44e0c..d9bf0400 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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()' } diff --git a/config/permissions.yaml b/config/permissions.yaml index 39e91b57..704da880 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -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 + diff --git a/config/reference.php b/config/reference.php index 885525d8..0d2478a1 100644 --- a/config/reference.php +++ b/config/reference.php @@ -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, - * exclude_dirs?: list, - * }, * apps?: array{ // MCP Apps support (interactive HTML UI resources). Apps are registered with the #[AsMcpApp] attribute. * enabled?: bool|Param|null, // Default: null * }, diff --git a/docs/api/mcp.md b/docs/api/mcp.md new file mode 100644 index 00000000..66cea4c2 --- /dev/null +++ b/docs/api/mcp.md @@ -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_`. 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_" +``` + +### 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_" + ] + } + } +} +``` + + +### 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_" + } + } + } +} +``` + +### 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_" + } + } + } +} +``` + +### 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_" + } + } + } +} +``` + +### 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_` + +## 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`. diff --git a/docs/configuration.md b/docs/configuration.md index 6bf59529..d6309351 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/installation/installation_docker.md b/docs/installation/installation_docker.md index 0d5f8a8b..1331f2a3 100644 --- a/docs/installation/installation_docker.md +++ b/docs/installation/installation_docker.md @@ -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: diff --git a/docs/installation/reverse-proxy.md b/docs/installation/reverse-proxy.md index 24d502b4..30c74223 100644 --- a/docs/installation/reverse-proxy.md +++ b/docs/installation/reverse-proxy.md @@ -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 diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index 80fcc43c..316e116f 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -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"])] diff --git a/src/Entity/Parts/Footprint.php b/src/Entity/Parts/Footprint.php index 2027a310..54c85734 100644 --- a/src/Entity/Parts/Footprint.php +++ b/src/Entity/Parts/Footprint.php @@ -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"])] diff --git a/src/Entity/Parts/Manufacturer.php b/src/Entity/Parts/Manufacturer.php index 76526c31..343ce685 100644 --- a/src/Entity/Parts/Manufacturer.php +++ b/src/Entity/Parts/Manufacturer.php @@ -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"])] diff --git a/src/Entity/Parts/MeasurementUnit.php b/src/Entity/Parts/MeasurementUnit.php index e775b65b..dbf8036b 100644 --- a/src/Entity/Parts/MeasurementUnit.php +++ b/src/Entity/Parts/MeasurementUnit.php @@ -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"])] diff --git a/src/Entity/Parts/PartCustomState.php b/src/Entity/Parts/PartCustomState.php index 136ff984..b5007e89 100644 --- a/src/Entity/Parts/PartCustomState.php +++ b/src/Entity/Parts/PartCustomState.php @@ -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"])] diff --git a/src/Entity/Parts/StorageLocation.php b/src/Entity/Parts/StorageLocation.php index 9571da6e..a7c7176e 100644 --- a/src/Entity/Parts/StorageLocation.php +++ b/src/Entity/Parts/StorageLocation.php @@ -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"])] diff --git a/src/Entity/Parts/Supplier.php b/src/Entity/Parts/Supplier.php index 75cf62d1..466f1789 100644 --- a/src/Entity/Parts/Supplier.php +++ b/src/Entity/Parts/Supplier.php @@ -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"])] diff --git a/src/Entity/ProjectSystem/Project.php b/src/Entity/ProjectSystem/Project.php index 34e77051..e170b376 100644 --- a/src/Entity/ProjectSystem/Project.php +++ b/src/Entity/ProjectSystem/Project.php @@ -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 */ #[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'])] diff --git a/src/Entity/ProjectSystem/ProjectBOMEntry.php b/src/Entity/ProjectSystem/ProjectBOMEntry.php index c016d741..b5fe61e7 100644 --- a/src/Entity/ProjectSystem/ProjectBOMEntry.php +++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php @@ -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; /** diff --git a/src/Mcp/DTO/StructuralElementOverview.php b/src/Mcp/DTO/StructuralElementOverview.php new file mode 100644 index 00000000..40591b44 --- /dev/null +++ b/src/Mcp/DTO/StructuralElementOverview.php @@ -0,0 +1,50 @@ +. + */ + +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, + ) { + } +} diff --git a/src/Mcp/DTO/StructuralElementSearchInput.php b/src/Mcp/DTO/StructuralElementSearchInput.php new file mode 100644 index 00000000..22a164e2 --- /dev/null +++ b/src/Mcp/DTO/StructuralElementSearchInput.php @@ -0,0 +1,36 @@ +. + */ + +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, + ) { + } +} diff --git a/src/Serializer/StructuralElementOverviewNormalizer.php b/src/Serializer/StructuralElementOverviewNormalizer.php new file mode 100644 index 00000000..04a3ee9c --- /dev/null +++ b/src/Serializer/StructuralElementOverviewNormalizer.php @@ -0,0 +1,69 @@ +. + */ + +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, + ]; + } +} diff --git a/src/State/Mcp/GetStructuralElementDetailsProcessor.php b/src/State/Mcp/GetStructuralElementDetailsProcessor.php new file mode 100644 index 00000000..27cab45a --- /dev/null +++ b/src/State/Mcp/GetStructuralElementDetailsProcessor.php @@ -0,0 +1,70 @@ +. + */ + +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; + } +} diff --git a/src/State/Mcp/ListStructuralElementsProcessor.php b/src/State/Mcp/ListStructuralElementsProcessor.php new file mode 100644 index 00000000..115312b4 --- /dev/null +++ b/src/State/Mcp/ListStructuralElementsProcessor.php @@ -0,0 +1,98 @@ +. + */ + +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(), + ); + } +} diff --git a/templates/bundles/TwigBundle/Exception/error400.html.twig b/templates/bundles/TwigBundle/Exception/error400.html.twig index f5ed56b9..2ad31626 100644 --- a/templates/bundles/TwigBundle/Exception/error400.html.twig +++ b/templates/bundles/TwigBundle/Exception/error400.html.twig @@ -23,12 +23,16 @@

Try following things:

  • Ensure that you are using the correct URL to access Part-DB.
  • -
  • Set the TRUSTED_HOSTS environment variable in your .env.local 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 %} - TRUSTED_HOSTS='^({{ seen_host|replace({'.': '\\.'}) }})$' (if {{ seen_host }} is a host name you trust) - {% else %} - TRUSTED_HOSTS='^(localhost|example\.com)$' - {% endif %} +
  • Set the TRUSTED_HOSTS 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: +
      + {% if seen_host %} +
    • In your .env.local file, the value must be wrapped in single quotes: TRUSTED_HOSTS='^({{ seen_host|replace({'.': '\\.'}) }})$' (if {{ seen_host }} is a host name you trust)
    • +
    • In docker-compose.yaml, the value must NOT be quoted: TRUSTED_HOSTS=^({{ seen_host|replace({'.': '\\.'}) }})$ (if {{ seen_host }} is a host name you trust)
    • + {% else %} +
    • In your .env.local file, the value must be wrapped in single quotes: TRUSTED_HOSTS='^(localhost|example\.com)$'
    • +
    • In docker-compose.yaml, the value must NOT be quoted: TRUSTED_HOSTS=^(localhost|example\.com)$
    • + {% endif %} +
  • If Part-DB is running behind a reverse proxy, also make sure that TRUSTED_PROXIES is configured correctly, so the original host name is forwarded properly.
  • Run php bin/console cache:clear after changing the configuration.
  • diff --git a/templates/homepage.html.twig b/templates/homepage.html.twig index 7b000e9d..b05c20b3 100644 --- a/templates/homepage.html.twig +++ b/templates/homepage.html.twig @@ -98,9 +98,12 @@ {% if trusted_hosts_unconfigured and is_granted('@system.server_infos') %} {% endif %} diff --git a/templates/users/_api_tokens.html.twig b/templates/users/_api_tokens.html.twig index b274c9b6..e98c9670 100644 --- a/templates/users/_api_tokens.html.twig +++ b/templates/users/_api_tokens.html.twig @@ -94,6 +94,21 @@ + {% if is_granted("@api.use_mcp") %} +
    +
    + {% trans %}api.api_endpoints.mcp_url{% endtrans %}: +
    +
    + + {{ absolute_url('/mcp') }} +
    +
    + + {% endif %} + - \ No newline at end of file + diff --git a/tests/Serializer/StructuralElementOverviewNormalizerTest.php b/tests/Serializer/StructuralElementOverviewNormalizerTest.php new file mode 100644 index 00000000..b0fbf467 --- /dev/null +++ b/tests/Serializer/StructuralElementOverviewNormalizerTest.php @@ -0,0 +1,55 @@ +. + */ + +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'))); + } +} diff --git a/tests/State/Mcp/StructuralElementProcessorsTest.php b/tests/State/Mcp/StructuralElementProcessorsTest.php new file mode 100644 index 00000000..87b0c157 --- /dev/null +++ b/tests/State/Mcp/StructuralElementProcessorsTest.php @@ -0,0 +1,326 @@ +. + */ + +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 + */ + 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']; + } +} diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index dd81e083..366db8c1 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -13728,7 +13728,19 @@ Buerklin-API-Authentication-Server: system.trusted_hosts.unconfigured.suggestion - 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 <code>.env.local</code> oder <code>docker-compose.yaml</code>: + 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: + + + + + system.trusted_hosts.unconfigured.env_example + In Ihrer Datei <code>.env.local</code> muss der Wert in einfache Anführungszeichen eingeschlossen werden: + + + + + system.trusted_hosts.unconfigured.docker_example + In <code>docker-compose.yaml</code> darf der Wert NICHT in Anführungszeichen stehen: diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 81b71d8e..ccf977e3 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13730,7 +13730,19 @@ Buerklin-API Authentication server: system.trusted_hosts.unconfigured.suggestion - Set it to a regular expression matching all host names Part-DB should be reachable under in your <code>.env.local</code> or <code>docker-compose.yaml</code> file, e.g. based on the host name you used to access this page: + 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: + + + + + system.trusted_hosts.unconfigured.env_example + In your <code>.env.local</code> file, the value must be wrapped in single quotes: + + + + + system.trusted_hosts.unconfigured.docker_example + In <code>docker-compose.yaml</code>, the value must NOT be quoted: @@ -13781,5 +13793,17 @@ Buerklin-API Authentication server: Warning: Changing values here can break the info retrieval mechanism! You should use the "update from info provider" functionality whenever possible. + + + perm.api.use_mcp + Use MCP tools (for AI agents) + + + + + api.api_endpoints.mcp_url + MCP endpoint + +