From f7b2539ef16a74c7bf3343e761d3cfe86025e6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Wed, 6 May 2026 22:58:38 +0200 Subject: [PATCH 01/25] Added dependencies and basic structures --- composer.json | 2 + composer.lock | 660 ++++++++++++++++++++++++++++-- config/bundles.php | 1 + config/packages/api_platform.yaml | 4 + config/packages/mcp.yaml | 10 + config/reference.php | 36 ++ config/routes.yaml | 6 + symfony.lock | 3 + 8 files changed, 698 insertions(+), 24 deletions(-) create mode 100644 config/packages/mcp.yaml diff --git a/composer.json b/composer.json index a1f15834..121c41bf 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "amphp/http-client": "^5.1", "api-platform/doctrine-orm": "^4.1", "api-platform/json-api": "^4.0.0", + "api-platform/mcp": "^4.3", "api-platform/symfony": "^4.0.0", "beberlei/doctrineextensions": "^1.2", "brick/math": "^0.17.0", @@ -73,6 +74,7 @@ "symfony/http-client": "7.4.*", "symfony/http-kernel": "7.4.*", "symfony/mailer": "7.4.*", + "symfony/mcp-bundle": "^0.8.0", "symfony/monolog-bundle": "^4.0", "symfony/process": "7.4.*", "symfony/property-access": "7.4.*", diff --git a/composer.lock b/composer.lock index 34715b7b..e9d07b43 100644 --- a/composer.lock +++ b/composer.lock @@ -1626,6 +1626,83 @@ }, "time": "2026-04-30T12:21:24+00:00" }, + { + "name": "api-platform/mcp", + "version": "v4.3.4", + "source": { + "type": "git", + "url": "https://github.com/api-platform/mcp.git", + "reference": "a984d05c20a02dfc0dd7691aaac8a9ba546f93ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/mcp/zipball/a984d05c20a02dfc0dd7691aaac8a9ba546f93ee", + "reference": "a984d05c20a02dfc0dd7691aaac8a9ba546f93ee", + "shasum": "" + }, + "require": { + "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.3", + "mcp/sdk": "^0.4.0", + "php": ">=8.2", + "symfony/object-mapper": "^7.4 || ^8.0", + "symfony/polyfill-php85": "^1.32" + }, + "require-dev": { + "phpunit/phpunit": "^11.5 || ^12.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Mcp\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "Antoine Bluchet", + "email": "soyuka@gmail.com", + "homepage": "https://soyuka.me" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Model Context Protocol (MCP) integration for API Platform", + "homepage": "https://api-platform.com", + "keywords": [ + "Model Context Protocol", + "api", + "mcp", + "rest" + ], + "support": { + "issues": "https://github.com/api-platform/mcp/issues", + "source": "https://github.com/api-platform/mcp/tree/v4.3.4" + }, + "time": "2026-04-30T12:21:24+00:00" + }, { "name": "api-platform/metadata", "version": "v4.3.6", @@ -7068,6 +7145,82 @@ }, "time": "2025-07-25T09:04:22+00:00" }, + { + "name": "mcp/sdk", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/php-sdk.git", + "reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024", + "reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "php": "^8.1", + "php-http/discovery": "^1.20", + "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", + "psr/container": "^1.0 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "require-dev": { + "laminas/laminas-httphandlerrunner": "^2.12", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phar-io/composer-distributor": "^1.0.2", + "php-cs-fixer/shim": "^3.91", + "phpdocumentor/shim": "^3", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "psr/simple-cache": "^2.0 || ^3.0", + "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "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.4.0" + }, + "time": "2026-02-23T21:42:54+00:00" + }, { "name": "mf2/mf2", "version": "0.4.6", @@ -8154,6 +8307,196 @@ ], "time": "2026-05-07T22:38:04+00:00" }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "oskarstark/enum-helper", "version": "1.8.4", @@ -8831,16 +9174,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "6.0.3", + "version": "5.6.7", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", "shasum": "" }, "require": { @@ -8848,8 +9191,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^2.0", - "phpstan/phpdoc-parser": "^2.0", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -8859,8 +9202,7 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26", - "shipmonk/dead-code-detector": "^0.5.1" + "psalm/phar": "^5.26" }, "type": "library", "extra": { @@ -8890,44 +9232,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" }, - "time": "2026-03-18T20:49:53+00:00" + "time": "2026-03-18T20:47:46+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "2.0.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.4 || ^8.0", + "php": "^7.3 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^2.0" + "phpstan/phpdoc-parser": "^1.18|^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", "phpunit/phpunit": "^9.5", - "psalm/phar": "^4" + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev", - "dev-2.x": "2.x-dev" + "dev-1.x": "1.x-dev" } }, "autoload": { @@ -8948,9 +9290,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2026-01-06T21:53:42+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpoffice/phpspreadsheet", @@ -9468,6 +9810,119 @@ }, "time": "2023-04-04T09:50:52+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/link", "version": "2.0.1", @@ -13646,6 +14101,90 @@ ], "time": "2026-05-20T07:20:23+00:00" }, + { + "name": "symfony/mcp-bundle", + "version": "v0.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mcp-bundle.git", + "reference": "b8db100e64fcb2d651e3bfd6c6ab70ad3599833d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/b8db100e64fcb2d651e3bfd6c6ab70ad3599833d", + "reference": "b8db100e64fcb2d651e3bfd6c6ab70ad3599833d", + "shasum": "" + }, + "require": { + "mcp/sdk": "^0.4", + "php-http/discovery": "^1.20", + "symfony/config": "^7.3|^8.0", + "symfony/console": "^7.3|^8.0", + "symfony/dependency-injection": "^7.3|^8.0", + "symfony/framework-bundle": "^7.3|^8.0", + "symfony/http-foundation": "^7.3|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/psr-http-message-bridge": "^7.3|^8.0", + "symfony/routing": "^7.3|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.53", + "symfony/monolog-bundle": "^3.10 || ^4.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\McpBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)", + "support": { + "source": "https://github.com/symfony/mcp-bundle/tree/v0.8.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-12T00:28:34+00:00" + }, { "name": "symfony/mime", "version": "v7.4.12", @@ -13897,6 +14436,79 @@ ], "time": "2026-04-02T18:27:21+00:00" }, + { + "name": "symfony/object-mapper", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/object-mapper.git", + "reference": "4f3f4973993b70e88e58dbcc3e332135d593f70d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/object-mapper/zipball/4f3f4973993b70e88e58dbcc3e332135d593f70d", + "reference": "4f3f4973993b70e88e58dbcc3e332135d593f70d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^2.0" + }, + "conflict": { + "symfony/property-access": "<7.2" + }, + "require-dev": { + "symfony/property-access": "^7.2|^8.0", + "symfony/var-exporter": "^7.2|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ObjectMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to map an object to another object", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/object-mapper/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-22T15:21:55+00:00" + }, { "name": "symfony/options-resolver", "version": "v7.4.8", diff --git a/config/bundles.php b/config/bundles.php index 000c58a1..830bab91 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -34,4 +34,5 @@ return [ Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true], ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], Symfony\AI\AiBundle\AiBundle::class => ['all' => true], + Symfony\AI\McpBundle\McpBundle::class => ['all' => true], ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 1b679cd1..d706f40e 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -38,3 +38,7 @@ api_platform: serializer: # Change this to false later, to remove the hydra prefix on the API hydra_prefix: true + + mcp: + enabled: true # default: true + format: jsonld # default: 'jsonld' diff --git a/config/packages/mcp.yaml b/config/packages/mcp.yaml new file mode 100644 index 00000000..cabcad1b --- /dev/null +++ b/config/packages/mcp.yaml @@ -0,0 +1,10 @@ +mcp: + client_transports: + http: true + stdio: false + http: + path: "/mcp" + session: + store: "file" + directory: "%kernel.cache_dir%/mcp" + ttl: 3600 diff --git a/config/reference.php b/config/reference.php index 38a275d6..91694bd9 100644 --- a/config/reference.php +++ b/config/reference.php @@ -3184,6 +3184,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * store?: string|Param, // Service name of store // Default: "Symfony\\AI\\Store\\StoreInterface" * }>, * } + * @psalm-type McpConfig = array{ + * app?: scalar|Param|null, // Default: "app" + * version?: scalar|Param|null, // Default: "0.0.1" + * description?: scalar|Param|null, // Default: null + * icons?: list, + * }>, + * website_url?: scalar|Param|null, // Default: null + * pagination_limit?: int|Param, // Default: 50 + * instructions?: scalar|Param|null, // Default: null + * client_transports?: array{ + * stdio?: bool|Param, // Default: false + * http?: bool|Param, // Default: false + * }, + * discovery?: array{ + * scan_dirs?: list, + * exclude_dirs?: list, + * }, + * http?: array{ + * path?: scalar|Param|null, // Default: "/_mcp" + * session?: array{ + * store?: "file"|"memory"|"cache"|"framework"|Param, // Default: "file" + * directory?: scalar|Param|null, // Default: "%kernel.cache_dir%/mcp-sessions" + * cache_pool?: scalar|Param|null, // Default: "cache.mcp.sessions" + * prefix?: scalar|Param|null, // Default: "mcp-" + * ttl?: int|Param, // Default: 3600 + * }, + * }, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -3214,6 +3245,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, * ai?: AiConfig, + * mcp?: McpConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -3248,6 +3280,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * jbtronics_translation_editor?: JbtronicsTranslationEditorConfig, * api_platform?: ApiPlatformConfig, * ai?: AiConfig, + * mcp?: McpConfig, * }, * "when@docker"?: array{ * imports?: ImportsConfig, @@ -3279,6 +3312,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, * ai?: AiConfig, + * mcp?: McpConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -3310,6 +3344,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, * ai?: AiConfig, + * mcp?: McpConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -3344,6 +3379,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, * ai?: AiConfig, + * mcp?: McpConfig, * }, * ... Date: Wed, 6 May 2026 23:37:38 +0200 Subject: [PATCH 02/25] Fixed mcp compatibility --- composer.json | 4 +- composer.lock | 169 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 133 insertions(+), 40 deletions(-) diff --git a/composer.json b/composer.json index 121c41bf..6fc60d18 100644 --- a/composer.json +++ b/composer.json @@ -15,8 +15,9 @@ "amphp/http-client": "^5.1", "api-platform/doctrine-orm": "^4.1", "api-platform/json-api": "^4.0.0", - "api-platform/mcp": "^4.3", + "api-platform/mcp": "4.3.x-dev", "api-platform/symfony": "^4.0.0", + "api-platform/metadata": "4.3.x-dev", "beberlei/doctrineextensions": "^1.2", "brick/math": "^0.17.0", "brick/schema": "^0.2.0", @@ -41,6 +42,7 @@ "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", "maennchen/zipstream-php": "2.1", + "mcp/sdk": "v0.5.0 as 0.4.0", "nbgrp/onelogin-saml-bundle": "^v2.0.2", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.3", diff --git a/composer.lock b/composer.lock index e9d07b43..c950c757 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,11 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], +<<<<<<< HEAD "content-hash": "d6bda397c505e1e6d540c814a2368fbb", +======= + "content-hash": "79f9618efcdf9891b62843fb5c3fb8de", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "packages": [ { "name": "amphp/amp", @@ -1628,22 +1632,22 @@ }, { "name": "api-platform/mcp", - "version": "v4.3.4", + "version": "4.3.x-dev", "source": { "type": "git", "url": "https://github.com/api-platform/mcp.git", - "reference": "a984d05c20a02dfc0dd7691aaac8a9ba546f93ee" + "reference": "407c1039e8e022f7baed7d2cfbf63f5f09957f6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/mcp/zipball/a984d05c20a02dfc0dd7691aaac8a9ba546f93ee", - "reference": "a984d05c20a02dfc0dd7691aaac8a9ba546f93ee", + "url": "https://api.github.com/repos/api-platform/mcp/zipball/407c1039e8e022f7baed7d2cfbf63f5f09957f6b", + "reference": "407c1039e8e022f7baed7d2cfbf63f5f09957f6b", "shasum": "" }, "require": { "api-platform/json-schema": "^4.3", "api-platform/metadata": "^4.3", - "mcp/sdk": "^0.4.0", + "mcp/sdk": ">=0.4 <1.0", "php": ">=8.2", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/polyfill-php85": "^1.32" @@ -1699,12 +1703,13 @@ ], "support": { "issues": "https://github.com/api-platform/mcp/issues", - "source": "https://github.com/api-platform/mcp/tree/v4.3.4" + "source": "https://github.com/api-platform/mcp/tree/4.3" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-06T12:07:59+00:00" }, { "name": "api-platform/metadata", +<<<<<<< HEAD "version": "v4.3.6", "source": { "type": "git", @@ -1715,6 +1720,18 @@ "type": "zip", "url": "https://api.github.com/repos/api-platform/metadata/zipball/e9e8a7b85d2d513edff3b108072f8ab23a9d6344", "reference": "e9e8a7b85d2d513edff3b108072f8ab23a9d6344", +======= + "version": "4.3.x-dev", + "source": { + "type": "git", + "url": "https://github.com/api-platform/metadata.git", + "reference": "52b367f046c5d202629e9441aece39b0e6b37838" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/52b367f046c5d202629e9441aece39b0e6b37838", + "reference": "52b367f046c5d202629e9441aece39b0e6b37838", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "shasum": "" }, "require": { @@ -1797,9 +1814,15 @@ "swagger" ], "support": { +<<<<<<< HEAD "source": "https://github.com/api-platform/metadata/tree/v4.3.6" }, "time": "2026-05-22T12:00:17+00:00" +======= + "source": "https://github.com/api-platform/metadata/tree/4.3" + }, + "time": "2026-05-06T12:07:59+00:00" +>>>>>>> dc5b8184 (Fixed mcp compatibility) }, { "name": "api-platform/openapi", @@ -7147,16 +7170,16 @@ }, { "name": "mcp/sdk", - "version": "v0.4.0", + "version": "v0.5.0", "source": { "type": "git", "url": "https://github.com/modelcontextprotocol/php-sdk.git", - "reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024" + "reference": "fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024", - "reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8", + "reference": "fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8", "shasum": "" }, "require": { @@ -7164,19 +7187,22 @@ "opis/json-schema": "^2.4", "php": "^8.1", "php-http/discovery": "^1.20", - "phpdocumentor/reflection-docblock": "^5.6", + "phpdocumentor/reflection-docblock": "^5.6 || ^6.0", "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "require-dev": { + "composer/semver": "^3.0", + "ext-openssl": "*", + "firebase/php-jwt": "^6.10 || ^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", @@ -7188,8 +7214,13 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, + "suggest": { + "symfony/finder": "Required for file-based discovery." + }, "type": "library", "autoload": { "psr-4": { @@ -7217,9 +7248,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.4.0" + "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.5.0" }, - "time": "2026-02-23T21:42:54+00:00" + "time": "2026-04-26T13:37:40+00:00" }, { "name": "mf2/mf2", @@ -9174,16 +9205,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.7", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -9191,8 +9222,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -9202,7 +9233,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -9232,44 +9264,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2026-03-18T20:47:46+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -9290,9 +9322,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpoffice/phpspreadsheet", @@ -17930,6 +17962,7 @@ }, { "name": "symfony/yaml", +<<<<<<< HEAD "version": "v7.4.12", "source": { "type": "git", @@ -17940,6 +17973,18 @@ "type": "zip", "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", +======= + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c660d6538545a3e8e65a5621ee3d7a6d352892c7", + "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "shasum": "" }, "require": { @@ -17982,7 +18027,11 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { +<<<<<<< HEAD "source": "https://github.com/symfony/yaml/tree/v7.4.12" +======= + "source": "https://github.com/symfony/yaml/tree/v7.4.10" +>>>>>>> dc5b8184 (Fixed mcp compatibility) }, "funding": [ { @@ -18002,7 +18051,11 @@ "type": "tidelift" } ], +<<<<<<< HEAD "time": "2026-05-20T07:20:23+00:00" +======= + "time": "2026-05-05T08:01:55+00:00" +>>>>>>> dc5b8184 (Fixed mcp compatibility) }, { "name": "symplify/easy-coding-standard", @@ -20811,12 +20864,21 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", +<<<<<<< HEAD "reference": "f9f1a88a11437cacd4d26b4953416af5c5425389" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/f9f1a88a11437cacd4d26b4953416af5c5425389", "reference": "f9f1a88a11437cacd4d26b4953416af5c5425389", +======= + "reference": "de95f6d567110e037eddcefa3d0d8f22d75d4053" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/de95f6d567110e037eddcefa3d0d8f22d75d4053", + "reference": "de95f6d567110e037eddcefa3d0d8f22d75d4053", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "shasum": "" }, "conflict": { @@ -21139,7 +21201,11 @@ "georgringer/news": "<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", +<<<<<<< HEAD "getgrav/grav": "<=2.0.0.0-RC1", +======= + "getgrav/grav": "<2.0.0.0-beta4", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "getgrav/grav-plugin-api": "<1.0.0.0-beta15", "getgrav/grav-plugin-form": "<9.1", "getkirby/cms": "<4.9|>=5,<5.4", @@ -21440,7 +21506,11 @@ "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", +<<<<<<< HEAD "phpmyfaq/phpmyfaq": "<4.1.3", +======= + "phpmyfaq/phpmyfaq": "<=4.1.1", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", @@ -21600,7 +21670,11 @@ "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", +<<<<<<< HEAD "statamic/cms": "<5.73.22|>=6,<6.18.1", +======= + "statamic/cms": "<5.73.21|>=6,<6.15", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.67", "studiomitte/friendlycaptcha": "<0.1.4", @@ -21683,7 +21757,11 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", +<<<<<<< HEAD "thorsten/phpmyfaq": "<4.1.3", +======= + "thorsten/phpmyfaq": "<=4.1.1", +>>>>>>> dc5b8184 (Fixed mcp compatibility) "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.2", @@ -21889,7 +21967,11 @@ "type": "tidelift" } ], +<<<<<<< HEAD "time": "2026-05-24T20:23:40+00:00" +======= + "time": "2026-05-06T21:27:25+00:00" +>>>>>>> dc5b8184 (Fixed mcp compatibility) }, { "name": "sebastian/cli-parser", @@ -23404,9 +23486,18 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "mcp/sdk", + "version": "0.5.0.0", + "alias": "0.4.0", + "alias_normalized": "0.4.0.0" + } + ], "minimum-stability": "stable", "stability-flags": { + "api-platform/mcp": 20, + "api-platform/metadata": 20, "roave/security-advisories": 20 }, "prefer-stable": false, From 75ef304fdb9086560387faf3e611199e30d68ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 10 May 2026 12:28:25 +0200 Subject: [PATCH 03/25] Added basic MCP tools to search for parts and get part details --- src/Entity/Parts/Part.php | 23 ++++++++ src/Mcp/DTO/ElementByIdInput.php | 35 +++++++++++ src/State/Mcp/GetPartByIdProcessor.php | 60 +++++++++++++++++++ src/State/Mcp/SearchPartsProcessor.php | 80 ++++++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 src/Mcp/DTO/ElementByIdInput.php create mode 100644 src/State/Mcp/GetPartByIdProcessor.php create mode 100644 src/State/Mcp/SearchPartsProcessor.php diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 5ac81b60..97c051fb 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -32,6 +32,8 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Serializer\Filter\PropertyFilter; @@ -39,6 +41,7 @@ use App\ApiPlatform\Filter\EntityFilter; use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\PartStoragelocationFilter; use App\ApiPlatform\Filter\TagFilter; +use App\DataTables\Filters\PartSearchFilter; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Attachments\PartAttachment; @@ -55,7 +58,10 @@ use App\Entity\Parts\PartTraits\ManufacturerTrait; use App\Entity\Parts\PartTraits\OrderTrait; use App\Entity\Parts\PartTraits\ProjectTrait; use App\EntityListeners\TreeCacheInvalidationListener; +use App\Mcp\DTO\ElementByIdInput; use App\Repository\PartRepository; +use App\State\Mcp\GetPartByIdProcessor; +use App\State\Mcp\SearchPartsProcessor; use App\Validator\Constraints\UniqueObjectCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -104,6 +110,23 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; ], normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], + mcp: [ + 'search_parts' => new McpToolCollection( + title: "Search parts by keyword", + description: 'Search for parts', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + input: PartSearchFilter::class, + processor: SearchPartsProcessor::class, + ), + 'get_part_details' => new McpTool( + title: 'Get part details by ID', + description: 'Get detailed information about a specific part by its database ID', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read']], + input: ElementByIdInput::class, + processor: GetPartByIdProcessor::class + ), + ], )] #[ApiFilter(PropertyFilter::class)] #[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit", "partCustomState"])] diff --git a/src/Mcp/DTO/ElementByIdInput.php b/src/Mcp/DTO/ElementByIdInput.php new file mode 100644 index 00000000..bade3af2 --- /dev/null +++ b/src/Mcp/DTO/ElementByIdInput.php @@ -0,0 +1,35 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +use Symfony\Component\Validator\Constraints as Assert; + +readonly class ElementByIdInput +{ + public function __construct( + #[Assert\NotNull] + #[Assert\Positive] + public int $id, + ) { + } +} diff --git a/src/State/Mcp/GetPartByIdProcessor.php b/src/State/Mcp/GetPartByIdProcessor.php new file mode 100644 index 00000000..b147ef68 --- /dev/null +++ b/src/State/Mcp/GetPartByIdProcessor.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Entity\Parts\Part; +use App\Mcp\DTO\ElementByIdInput; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; + +class GetPartByIdProcessor implements ProcessorInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof ElementByIdInput) { + throw new \InvalidArgumentException('Expected PartByIdInput'); + } + + $part = $this->entityManager->find(Part::class, $data->id); + + if (!$part instanceof Part) { + throw new NotFoundHttpException(sprintf('Part with id %d not found', $data->id)); + } + + if (!$this->authorizationChecker->isGranted('read', $part)) { + throw new AccessDeniedException(sprintf('Access denied to part with id %d', $data->id)); + } + + return $part; + } +} diff --git a/src/State/Mcp/SearchPartsProcessor.php b/src/State/Mcp/SearchPartsProcessor.php new file mode 100644 index 00000000..8d922394 --- /dev/null +++ b/src/State/Mcp/SearchPartsProcessor.php @@ -0,0 +1,80 @@ +. + */ + +declare(strict_types=1); + + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\DataTables\Filters\PartSearchFilter; +use App\Entity\Parts\Part; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; + +class SearchPartsProcessor implements ProcessorInterface +{ + + public function __construct( + private readonly EntityManagerInterface $entityManager, + ) { + + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof PartSearchFilter) { + return []; + } + + $qb = $this->entityManager->getRepository(Part::class)->createQueryBuilder('part'); + + $data->apply($qb); + $this->addJoins($qb); + + $qb->addGroupBy('part'); + + return $qb->getQuery()->getResult(); + } + + private function addJoins(QueryBuilder $qb): void + { + $dql = $qb->getDQL(); + + if (str_contains($dql, '_category')) { + $qb->leftJoin('part.category', '_category'); + } + if (str_contains($dql, '_storelocations')) { + $qb->leftJoin('part.partLots', '_partLots'); + $qb->leftJoin('_partLots.storage_location', '_storelocations'); + } + if (str_contains($dql, '_orderdetails') || str_contains($dql, '_suppliers')) { + $qb->leftJoin('part.orderdetails', '_orderdetails'); + $qb->leftJoin('_orderdetails.supplier', '_suppliers'); + } + if (str_contains($dql, '_manufacturer')) { + $qb->leftJoin('part.manufacturer', '_manufacturer'); + } + if (str_contains($dql, '_footprint')) { + $qb->leftJoin('part.footprint', '_footprint'); + } + } +} From fc8769e220e6d8a7a4ee9f4c78a4c896935615fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 10 May 2026 13:44:49 +0200 Subject: [PATCH 04/25] updated dependencies --- composer.lock | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/composer.lock b/composer.lock index c950c757..eff8a735 100644 --- a/composer.lock +++ b/composer.lock @@ -4085,6 +4085,7 @@ }, { "name": "doctrine/orm", +<<<<<<< HEAD "version": "3.6.7", "source": { "type": "git", @@ -4095,6 +4096,18 @@ "type": "zip", "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", +======= + "version": "3.6.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/orm.git", + "reference": "156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/orm/zipball/156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd", + "reference": "156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd", +>>>>>>> 3f10a123 (updated dependencies) "shasum": "" }, "require": { @@ -4167,9 +4180,15 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", +<<<<<<< HEAD "source": "https://github.com/doctrine/orm/tree/3.6.7" }, "time": "2026-05-25T16:45:47+00:00" +======= + "source": "https://github.com/doctrine/orm/tree/3.6.4" + }, + "time": "2026-05-07T07:04:34+00:00" +>>>>>>> 3f10a123 (updated dependencies) }, { "name": "doctrine/persistence", @@ -20269,6 +20288,7 @@ }, { "name": "phpstan/phpstan-symfony", +<<<<<<< HEAD "version": "2.0.18", "source": { "type": "git", @@ -20279,6 +20299,18 @@ "type": "zip", "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", +======= + "version": "2.0.17", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/fdd0cb5f08d1980c612d6f259d825ea644ed03f4", + "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4", +>>>>>>> 3f10a123 (updated dependencies) "shasum": "" }, "require": { @@ -20337,9 +20369,15 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", +<<<<<<< HEAD "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.18" }, "time": "2026-05-18T14:51:49+00:00" +======= + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.17" + }, + "time": "2026-05-10T08:14:07+00:00" +>>>>>>> 3f10a123 (updated dependencies) }, { "name": "phpunit/php-code-coverage", @@ -20864,6 +20902,7 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", +<<<<<<< HEAD <<<<<<< HEAD "reference": "f9f1a88a11437cacd4d26b4953416af5c5425389" }, @@ -20879,6 +20918,14 @@ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/de95f6d567110e037eddcefa3d0d8f22d75d4053", "reference": "de95f6d567110e037eddcefa3d0d8f22d75d4053", >>>>>>> dc5b8184 (Fixed mcp compatibility) +======= + "reference": "5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c", + "reference": "5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c", +>>>>>>> 3f10a123 (updated dependencies) "shasum": "" }, "conflict": { @@ -21402,7 +21449,10 @@ "miraheze/ts-portal": "<=33", "mittwald/typo3_forum": "<1.2.1", "mix/mix": ">=2,<=2.2.17", +<<<<<<< HEAD "mmc/ceselector": "<3.0.3|>=4,<4.0.2|>=5,<5.0.1|>=6,<6.0.1", +======= +>>>>>>> 3f10a123 (updated dependencies) "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", @@ -21967,11 +22017,15 @@ "type": "tidelift" } ], +<<<<<<< HEAD <<<<<<< HEAD "time": "2026-05-24T20:23:40+00:00" ======= "time": "2026-05-06T21:27:25+00:00" >>>>>>> dc5b8184 (Fixed mcp compatibility) +======= + "time": "2026-05-08T23:22:52+00:00" +>>>>>>> 3f10a123 (updated dependencies) }, { "name": "sebastian/cli-parser", From c4f0583e54f74e72408f5838c5b550cd5142fee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 10 May 2026 16:21:34 +0200 Subject: [PATCH 05/25] Fixed get part details tool --- src/Entity/Parts/Part.php | 8 +- src/Mcp/JsonSchema/FixedSchemaFactory.php | 162 ++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/Mcp/JsonSchema/FixedSchemaFactory.php diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 97c051fb..bbc0f8d1 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -86,7 +86,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')] #[ORM\Index(columns: ['name'], name: 'parts_idx_name')] #[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')] -#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')] +#[ORM\Index(name: 'parts_idx_gtin', columns: ['gtin'])] #[ApiResource( operations: [ new Get(normalizationContext: [ @@ -122,8 +122,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; title: 'Get part details by ID', description: 'Get detailed information about a specific part by its database ID', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], - normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read']], + normalizationContext: [ + 'groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'], + 'item_uri_template' => '/api/parts/{id}', + ], input: ElementByIdInput::class, + validate: true, processor: GetPartByIdProcessor::class ), ], diff --git a/src/Mcp/JsonSchema/FixedSchemaFactory.php b/src/Mcp/JsonSchema/FixedSchemaFactory.php new file mode 100644 index 00000000..c851c394 --- /dev/null +++ b/src/Mcp/JsonSchema/FixedSchemaFactory.php @@ -0,0 +1,162 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\DependencyInjection\Attribute\AsAlias; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; + +/** + * Overwrite the default JSON Schema factory to resolve $ref and allOf into a flat schema. + * This is a workaround until https://github.com/api-platform/core/pull/7962 is merged + */ +#[AsAlias('api_platform.mcp.json_schema.schema_factory')] +readonly class FixedSchemaFactory implements SchemaFactoryInterface +{ + public function __construct( + private readonly SchemaFactoryInterface $decorated, + ) { + } + + public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema + { + $schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + + $definitions = []; + foreach ($schema->getDefinitions() as $key => $definition) { + $definitions[$key] = $definition instanceof \ArrayObject ? $definition->getArrayCopy() : (array) $definition; + } + + $rootKey = $schema->getRootDefinitionKey(); + if (null !== $rootKey) { + $root = $definitions[$rootKey] ?? []; + } else { + // Collection schemas (and others) put allOf/type directly on the root + $root = $schema->getArrayCopy(false); + } + + $flat = self::resolveNode($root, $definitions); + + $flatSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($flatSchema['$schema']); + foreach ($flat as $key => $value) { + $flatSchema[$key] = $value; + } + + return $flatSchema; + } + + /** + * Recursively resolve $ref, allOf, and nested structures into a flat schema node. + * + * @param array $resolving Tracks the current $ref resolution chain to detect circular references + */ + public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array + { + if ($node instanceof \ArrayObject) { + $node = $node->getArrayCopy(); + } + + if (isset($node['$ref'])) { + $refKey = str_replace('#/definitions/', '', $node['$ref']); + if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) { + return ['type' => 'object']; + } + $resolving[$refKey] = true; + $resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving); + unset($resolving[$refKey]); + + return $resolved; + } + + if (isset($node['allOf'])) { + $merged = ['type' => 'object', 'properties' => []]; + $requiredSets = []; + foreach ($node['allOf'] as $entry) { + $resolved = self::resolveNode($entry, $definitions, $resolving); + if (isset($resolved['properties'])) { + foreach ($resolved['properties'] as $k => $v) { + $merged['properties'][$k] = $v; + } + } + if (isset($resolved['required'])) { + $requiredSets[] = $resolved['required']; + } + } + + if ($requiredSets) { + $merged['required'] = array_merge(...$requiredSets); + } + if ([] === $merged['properties']) { + unset($merged['properties']); + } + if (isset($node['description'])) { + $merged['description'] = $node['description']; + } + + return self::resolveDeep($merged, $definitions, $resolving); + } + + // oneOf/anyOf nodes must not receive a type fallback — their type is expressed + // through the sub-schemas. Adding 'type: object' here would break schemas like + // HydraItemBaseSchema's @context, which is oneOf: [string, object]. + if (isset($node['oneOf']) || isset($node['anyOf'])) { + return self::resolveDeep($node, $definitions, $resolving); + } + + if (!isset($node['type'])) { + $node['type'] = 'object'; + } + + return self::resolveDeep($node, $definitions, $resolving); + } + + /** + * Recursively resolve nested properties and array items. + */ + private static function resolveDeep(array $node, array $definitions, array &$resolving): array + { + if (isset($node['items'])) { + $node['items'] = self::resolveNode( + $node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'], + $definitions, + $resolving, + ); + } + + if (isset($node['properties']) && \is_array($node['properties'])) { + foreach ($node['properties'] as $propName => $propSchema) { + $node['properties'][$propName] = self::resolveNode( + $propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema, + $definitions, + $resolving, + ); + } + } + + return $node; + } +} From a9d75554d7112291627f696926346e6e7ac7e543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 26 May 2026 22:00:26 +0200 Subject: [PATCH 06/25] Updated dependencies --- composer.json | 6 +- composer.lock | 275 +++++++++++++------------------------------------- 2 files changed, 74 insertions(+), 207 deletions(-) diff --git a/composer.json b/composer.json index 6fc60d18..3fcaa5e3 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "amphp/http-client": "^5.1", "api-platform/doctrine-orm": "^4.1", "api-platform/json-api": "^4.0.0", - "api-platform/mcp": "4.3.x-dev", + "api-platform/mcp": "^v4.3.6", "api-platform/symfony": "^4.0.0", "api-platform/metadata": "4.3.x-dev", "beberlei/doctrineextensions": "^1.2", @@ -42,7 +42,7 @@ "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", "maennchen/zipstream-php": "2.1", - "mcp/sdk": "v0.5.0 as 0.4.0", + "mcp/sdk": "v0.5.0", "nbgrp/onelogin-saml-bundle": "^v2.0.2", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.3", @@ -76,7 +76,7 @@ "symfony/http-client": "7.4.*", "symfony/http-kernel": "7.4.*", "symfony/mailer": "7.4.*", - "symfony/mcp-bundle": "^0.8.0", + "symfony/mcp-bundle": "^v0.9.0.0", "symfony/monolog-bundle": "^4.0", "symfony/process": "7.4.*", "symfony/property-access": "7.4.*", diff --git a/composer.lock b/composer.lock index eff8a735..c409386c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,11 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], -<<<<<<< HEAD - "content-hash": "d6bda397c505e1e6d540c814a2368fbb", -======= - "content-hash": "79f9618efcdf9891b62843fb5c3fb8de", ->>>>>>> dc5b8184 (Fixed mcp compatibility) + "content-hash": "9171608b0a2f6caa65f4eca3b66ae351", "packages": [ { "name": "amphp/amp", @@ -1632,16 +1628,16 @@ }, { "name": "api-platform/mcp", - "version": "4.3.x-dev", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/mcp.git", - "reference": "407c1039e8e022f7baed7d2cfbf63f5f09957f6b" + "reference": "35177126b8beb69169e7a33a325e00dbbdbbd7bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/mcp/zipball/407c1039e8e022f7baed7d2cfbf63f5f09957f6b", - "reference": "407c1039e8e022f7baed7d2cfbf63f5f09957f6b", + "url": "https://api.github.com/repos/api-platform/mcp/zipball/35177126b8beb69169e7a33a325e00dbbdbbd7bf", + "reference": "35177126b8beb69169e7a33a325e00dbbdbbd7bf", "shasum": "" }, "require": { @@ -1703,14 +1699,13 @@ ], "support": { "issues": "https://github.com/api-platform/mcp/issues", - "source": "https://github.com/api-platform/mcp/tree/4.3" + "source": "https://github.com/api-platform/mcp/tree/v4.3.6" }, - "time": "2026-05-06T12:07:59+00:00" + "time": "2026-05-11T06:02:06+00:00" }, { "name": "api-platform/metadata", -<<<<<<< HEAD - "version": "v4.3.6", + "version": "4.3.x-dev", "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", @@ -1720,18 +1715,6 @@ "type": "zip", "url": "https://api.github.com/repos/api-platform/metadata/zipball/e9e8a7b85d2d513edff3b108072f8ab23a9d6344", "reference": "e9e8a7b85d2d513edff3b108072f8ab23a9d6344", -======= - "version": "4.3.x-dev", - "source": { - "type": "git", - "url": "https://github.com/api-platform/metadata.git", - "reference": "52b367f046c5d202629e9441aece39b0e6b37838" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/api-platform/metadata/zipball/52b367f046c5d202629e9441aece39b0e6b37838", - "reference": "52b367f046c5d202629e9441aece39b0e6b37838", ->>>>>>> dc5b8184 (Fixed mcp compatibility) "shasum": "" }, "require": { @@ -1814,15 +1797,9 @@ "swagger" ], "support": { -<<<<<<< HEAD - "source": "https://github.com/api-platform/metadata/tree/v4.3.6" - }, - "time": "2026-05-22T12:00:17+00:00" -======= "source": "https://github.com/api-platform/metadata/tree/4.3" }, - "time": "2026-05-06T12:07:59+00:00" ->>>>>>> dc5b8184 (Fixed mcp compatibility) + "time": "2026-05-22T12:00:17+00:00" }, { "name": "api-platform/openapi", @@ -2442,16 +2419,16 @@ }, { "name": "brick/math", - "version": "0.17.1", + "version": "0.17.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b" + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", - "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", + "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "shasum": "" }, "require": { @@ -2489,7 +2466,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.1" + "source": "https://github.com/brick/math/tree/0.17.2" }, "funding": [ { @@ -2497,7 +2474,7 @@ "type": "github" } ], - "time": "2026-04-19T20:55:20+00:00" + "time": "2026-05-25T20:34:43+00:00" }, { "name": "brick/schema", @@ -4085,7 +4062,6 @@ }, { "name": "doctrine/orm", -<<<<<<< HEAD "version": "3.6.7", "source": { "type": "git", @@ -4096,18 +4072,6 @@ "type": "zip", "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", -======= - "version": "3.6.4", - "source": { - "type": "git", - "url": "https://github.com/doctrine/orm.git", - "reference": "156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd", - "reference": "156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd", ->>>>>>> 3f10a123 (updated dependencies) "shasum": "" }, "require": { @@ -4180,15 +4144,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", -<<<<<<< HEAD "source": "https://github.com/doctrine/orm/tree/3.6.7" }, "time": "2026-05-25T16:45:47+00:00" -======= - "source": "https://github.com/doctrine/orm/tree/3.6.4" - }, - "time": "2026-05-07T07:04:34+00:00" ->>>>>>> 3f10a123 (updated dependencies) }, { "name": "doctrine/persistence", @@ -4965,16 +4923,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.10.1", + "version": "2.10.2", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" + "reference": "a1bbdc172f32a25fe999965b65b6e71fd87da9ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", - "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a1bbdc172f32a25fe999965b65b6e71fd87da9ed", + "reference": "a1bbdc172f32a25fe999965b65b6e71fd87da9ed", "shasum": "" }, "require": { @@ -5062,7 +5020,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.1" + "source": "https://github.com/guzzle/psr7/tree/2.10.2" }, "funding": [ { @@ -5078,7 +5036,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:36+00:00" + "time": "2026-05-25T22:58:15+00:00" }, { "name": "hshn/base64-encoded-file", @@ -14154,24 +14112,25 @@ }, { "name": "symfony/mcp-bundle", - "version": "v0.8.0", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/symfony/mcp-bundle.git", - "reference": "b8db100e64fcb2d651e3bfd6c6ab70ad3599833d" + "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/b8db100e64fcb2d651e3bfd6c6ab70ad3599833d", - "reference": "b8db100e64fcb2d651e3bfd6c6ab70ad3599833d", + "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/654f639e94f4d7694771e6628380a9ff04c9d9c1", + "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1", "shasum": "" }, "require": { - "mcp/sdk": "^0.4", + "mcp/sdk": "^0.5", "php-http/discovery": "^1.20", "symfony/config": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", "symfony/dependency-injection": "^7.3|^8.0", + "symfony/finder": "^7.3|^8.0", "symfony/framework-bundle": "^7.3|^8.0", "symfony/http-foundation": "^7.3|^8.0", "symfony/http-kernel": "^7.3|^8.0", @@ -14214,7 +14173,7 @@ ], "description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)", "support": { - "source": "https://github.com/symfony/mcp-bundle/tree/v0.8.0" + "source": "https://github.com/symfony/mcp-bundle/tree/v0.9.0" }, "funding": [ { @@ -14234,7 +14193,7 @@ "type": "tidelift" } ], - "time": "2026-04-12T00:28:34+00:00" + "time": "2026-05-15T23:41:17+00:00" }, { "name": "symfony/mime", @@ -14792,16 +14751,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "9c862df890f7c833b1101ac5578ec4dcf199efb5" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/9c862df890f7c833b1101ac5578ec4dcf199efb5", - "reference": "9c862df890f7c833b1101ac5578ec4dcf199efb5", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -14850,7 +14809,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -14870,7 +14829,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T12:39:52+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-icu", @@ -14962,16 +14921,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -15025,7 +14984,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -15045,7 +15004,7 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", @@ -15134,16 +15093,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "c4406e07046227db8844a25f89d111da078aaa9c" + "reference": "8339098cae28673c15cce00d80734af0453054e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/c4406e07046227db8844a25f89d111da078aaa9c", - "reference": "c4406e07046227db8844a25f89d111da078aaa9c", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", "shasum": "" }, "require": { @@ -15190,7 +15149,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" }, "funding": [ { @@ -15210,20 +15169,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T12:39:52+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.38.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "a0e0aca0368801ec79f8791dea9a7c12af527c93" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/a0e0aca0368801ec79f8791dea9a7c12af527c93", - "reference": "a0e0aca0368801ec79f8791dea9a7c12af527c93", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -15270,7 +15229,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -15290,20 +15249,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T12:12:52+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.38.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "3e851ffb42624b64b3b9ec234a0e2074b0f880e3" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/3e851ffb42624b64b3b9ec234a0e2074b0f880e3", - "reference": "3e851ffb42624b64b3b9ec234a0e2074b0f880e3", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -15350,7 +15309,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -15370,7 +15329,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T11:50:50+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-uuid", @@ -17981,7 +17940,6 @@ }, { "name": "symfony/yaml", -<<<<<<< HEAD "version": "v7.4.12", "source": { "type": "git", @@ -17992,18 +17950,6 @@ "type": "zip", "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", -======= - "version": "v7.4.10", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/c660d6538545a3e8e65a5621ee3d7a6d352892c7", - "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7", ->>>>>>> dc5b8184 (Fixed mcp compatibility) "shasum": "" }, "require": { @@ -18046,11 +17992,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { -<<<<<<< HEAD "source": "https://github.com/symfony/yaml/tree/v7.4.12" -======= - "source": "https://github.com/symfony/yaml/tree/v7.4.10" ->>>>>>> dc5b8184 (Fixed mcp compatibility) }, "funding": [ { @@ -18070,11 +18012,7 @@ "type": "tidelift" } ], -<<<<<<< HEAD "time": "2026-05-20T07:20:23+00:00" -======= - "time": "2026-05-05T08:01:55+00:00" ->>>>>>> dc5b8184 (Fixed mcp compatibility) }, { "name": "symplify/easy-coding-standard", @@ -20107,11 +20045,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.55", + "version": "2.1.56", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", - "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/93a603c9fc3be8c3c93bbc8d22170ad766685537", + "reference": "93a603c9fc3be8c3c93bbc8d22170ad766685537", "shasum": "" }, "require": { @@ -20156,7 +20094,7 @@ "type": "github" } ], - "time": "2026-05-18T11:57:34+00:00" + "time": "2026-05-26T17:04:57+00:00" }, { "name": "phpstan/phpstan-doctrine", @@ -20288,7 +20226,6 @@ }, { "name": "phpstan/phpstan-symfony", -<<<<<<< HEAD "version": "2.0.18", "source": { "type": "git", @@ -20299,18 +20236,6 @@ "type": "zip", "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", -======= - "version": "2.0.17", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/fdd0cb5f08d1980c612d6f259d825ea644ed03f4", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4", ->>>>>>> 3f10a123 (updated dependencies) "shasum": "" }, "require": { @@ -20369,15 +20294,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", -<<<<<<< HEAD "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.18" }, "time": "2026-05-18T14:51:49+00:00" -======= - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.17" - }, - "time": "2026-05-10T08:14:07+00:00" ->>>>>>> 3f10a123 (updated dependencies) }, { "name": "phpunit/php-code-coverage", @@ -20902,30 +20821,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", -<<<<<<< HEAD -<<<<<<< HEAD - "reference": "f9f1a88a11437cacd4d26b4953416af5c5425389" + "reference": "11be66e3adc8bf2c9805209a598ad4b42ee3c0d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/f9f1a88a11437cacd4d26b4953416af5c5425389", - "reference": "f9f1a88a11437cacd4d26b4953416af5c5425389", -======= - "reference": "de95f6d567110e037eddcefa3d0d8f22d75d4053" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/de95f6d567110e037eddcefa3d0d8f22d75d4053", - "reference": "de95f6d567110e037eddcefa3d0d8f22d75d4053", ->>>>>>> dc5b8184 (Fixed mcp compatibility) -======= - "reference": "5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c", - "reference": "5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c", ->>>>>>> 3f10a123 (updated dependencies) + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/11be66e3adc8bf2c9805209a598ad4b42ee3c0d6", + "reference": "11be66e3adc8bf2c9805209a598ad4b42ee3c0d6", "shasum": "" }, "conflict": { @@ -21248,11 +21149,7 @@ "georgringer/news": "<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", -<<<<<<< HEAD "getgrav/grav": "<=2.0.0.0-RC1", -======= - "getgrav/grav": "<2.0.0.0-beta4", ->>>>>>> dc5b8184 (Fixed mcp compatibility) "getgrav/grav-plugin-api": "<1.0.0.0-beta15", "getgrav/grav-plugin-form": "<9.1", "getkirby/cms": "<4.9|>=5,<5.4", @@ -21449,10 +21346,7 @@ "miraheze/ts-portal": "<=33", "mittwald/typo3_forum": "<1.2.1", "mix/mix": ">=2,<=2.2.17", -<<<<<<< HEAD "mmc/ceselector": "<3.0.3|>=4,<4.0.2|>=5,<5.0.1|>=6,<6.0.1", -======= ->>>>>>> 3f10a123 (updated dependencies) "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", @@ -21556,11 +21450,7 @@ "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", -<<<<<<< HEAD "phpmyfaq/phpmyfaq": "<4.1.3", -======= - "phpmyfaq/phpmyfaq": "<=4.1.1", ->>>>>>> dc5b8184 (Fixed mcp compatibility) "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", @@ -21608,7 +21498,7 @@ "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", "propel/propel1": ">=1,<=1.7.1", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", - "pterodactyl/panel": "<1.12.1", + "pterodactyl/panel": "<1.12.3", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", @@ -21720,11 +21610,7 @@ "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", -<<<<<<< HEAD "statamic/cms": "<5.73.22|>=6,<6.18.1", -======= - "statamic/cms": "<5.73.21|>=6,<6.15", ->>>>>>> dc5b8184 (Fixed mcp compatibility) "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.67", "studiomitte/friendlycaptcha": "<0.1.4", @@ -21769,7 +21655,8 @@ "symfony/mime": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/monolog-bridge": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/polyfill": ">=1,<1.10", + "symfony/polyfill": ">=1,<1.10|>=1.17.1,<1.38.1", + "symfony/polyfill-intl-idn": ">=1.17.1,<1.38.1", "symfony/polyfill-php55": ">=1,<1.10", "symfony/process": "<5.4.51|>=6,<6.4.33|>=7,<7.1.7|>=7.3,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5", "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", @@ -21807,11 +21694,7 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", -<<<<<<< HEAD "thorsten/phpmyfaq": "<4.1.3", -======= - "thorsten/phpmyfaq": "<=4.1.1", ->>>>>>> dc5b8184 (Fixed mcp compatibility) "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.2", @@ -22017,15 +21900,7 @@ "type": "tidelift" } ], -<<<<<<< HEAD -<<<<<<< HEAD - "time": "2026-05-24T20:23:40+00:00" -======= - "time": "2026-05-06T21:27:25+00:00" ->>>>>>> dc5b8184 (Fixed mcp compatibility) -======= - "time": "2026-05-08T23:22:52+00:00" ->>>>>>> 3f10a123 (updated dependencies) + "time": "2026-05-26T19:41:38+00:00" }, { "name": "sebastian/cli-parser", @@ -23540,17 +23415,9 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [ - { - "package": "mcp/sdk", - "version": "0.5.0.0", - "alias": "0.4.0", - "alias_normalized": "0.4.0.0" - } - ], + "aliases": [], "minimum-stability": "stable", "stability-flags": { - "api-platform/mcp": 20, "api-platform/metadata": 20, "roave/security-advisories": 20 }, From bcf439ba2274e6248d7645e35b0e0d860e2d6743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 31 May 2026 22:47:53 +0200 Subject: [PATCH 07/25] Allow to enable/disable MCP endpoint --- src/EventSubscriber/McpAccessSubscriber.php | 60 +++++++++++++++++++++ src/Settings/AISettings/AISettings.php | 3 ++ src/Settings/AISettings/McpSettings.php | 45 ++++++++++++++++ translations/messages.de.xlf | 20 ++++++- translations/messages.en.xlf | 18 +++++++ 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/EventSubscriber/McpAccessSubscriber.php create mode 100644 src/Settings/AISettings/McpSettings.php diff --git a/src/EventSubscriber/McpAccessSubscriber.php b/src/EventSubscriber/McpAccessSubscriber.php new file mode 100644 index 00000000..c7cab6e2 --- /dev/null +++ b/src/EventSubscriber/McpAccessSubscriber.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + +namespace App\EventSubscriber; + +use App\Settings\AISettings\McpSettings; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; +use Symfony\Component\HttpKernel\KernelEvents; + +readonly class McpAccessSubscriber implements EventSubscriberInterface +{ + public function __construct(private McpSettings $mcpSettings) + { + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onKernelRequest', 10], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $path = $event->getRequest()->getPathInfo(); + + if (!str_starts_with($path, '/mcp')) { + return; + } + + if (!$this->mcpSettings->enabled) { + throw new ServiceUnavailableHttpException(null, 'The MCP endpoint is disabled. Enable it in the system settings.'); + } + } +} diff --git a/src/Settings/AISettings/AISettings.php b/src/Settings/AISettings/AISettings.php index 732eb597..14544c04 100644 --- a/src/Settings/AISettings/AISettings.php +++ b/src/Settings/AISettings/AISettings.php @@ -35,6 +35,9 @@ class AISettings { use SettingsTrait; + #[EmbeddedSettings] + public ?McpSettings $mcp = null; + #[EmbeddedSettings] public ?OpenRouterSettings $openRouter = null; diff --git a/src/Settings/AISettings/McpSettings.php b/src/Settings/AISettings/McpSettings.php new file mode 100644 index 00000000..929efa41 --- /dev/null +++ b/src/Settings/AISettings/McpSettings.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + +namespace App\Settings\AISettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.misc.mcp"))] +#[SettingsIcon("fa-robot")] +class McpSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.misc.mcp.enabled"), + description: new TM("settings.misc.mcp.enabled.help"), + envVar: "bool:MCP_ENABLED", + envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $enabled = false; +} diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index f736121a..8908dcaf 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1,6 +1,6 @@ - + attachment_type.caption @@ -10034,6 +10034,24 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön Servereinstellungen + + + settings.misc.mcp + MCP (Model Context Protocol) Server + + + + + settings.misc.mcp.enabled + MCP-Endpunkt aktivieren + + + + + settings.misc.mcp.enabled.help + Aktiviert den MCP-Endpunkt (Model Context Protocol) unter /mcp, der es KI-Assistenten ermöglicht, mit Part-DB zu interagieren. + + settings.misc.kicad_eda diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 11dcbd8e..760b01e1 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -10035,6 +10035,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g Server settings + + + settings.misc.mcp + MCP (Model Context Protocol) Server + + + + + settings.misc.mcp.enabled + Enable MCP endpoint + + + + + settings.misc.mcp.enabled.help + Enable the MCP (Model Context Protocol) endpoint at /mcp, which allows AI assistants to interact with Part-DB. + + settings.misc.kicad_eda From 6dd08f6aeb93cf3524b53d9dcf333d6bd68940e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 31 May 2026 23:02:20 +0200 Subject: [PATCH 08/25] Added some metadata to the MCP server --- config/packages/mcp.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/packages/mcp.yaml b/config/packages/mcp.yaml index cabcad1b..4c2a9ea9 100644 --- a/config/packages/mcp.yaml +++ b/config/packages/mcp.yaml @@ -1,4 +1,12 @@ mcp: + app: "Part-DB" + version: "0.1.0" + description: "Part-DB is a inventory management database for electronic parts." + instructions: | + This server provides inventory information for your current user. It is mostly used for electronic parts, + but can be used for any kind of inventory. The stored objects are called parts. + If you miss some information from an search endpoint, try to retrieve more details using the ID of an entity. + client_transports: http: true stdio: false From b0ea329fdf3230fc922e4cfaad3460453b3bb831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Jul 2026 21:03:39 +0200 Subject: [PATCH 09/25] Updated mcp dependencies --- composer.json | 4 +- composer.lock | 160 +++++++++++-------------------------------- config/reference.php | 4 ++ 3 files changed, 47 insertions(+), 121 deletions(-) diff --git a/composer.json b/composer.json index c84f40c7..6738e91c 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.5.0", + "mcp/sdk": "v0.7.0", "nbgrp/onelogin-saml-bundle": "^v2.0.2", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.3", @@ -78,7 +78,7 @@ "symfony/http-client": "7.4.*", "symfony/http-kernel": "7.4.*", "symfony/mailer": "7.4.*", - "symfony/mcp-bundle": "^v0.9.0.0", + "symfony/mcp-bundle": "^v0.11.0", "symfony/monolog-bundle": "^4.0", "symfony/process": "7.4.*", "symfony/property-access": "7.4.*", diff --git a/composer.lock b/composer.lock index dfc361df..7d1ceb42 100644 --- a/composer.lock +++ b/composer.lock @@ -4,11 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], -<<<<<<< HEAD - "content-hash": "9171608b0a2f6caa65f4eca3b66ae351", -======= - "content-hash": "71f989737d92309c38cbbf0e4e6e0e6e", ->>>>>>> master + "content-hash": "7c31eed5ac91b2ca3b7e87bd245ab2e2", "packages": [ { "name": "amphp/amp", @@ -1631,22 +1627,17 @@ "time": "2026-06-13T05:11:46+00:00" }, { -<<<<<<< HEAD "name": "api-platform/mcp", - "version": "v4.3.6", -======= - "name": "api-platform/metadata", - "version": "v4.3.17", ->>>>>>> master + "version": "v4.3.13", "source": { "type": "git", "url": "https://github.com/api-platform/mcp.git", - "reference": "35177126b8beb69169e7a33a325e00dbbdbbd7bf" + "reference": "0a351f21438b5c7fd16a05fdcd67bdd44e1aa20b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/mcp/zipball/35177126b8beb69169e7a33a325e00dbbdbbd7bf", - "reference": "35177126b8beb69169e7a33a325e00dbbdbbd7bf", + "url": "https://api.github.com/repos/api-platform/mcp/zipball/0a351f21438b5c7fd16a05fdcd67bdd44e1aa20b", + "reference": "0a351f21438b5c7fd16a05fdcd67bdd44e1aa20b", "shasum": "" }, "require": { @@ -1708,9 +1699,9 @@ ], "support": { "issues": "https://github.com/api-platform/mcp/issues", - "source": "https://github.com/api-platform/mcp/tree/v4.3.6" + "source": "https://github.com/api-platform/mcp/tree/v4.3.13" }, - "time": "2026-05-11T06:02:06+00:00" + "time": "2026-06-10T14:43:58+00:00" }, { "name": "api-platform/metadata", @@ -1806,11 +1797,7 @@ "swagger" ], "support": { -<<<<<<< HEAD "source": "https://github.com/api-platform/metadata/tree/4.3" -======= - "source": "https://github.com/api-platform/metadata/tree/v4.3.17" ->>>>>>> master }, "time": "2026-07-03T06:30:28+00:00" }, @@ -4311,16 +4298,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.5", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", "shasum": "" }, "require": { @@ -4369,9 +4356,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" }, - "time": "2026-03-03T13:54:37+00:00" + "time": "2026-07-20T12:29:38+00:00" }, { "name": "dompdf/php-font-lib", @@ -4940,18 +4927,6 @@ }, { "name": "guzzlehttp/psr7", -<<<<<<< HEAD - "version": "2.10.2", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "a1bbdc172f32a25fe999965b65b6e71fd87da9ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/a1bbdc172f32a25fe999965b65b6e71fd87da9ed", - "reference": "a1bbdc172f32a25fe999965b65b6e71fd87da9ed", -======= "version": "2.13.0", "source": { "type": "git", @@ -4962,7 +4937,6 @@ "type": "zip", "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", ->>>>>>> master "shasum": "" }, "require": { @@ -5052,11 +5026,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", -<<<<<<< HEAD - "source": "https://github.com/guzzle/psr7/tree/2.10.2" -======= "source": "https://github.com/guzzle/psr7/tree/2.13.0" ->>>>>>> master }, "funding": [ { @@ -5072,11 +5042,7 @@ "type": "tidelift" } ], -<<<<<<< HEAD - "time": "2026-05-25T22:58:15+00:00" -======= "time": "2026-07-16T22:23:49+00:00" ->>>>>>> master }, { "name": "hshn/base64-encoded-file", @@ -7187,16 +7153,16 @@ }, { "name": "mcp/sdk", - "version": "v0.5.0", + "version": "v0.7.0", "source": { "type": "git", "url": "https://github.com/modelcontextprotocol/php-sdk.git", - "reference": "fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8" + "reference": "a6f415578fa789783d010274d11c922e97659a8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8", - "reference": "fb2c8c2ee4ab2791239c5f534bb07bfb7589d4e8", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/a6f415578fa789783d010274d11c922e97659a8a", + "reference": "a6f415578fa789783d010274d11c922e97659a8a", "shasum": "" }, "require": { @@ -7265,9 +7231,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.5.0" + "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.7.0" }, - "time": "2026-04-26T13:37:40+00:00" + "time": "2026-07-14T22:58:00+00:00" }, { "name": "mf2/mf2", @@ -14314,20 +14280,20 @@ }, { "name": "symfony/mcp-bundle", - "version": "v0.9.0", + "version": "v0.11.0", "source": { "type": "git", "url": "https://github.com/symfony/mcp-bundle.git", - "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1" + "reference": "f973cf4f146674d362da3258ba35629ad447ecb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/654f639e94f4d7694771e6628380a9ff04c9d9c1", - "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1", + "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/f973cf4f146674d362da3258ba35629ad447ecb1", + "reference": "f973cf4f146674d362da3258ba35629ad447ecb1", "shasum": "" }, "require": { - "mcp/sdk": "^0.5", + "mcp/sdk": "^0.6|^0.7", "php-http/discovery": "^1.20", "symfony/config": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", @@ -14341,11 +14307,13 @@ "symfony/service-contracts": "^2.5|^3" }, "require-dev": { + "nyholm/psr7": "^1.8", "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5.53", - "symfony/monolog-bundle": "^3.10 || ^4.0" + "symfony/monolog-bundle": "^3.10 || ^4.0", + "symfony/twig-bundle": "^7.3|^8.0" }, "type": "symfony-bundle", "extra": { @@ -14375,7 +14343,7 @@ ], "description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)", "support": { - "source": "https://github.com/symfony/mcp-bundle/tree/v0.9.0" + "source": "https://github.com/symfony/mcp-bundle/tree/v0.11.0" }, "funding": [ { @@ -14395,7 +14363,7 @@ "type": "tidelift" } ], - "time": "2026-05-15T23:41:17+00:00" + "time": "2026-07-15T00:43:00+00:00" }, { "name": "symfony/mime", @@ -14650,16 +14618,16 @@ }, { "name": "symfony/object-mapper", - "version": "v7.4.9", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/object-mapper.git", - "reference": "4f3f4973993b70e88e58dbcc3e332135d593f70d" + "reference": "ce13a5bce3c8ddf5cf17f0f557e294f7e0532add" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/object-mapper/zipball/4f3f4973993b70e88e58dbcc3e332135d593f70d", - "reference": "4f3f4973993b70e88e58dbcc3e332135d593f70d", + "url": "https://api.github.com/repos/symfony/object-mapper/zipball/ce13a5bce3c8ddf5cf17f0f557e294f7e0532add", + "reference": "ce13a5bce3c8ddf5cf17f0f557e294f7e0532add", "shasum": "" }, "require": { @@ -14699,7 +14667,7 @@ "description": "Provides a way to map an object to another object", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/object-mapper/tree/v7.4.9" + "source": "https://github.com/symfony/object-mapper/tree/v7.4.14" }, "funding": [ { @@ -14719,7 +14687,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-06-16T15:47:47+00:00" }, { "name": "symfony/options-resolver", @@ -15295,18 +15263,6 @@ }, { "name": "symfony/polyfill-php83", -<<<<<<< HEAD - "version": "v1.38.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "8339098cae28673c15cce00d80734af0453054e2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", - "reference": "8339098cae28673c15cce00d80734af0453054e2", -======= "version": "v1.38.2", "source": { "type": "git", @@ -15317,7 +15273,6 @@ "type": "zip", "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", ->>>>>>> master "shasum": "" }, "require": { @@ -15364,11 +15319,7 @@ "shim" ], "support": { -<<<<<<< HEAD - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" -======= "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" ->>>>>>> master }, "funding": [ { @@ -15388,11 +15339,7 @@ "type": "tidelift" } ], -<<<<<<< HEAD - "time": "2026-05-26T12:51:13+00:00" -======= "time": "2026-05-27T06:51:48+00:00" ->>>>>>> master }, { "name": "symfony/polyfill-php84", @@ -18240,16 +18187,16 @@ }, { "name": "symplify/easy-coding-standard", - "version": "13.2.9", + "version": "13.2.13", "source": { "type": "git", "url": "https://github.com/ecsphp/ecs.git", - "reference": "f4730d9fde79bf27993e31d9fd7f9df00da0d1aa" + "reference": "6d28a6dfef3e18ddc013ba68b121487cad2cf14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ecsphp/ecs/zipball/f4730d9fde79bf27993e31d9fd7f9df00da0d1aa", - "reference": "f4730d9fde79bf27993e31d9fd7f9df00da0d1aa", + "url": "https://api.github.com/repos/ecsphp/ecs/zipball/6d28a6dfef3e18ddc013ba68b121487cad2cf14c", + "reference": "6d28a6dfef3e18ddc013ba68b121487cad2cf14c", "shasum": "" }, "require": { @@ -18284,9 +18231,9 @@ "static analysis" ], "support": { - "source": "https://github.com/ecsphp/ecs/tree/13.2.9" + "source": "https://github.com/ecsphp/ecs/tree/13.2.13" }, - "time": "2026-07-17T15:05:23+00:00" + "time": "2026-07-20T11:00:52+00:00" }, { "name": "tecnickcom/tc-lib-barcode", @@ -20263,19 +20210,11 @@ }, { "name": "phpstan/phpstan", -<<<<<<< HEAD - "version": "2.1.56", - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/93a603c9fc3be8c3c93bbc8d22170ad766685537", - "reference": "93a603c9fc3be8c3c93bbc8d22170ad766685537", -======= "version": "2.2.5", "dist": { "type": "zip", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", ->>>>>>> master "shasum": "" }, "require": { @@ -20331,11 +20270,7 @@ "type": "github" } ], -<<<<<<< HEAD - "time": "2026-05-26T17:04:57+00:00" -======= "time": "2026-07-05T06:31:06+00:00" ->>>>>>> master }, { "name": "phpstan/phpstan-doctrine", @@ -21047,21 +20982,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", -<<<<<<< HEAD - "reference": "11be66e3adc8bf2c9805209a598ad4b42ee3c0d6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/11be66e3adc8bf2c9805209a598ad4b42ee3c0d6", - "reference": "11be66e3adc8bf2c9805209a598ad4b42ee3c0d6", -======= "reference": "4fac0729c45b6dba8d6171a94eccb1d5d9514bf9" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/4fac0729c45b6dba8d6171a94eccb1d5d9514bf9", "reference": "4fac0729c45b6dba8d6171a94eccb1d5d9514bf9", ->>>>>>> master "shasum": "" }, "conflict": { @@ -22170,11 +22096,7 @@ "type": "tidelift" } ], -<<<<<<< HEAD - "time": "2026-05-26T19:41:38+00:00" -======= "time": "2026-07-17T19:07:03+00:00" ->>>>>>> master }, { "name": "sebastian/cli-parser", diff --git a/config/reference.php b/config/reference.php index e1904bdc..885525d8 100644 --- a/config/reference.php +++ b/config/reference.php @@ -3219,8 +3219,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * 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 + * }, * http?: array{ * path?: scalar|Param|null, // Default: "/_mcp" + * allowed_hosts?: mixed, // DNS rebinding protection hosts (without port). Leave unset to keep the SDK default (localhost only), set an array of hostnames to expose a public MCP server, or false to disable the protection entirely. // Default: null * session?: array{ * store?: "file"|"memory"|"cache"|"framework"|Param, // Default: "file" * directory?: scalar|Param|null, // Default: "%kernel.cache_dir%/mcp-sessions" From a3fe7ea5917cca9156f3484354fe0f2e772de062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Jul 2026 21:10:21 +0200 Subject: [PATCH 10/25] Fixed mcp dependencies combination --- composer.json | 6 +++--- composer.lock | 33 ++++++++++++++++----------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index 6738e91c..d3b97c04 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,9 @@ "amphp/http-client": "^5.1", "api-platform/doctrine-orm": "^4.1", "api-platform/json-api": "^4.0.0", - "api-platform/mcp": "^v4.3.6", + "api-platform/mcp": "^v4.3.17", "api-platform/symfony": "^4.0.0", - "api-platform/metadata": "4.3.x-dev", + "api-platform/metadata": "^v4.3.17", "beberlei/doctrineextensions": "^1.2", "brick/math": "^0.17.0", "brick/schema": "^0.2.0", @@ -42,7 +42,7 @@ "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", "maennchen/zipstream-php": "2.1", - "mcp/sdk": "v0.7.0", + "mcp/sdk": "v0.6.0", "nbgrp/onelogin-saml-bundle": "^v2.0.2", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.3", diff --git a/composer.lock b/composer.lock index 7d1ceb42..7bd338b4 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": "7c31eed5ac91b2ca3b7e87bd245ab2e2", + "content-hash": "d6329811b6e5a29717a34b5084129bf3", "packages": [ { "name": "amphp/amp", @@ -1628,22 +1628,22 @@ }, { "name": "api-platform/mcp", - "version": "v4.3.13", + "version": "v4.3.17", "source": { "type": "git", "url": "https://github.com/api-platform/mcp.git", - "reference": "0a351f21438b5c7fd16a05fdcd67bdd44e1aa20b" + "reference": "97eaecbf7b30fe5cf05a0278251c421d39be047a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/mcp/zipball/0a351f21438b5c7fd16a05fdcd67bdd44e1aa20b", - "reference": "0a351f21438b5c7fd16a05fdcd67bdd44e1aa20b", + "url": "https://api.github.com/repos/api-platform/mcp/zipball/97eaecbf7b30fe5cf05a0278251c421d39be047a", + "reference": "97eaecbf7b30fe5cf05a0278251c421d39be047a", "shasum": "" }, "require": { "api-platform/json-schema": "^4.3", "api-platform/metadata": "^4.3", - "mcp/sdk": ">=0.4 <1.0", + "mcp/sdk": "^0.6", "php": ">=8.2", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/polyfill-php85": "^1.32" @@ -1699,13 +1699,13 @@ ], "support": { "issues": "https://github.com/api-platform/mcp/issues", - "source": "https://github.com/api-platform/mcp/tree/v4.3.13" + "source": "https://github.com/api-platform/mcp/tree/v4.3.17" }, - "time": "2026-06-10T14:43:58+00:00" + "time": "2026-07-03T04:48:00+00:00" }, { "name": "api-platform/metadata", - "version": "4.3.x-dev", + "version": "v4.3.17", "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", @@ -1797,7 +1797,7 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/metadata/tree/4.3" + "source": "https://github.com/api-platform/metadata/tree/v4.3.17" }, "time": "2026-07-03T06:30:28+00:00" }, @@ -7153,16 +7153,16 @@ }, { "name": "mcp/sdk", - "version": "v0.7.0", + "version": "v0.6.0", "source": { "type": "git", "url": "https://github.com/modelcontextprotocol/php-sdk.git", - "reference": "a6f415578fa789783d010274d11c922e97659a8a" + "reference": "433c84b58af346dd32f15f9909679e96a46ebe23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/a6f415578fa789783d010274d11c922e97659a8a", - "reference": "a6f415578fa789783d010274d11c922e97659a8a", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/433c84b58af346dd32f15f9909679e96a46ebe23", + "reference": "433c84b58af346dd32f15f9909679e96a46ebe23", "shasum": "" }, "require": { @@ -7231,9 +7231,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.7.0" + "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.6.0" }, - "time": "2026-07-14T22:58:00+00:00" + "time": "2026-06-02T15:47:04+00:00" }, { "name": "mf2/mf2", @@ -23612,7 +23612,6 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "api-platform/metadata": 20, "roave/security-advisories": 20 }, "prefer-stable": false, From 7e53c7ae9a8f47fcc5940762b76e140e9c2c4fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 20 Jul 2026 22:07:06 +0200 Subject: [PATCH 11/25] Added MCP tools to access info provider features --- src/Mcp/DTO/InfoProviderPartDetailsInput.php | 38 ++++++ src/Mcp/DTO/InfoProviderSearchInput.php | 41 ++++++ src/Mcp/DTO/ListInfoProvidersInput.php | 30 ++++ .../DTOs/InfoProviderDTO.php | 63 +++++++++ .../InfoProviderSystem/DTOs/PartDetailDTO.php | 19 +++ .../InfoProviderSystem/DTOs/PriceDTO.php | 3 + .../DTOs/SearchResultDTO.php | 19 +++ .../InfoProviderSystem/PartInfoRetriever.php | 1 + .../GetInfoProviderPartDetailsProcessor.php | 51 +++++++ src/State/Mcp/ListInfoProvidersProcessor.php | 58 ++++++++ .../Mcp/SearchInfoProvidersProcessor.php | 90 ++++++++++++ ...etInfoProviderPartDetailsProcessorTest.php | 99 ++++++++++++++ .../Mcp/ListInfoProvidersProcessorTest.php | 68 ++++++++++ .../Mcp/SearchInfoProvidersProcessorTest.php | 128 ++++++++++++++++++ 14 files changed, 708 insertions(+) create mode 100644 src/Mcp/DTO/InfoProviderPartDetailsInput.php create mode 100644 src/Mcp/DTO/InfoProviderSearchInput.php create mode 100644 src/Mcp/DTO/ListInfoProvidersInput.php create mode 100644 src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php create mode 100644 src/State/Mcp/GetInfoProviderPartDetailsProcessor.php create mode 100644 src/State/Mcp/ListInfoProvidersProcessor.php create mode 100644 src/State/Mcp/SearchInfoProvidersProcessor.php create mode 100644 tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php create mode 100644 tests/State/Mcp/ListInfoProvidersProcessorTest.php create mode 100644 tests/State/Mcp/SearchInfoProvidersProcessorTest.php diff --git a/src/Mcp/DTO/InfoProviderPartDetailsInput.php b/src/Mcp/DTO/InfoProviderPartDetailsInput.php new file mode 100644 index 00000000..549b2f55 --- /dev/null +++ b/src/Mcp/DTO/InfoProviderPartDetailsInput.php @@ -0,0 +1,38 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +use Symfony\Component\Validator\Constraints as Assert; + +readonly class InfoProviderPartDetailsInput +{ + public function __construct( + /** @var string The key of the info provider (e.g. "digikey", "mouser", "lcsc"), as returned by search_info_providers */ + #[Assert\NotBlank] + public string $provider_key, + /** @var string The provider-specific ID of the part, as returned by search_info_providers */ + #[Assert\NotBlank] + public string $provider_id, + ) { + } +} diff --git a/src/Mcp/DTO/InfoProviderSearchInput.php b/src/Mcp/DTO/InfoProviderSearchInput.php new file mode 100644 index 00000000..e588b6da --- /dev/null +++ b/src/Mcp/DTO/InfoProviderSearchInput.php @@ -0,0 +1,41 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +use Symfony\Component\Validator\Constraints as Assert; + +readonly class InfoProviderSearchInput +{ + public function __construct( + /** @var string The keyword to search for (e.g. a part name, manufacturer product number or GTIN) */ + #[Assert\NotBlank] + public string $keyword, + /** + * @var string[]|null The keys of the info providers to search in (e.g. "digikey", "mouser", "lcsc"). If not + * given, the default search providers configured in the system settings are used. + */ + #[Assert\All([new Assert\Type('string')])] + public ?array $providers = null, + ) { + } +} diff --git a/src/Mcp/DTO/ListInfoProvidersInput.php b/src/Mcp/DTO/ListInfoProvidersInput.php new file mode 100644 index 00000000..83534ba9 --- /dev/null +++ b/src/Mcp/DTO/ListInfoProvidersInput.php @@ -0,0 +1,30 @@ +. + */ + +declare(strict_types=1); + +namespace App\Mcp\DTO; + +/** + * This tool takes no parameters, it just lists the currently available info providers. + */ +readonly class ListInfoProvidersInput +{ +} diff --git a/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php b/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php new file mode 100644 index 00000000..00b98436 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php @@ -0,0 +1,63 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem\DTOs; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpToolCollection; +use App\Mcp\DTO\ListInfoProvidersInput; +use App\State\Mcp\ListInfoProvidersProcessor; + +/** + * This DTO represents an available info provider (a distributor or manufacturer catalog which can be + * searched via search_info_providers / get_info_provider_part_details). + */ +#[ApiResource( + description: 'An info provider which can be used to search for parts and retrieve part details.', + operations: [], + mcp: [ + 'list_info_providers' => new McpToolCollection( + title: 'List available info providers', + description: 'List the info providers (e.g. distributors like Digikey, Mouser, LCSC) which are currently active and can be used with search_info_providers and get_info_provider_part_details.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], + input: ListInfoProvidersInput::class, + security: 'is_granted("@info_providers.create_parts")', + processor: ListInfoProvidersProcessor::class, + ), + ], +)] +readonly class InfoProviderDTO +{ + public function __construct( + /** @var string The unique key of the provider, to be used as provider_key in the other info provider tools */ + public string $key, + /** @var string The (user friendly) name of the provider (e.g. "Digikey") */ + public string $name, + /** @var string|null A short description of the provider */ + public ?string $description = null, + /** @var string|null The url of the provider (e.g. "https://www.digikey.com") */ + public ?string $url = null, + /** @var string[] The kinds of data this provider can supply (e.g. "PRICE", "DATASHEET", "PICTURE") */ + public array $capabilities = [], + ) { + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 9700ae57..f9bb442c 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -23,11 +23,30 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; use App\Entity\Parts\ManufacturingStatus; +use App\Mcp\DTO\InfoProviderPartDetailsInput; +use App\State\Mcp\GetInfoProviderPartDetailsProcessor; /** * This DTO represents a part with all its details. */ +#[ApiResource( + description: 'Detailed information about a part from an external info provider (e.g. a distributor or manufacturer catalog), including datasheets, images, parameters and purchase information.', + operations: [], + mcp: [ + 'get_info_provider_part_details' => new McpTool( + title: 'Get part details from an info provider', + description: 'Get full detailed information (datasheets, images, parameters, prices, ...) about a specific part from an external info provider, identified by the provider key and the provider-specific part ID (both returned by search_info_providers).', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => true], + input: InfoProviderPartDetailsInput::class, + security: 'is_granted("@info_providers.create_parts")', + validate: true, + processor: GetInfoProviderPartDetailsProcessor::class, + ), + ], +)] class PartDetailDTO extends SearchResultDTO { public function __construct( diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php index cf1f577d..8c80a1be 100644 --- a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -24,12 +24,14 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; use Brick\Math\BigDecimal; +use Symfony\Component\Serializer\Attribute\Ignore; /** * This DTO represents a price for a single unit in a certain discount range */ readonly class PriceDTO { + #[Ignore] private BigDecimal $price_as_big_decimal; public function __construct( @@ -54,6 +56,7 @@ readonly class PriceDTO * Gets the price as BigDecimal * @return BigDecimal */ + #[Ignore] public function getPriceAsBigDecimal(): BigDecimal { return $this->price_as_big_decimal; diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 085ae17e..ec9f4bbc 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -23,12 +23,31 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpToolCollection; use App\Entity\Parts\ManufacturingStatus; +use App\Mcp\DTO\InfoProviderSearchInput; +use App\State\Mcp\SearchInfoProvidersProcessor; /** * This DTO represents a search result for a part. * @see \App\Tests\Services\InfoProviderSystem\DTOs\SearchResultDTOTest */ +#[ApiResource( + description: 'A search result for a part from an external info provider (e.g. a distributor or manufacturer catalog).', + operations: [], + mcp: [ + 'search_info_providers' => new McpToolCollection( + title: 'Search external info providers', + description: 'Search external info providers (e.g. distributors like Digikey, Mouser, LCSC) for parts matching a keyword. Returns a list of search results, which can be passed to get_info_provider_part_details to retrieve full details for a specific result.', + annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => true], + input: InfoProviderSearchInput::class, + security: 'is_granted("@info_providers.create_parts")', + validate: true, + processor: SearchInfoProvidersProcessor::class, + ), + ], +)] class SearchResultDTO { /** @var string|null An URL to a preview image */ diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index f5ff144d..9cff3312 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -126,6 +126,7 @@ final class PartInfoRetriever * @param array $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation. * @return PartDetailDTO * @throws InfoProviderNotActiveException if the the given providers is not active + * @throws \InvalidArgumentException if the given provider key does not match any registered provider */ public function getDetails(string $provider_key, string $part_id, array $options = []): PartDetailDTO { diff --git a/src/State/Mcp/GetInfoProviderPartDetailsProcessor.php b/src/State/Mcp/GetInfoProviderPartDetailsProcessor.php new file mode 100644 index 00000000..c7949256 --- /dev/null +++ b/src/State/Mcp/GetInfoProviderPartDetailsProcessor.php @@ -0,0 +1,51 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Exceptions\InfoProviderNotActiveException; +use App\Mcp\DTO\InfoProviderPartDetailsInput; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +class GetInfoProviderPartDetailsProcessor implements ProcessorInterface +{ + public function __construct( + private readonly PartInfoRetriever $infoRetriever, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof InfoProviderPartDetailsInput) { + throw new BadRequestHttpException('Expected InfoProviderPartDetailsInput'); + } + + try { + return $this->infoRetriever->getDetails($data->provider_key, $data->provider_id); + } catch (InfoProviderNotActiveException|\InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + } +} diff --git a/src/State/Mcp/ListInfoProvidersProcessor.php b/src/State/Mcp/ListInfoProvidersProcessor.php new file mode 100644 index 00000000..7b46a80a --- /dev/null +++ b/src/State/Mcp/ListInfoProvidersProcessor.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Services\InfoProviderSystem\DTOs\InfoProviderDTO; +use App\Services\InfoProviderSystem\ProviderRegistry; + +class ListInfoProvidersProcessor implements ProcessorInterface +{ + public function __construct( + private readonly ProviderRegistry $providerRegistry, + ) { + } + + /** + * @return InfoProviderDTO[] + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array + { + $result = []; + + foreach ($this->providerRegistry->getActiveProviders() as $provider) { + $info = $provider->getProviderInfo(); + + $result[] = new InfoProviderDTO( + key: $provider->getProviderKey(), + name: $info['name'], + description: $info['description'] ?? null, + url: $info['url'] ?? null, + capabilities: array_map(static fn ($capability) => $capability->name, $provider->getCapabilities()), + ); + } + + return $result; + } +} diff --git a/src/State/Mcp/SearchInfoProvidersProcessor.php b/src/State/Mcp/SearchInfoProvidersProcessor.php new file mode 100644 index 00000000..6cd76145 --- /dev/null +++ b/src/State/Mcp/SearchInfoProvidersProcessor.php @@ -0,0 +1,90 @@ +. + */ + +declare(strict_types=1); + +namespace App\State\Mcp; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\Exceptions\InfoProviderNotActiveException; +use App\Mcp\DTO\InfoProviderSearchInput; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +class SearchInfoProvidersProcessor implements ProcessorInterface +{ + public function __construct( + private readonly PartInfoRetriever $infoRetriever, + private readonly ProviderRegistry $providerRegistry, + private readonly InfoProviderGeneralSettings $infoProviderSettings, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if (!$data instanceof InfoProviderSearchInput) { + throw new BadRequestHttpException('Expected InfoProviderSearchInput'); + } + + $providers = $this->resolveProviderKeys($data->providers); + + try { + return $this->infoRetriever->searchByKeyword($data->keyword, $providers); + } catch (InfoProviderNotActiveException|\InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + } + + /** + * Resolves the provider keys to search in. If none are given, falls back to the active default search + * providers configured in the system settings (mirroring the behavior of the info providers search page). + * @param string[]|null $providerKeys + * @return string[] + */ + private function resolveProviderKeys(?array $providerKeys): array + { + if (!empty($providerKeys)) { + return $providerKeys; + } + + $providers = []; + foreach ($this->infoProviderSettings->defaultSearchProviders as $providerKey) { + try { + if ($this->providerRegistry->getProviderByKey($providerKey)->isActive()) { + $providers[] = $providerKey; + } + } catch (\InvalidArgumentException) { + //Ignore providers configured as default, which do not exist (anymore) + } + } + + if ($providers === []) { + throw new BadRequestHttpException(sprintf( + 'No providers were given and no default search providers are configured or active. Available provider keys: %s', + implode(', ', array_keys($this->providerRegistry->getActiveProviders())) + )); + } + + return $providers; + } +} diff --git a/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php b/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php new file mode 100644 index 00000000..07fcdc27 --- /dev/null +++ b/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php @@ -0,0 +1,99 @@ +. + */ + +namespace App\Tests\State\Mcp; + +use ApiPlatform\Metadata\Get; +use App\Mcp\DTO\InfoProviderPartDetailsInput; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOtoEntityConverter; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Settings\SystemSettings\LocalizationSettings; +use App\State\Mcp\GetInfoProviderPartDetailsProcessor; +use App\Tests\SettingsTestHelper; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +final class GetInfoProviderPartDetailsProcessorTest extends TestCase +{ + private PartInfoRetriever $infoRetriever; + + protected function setUp(): void + { + $activeProvider = $this->createMock(InfoProviderInterface::class); + $activeProvider->method('getProviderKey')->willReturn('test1'); + $activeProvider->method('isActive')->willReturn(true); + $activeProvider->method('getDetails')->willReturn( + new PartDetailDTO(provider_key: 'test1', provider_id: '42', name: 'Element 42', description: 'desc') + ); + + $inactiveProvider = $this->createMock(InfoProviderInterface::class); + $inactiveProvider->method('getProviderKey')->willReturn('test2'); + $inactiveProvider->method('isActive')->willReturn(false); + + $providerRegistry = new ProviderRegistry([$activeProvider, $inactiveProvider]); + + $dtoToEntityConverter = new DTOtoEntityConverter( + $this->createMock(EntityManagerInterface::class), + SettingsTestHelper::createSettingsDummy(LocalizationSettings::class) + ); + + $this->infoRetriever = new PartInfoRetriever( + $providerRegistry, + $dtoToEntityConverter, + new ArrayAdapter(), + debugMode: true + ); + } + + public function testGetDetails(): void + { + $processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever); + + $result = $processor->process(new InfoProviderPartDetailsInput(provider_key: 'test1', provider_id: '42'), new Get()); + + $this->assertInstanceOf(PartDetailDTO::class, $result); + $this->assertSame('test1', $result->provider_key); + $this->assertSame('42', $result->provider_id); + } + + public function testGetDetailsWithUnknownProviderThrowsBadRequest(): void + { + $processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderPartDetailsInput(provider_key: 'unknown', provider_id: '42'), new Get()); + } + + public function testGetDetailsWithInactiveProviderThrowsBadRequest(): void + { + $processor = new GetInfoProviderPartDetailsProcessor($this->infoRetriever); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderPartDetailsInput(provider_key: 'test2', provider_id: '42'), new Get()); + } +} diff --git a/tests/State/Mcp/ListInfoProvidersProcessorTest.php b/tests/State/Mcp/ListInfoProvidersProcessorTest.php new file mode 100644 index 00000000..a255aee3 --- /dev/null +++ b/tests/State/Mcp/ListInfoProvidersProcessorTest.php @@ -0,0 +1,68 @@ +. + */ + +namespace App\Tests\State\Mcp; + +use ApiPlatform\Metadata\Get; +use App\Mcp\DTO\ListInfoProvidersInput; +use App\Services\InfoProviderSystem\DTOs\InfoProviderDTO; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\Providers\ProviderCapabilities; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\State\Mcp\ListInfoProvidersProcessor; +use PHPUnit\Framework\TestCase; + +final class ListInfoProvidersProcessorTest extends TestCase +{ + public function testOnlyActiveProvidersAreListed(): void + { + $active = $this->createMock(InfoProviderInterface::class); + $active->method('getProviderKey')->willReturn('active_provider'); + $active->method('isActive')->willReturn(true); + $active->method('getProviderInfo')->willReturn([ + 'name' => 'Active Provider', + 'description' => 'A provider that is active', + 'url' => 'https://example.com', + ]); + $active->method('getCapabilities')->willReturn([ProviderCapabilities::BASIC, ProviderCapabilities::PRICE]); + + $disabled = $this->createMock(InfoProviderInterface::class); + $disabled->method('getProviderKey')->willReturn('disabled_provider'); + $disabled->method('isActive')->willReturn(false); + $disabled->method('getProviderInfo')->willReturn(['name' => 'Disabled Provider']); + $disabled->method('getCapabilities')->willReturn([]); + + $registry = new ProviderRegistry([$active, $disabled]); + $processor = new ListInfoProvidersProcessor($registry); + + $result = $processor->process(new ListInfoProvidersInput(), new Get()); + + $this->assertCount(1, $result); + $this->assertInstanceOf(InfoProviderDTO::class, $result[0]); + $this->assertSame('active_provider', $result[0]->key); + $this->assertSame('Active Provider', $result[0]->name); + $this->assertSame('A provider that is active', $result[0]->description); + $this->assertSame('https://example.com', $result[0]->url); + $this->assertSame(['BASIC', 'PRICE'], $result[0]->capabilities); + } +} diff --git a/tests/State/Mcp/SearchInfoProvidersProcessorTest.php b/tests/State/Mcp/SearchInfoProvidersProcessorTest.php new file mode 100644 index 00000000..2a56a9a2 --- /dev/null +++ b/tests/State/Mcp/SearchInfoProvidersProcessorTest.php @@ -0,0 +1,128 @@ +. + */ + +namespace App\Tests\State\Mcp; + +use ApiPlatform\Metadata\Get; +use App\Mcp\DTO\InfoProviderSearchInput; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\InfoProviderSystem\DTOtoEntityConverter; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings; +use App\Settings\SystemSettings\LocalizationSettings; +use App\State\Mcp\SearchInfoProvidersProcessor; +use App\Tests\SettingsTestHelper; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +final class SearchInfoProvidersProcessorTest extends TestCase +{ + private ProviderRegistry $providerRegistry; + private PartInfoRetriever $infoRetriever; + + protected function setUp(): void + { + $providers = [ + $this->getMockProvider('test1'), + $this->getMockProvider('test2', active: false), + ]; + + $this->providerRegistry = new ProviderRegistry($providers); + + $dtoToEntityConverter = new DTOtoEntityConverter( + $this->createMock(EntityManagerInterface::class), + SettingsTestHelper::createSettingsDummy(LocalizationSettings::class) + ); + + $this->infoRetriever = new PartInfoRetriever( + $this->providerRegistry, + $dtoToEntityConverter, + new ArrayAdapter(), + debugMode: true + ); + } + + private function getMockProvider(string $key, bool $active = true): InfoProviderInterface + { + $mock = $this->createMock(InfoProviderInterface::class); + $mock->method('getProviderKey')->willReturn($key); + $mock->method('isActive')->willReturn($active); + $mock->method('searchByKeyword')->willReturn([ + new SearchResultDTO(provider_key: $key, provider_id: '1', name: 'Element 1', description: 'desc'), + ]); + + return $mock; + } + + public function testSearchWithExplicitProvider(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $result = $processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['test1']), new Get()); + + $this->assertCount(1, $result); + $this->assertInstanceOf(SearchResultDTO::class, $result[0]); + $this->assertSame('test1', $result[0]->provider_key); + } + + public function testSearchWithUnknownProviderThrowsBadRequest(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['unknown']), new Get()); + } + + public function testSearchWithInactiveProviderThrowsBadRequest(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderSearchInput(keyword: 'foo', providers: ['test2']), new Get()); + } + + public function testSearchFallsBackToConfiguredDefaultProviders(): void + { + $settings = SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class); + $settings->defaultSearchProviders = ['test1']; + + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, $settings); + + $result = $processor->process(new InfoProviderSearchInput(keyword: 'foo'), new Get()); + + $this->assertCount(1, $result); + $this->assertSame('test1', $result[0]->provider_key); + } + + public function testSearchWithoutProvidersAndNoDefaultsThrowsBadRequest(): void + { + $processor = new SearchInfoProvidersProcessor($this->infoRetriever, $this->providerRegistry, SettingsTestHelper::createSettingsDummy(InfoProviderGeneralSettings::class)); + + $this->expectException(BadRequestHttpException::class); + $processor->process(new InfoProviderSearchInput(keyword: 'foo'), new Get()); + } +} From 77724a522b6df3c9be8adeb87020d06decb8ba12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 21 Jul 2026 00:07:52 +0200 Subject: [PATCH 12/25] Expose InfoProvider capabilities via REST API --- .../DTOs/InfoProviderDTO.php | 12 +- .../InfoProviderSystem/DTOs/PartDetailDTO.php | 13 +- .../DTOs/SearchResultDTO.php | 13 +- src/State/Mcp/ListInfoProvidersProcessor.php | 23 ++- .../Endpoints/InfoProviderEndpointTest.php | 148 ++++++++++++++++++ 5 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 tests/API/Endpoints/InfoProviderEndpointTest.php diff --git a/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php b/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php index 00b98436..5773bc49 100644 --- a/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php @@ -23,7 +23,9 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\McpToolCollection; +use ApiPlatform\OpenApi\Model\Operation; use App\Mcp\DTO\ListInfoProvidersInput; use App\State\Mcp\ListInfoProvidersProcessor; @@ -32,8 +34,16 @@ use App\State\Mcp\ListInfoProvidersProcessor; * searched via search_info_providers / get_info_provider_part_details). */ #[ApiResource( + uriTemplate: '/info_providers', description: 'An info provider which can be used to search for parts and retrieve part details.', - operations: [], + operations: [ + new GetCollection( + security: 'is_granted("@info_providers.create_parts")', + provider: ListInfoProvidersProcessor::class, + openapi: new Operation(summary: 'List the info providers which are currently active and can be used for searching parts.'), + ), + ], + paginationEnabled: false, mcp: [ 'list_info_providers' => new McpToolCollection( title: 'List available info providers', diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index f9bb442c..829b8805 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -25,6 +25,8 @@ namespace App\Services\InfoProviderSystem\DTOs; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model\Operation; use App\Entity\Parts\ManufacturingStatus; use App\Mcp\DTO\InfoProviderPartDetailsInput; use App\State\Mcp\GetInfoProviderPartDetailsProcessor; @@ -34,7 +36,16 @@ use App\State\Mcp\GetInfoProviderPartDetailsProcessor; */ #[ApiResource( description: 'Detailed information about a part from an external info provider (e.g. a distributor or manufacturer catalog), including datasheets, images, parameters and purchase information.', - operations: [], + operations: [ + new Post( + uriTemplate: '/info_providers/details', + security: 'is_granted("@info_providers.create_parts")', + input: InfoProviderPartDetailsInput::class, + validate: true, + processor: GetInfoProviderPartDetailsProcessor::class, + openapi: new Operation(summary: 'Get full detailed information about a specific part from an external info provider.'), + ), + ], mcp: [ 'get_info_provider_part_details' => new McpTool( title: 'Get part details from an info provider', diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index ec9f4bbc..0fc64533 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -25,6 +25,8 @@ namespace App\Services\InfoProviderSystem\DTOs; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\McpToolCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model\Operation; use App\Entity\Parts\ManufacturingStatus; use App\Mcp\DTO\InfoProviderSearchInput; use App\State\Mcp\SearchInfoProvidersProcessor; @@ -35,7 +37,16 @@ use App\State\Mcp\SearchInfoProvidersProcessor; */ #[ApiResource( description: 'A search result for a part from an external info provider (e.g. a distributor or manufacturer catalog).', - operations: [], + operations: [ + new Post( + uriTemplate: '/info_providers/search', + security: 'is_granted("@info_providers.create_parts")', + input: InfoProviderSearchInput::class, + validate: true, + processor: SearchInfoProvidersProcessor::class, + openapi: new Operation(summary: 'Search external info providers (e.g. distributors like Digikey, Mouser, LCSC) for parts matching a keyword.'), + ), + ], mcp: [ 'search_info_providers' => new McpToolCollection( title: 'Search external info providers', diff --git a/src/State/Mcp/ListInfoProvidersProcessor.php b/src/State/Mcp/ListInfoProvidersProcessor.php index 7b46a80a..774ad4ce 100644 --- a/src/State/Mcp/ListInfoProvidersProcessor.php +++ b/src/State/Mcp/ListInfoProvidersProcessor.php @@ -24,20 +24,41 @@ namespace App\State\Mcp; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; use App\Services\InfoProviderSystem\DTOs\InfoProviderDTO; use App\Services\InfoProviderSystem\ProviderRegistry; -class ListInfoProvidersProcessor implements ProcessorInterface +/** + * Used both as the state processor for the MCP list_info_providers tool and as the state provider for the + * REST GET /api/info_providers collection endpoint. + */ +class ListInfoProvidersProcessor implements ProcessorInterface, ProviderInterface { public function __construct( private readonly ProviderRegistry $providerRegistry, ) { } + /** + * @return InfoProviderDTO[] + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + return $this->listActiveProviders(); + } + /** * @return InfoProviderDTO[] */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array + { + return $this->listActiveProviders(); + } + + /** + * @return InfoProviderDTO[] + */ + private function listActiveProviders(): array { $result = []; diff --git a/tests/API/Endpoints/InfoProviderEndpointTest.php b/tests/API/Endpoints/InfoProviderEndpointTest.php new file mode 100644 index 00000000..7203b15c --- /dev/null +++ b/tests/API/Endpoints/InfoProviderEndpointTest.php @@ -0,0 +1,148 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\API\Endpoints; + +use App\DataFixtures\APITokenFixtures; +use App\Tests\API\AuthenticatedApiTestCase; + +class InfoProviderEndpointTest extends AuthenticatedApiTestCase +{ + public function testListInfoProviders(): void + { + $response = self::createAuthenticatedClient()->request('GET', '/api/info_providers'); + + self::assertResponseIsSuccessful(); + + $json = $response->toArray(); + self::assertIsArray($json['hydra:member']); + self::assertNotEmpty($json['hydra:member']); + + $keys = array_column($json['hydra:member'], 'key'); + self::assertContains('test', $keys); + } + + public function testListInfoProvidersRequiresAuthentication(): void + { + self::createClient()->request('GET', '/api/info_providers'); + self::assertResponseStatusCodeSame(401); + } + + public function testListInfoProvidersRequiresPermission(): void + { + self::createAuthenticatedClient(APITokenFixtures::TOKEN_READONLY)->request('GET', '/api/info_providers'); + self::assertResponseStatusCodeSame(403); + } + + public function testSearchInfoProviders(): void + { + $response = self::createAuthenticatedClient()->request('POST', '/api/info_providers/search', [ + 'json' => [ + 'keyword' => 'foo', + 'providers' => ['test'], + ], + ]); + + self::assertResponseIsSuccessful(); + + $json = $response->toArray(); + self::assertIsArray($json['hydra:member']); + self::assertNotEmpty($json['hydra:member']); + self::assertSame('test', $json['hydra:member'][0]['provider_key']); + } + + public function testSearchInfoProvidersWithUnknownProviderReturnsBadRequest(): void + { + self::createAuthenticatedClient()->request('POST', '/api/info_providers/search', [ + 'json' => [ + 'keyword' => 'foo', + 'providers' => ['unknown'], + ], + ]); + + self::assertResponseStatusCodeSame(400); + } + + public function testSearchInfoProvidersRequiresAuthentication(): void + { + self::createClient()->request('POST', '/api/info_providers/search', [ + 'json' => ['keyword' => 'foo', 'providers' => ['test']], + ]); + + self::assertResponseStatusCodeSame(401); + } + + public function testSearchInfoProvidersRequiresPermission(): void + { + self::createAuthenticatedClient(APITokenFixtures::TOKEN_READONLY)->request('POST', '/api/info_providers/search', [ + 'json' => ['keyword' => 'foo', 'providers' => ['test']], + ]); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetInfoProviderPartDetails(): void + { + $response = self::createAuthenticatedClient()->request('POST', '/api/info_providers/details', [ + 'json' => [ + 'provider_key' => 'test', + 'provider_id' => 'element1', + ], + ]); + + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + 'provider_key' => 'test', + 'provider_id' => 'element1', + ]); + } + + public function testGetInfoProviderPartDetailsWithUnknownProviderReturnsBadRequest(): void + { + self::createAuthenticatedClient()->request('POST', '/api/info_providers/details', [ + 'json' => [ + 'provider_key' => 'unknown', + 'provider_id' => 'element1', + ], + ]); + + self::assertResponseStatusCodeSame(400); + } + + public function testGetInfoProviderPartDetailsRequiresAuthentication(): void + { + self::createClient()->request('POST', '/api/info_providers/details', [ + 'json' => ['provider_key' => 'test', 'provider_id' => 'element1'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + public function testGetInfoProviderPartDetailsRequiresPermission(): void + { + self::createAuthenticatedClient(APITokenFixtures::TOKEN_READONLY)->request('POST', '/api/info_providers/details', [ + 'json' => ['provider_key' => 'test', 'provider_id' => 'element1'], + ]); + + self::assertResponseStatusCodeSame(403); + } +} From 3056809afde90a03ed9ca3443ed8216ba4976d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Wed, 22 Jul 2026 21:29:17 +0200 Subject: [PATCH 13/25] Update dependencies --- composer.lock | 52 ++++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/composer.lock b/composer.lock index 7bd338b4..978415cc 100644 --- a/composer.lock +++ b/composer.lock @@ -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.18.4", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a" + "reference": "175f8ffa7ad574e1b5ccb66f1301a21524c88ce6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/241d61f6bbc77275d5a9f95d514241c058bf2d0a", - "reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/175f8ffa7ad574e1b5ccb66f1301a21524c88ce6", + "reference": "175f8ffa7ad574e1b5ccb66f1301a21524c88ce6", "shasum": "" }, "require": { @@ -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", @@ -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.18.4" }, "funding": [ { @@ -3541,7 +3543,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T08:22:50+00:00" + "time": "2026-07-11T08:41:11+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -18187,16 +18189,16 @@ }, { "name": "symplify/easy-coding-standard", - "version": "13.2.13", + "version": "13.2.16", "source": { "type": "git", "url": "https://github.com/ecsphp/ecs.git", - "reference": "6d28a6dfef3e18ddc013ba68b121487cad2cf14c" + "reference": "5176cf0092e9a381d568bbeffd744c17a6f4993f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ecsphp/ecs/zipball/6d28a6dfef3e18ddc013ba68b121487cad2cf14c", - "reference": "6d28a6dfef3e18ddc013ba68b121487cad2cf14c", + "url": "https://api.github.com/repos/ecsphp/ecs/zipball/5176cf0092e9a381d568bbeffd744c17a6f4993f", + "reference": "5176cf0092e9a381d568bbeffd744c17a6f4993f", "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.16" }, - "time": "2026-07-20T11:00:52+00:00" + "time": "2026-07-22T19:18:33+00:00" }, { "name": "tecnickcom/tc-lib-barcode", @@ -20982,12 +20984,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "4fac0729c45b6dba8d6171a94eccb1d5d9514bf9" + "reference": "4991713e587e8b1f2fb7ce80475b4df68fe71be1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/4fac0729c45b6dba8d6171a94eccb1d5d9514bf9", - "reference": "4fac0729c45b6dba8d6171a94eccb1d5d9514bf9", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/4991713e587e8b1f2fb7ce80475b4df68fe71be1", + "reference": "4991713e587e8b1f2fb7ce80475b4df68fe71be1", "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", @@ -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", @@ -22096,7 +22098,7 @@ "type": "tidelift" } ], - "time": "2026-07-17T19:07:03+00:00" + "time": "2026-07-21T19:20:48+00:00" }, { "name": "sebastian/cli-parser", From f14418d889006343e777133bb941ce7204bdb9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Wed, 22 Jul 2026 23:47:05 +0200 Subject: [PATCH 14/25] Added MCP tools to list footprints, storelocations, etc. via MCP --- src/Entity/Parts/Category.php | 29 +++ src/Entity/Parts/Footprint.php | 29 +++ src/Entity/Parts/Manufacturer.php | 29 +++ src/Entity/Parts/MeasurementUnit.php | 29 +++ src/Entity/Parts/PartCustomState.php | 29 +++ src/Entity/Parts/StorageLocation.php | 29 +++ src/Entity/Parts/Supplier.php | 29 +++ src/Mcp/DTO/StructuralElementOverview.php | 47 ++++ src/Mcp/DTO/StructuralElementSearchInput.php | 36 +++ .../StructuralElementOverviewNormalizer.php | 68 ++++++ .../GetStructuralElementDetailsProcessor.php | 70 ++++++ .../Mcp/ListStructuralElementsProcessor.php | 78 +++++++ ...tructuralElementOverviewNormalizerTest.php | 52 +++++ .../Mcp/StructuralElementProcessorsTest.php | 216 ++++++++++++++++++ 14 files changed, 770 insertions(+) create mode 100644 src/Mcp/DTO/StructuralElementOverview.php create mode 100644 src/Mcp/DTO/StructuralElementSearchInput.php create mode 100644 src/Serializer/StructuralElementOverviewNormalizer.php create mode 100644 src/State/Mcp/GetStructuralElementDetailsProcessor.php create mode 100644 src/State/Mcp/ListStructuralElementsProcessor.php create mode 100644 tests/Serializer/StructuralElementOverviewNormalizerTest.php create mode 100644 tests/State/Mcp/StructuralElementProcessorsTest.php diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index 80fcc43c..cd2933fa 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.', + 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..6e38465e 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.', + 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..ef491277 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.', + 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..bd8ffd84 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).', + 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..e9e1fa87 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").', + 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..3e8cc611 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.', + 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..11dcf980 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.', + 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/Mcp/DTO/StructuralElementOverview.php b/src/Mcp/DTO/StructuralElementOverview.php new file mode 100644 index 00000000..aa96de38 --- /dev/null +++ b/src/Mcp/DTO/StructuralElementOverview.php @@ -0,0 +1,47 @@ +. + */ + +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, + ) { + } +} 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..4205593f --- /dev/null +++ b/src/Serializer/StructuralElementOverviewNormalizer.php @@ -0,0 +1,68 @@ +. + */ + +namespace App\Serializer; + +use App\Mcp\DTO\StructuralElementOverview; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes StructuralElementOverview as a plain {id, name} 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 id+name 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} + */ + 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, + ]; + } + + /** + * @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..a3936b59 --- /dev/null +++ b/src/State/Mcp/ListStructuralElementsProcessor.php @@ -0,0 +1,78 @@ +. + */ + +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 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 overview only; use the corresponding get_X_details tool for full details. + */ +class ListStructuralElementsProcessor implements ProcessorInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + ) { + } + + /** + * @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)); + } + + $qb = $this->entityManager->getRepository($class)->createQueryBuilder('element'); + $qb->select('element.id AS id', 'element.name AS name'); + + if ($data->keyword !== null && $data->keyword !== '') { + //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.'%'); + } + + $qb->orderBy('element.name', 'ASC'); + + $rows = $qb->getQuery()->getArrayResult(); + + return array_map( + static fn (array $row): StructuralElementOverview => new StructuralElementOverview(id: $row['id'], name: $row['name']), + $rows + ); + } +} diff --git a/tests/Serializer/StructuralElementOverviewNormalizerTest.php b/tests/Serializer/StructuralElementOverviewNormalizerTest.php new file mode 100644 index 00000000..d657114e --- /dev/null +++ b/tests/Serializer/StructuralElementOverviewNormalizerTest.php @@ -0,0 +1,52 @@ +. + */ + +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'); + + //Must be a plain array with exactly id+name, no JSON-LD "@id"/"@type" clutter + $this->assertSame(['id' => 42, 'name' => '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'))); + } +} diff --git a/tests/State/Mcp/StructuralElementProcessorsTest.php b/tests/State/Mcp/StructuralElementProcessorsTest.php new file mode 100644 index 00000000..d4fd8122 --- /dev/null +++ b/tests/State/Mcp/StructuralElementProcessorsTest.php @@ -0,0 +1,216 @@ +. + */ + +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\UserSystem\User; +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 overview only + self::assertIsInt($overview->id); + self::assertIsString($overview->name); + } + } + + 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); + + $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 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)); + } + + /** + * 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'); + + $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); + //The list_X tools are a lean id+name 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']; + } +} From e323ed98033d2a2593d88877f9339a3a9558ea13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jul 2026 21:03:05 +0200 Subject: [PATCH 15/25] Return full path and sort structures by full pathes in list tools --- src/Entity/Parts/Category.php | 2 +- src/Entity/Parts/Footprint.php | 2 +- src/Entity/Parts/Manufacturer.php | 2 +- src/Entity/Parts/MeasurementUnit.php | 2 +- src/Entity/Parts/PartCustomState.php | 2 +- src/Entity/Parts/StorageLocation.php | 2 +- src/Entity/Parts/Supplier.php | 2 +- src/Mcp/DTO/StructuralElementOverview.php | 3 ++ .../StructuralElementOverviewNormalizer.php | 11 ++-- .../Mcp/ListStructuralElementsProcessor.php | 50 ++++++++++++------ ...tructuralElementOverviewNormalizerTest.php | 11 ++-- .../Mcp/StructuralElementProcessorsTest.php | 51 +++++++++++++++++-- 12 files changed, 106 insertions(+), 34 deletions(-) diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index cd2933fa..316e116f 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -87,7 +87,7 @@ use Symfony\Component\Validator\Constraints as Assert; 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.', + 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']], diff --git a/src/Entity/Parts/Footprint.php b/src/Entity/Parts/Footprint.php index 6e38465e..54c85734 100644 --- a/src/Entity/Parts/Footprint.php +++ b/src/Entity/Parts/Footprint.php @@ -86,7 +86,7 @@ use Symfony\Component\Validator\Constraints as Assert; 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.', + 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']], diff --git a/src/Entity/Parts/Manufacturer.php b/src/Entity/Parts/Manufacturer.php index ef491277..343ce685 100644 --- a/src/Entity/Parts/Manufacturer.php +++ b/src/Entity/Parts/Manufacturer.php @@ -85,7 +85,7 @@ use Symfony\Component\Validator\Constraints as Assert; mcp: [ 'list_manufacturers' => new McpToolCollection( title: 'List/search manufacturers', - description: 'List all manufacturers, optionally filtered by a keyword matched against the name and comment.', + 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']], diff --git a/src/Entity/Parts/MeasurementUnit.php b/src/Entity/Parts/MeasurementUnit.php index bd8ffd84..dbf8036b 100644 --- a/src/Entity/Parts/MeasurementUnit.php +++ b/src/Entity/Parts/MeasurementUnit.php @@ -90,7 +90,7 @@ use Symfony\Component\Validator\Constraints\Length; 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).', + 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']], diff --git a/src/Entity/Parts/PartCustomState.php b/src/Entity/Parts/PartCustomState.php index e9e1fa87..b5007e89 100644 --- a/src/Entity/Parts/PartCustomState.php +++ b/src/Entity/Parts/PartCustomState.php @@ -77,7 +77,7 @@ use Symfony\Component\Validator\Constraints as Assert; 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").', + 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']], diff --git a/src/Entity/Parts/StorageLocation.php b/src/Entity/Parts/StorageLocation.php index 3e8cc611..a7c7176e 100644 --- a/src/Entity/Parts/StorageLocation.php +++ b/src/Entity/Parts/StorageLocation.php @@ -86,7 +86,7 @@ use Symfony\Component\Validator\Constraints as Assert; 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.', + 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']], diff --git a/src/Entity/Parts/Supplier.php b/src/Entity/Parts/Supplier.php index 11dcf980..466f1789 100644 --- a/src/Entity/Parts/Supplier.php +++ b/src/Entity/Parts/Supplier.php @@ -90,7 +90,7 @@ use Symfony\Component\Validator\Constraints as Assert; mcp: [ 'list_suppliers' => new McpToolCollection( title: 'List/search suppliers', - description: 'List all suppliers, optionally filtered by a keyword matched against the name and comment.', + 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']], diff --git a/src/Mcp/DTO/StructuralElementOverview.php b/src/Mcp/DTO/StructuralElementOverview.php index aa96de38..40591b44 100644 --- a/src/Mcp/DTO/StructuralElementOverview.php +++ b/src/Mcp/DTO/StructuralElementOverview.php @@ -42,6 +42,9 @@ readonly class StructuralElementOverview /** @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/Serializer/StructuralElementOverviewNormalizer.php b/src/Serializer/StructuralElementOverviewNormalizer.php index 4205593f..04a3ee9c 100644 --- a/src/Serializer/StructuralElementOverviewNormalizer.php +++ b/src/Serializer/StructuralElementOverviewNormalizer.php @@ -27,10 +27,10 @@ use App\Mcp\DTO\StructuralElementOverview; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** - * Normalizes StructuralElementOverview as a plain {id, name} 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 id+name overview. + * 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 */ @@ -42,7 +42,7 @@ class StructuralElementOverviewNormalizer implements NormalizerInterface } /** - * @return array{id: int, name: string} + * @return array{id: int, name: string, full_path: string} */ public function normalize($object, ?string $format = null, array $context = []): array { @@ -53,6 +53,7 @@ class StructuralElementOverviewNormalizer implements NormalizerInterface return [ 'id' => $object->id, 'name' => $object->name, + 'full_path' => $object->full_path, ]; } diff --git a/src/State/Mcp/ListStructuralElementsProcessor.php b/src/State/Mcp/ListStructuralElementsProcessor.php index a3936b59..115312b4 100644 --- a/src/State/Mcp/ListStructuralElementsProcessor.php +++ b/src/State/Mcp/ListStructuralElementsProcessor.php @@ -27,18 +27,20 @@ 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 overview only; use the corresponding get_X_details tool for full details. + * Returns a lean id+name+full_path overview only; use the corresponding get_X_details tool for full details. */ -class ListStructuralElementsProcessor implements ProcessorInterface +readonly class ListStructuralElementsProcessor implements ProcessorInterface { public function __construct( - private readonly EntityManagerInterface $entityManager, + private EntityManagerInterface $entityManager, + private NodesListBuilder $nodesListBuilder, ) { } @@ -56,23 +58,41 @@ class ListStructuralElementsProcessor implements ProcessorInterface throw new \LogicException(sprintf('%s can only be used for resources extending %s', self::class, AbstractStructuralDBElement::class)); } - $qb = $this->entityManager->getRepository($class)->createQueryBuilder('element'); - $qb->select('element.id AS id', 'element.name AS name'); + 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); - if ($data->keyword !== null && $data->keyword !== '') { - //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.'%'); + return array_map($this->toOverview(...), $elements); } - $qb->orderBy('element.name', 'ASC'); + $qb = $this->entityManager->getRepository($class)->createQueryBuilder('element'); - $rows = $qb->getQuery()->getArrayResult(); + //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.'%'); - return array_map( - static fn (array $row): StructuralElementOverview => new StructuralElementOverview(id: $row['id'], name: $row['name']), - $rows + /** @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/tests/Serializer/StructuralElementOverviewNormalizerTest.php b/tests/Serializer/StructuralElementOverviewNormalizerTest.php index d657114e..b0fbf467 100644 --- a/tests/Serializer/StructuralElementOverviewNormalizerTest.php +++ b/tests/Serializer/StructuralElementOverviewNormalizerTest.php @@ -38,15 +38,18 @@ final class StructuralElementOverviewNormalizerTest extends TestCase public function testNormalize(): void { - $overview = new StructuralElementOverview(id: 42, name: 'Test Node'); + $overview = new StructuralElementOverview(id: 42, name: 'Test Node', full_path: 'Parent → Test Node'); - //Must be a plain array with exactly id+name, no JSON-LD "@id"/"@type" clutter - $this->assertSame(['id' => 42, 'name' => 'Test Node'], $this->service->normalize($overview)); + //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'))); + $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 index d4fd8122..2f58ac2e 100644 --- a/tests/State/Mcp/StructuralElementProcessorsTest.php +++ b/tests/State/Mcp/StructuralElementProcessorsTest.php @@ -81,9 +81,10 @@ class StructuralElementProcessorsTest extends WebTestCase self::assertGreaterThanOrEqual(7, count($result)); foreach ($result as $overview) { self::assertInstanceOf(StructuralElementOverview::class, $overview); - //The list tool is a lean id+name overview only + //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); } } @@ -96,6 +97,8 @@ class StructuralElementProcessorsTest extends WebTestCase 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); @@ -113,6 +116,47 @@ class StructuralElementProcessorsTest extends WebTestCase 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); @@ -183,7 +227,7 @@ class StructuralElementProcessorsTest extends WebTestCase 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'); + $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, @@ -195,7 +239,8 @@ class StructuralElementProcessorsTest extends WebTestCase 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); - //The list_X tools are a lean id+name overview - no JSON-LD "@id"/"@type" clutter per item + 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]); } From 8b484d5776aa2478c79ecb7686674f1f4116d77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jul 2026 21:30:41 +0200 Subject: [PATCH 16/25] Add mcp tools for projects --- src/Entity/ProjectSystem/Project.php | 31 ++++++++- src/Entity/ProjectSystem/ProjectBOMEntry.php | 12 ++-- .../Mcp/StructuralElementProcessorsTest.php | 65 +++++++++++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) 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/tests/State/Mcp/StructuralElementProcessorsTest.php b/tests/State/Mcp/StructuralElementProcessorsTest.php index 2f58ac2e..87b0c157 100644 --- a/tests/State/Mcp/StructuralElementProcessorsTest.php +++ b/tests/State/Mcp/StructuralElementProcessorsTest.php @@ -34,7 +34,9 @@ use App\Entity\Parts\Part; use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; +use App\Entity\ProjectSystem\Project; use App\Entity\UserSystem\User; +use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Mcp\DTO\ElementByIdInput; use App\Mcp\DTO\StructuralElementOverview; use App\Mcp\DTO\StructuralElementSearchInput; @@ -201,6 +203,68 @@ class StructuralElementProcessorsTest extends WebTestCase $this->getProcessor->process(new ElementByIdInput($node1->getID()), $this->getOperationForClass(Category::class)); } + /** + * get_project_details is the only get_X_details tool whose entity (Project) has a substantial nested + * collection (bom_entries) worth exposing. Since bom_entries -> part is a relation to the (very large) + * Part entity, the mcp_project_details:read group deliberately does NOT pull in the "full"/"part:read" + * groups on Part, so that each bom entry's part reference stays a lean id+name reference instead of + * exploding into the part's whole object graph (category, storage locations, lots, ...). + */ + public function testGetProjectDetailsSerializesBomEntriesWithLeanPartReference(): void + { + $project = $this->em->getRepository(Project::class)->findOneBy(['name' => 'Node 1']); + self::assertNotNull($project); + + $part = $this->em->getRepository(Part::class)->findOneBy([]); + self::assertNotNull($part); + + $bomEntry = (new ProjectBOMEntry())->setPart($part)->setQuantity(3)->setMountnames('R1,R2,R3'); + $project->addBomEntry($bomEntry); + $this->em->persist($bomEntry); + $this->em->flush(); + + $resourceMetadataCollectionFactory = self::getContainer()->get(ResourceMetadataCollectionFactoryInterface::class); + $metadata = $resourceMetadataCollectionFactory->create(Project::class); + + $operation = null; + foreach ($metadata as $resource) { + foreach ($resource->getMcp() ?? [] as $mcp) { + if ($mcp instanceof McpTool && $mcp->getName() === 'get_project_details') { + $operation = $mcp; + } + } + } + self::assertNotNull($operation); + + $result = $this->getProcessor->process(new ElementByIdInput($project->getID()), $operation); + + $serializer = self::getContainer()->get(SerializerInterface::class); + $json = $serializer->serialize($result, 'jsonld', [ + 'groups' => $operation->getNormalizationContext()['groups'] ?? null, + 'resource_class' => Project::class, + 'operation' => $operation, + ]); + $decoded = json_decode($json, true, flags: \JSON_THROW_ON_ERROR); + + self::assertArrayHasKey('bom_entries', $decoded); + $entry = null; + foreach ($decoded['bom_entries'] as $candidate) { + if ($candidate['id'] === $bomEntry->getId()) { + $entry = $candidate; + } + } + self::assertNotNull($entry, 'Newly added bom entry not found in serialized output'); + + self::assertEquals(3.0, $entry['quantity']); + self::assertSame('R1,R2,R3', $entry['mountnames']); + self::assertArrayHasKey('part', $entry); + self::assertSame($part->getID(), $entry['part']['id']); + self::assertSame($part->getName(), $entry['part']['name']); + //The part reference must stay lean (id+name only) - it must NOT explode into the part's full object graph + self::assertArrayNotHasKey('category', $entry['part']); + self::assertArrayNotHasKey('partLots', $entry['part']); + } + /** * Regression test: a list_X tool's output must actually serialize id+name through the real * pipeline (JSON-LD normalizer with the operation's own normalizationContext), not just when the @@ -257,5 +321,6 @@ class StructuralElementProcessorsTest extends WebTestCase yield 'suppliers' => [Supplier::class, 'list_suppliers']; yield 'measurement_units' => [MeasurementUnit::class, 'list_measurement_units']; yield 'part_custom_states' => [PartCustomState::class, 'list_part_custom_states']; + yield 'projects' => [Project::class, 'list_projects']; } } From 32669b2cc06ed832d8ac3bccea038510d0e49dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jul 2026 21:35:27 +0200 Subject: [PATCH 17/25] Require authentication for accessing MCP tools --- config/packages/security.yaml | 3 +++ config/permissions.yaml | 5 +++++ translations/messages.en.xlf | 6 ++++++ 3 files changed, 14 insertions(+) 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/translations/messages.en.xlf b/translations/messages.en.xlf index 4d59c51a..0f0bae0e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13793,5 +13793,11 @@ 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) + + From 9e404e3a9350a609ef7e5eb2592c38de8b04da35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jul 2026 21:40:30 +0200 Subject: [PATCH 18/25] Updated symfony AI dependencies --- composer.json | 12 +- composer.lock | 307 ++++++++++++++++++++++--------------------- config/reference.php | 4 - 3 files changed, 163 insertions(+), 160 deletions(-) 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 978415cc..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", @@ -3423,16 +3423,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.18.4", + "version": "2.19.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "175f8ffa7ad574e1b5ccb66f1301a21524c88ce6" + "reference": "07b90f707b82981097731c419f546e7ba97fba3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/175f8ffa7ad574e1b5ccb66f1301a21524c88ce6", - "reference": "175f8ffa7ad574e1b5ccb66f1301a21524c88ce6", + "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", @@ -3482,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.", @@ -3527,7 +3527,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.4" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.0" }, "funding": [ { @@ -3543,7 +3543,7 @@ "type": "tidelift" } ], - "time": "2026-07-11T08:41:11+00:00" + "time": "2026-07-23T14:52:05+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -7155,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": { @@ -7233,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", @@ -11201,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", @@ -11230,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", @@ -11337,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": [ { @@ -11357,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": { @@ -11422,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": [ { @@ -11442,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": { @@ -11509,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": [ { @@ -11529,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": { @@ -11595,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": [ { @@ -11615,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": { @@ -11682,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": [ { @@ -11702,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": { @@ -11814,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": [ { @@ -11834,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", @@ -14282,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", @@ -14345,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": [ { @@ -14365,7 +14365,7 @@ "type": "tidelift" } ], - "time": "2026-07-15T00:43:00+00:00" + "time": "2026-07-21T01:04:45+00:00" }, { "name": "symfony/mime", @@ -18189,16 +18189,16 @@ }, { "name": "symplify/easy-coding-standard", - "version": "13.2.16", + "version": "13.2.17", "source": { "type": "git", "url": "https://github.com/ecsphp/ecs.git", - "reference": "5176cf0092e9a381d568bbeffd744c17a6f4993f" + "reference": "f5f8131520fb7d3efcf348b72205372ea619fe0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ecsphp/ecs/zipball/5176cf0092e9a381d568bbeffd744c17a6f4993f", - "reference": "5176cf0092e9a381d568bbeffd744c17a6f4993f", + "url": "https://api.github.com/repos/ecsphp/ecs/zipball/f5f8131520fb7d3efcf348b72205372ea619fe0e", + "reference": "f5f8131520fb7d3efcf348b72205372ea619fe0e", "shasum": "" }, "require": { @@ -18233,9 +18233,9 @@ "static analysis" ], "support": { - "source": "https://github.com/ecsphp/ecs/tree/13.2.16" + "source": "https://github.com/ecsphp/ecs/tree/13.2.17" }, - "time": "2026-07-22T19:18:33+00:00" + "time": "2026-07-22T20:02:43+00:00" }, { "name": "tecnickcom/tc-lib-barcode", @@ -20984,12 +20984,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "4991713e587e8b1f2fb7ce80475b4df68fe71be1" + "reference": "e7d396822cefa7207fd352f61f68660811e108a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/4991713e587e8b1f2fb7ce80475b4df68fe71be1", - "reference": "4991713e587e8b1f2fb7ce80475b4df68fe71be1", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/e7d396822cefa7207fd352f61f68660811e108a4", + "reference": "e7d396822cefa7207fd352f61f68660811e108a4", "shasum": "" }, "conflict": { @@ -21186,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", @@ -21619,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", @@ -21635,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", @@ -21662,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", @@ -22098,7 +22098,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T19:20:48+00:00" + "time": "2026-07-24T23:00:42+00:00" }, { "name": "sebastian/cli-parser", @@ -23611,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/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 * }, From 16ceccb083567b88ff12da9a59ba9cd37554a0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jul 2026 21:48:29 +0200 Subject: [PATCH 19/25] Show MCP endpoint url in api panel in user settings --- templates/users/_api_tokens.html.twig | 17 ++++++++++++++++- translations/messages.en.xlf | 6 ++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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/translations/messages.en.xlf b/translations/messages.en.xlf index 0f0bae0e..ccf977e3 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13799,5 +13799,11 @@ Buerklin-API Authentication server: Use MCP tools (for AI agents) + + + api.api_endpoints.mcp_url + MCP endpoint + + From 661cc5a052f76d071686587ebe69cad8f1c9bdbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jul 2026 22:06:25 +0200 Subject: [PATCH 20/25] Added documentation on MCP server capabilities --- docs/api/mcp.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/api/mcp.md 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`. From 071ffe322dc8a34ff59eaa4227da878375cee05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jul 2026 13:55:27 +0200 Subject: [PATCH 21/25] Reworked info provider system to merge all provider metadata into a unified DTO --- src/Controller/BrowserPluginController.php | 2 +- src/Controller/InfoProviderController.php | 2 +- .../InfoProviderNotActiveException.php | 3 +- .../InfoProviderSystem/ProviderSelectType.php | 8 +-- .../CreateFromUrlHelper.php | 2 +- .../DTOs/BulkSearchFieldMappingDTO.php | 2 +- ...nfoProviderDTO.php => ProviderInfoDTO.php} | 38 +++++++++-- .../InfoProviderSystem/PartInfoRetriever.php | 2 +- .../InfoProviderSystem/ProviderRegistry.php | 2 +- .../Providers/AIWebProvider.php | 44 ++++++------- .../Providers/BuerklinProvider.php | 44 ++++++------- .../Providers/CanopyProvider.php | 41 ++++++------ .../Providers/ConradProvider.php | 48 ++++++-------- .../Providers/DigikeyProvider.php | 52 +++++++-------- .../Providers/Element14Provider.php | 40 +++++------- .../Providers/EmptyProvider.php | 32 ++++------ .../Providers/GenericWebProvider.php | 42 ++++++------ .../Providers/InfoProviderInterface.php | 29 +-------- .../Providers/LCSCProvider.php | 44 ++++++------- .../Providers/MouserProvider.php | 43 ++++++------- .../Providers/OEMSecretsProvider.php | 64 ++++++------------- .../Providers/OctopartProvider.php | 44 ++++++------- .../Providers/PollinProvider.php | 44 ++++++------- .../Providers/ProviderCapabilities.php | 16 ++--- .../Providers/ReicheltProvider.php | 45 ++++++------- .../Providers/TMEProvider.php | 46 ++++++------- .../Providers/TestProvider.php | 41 +++++------- src/State/Mcp/ListInfoProvidersProcessor.php | 18 ++---- src/Twig/InfoProviderExtension.php | 2 +- .../info_providers/providers.macro.html.twig | 18 +++--- .../settings/provider_settings.html.twig | 2 +- .../Endpoints/InfoProviderEndpointTest.php | 10 +++ .../DTOs/BulkSearchFieldMappingDTOTest.php | 3 +- .../ProviderRegistryTest.php | 3 +- .../Providers/LCSCProviderTest.php | 19 ++---- .../Providers/TMEProviderTest.php | 18 ++---- ...etInfoProviderPartDetailsProcessorTest.php | 5 +- .../Mcp/ListInfoProvidersProcessorTest.php | 24 ++++--- .../Mcp/SearchInfoProvidersProcessorTest.php | 3 +- 39 files changed, 412 insertions(+), 533 deletions(-) rename src/Services/InfoProviderSystem/DTOs/{InfoProviderDTO.php => ProviderInfoDTO.php} (55%) diff --git a/src/Controller/BrowserPluginController.php b/src/Controller/BrowserPluginController.php index 1bb95787..403b0dc1 100644 --- a/src/Controller/BrowserPluginController.php +++ b/src/Controller/BrowserPluginController.php @@ -73,7 +73,7 @@ class BrowserPluginController extends AbstractController if (isset($activeProviders[$key])) { $urlProviders[] = [ 'id' => $key, - 'label' => $activeProviders[$key]->getProviderInfo()['name'], + 'label' => $activeProviders[$key]->getProviderInfo()->name, ]; } } diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index 28c281d0..09251043 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -88,7 +88,7 @@ class InfoProviderController extends AbstractController $this->denyAccessUnlessGranted('@info_providers.create_parts'); $providerInstance = $this->providerRegistry->getProviderByKey($provider); - $settingsClass = $providerInstance->getProviderInfo()['settings_class'] ?? throw new \LogicException('Provider ' . $provider . ' does not have a settings class defined'); + $settingsClass = $providerInstance->getProviderInfo()->settingsClass ?? throw new \LogicException('Provider ' . $provider . ' does not have a settings class defined'); //Create a clone of the settings object $settings = $this->settingsManager->createTemporaryCopy($settingsClass); diff --git a/src/Exceptions/InfoProviderNotActiveException.php b/src/Exceptions/InfoProviderNotActiveException.php index 02f7cfb7..16e71af8 100644 --- a/src/Exceptions/InfoProviderNotActiveException.php +++ b/src/Exceptions/InfoProviderNotActiveException.php @@ -43,6 +43,7 @@ class InfoProviderNotActiveException extends \RuntimeException */ public static function fromProvider(InfoProviderInterface $provider): self { - return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???'); + $info = $provider->getProviderInfo(); + return new self($info->key, $info->name); } } diff --git a/src/Form/InfoProviderSystem/ProviderSelectType.php b/src/Form/InfoProviderSystem/ProviderSelectType.php index e59dc85f..a29ebdf2 100644 --- a/src/Form/InfoProviderSystem/ProviderSelectType.php +++ b/src/Form/InfoProviderSystem/ProviderSelectType.php @@ -66,8 +66,8 @@ class ProviderSelectType extends AbstractType $tmp = []; foreach ($providers as $provider) { - $name = $provider->getProviderInfo()['name']; - $tmp[$name] = $provider->getProviderKey(); + $info = $provider->getProviderInfo(); + $tmp[$info->name] = $info->key; } return $tmp; @@ -77,7 +77,7 @@ class ProviderSelectType extends AbstractType $resolver->setDefault('choice_label', function (Options $options) { if ('object' === $options['input']) { return ChoiceList::label($this, static fn(?InfoProviderInterface $choice - ) => new StaticMessage($choice?->getProviderInfo()['name'])); + ) => new StaticMessage($choice?->getProviderInfo()->name)); } return static fn($choice, $key, $value) => new StaticMessage($key); @@ -85,7 +85,7 @@ class ProviderSelectType extends AbstractType $resolver->setDefault('choice_value', function (Options $options) { if ('object' === $options['input']) { return ChoiceList::value($this, - static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()); + static fn(?InfoProviderInterface $choice) => $choice?->getProviderInfo()->key); } return null; diff --git a/src/Services/InfoProviderSystem/CreateFromUrlHelper.php b/src/Services/InfoProviderSystem/CreateFromUrlHelper.php index 0291142f..d844cdaa 100644 --- a/src/Services/InfoProviderSystem/CreateFromUrlHelper.php +++ b/src/Services/InfoProviderSystem/CreateFromUrlHelper.php @@ -72,7 +72,7 @@ final readonly class CreateFromUrlHelper $provider = $this->providerRegistry->getProviderHandlingDomain($host); - if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $callingInfoProvider->getProviderKey()) { + if ($provider !== null && $provider->isActive() && $provider->getProviderInfo()->key !== $callingInfoProvider->getProviderInfo()->key) { try { $id = $provider->getIDFromURL($url); if ($id !== null) { diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php index 47d8ac69..e13d3b82 100644 --- a/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php @@ -49,7 +49,7 @@ readonly class BulkSearchFieldMappingDTO //Ensure that providers are provided as keys foreach ($providers as &$provider) { if ($provider instanceof InfoProviderInterface) { - $provider = $provider->getProviderKey(); + $provider = $provider->getProviderInfo()->key; } if (!is_string($provider)) { throw new \InvalidArgumentException('Providers must be provided as strings or InfoProviderInterface instances'); diff --git a/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php b/src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php similarity index 55% rename from src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php rename to src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php index 5773bc49..1e51e5c6 100644 --- a/src/Services/InfoProviderSystem/DTOs/InfoProviderDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php @@ -27,11 +27,17 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\McpToolCollection; use ApiPlatform\OpenApi\Model\Operation; use App\Mcp\DTO\ListInfoProvidersInput; +use App\Services\InfoProviderSystem\Providers\ProviderCapabilities; use App\State\Mcp\ListInfoProvidersProcessor; +use Symfony\Component\Serializer\Annotation\Groups; /** - * This DTO represents an available info provider (a distributor or manufacturer catalog which can be - * searched via search_info_providers / get_info_provider_part_details). + * Immutable, structured description of an info provider, returned by InfoProviderInterface::getProviderInfo() + * and (via the 'info_provider:read' group) exposed as the REST GET /api/info_providers collection and the + * list_info_providers MCP tool. + * + * disabledHelp, oauthAppName and settingsClass are internal-only (used by the settings UI) and deliberately + * not tagged with the 'info_provider:read' group, so they never appear in the API/MCP output. */ #[ApiResource( uriTemplate: '/info_providers', @@ -44,6 +50,7 @@ use App\State\Mcp\ListInfoProvidersProcessor; ), ], paginationEnabled: false, + normalizationContext: ['groups' => ['info_provider:read']], mcp: [ 'list_info_providers' => new McpToolCollection( title: 'List available info providers', @@ -52,21 +59,38 @@ use App\State\Mcp\ListInfoProvidersProcessor; input: ListInfoProvidersInput::class, security: 'is_granted("@info_providers.create_parts")', processor: ListInfoProvidersProcessor::class, + normalizationContext: ['groups' => ['info_provider:read']], ), ], )] -readonly class InfoProviderDTO +readonly class ProviderInfoDTO { public function __construct( - /** @var string The unique key of the provider, to be used as provider_key in the other info provider tools */ + /** @var string A unique key for this provider (e.g. "digikey"), which is saved into the database and used to identify the provider */ + #[Groups(['info_provider:read'])] public string $key, - /** @var string The (user friendly) name of the provider (e.g. "Digikey") */ + /** @var string The (user friendly) name of the provider (e.g. "Digikey"), will be translated */ + #[Groups(['info_provider:read'])] public string $name, - /** @var string|null A short description of the provider */ + /** @var string|null A short description of the provider (e.g. "Digikey is a ..."), will be translated */ + #[Groups(['info_provider:read'])] public ?string $description = null, /** @var string|null The url of the provider (e.g. "https://www.digikey.com") */ + #[Groups(['info_provider:read'])] public ?string $url = null, - /** @var string[] The kinds of data this provider can supply (e.g. "PRICE", "DATASHEET", "PICTURE") */ + /** @var string|null A help text which is shown when the provider is disabled, explaining how to enable it */ + public ?string $disabledHelp = null, + /** @var string|null The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown */ + public ?string $oauthAppName = null, + /** @var class-string|null The class name of the settings class which contains the settings for this provider (e.g. "App\Settings\InfoProviderSettings\DigikeySettings"). If this is set a link to the settings will be shown */ + public ?string $settingsClass = null, + /** + * A list of capabilities this provider supports (which kind of data it can provide). + * Not every part have to contain all of these data, but the provider should be able to provide them in general. + * Currently, this list is purely informational and not used in functional checks. + * @var ProviderCapabilities[] + */ + #[Groups(['info_provider:read'])] public array $capabilities = [], ) { } diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 9cff3312..f99970af 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -103,7 +103,7 @@ final class PartInfoRetriever //Generate a hash for the options, to ensure that different options result in different cache entries $options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR)); - $cache_key = "search_{$provider->getProviderKey()}_{$escaped_keyword}_{$options_hash}"; + $cache_key = "search_{$provider->getProviderInfo()->key}_{$escaped_keyword}_{$options_hash}"; //If no_cache is set, bypass the cache and get fresh results from the provider if ($no_cache) { diff --git a/src/Services/InfoProviderSystem/ProviderRegistry.php b/src/Services/InfoProviderSystem/ProviderRegistry.php index 18b8a37a..1b0b9785 100644 --- a/src/Services/InfoProviderSystem/ProviderRegistry.php +++ b/src/Services/InfoProviderSystem/ProviderRegistry.php @@ -72,7 +72,7 @@ final class ProviderRegistry private function initStructures(): void { foreach ($this->providers as $provider) { - $key = $provider->getProviderKey(); + $key = $provider->getProviderInfo()->key; if (isset($this->providers_by_name[$key])) { throw new \LogicException("Provider with key $key already registered"); diff --git a/src/Services/InfoProviderSystem/Providers/AIWebProvider.php b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php index be91041e..7f977036 100644 --- a/src/Services/InfoProviderSystem/Providers/AIWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php @@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\SubmittedPageStorage; use App\Services\InfoProviderSystem\CreateFromUrlHelper; use App\Services\InfoProviderSystem\DTOJsonSchemaConverter; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Settings\InfoProviderSystem\AIExtractorSettings; use Jkphl\Micrometa; use League\HTMLToMarkdown\HtmlConverter; @@ -50,6 +51,8 @@ final class AIWebProvider implements InfoProviderInterface { use FixAndValidateUrlTrait; + public const PROVIDER_KEY = 'ai_web'; + private const DISTRIBUTOR_NAME = 'Website'; private readonly HttpClientInterface $httpClient; @@ -71,20 +74,22 @@ final class AIWebProvider implements InfoProviderInterface ); } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'AI Web Extractor', - 'description' => 'Extract part info from any URL using LLM', - //'url' => 'https://openrouter.ai', - 'disabled_help' => 'Configure AI settings', - 'settings_class' => AIExtractorSettings::class, - ]; - } - - public function getProviderKey(): string - { - return 'ai_web'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'AI Web Extractor', + description: 'Extract part info from any URL using LLM', + disabledHelp: 'Configure AI settings', + settingsClass: AIExtractorSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ProviderCapabilities::PARAMETERS, + ], + ); } public function isActive(): bool @@ -166,7 +171,7 @@ final class AIWebProvider implements InfoProviderInterface $llmResponse = $this->callLLM($markdown, $url, $structuredData); // Build and return PartDetailDTO - $result = $this->jsonSchemaConverter->jsonToDTO($llmResponse, $this->getProviderKey(), $url, $url, self::DISTRIBUTOR_NAME); + $result = $this->jsonSchemaConverter->jsonToDTO($llmResponse, self::PROVIDER_KEY, $url, $url, self::DISTRIBUTOR_NAME); // Cache the result for future use, to improve performance and reduce costs. $cacheItem->set($result); @@ -256,17 +261,6 @@ final class AIWebProvider implements InfoProviderInterface return $converter->convert($htmlToConvert); } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ProviderCapabilities::PARAMETERS, - ]; - } - private function callLLM(string $htmlContent, string $url, ?string $structuredData = null): array { $input = new MessageBag( diff --git a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php index ca6e26e1..fe110971 100644 --- a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php @@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\BuerklinSettings; @@ -40,6 +41,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin'; public const DISTRIBUTOR_NAME = 'Buerklin'; + public const PROVIDER_KEY = 'buerklin'; private const CACHE_TTL = 600; /** @@ -175,20 +177,23 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Buerklin', - 'description' => 'This provider uses the Buerklin API to search for parts.', - 'url' => 'https://www.buerklin.com/', - 'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.', - 'settings_class' => BuerklinSettings::class - ]; - } - - public function getProviderKey(): string - { - return 'buerklin'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Buerklin', + description: 'This provider uses the Buerklin API to search for parts.', + url: 'https://www.buerklin.com/', + disabledHelp: 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.', + settingsClass: BuerklinSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + //ProviderCapabilities::DATASHEET, // currently not implemented + ProviderCapabilities::PRICE, + ProviderCapabilities::FOOTPRINT, + ], + ); } // This provider is considered active if settings are present @@ -283,7 +288,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv } return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: (string) ($product['code'] ?? $code), name: (string) ($product['manufacturerProductId'] ?? $code), @@ -509,17 +514,6 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv return $this->getPartDetail($response); } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - //ProviderCapabilities::DATASHEET, // currently not implemented - ProviderCapabilities::PRICE, - ProviderCapabilities::FOOTPRINT, - ]; - } - private function complianceToParameters(array $product, ?string $group = 'Compliance'): array { $params = []; diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php index aee30d6b..2f0f355a 100644 --- a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -26,6 +26,7 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\BuerklinSettings; @@ -39,6 +40,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ class CanopyProvider implements InfoProviderInterface { + public const PROVIDER_KEY = 'canopy'; public const BASE_URL = "https://rest.canopyapi.co/api"; public const SEARCH_API_URL = self::BASE_URL . "/amazon/search"; @@ -52,20 +54,21 @@ class CanopyProvider implements InfoProviderInterface } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Amazon (Canopy)', - 'description' => 'Retrieves part infos from Amazon using the Canopy API', - 'url' => 'https://canopyapi.co', - 'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider', - 'settings_class' => CanopySettings::class - ]; - } - - public function getProviderKey(): string - { - return 'canopy'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Amazon (Canopy)', + description: 'Retrieves part infos from Amazon using the Canopy API', + url: 'https://canopyapi.co', + disabledHelp: 'Set Canopy API key in the provider configuration to enable this provider', + settingsClass: CanopySettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::PRICE, + ], + ); } public function isActive(): bool @@ -131,7 +134,7 @@ class CanopyProvider implements InfoProviderInterface $dto = new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $result['asin'], name: $result["title"], description: "", @@ -209,7 +212,7 @@ class CanopyProvider implements InfoProviderInterface } return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $product['asin'], name: $product['title'], description: '', @@ -222,12 +225,4 @@ class CanopyProvider implements InfoProviderInterface ); } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::PRICE, - ]; - } } diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 2e6708be..9af1513f 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -27,6 +27,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\ConradSettings; @@ -38,6 +39,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch'; public const DISTRIBUTOR_NAME = 'Conrad'; + public const PROVIDER_KEY = 'conrad'; private HttpClientInterface $httpClient; @@ -51,20 +53,24 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr ]); } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Conrad', - 'description' => 'Retrieves part information from conrad.de', - 'url' => 'https://www.conrad.de/', - 'disabled_help' => 'Set API key in settings', - 'settings_class' => ConradSettings::class, - ]; - } - - public function getProviderKey(): string - { - return 'conrad'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Conrad', + description: 'Retrieves part information from conrad.de', + url: 'https://www.conrad.de/', + disabledHelp: 'Set API key in settings', + settingsClass: ConradSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::GTIN, + ], + ); } public function isActive(): bool @@ -111,7 +117,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr foreach($results['hits'] as $result) { $out[] = new SearchResultDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $result['productId'], name: $result['manufacturerId'] ?? $result['productId'], description: $result['title'] ?? '', @@ -293,7 +299,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr $data = $response->toArray(); return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $data['shortProductNumber'], name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'], description: $data['productShortInformation']['title'] ?? '', @@ -311,18 +317,6 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr ); } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ProviderCapabilities::FOOTPRINT, - ProviderCapabilities::GTIN, - ]; - } - public function getHandledDomains(): array { $domains = []; diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index e7a62aa4..4bd29a5c 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\OAuth\OAuthTokenManager; @@ -37,6 +38,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class DigikeyProvider implements InfoProviderInterface { + public const PROVIDER_KEY = 'digikey'; private const OAUTH_APP_NAME = 'ip_digikey_oauth'; @@ -72,32 +74,24 @@ class DigikeyProvider implements InfoProviderInterface ]); } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'DigiKey', - 'description' => 'This provider uses the DigiKey API to search for parts.', - 'url' => 'https://www.digikey.com/', - 'oauth_app_name' => self::OAUTH_APP_NAME, - 'disabled_help' => 'Set the Client ID and Secret in provider settings and connect OAuth to enable.', - 'settings_class' => DigikeySettings::class, - ]; - } - - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ]; - } - - public function getProviderKey(): string - { - return 'digikey'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'DigiKey', + description: 'This provider uses the DigiKey API to search for parts.', + url: 'https://www.digikey.com/', + disabledHelp: 'Set the Client ID and Secret in provider settings and connect OAuth to enable.', + oauthAppName: self::OAUTH_APP_NAME, + settingsClass: DigikeySettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ], + ); } public function isActive(): bool @@ -128,7 +122,7 @@ class DigikeyProvider implements InfoProviderInterface } catch (\InvalidArgumentException $exception) { //Check if the exception was caused by an invalid or expired token if (str_contains($exception->getMessage(), 'access_token')) { - throw OAuthReconnectRequiredException::forProvider($this->getProviderKey()); + throw OAuthReconnectRequiredException::forProvider(self::PROVIDER_KEY); } throw $exception; @@ -141,7 +135,7 @@ class DigikeyProvider implements InfoProviderInterface foreach ($products as $product) { foreach ($product['ProductVariations'] as $variation) { $result[] = new SearchResultDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $variation['DigiKeyProductNumber'], name: $product['ManufacturerProductNumber'], description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'], @@ -168,7 +162,7 @@ class DigikeyProvider implements InfoProviderInterface } catch (\InvalidArgumentException $exception) { //Check if the exception was caused by an invalid or expired token if (str_contains($exception->getMessage(), 'access_token')) { - throw OAuthReconnectRequiredException::forProvider($this->getProviderKey()); + throw OAuthReconnectRequiredException::forProvider(self::PROVIDER_KEY); } throw $exception; @@ -191,7 +185,7 @@ class DigikeyProvider implements InfoProviderInterface } return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $id, name: $product['ManufacturerProductNumber'], description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'], diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 1d9e092c..179c2fb6 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Settings\InfoProviderSystem\Element14Settings; use Composer\CaBundle\CaBundle; @@ -35,6 +36,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class Element14Provider implements InfoProviderInterface, URLHandlerInfoProviderInterface { + public const PROVIDER_KEY = 'element14'; private const ENDPOINT_URL = 'https://api.element14.com/catalog/products'; private const API_VERSION_NUMBER = '1.4'; @@ -60,20 +62,21 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider ]); } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Farnell element14', - 'description' => 'This provider uses the Farnell element14 API to search for parts.', - 'url' => 'https://www.element14.com/', - 'disabled_help' => 'Configure the API key in the provider settings to enable.', - 'settings_class' => Element14Settings::class, - ]; - } - - public function getProviderKey(): string - { - return 'element14'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Farnell element14', + description: 'This provider uses the Farnell element14 API to search for parts.', + url: 'https://www.element14.com/', + disabledHelp: 'Configure the API key in the provider settings to enable.', + settingsClass: Element14Settings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ], + ); } public function isActive(): bool @@ -113,7 +116,7 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider foreach ($products as $product) { $result[] = new PartDetailDTO( - provider_key: $this->getProviderKey(), provider_id: $product['sku'], + provider_key: self::PROVIDER_KEY, provider_id: $product['sku'], name: $product['translatedManufacturerPartNumber'], description: $this->displayNameToDescription($product['displayName'], $product['translatedManufacturerPartNumber']), manufacturer: $product['vendorName'] ?? $product['brandName'] ?? null, @@ -301,15 +304,6 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider return $tmp[0]; } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ]; - } - public function getHandledDomains(): array { return ['element14.com', 'farnell.com', 'newark.com']; diff --git a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php index 915a118c..eea14ed8 100644 --- a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php @@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use Symfony\Component\DependencyInjection\Attribute\When; @@ -34,19 +35,20 @@ use Symfony\Component\DependencyInjection\Attribute\When; #[When(env: 'test')] class EmptyProvider implements InfoProviderInterface { - public function getProviderInfo(): array - { - return [ - 'name' => 'Empty Provider', - 'description' => 'This is a test provider', - //'url' => 'https://example.com', - 'disabled_help' => 'This provider is disabled for testing purposes' - ]; - } + public const PROVIDER_KEY = 'empty'; - public function getProviderKey(): string + public function getProviderInfo(): ProviderInfoDTO { - return 'empty'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Empty Provider', + description: 'This is a test provider', + disabledHelp: 'This provider is disabled for testing purposes', + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ], + ); } public function isActive(): bool @@ -61,14 +63,6 @@ class EmptyProvider implements InfoProviderInterface ]; } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, - ]; - } - public function getDetails(string $id, array $options = []): PartDetailDTO { throw new \RuntimeException('No part details available'); diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index 45777f9e..420049b3 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\CreateFromUrlHelper; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\PartInfoRetriever; @@ -53,6 +54,7 @@ class GenericWebProvider implements InfoProviderInterface use FixAndValidateUrlTrait; public const DISTRIBUTOR_NAME = 'Website'; + public const PROVIDER_KEY = 'generic_web'; private readonly HttpClientInterface $httpClient; @@ -69,20 +71,21 @@ class GenericWebProvider implements InfoProviderInterface ); } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Generic Web URL', - 'description' => 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.', - //'url' => 'https://example.com', - 'disabled_help' => 'Enable in settings to use this provider', - 'settings_class' => GenericWebProviderSettings::class, - ]; - } - - public function getProviderKey(): string - { - return 'generic_web'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Generic Web URL', + description: 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.', + disabledHelp: 'Enable in settings to use this provider', + settingsClass: GenericWebProviderSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::PRICE, + ProviderCapabilities::GTIN, + ], + ); } public function isActive(): bool @@ -227,7 +230,7 @@ class GenericWebProvider implements InfoProviderInterface } return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $url, name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product->mpn?->toString() ?? 'Unknown Name', description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '', @@ -376,7 +379,7 @@ class GenericWebProvider implements InfoProviderInterface )]; return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $canonicalURL, name: $this->getMetaContent($dom, 'og:title') ?? $pageTitle, description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '', @@ -387,13 +390,4 @@ class GenericWebProvider implements InfoProviderInterface ); } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::PRICE, - ProviderCapabilities::GTIN, - ]; - } } diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php index d3895795..cdc8086c 100644 --- a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; interface InfoProviderInterface @@ -34,26 +35,8 @@ interface InfoProviderInterface /** * Get information about this provider - * - * @return array An associative array with the following keys (? means optional): - * - name: The (user friendly) name of the provider (e.g. "Digikey"), will be translated - * - description?: A short description of the provider (e.g. "Digikey is a ..."), will be translated - * - logo?: The logo of the provider (e.g. "digikey.png") - * - url?: The url of the provider (e.g. "https://www.digikey.com") - * - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it - * - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown - * - settings_class?: The class name of the settings class which contains the settings for this provider (e.g. "App\Settings\InfoProviderSettings\DigikeySettings"). If this is set a link to the settings will be shown - * - * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string, settings_class?: class-string } */ - public function getProviderInfo(): array; - - /** - * Returns a unique key for this provider, which will be saved into the database - * and used to identify the provider - * @return string A unique key for this provider (e.g. "digikey") - */ - public function getProviderKey(): string; + public function getProviderInfo(): ProviderInfoDTO; /** * Checks if this provider is enabled or not (meaning that it can be used for searching) @@ -76,12 +59,4 @@ interface InfoProviderInterface * @return PartDetailDTO */ public function getDetails(string $id, array $options = []): PartDetailDTO; - - /** - * A list of capabilities this provider supports (which kind of data it can provide). - * Not every part have to contain all of these data, but the provider should be able to provide them in general. - * Currently, this list is purely informational and not used in functional checks. - * @return ProviderCapabilities[] - */ - public function getCapabilities(): array; } diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index b3da3cda..ab96ec0d 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Settings\InfoProviderSystem\LCSCSettings; use Symfony\Component\HttpFoundation\Cookie; @@ -39,26 +40,30 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm'; public const DISTRIBUTOR_NAME = 'LCSC'; + public const PROVIDER_KEY = 'lcsc'; public function __construct(private readonly HttpClientInterface $lcscClient, private readonly LCSCSettings $settings) { } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'LCSC', - 'description' => 'This provider uses the (unofficial) LCSC API to search for parts.', - 'url' => 'https://www.lcsc.com/', - 'disabled_help' => 'Enable this provider in the provider settings.', - 'settings_class' => LCSCSettings::class, - ]; - } - - public function getProviderKey(): string - { - return 'lcsc'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'LCSC', + description: 'This provider uses the (unofficial) LCSC API to search for parts.', + url: 'https://www.lcsc.com/', + disabledHelp: 'Enable this provider in the provider settings.', + settingsClass: LCSCSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ProviderCapabilities::FOOTPRINT, + ], + ); } // This provider is always active @@ -227,7 +232,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider } return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $product['productCode'], name: $product['productModel'], description: $this->sanitizeField($product['productIntroEn']) ?? '', @@ -455,17 +460,6 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider return $tmp[0]; } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ProviderCapabilities::FOOTPRINT, - ]; - } - public function getHandledDomains(): array { return ['lcsc.com']; diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php index 49ca2d50..c1b91faa 100644 --- a/src/Services/InfoProviderSystem/Providers/MouserProvider.php +++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php @@ -36,6 +36,7 @@ use App\Entity\Parts\ManufacturingStatus; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Settings\InfoProviderSystem\MouserSettings; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -48,6 +49,7 @@ class MouserProvider implements InfoProviderInterface private const ENDPOINT_URL = 'https://api.mouser.com/api/v2/search'; public const DISTRIBUTOR_NAME = 'Mouser'; + public const PROVIDER_KEY = 'mouser'; public function __construct( private readonly HttpClientInterface $mouserClient, @@ -55,20 +57,22 @@ class MouserProvider implements InfoProviderInterface ) { } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Mouser', - 'description' => 'This provider uses the Mouser API to search for parts.', - 'url' => 'https://www.mouser.com/', - 'disabled_help' => 'Configure the API key in the provider settings to enable.', - 'settings_class' => MouserSettings::class - ]; - } - - public function getProviderKey(): string - { - return 'mouser'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Mouser', + description: 'This provider uses the Mouser API to search for parts.', + url: 'https://www.mouser.com/', + disabledHelp: 'Configure the API key in the provider settings to enable.', + settingsClass: MouserSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ], + ); } public function isActive(): bool @@ -207,17 +211,6 @@ class MouserProvider implements InfoProviderInterface return reset($tmp); } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ]; - } - - /** * @param ResponseInterface $response * @return PartDetailDTO[] @@ -251,7 +244,7 @@ class MouserProvider implements InfoProviderInterface $result[] = new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $product['MouserPartNumber'], name: $product['ManufacturerPartNumber'], description: $product['Description'], diff --git a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php index 9764517b..4d3780da 100644 --- a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php @@ -88,6 +88,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Settings\InfoProviderSystem\OEMSecretsSettings; use App\Settings\InfoProviderSystem\OEMSecretsSortMode; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -96,6 +97,7 @@ use Psr\Cache\CacheItemPoolInterface; class OEMSecretsProvider implements InfoProviderInterface { + public const PROVIDER_KEY = 'oemsecrets'; private const ENDPOINT_URL = 'https://oemsecretsapi.com/partsearch'; @@ -227,37 +229,22 @@ class OEMSecretsProvider implements InfoProviderInterface private array $distributorCountryCodes = []; private array $countryCodeToRegionMap = []; - /** - * Get information about this provider - * - * @return array An associative array with the following keys (? means optional): - * - name: The (user friendly) name of the provider (e.g. "Digikey"), will be translated - * - description?: A short description of the provider (e.g. "Digikey is a ..."), will be translated - * - logo?: The logo of the provider (e.g. "digikey.png") - * - url?: The url of the provider (e.g. "https://www.digikey.com") - * - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it - * - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown - * - * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string } - */ - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'OEMSecrets', - 'description' => 'This provider uses the OEMSecrets API to search for parts.', - 'url' => 'https://www.oemsecrets.com/', - 'disabled_help' => 'Configure the API key in the provider settings to enable.', - 'settings_class' => OEMSecretsSettings::class - ]; - } - /** - * Returns a unique key for this provider, which will be saved into the database - * and used to identify the provider - * @return string A unique key for this provider (e.g. "digikey") - */ - public function getProviderKey(): string - { - return 'oemsecrets'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'OEMSecrets', + description: 'This provider uses the OEMSecrets API to search for parts.', + url: 'https://www.oemsecrets.com/', + disabledHelp: 'Configure the API key in the provider settings to enable.', + settingsClass: OEMSecretsSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ], + ); } /** @@ -456,17 +443,6 @@ class OEMSecretsProvider implements InfoProviderInterface * Currently, this list is purely informational and not used in functional checks. * @return ProviderCapabilities[] */ - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ]; - } - - /** * Processes a single product and updates arrays for basic information, datasheets, images, parameters, * and purchase information. Aggregates and organizes data received for a specific `part_number` and `manufacturer_id`. @@ -896,7 +872,7 @@ class OEMSecretsProvider implements InfoProviderInterface // If there is no existing basic info array, we create a new one if (is_null($existingBasicInfo)) { return [ - 'provider_key' => $this->getProviderKey(), + 'provider_key' => self::PROVIDER_KEY, 'provider_id' => $provider_id, 'name' => $product['part_number'], 'description' => $description, @@ -916,7 +892,7 @@ class OEMSecretsProvider implements InfoProviderInterface // Update fields only if empty or undefined, with additional check for preview_image_url return [ - 'provider_key' => $existingBasicInfo['provider_key'] ?? $this->getProviderKey(), + 'provider_key' => $existingBasicInfo['provider_key'] ?? self::PROVIDER_KEY, 'provider_id' => $existingBasicInfo['provider_id'] ?? $provider_id, 'name' => $existingBasicInfo['name'] ?? $product['part_number'], // Update description if it's null/empty @@ -1270,7 +1246,7 @@ class OEMSecretsProvider implements InfoProviderInterface */ private function generateInquiryUrl(string $partNumber, string $oemInquiry = 'compare/'): string { - $baseUrl = rtrim($this->getProviderInfo()['url'], '/') . '/'; + $baseUrl = rtrim($this->getProviderInfo()->url, '/') . '/'; $inquiryPath = trim($oemInquiry, '/') . '/'; $encodedPartNumber = urlencode(trim($partNumber)); return $baseUrl . $inquiryPath . $encodedPartNumber; diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php index de404e18..b26df2bb 100644 --- a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php @@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\OAuth\OAuthTokenManager; use App\Settings\InfoProviderSystem\OctopartSettings; @@ -43,6 +44,8 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ class OctopartProvider implements InfoProviderInterface { + public const PROVIDER_KEY = 'octopart'; + private const OAUTH_APP_NAME = 'ip_octopart_oauth'; /** @@ -164,20 +167,23 @@ class OctopartProvider implements InfoProviderInterface return $response->toArray(true); } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Octopart', - 'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.', - 'url' => 'https://www.octopart.com/', - 'disabled_help' => 'Set the Client ID and Secret in provider settings.', - 'settings_class' => OctopartSettings::class - ]; - } - - public function getProviderKey(): string - { - return 'octopart'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Octopart', + description: 'This provider uses the Nexar/Octopart API to search for parts on Octopart.', + url: 'https://www.octopart.com/', + disabledHelp: 'Set the Client ID and Secret in provider settings.', + settingsClass: OctopartSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ], + ); } public function isActive(): bool @@ -307,7 +313,7 @@ class OctopartProvider implements InfoProviderInterface } return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $part['id'], name: $part['mpn'], description: $part['shortDescription'] ?? null, @@ -397,14 +403,4 @@ class OctopartProvider implements InfoProviderInterface return $tmp; } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ]; - } } diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php index 7acecc3a..bf7ac732 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\PollinSettings; @@ -38,6 +39,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface { + public const PROVIDER_KEY = 'pollin'; public function __construct(private readonly HttpClientInterface $client, private readonly PollinSettings $settings, @@ -45,20 +47,22 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt { } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Pollin', - 'description' => 'Webscraping from pollin.de to get part information', - 'url' => 'https://www.pollin.de/', - 'disabled_help' => 'Enable the provider in provider settings', - 'settings_class' => PollinSettings::class, - ]; - } - - public function getProviderKey(): string - { - return 'pollin'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Pollin', + description: 'Webscraping from pollin.de to get part information', + url: 'https://www.pollin.de/', + disabledHelp: 'Enable the provider in provider settings', + settingsClass: PollinSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::PRICE, + ProviderCapabilities::DATASHEET, + ], + ); } public function isActive(): bool @@ -88,7 +92,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt //Iterate over each div.product-box $dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) { $results[] = new SearchResultDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'), name: $node->filter('a.product-name')->text(), description: '', @@ -156,7 +160,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt $purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl); return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $orderId, name: trim($dom->filter('meta[property="og:title"]')->attr('content')), description: $dom->filter('meta[property="og:description"]')->attr('content'), @@ -244,16 +248,6 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt ]; } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::PRICE, - ProviderCapabilities::DATASHEET - ]; - } - public function getHandledDomains(): array { return ['pollin.de']; diff --git a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php index 3a7d03e9..a97aa347 100644 --- a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php +++ b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php @@ -26,28 +26,28 @@ namespace App\Services\InfoProviderSystem\Providers; /** * This enum contains all capabilities (which data it can provide) a provider can have. */ -enum ProviderCapabilities +enum ProviderCapabilities: string { /** Basic information about a part, like the name, description, part number, manufacturer etc */ - case BASIC; + case BASIC = 'BASIC'; /** Provider can provide a picture for a part */ - case PICTURE; + case PICTURE = 'PICTURE'; /** Provider can provide datasheets for a part */ - case DATASHEET; + case DATASHEET = 'DATASHEET'; /** Provider can provide prices for a part */ - case PRICE; + case PRICE = 'PRICE'; /** Information about the footprint of a part */ - case FOOTPRINT; + case FOOTPRINT = 'FOOTPRINT'; /** Provider can provide GTIN for a part */ - case GTIN; + case GTIN = 'GTIN'; /** Provider can provide parameters/specifications for a part */ - case PARAMETERS; + case PARAMETERS = 'PARAMETERS'; /** * Get the order index for displaying capabilities in a stable order. diff --git a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php index 9dfb099f..b92a8e4c 100644 --- a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php @@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\ReicheltSettings; @@ -38,6 +39,7 @@ class ReicheltProvider implements InfoProviderInterface { public const DISTRIBUTOR_NAME = "Reichelt"; + public const PROVIDER_KEY = 'reichelt'; private readonly HttpClientInterface $client; @@ -48,20 +50,23 @@ class ReicheltProvider implements InfoProviderInterface $this->client = new RandomizeUseragentHttpClient($client); } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Reichelt', - 'description' => 'Webscraping from reichelt.com to get part information', - 'url' => 'https://www.reichelt.com/', - 'disabled_help' => 'Enable provider in provider settings.', - 'settings_class' => ReicheltSettings::class, - ]; - } - - public function getProviderKey(): string - { - return 'reichelt'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Reichelt', + description: 'Webscraping from reichelt.com to get part information', + url: 'https://www.reichelt.com/', + disabledHelp: 'Enable provider in provider settings.', + settingsClass: ReicheltSettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ProviderCapabilities::GTIN, + ], + ); } public function isActive(): bool @@ -93,7 +98,7 @@ class ReicheltProvider implements InfoProviderInterface $pictureURL = $element->filter("div.al_artlogo img")->attr('src'); $results[] = new SearchResultDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $artId, name: $productID, description: $name, @@ -173,7 +178,7 @@ class ReicheltProvider implements InfoProviderInterface //Create part object return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $id, name: $json[0]['article_artnr'], description: $json[0]['article_besch'], @@ -282,14 +287,4 @@ class ReicheltProvider implements InfoProviderInterface return 'https://www.reichelt.com/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language); } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ProviderCapabilities::GTIN, - ]; - } } diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 24ba0ea7..0ffc8bb7 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -28,12 +28,14 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\TMESettings; class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface { + public const PROVIDER_KEY = 'tme'; private const VENDOR_NAME = 'TME'; @@ -48,20 +50,23 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf } } - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'TME', - 'description' => 'This provider uses the API of TME (Transfer Multipart).', - 'url' => 'https://tme.eu/', - 'disabled_help' => 'Configure the API Token and secret in provider settings to use this provider.', - 'settings_class' => TMESettings::class - ]; - } - - public function getProviderKey(): string - { - return 'tme'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'TME', + description: 'This provider uses the API of TME (Transfer Multipart).', + url: 'https://tme.eu/', + disabledHelp: 'Configure the API Token and secret in provider settings to use this provider.', + settingsClass: TMESettings::class, + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ], + ); } public function isActive(): bool @@ -83,7 +88,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf foreach($data['ProductList'] as $product) { $result[] = new SearchResultDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $product['Symbol'], name: empty($product['OriginalSymbol']) ? $product['Symbol'] : $product['OriginalSymbol'], description: $product['Description'], @@ -119,7 +124,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf $parameters = $this->getParameters($id, $footprint); return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $product['Symbol'], name: empty($product['OriginalSymbol']) ? $product['Symbol'] : $product['OriginalSymbol'], description: $product['Description'], @@ -290,17 +295,6 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf return $url; } - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, - ProviderCapabilities::PICTURE, - ProviderCapabilities::DATASHEET, - ProviderCapabilities::PRICE, - ]; - } - public function getHandledDomains(): array { return ['tme.eu']; diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php index 42927abd..f4528d34 100644 --- a/src/Services/InfoProviderSystem/Providers/TestProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use Symfony\Component\DependencyInjection\Attribute\When; @@ -34,20 +35,20 @@ use Symfony\Component\DependencyInjection\Attribute\When; #[When(env: 'test')] class TestProvider implements InfoProviderInterface { + public const PROVIDER_KEY = 'test'; - public function getProviderInfo(): array + public function getProviderInfo(): ProviderInfoDTO { - return [ - 'name' => 'Test Provider', - 'description' => 'This is a test provider', - //'url' => 'https://example.com', - 'disabled_help' => 'This provider is disabled for testing purposes' - ]; - } - - public function getProviderKey(): string - { - return 'test'; + return new ProviderInfoDTO( + key: self::PROVIDER_KEY, + name: 'Test Provider', + description: 'This is a test provider', + disabledHelp: 'This provider is disabled for testing purposes', + capabilities: [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ], + ); } public function isActive(): bool @@ -58,24 +59,16 @@ class TestProvider implements InfoProviderInterface public function searchByKeyword(string $keyword, array $options = []): array { return [ - new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element1', name: 'Element 1', description: 'fd'), - new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element2', name: 'Element 2', description: 'fd'), - new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element3', name: 'Element 3', description: 'fd'), - ]; - } - - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, + new SearchResultDTO(provider_key: self::PROVIDER_KEY, provider_id: 'element1', name: 'Element 1', description: 'fd'), + new SearchResultDTO(provider_key: self::PROVIDER_KEY, provider_id: 'element2', name: 'Element 2', description: 'fd'), + new SearchResultDTO(provider_key: self::PROVIDER_KEY, provider_id: 'element3', name: 'Element 3', description: 'fd'), ]; } public function getDetails(string $id, array $options = []): PartDetailDTO { return new PartDetailDTO( - provider_key: $this->getProviderKey(), + provider_key: self::PROVIDER_KEY, provider_id: $id, name: 'Test Element', description: 'fd', diff --git a/src/State/Mcp/ListInfoProvidersProcessor.php b/src/State/Mcp/ListInfoProvidersProcessor.php index 774ad4ce..6b995a15 100644 --- a/src/State/Mcp/ListInfoProvidersProcessor.php +++ b/src/State/Mcp/ListInfoProvidersProcessor.php @@ -25,7 +25,7 @@ namespace App\State\Mcp; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; -use App\Services\InfoProviderSystem\DTOs\InfoProviderDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\ProviderRegistry; /** @@ -40,7 +40,7 @@ class ListInfoProvidersProcessor implements ProcessorInterface, ProviderInterfac } /** - * @return InfoProviderDTO[] + * @return ProviderInfoDTO[] */ public function provide(Operation $operation, array $uriVariables = [], array $context = []): array { @@ -48,7 +48,7 @@ class ListInfoProvidersProcessor implements ProcessorInterface, ProviderInterfac } /** - * @return InfoProviderDTO[] + * @return ProviderInfoDTO[] */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array { @@ -56,22 +56,14 @@ class ListInfoProvidersProcessor implements ProcessorInterface, ProviderInterfac } /** - * @return InfoProviderDTO[] + * @return ProviderInfoDTO[] */ private function listActiveProviders(): array { $result = []; foreach ($this->providerRegistry->getActiveProviders() as $provider) { - $info = $provider->getProviderInfo(); - - $result[] = new InfoProviderDTO( - key: $provider->getProviderKey(), - name: $info['name'], - description: $info['description'] ?? null, - url: $info['url'] ?? null, - capabilities: array_map(static fn ($capability) => $capability->name, $provider->getCapabilities()), - ); + $result[] = $provider->getProviderInfo(); } return $result; diff --git a/src/Twig/InfoProviderExtension.php b/src/Twig/InfoProviderExtension.php index 54dbf93a..4de13115 100644 --- a/src/Twig/InfoProviderExtension.php +++ b/src/Twig/InfoProviderExtension.php @@ -59,7 +59,7 @@ final readonly class InfoProviderExtension public function getInfoProviderName(string $key): ?string { try { - return $this->providerRegistry->getProviderByKey($key)->getProviderInfo()['name']; + return $this->providerRegistry->getProviderByKey($key)->getProviderInfo()->name; } catch (\InvalidArgumentException) { return null; } diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig index bec8f24b..62a32502 100644 --- a/templates/info_providers/providers.macro.html.twig +++ b/templates/info_providers/providers.macro.html.twig @@ -8,35 +8,35 @@
- {% if provider.providerInfo.url is defined and provider.providerInfo.url is not empty %} + {% if provider.providerInfo.url is not empty %} {{ provider.providerInfo.name }} {% else %} {{ provider.providerInfo.name | trans }} {% endif %}
- {% if provider.providerInfo.description is defined and provider.providerInfo.description is not null %} + {% if provider.providerInfo.description is not null %} {{ provider.providerInfo.description | trans }} {% endif %}
- {% if provider.providerInfo.settings_class is defined %} - {% endif %} - {% for capability in provider.capabilities|sort((a, b) => a.orderIndex <=> b.orderIndex) %} + {% for capability in provider.providerInfo.capabilities|sort((a, b) => a.orderIndex <=> b.orderIndex) %} {# @var capability \App\Services\InfoProviderSystem\Providers\ProviderCapabilities #} {{ capability.translationKey|trans }} {% endfor %} - {% if provider.providerInfo.oauth_app_name is defined and provider.providerInfo.oauth_app_name is not empty %} + {% if provider.providerInfo.oauthAppName is not empty %}
- {% trans %}oauth_client.connect.btn{% endtrans %} + {% trans %}oauth_client.connect.btn{% endtrans %} {% endif %}
@@ -44,9 +44,9 @@
{% trans %}info_providers.providers_list.disabled{% endtrans %} - {% if provider.providerInfo.disabled_help is defined and provider.providerInfo.disabled_help is not empty %} + {% if provider.providerInfo.disabledHelp is not empty %}
- {{ provider.providerInfo.disabled_help|trans }} + {{ provider.providerInfo.disabledHelp|trans }} {% endif %}
diff --git a/templates/info_providers/settings/provider_settings.html.twig b/templates/info_providers/settings/provider_settings.html.twig index db942f8a..1159fbf3 100644 --- a/templates/info_providers/settings/provider_settings.html.twig +++ b/templates/info_providers/settings/provider_settings.html.twig @@ -10,7 +10,7 @@ {% block card_content %}

- {% if info_provider_info.url is defined %} + {% if info_provider_info.url is not empty %} {{ info_provider_info.name }} {% else %} {{ info_provider_info.name }} diff --git a/tests/API/Endpoints/InfoProviderEndpointTest.php b/tests/API/Endpoints/InfoProviderEndpointTest.php index 7203b15c..fc89ad0d 100644 --- a/tests/API/Endpoints/InfoProviderEndpointTest.php +++ b/tests/API/Endpoints/InfoProviderEndpointTest.php @@ -39,6 +39,16 @@ class InfoProviderEndpointTest extends AuthenticatedApiTestCase $keys = array_column($json['hydra:member'], 'key'); self::assertContains('test', $keys); + + //The 'test' provider has a disabledHelp set internally, but it must not leak into the API response + $testProvider = $json['hydra:member'][array_search('test', $keys, true)]; + self::assertArrayHasKey('capabilities', $testProvider); + self::assertArrayNotHasKey('disabledHelp', $testProvider); + self::assertArrayNotHasKey('oauthAppName', $testProvider); + self::assertArrayNotHasKey('settingsClass', $testProvider); + + //Capabilities must serialize as plain enum-name strings, not objects + self::assertSame(['BASIC', 'FOOTPRINT'], $testProvider['capabilities']); } public function testListInfoProvidersRequiresAuthentication(): void diff --git a/tests/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTOTest.php index 2fd50f9a..a8d87dc5 100644 --- a/tests/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTOTest.php +++ b/tests/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTOTest.php @@ -24,6 +24,7 @@ namespace App\Tests\Services\InfoProviderSystem\DTOs; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use PHPUnit\Framework\TestCase; final class BulkSearchFieldMappingDTOTest extends TestCase @@ -32,7 +33,7 @@ final class BulkSearchFieldMappingDTOTest extends TestCase public function testProviderInstanceNormalization(): void { $mockProvider = $this->createMock(InfoProviderInterface::class); - $mockProvider->method('getProviderKey')->willReturn('mock_provider'); + $mockProvider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'mock_provider', name: 'mock_provider')); $fieldMapping = new BulkSearchFieldMappingDTO(field: 'mpn', providers: ['provider1', $mockProvider], priority: 5); $this->assertSame(['provider1', 'mock_provider'], $fieldMapping->providers); diff --git a/tests/Services/InfoProviderSystem/ProviderRegistryTest.php b/tests/Services/InfoProviderSystem/ProviderRegistryTest.php index d3fce441..3043edff 100644 --- a/tests/Services/InfoProviderSystem/ProviderRegistryTest.php +++ b/tests/Services/InfoProviderSystem/ProviderRegistryTest.php @@ -22,6 +22,7 @@ declare(strict_types=1); */ namespace App\Tests\Services\InfoProviderSystem; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface; @@ -46,7 +47,7 @@ final class ProviderRegistryTest extends TestCase public function getMockProvider(string $key, bool $active = true): InfoProviderInterface { $mock = $this->createMockForIntersectionOfInterfaces([InfoProviderInterface::class, URLHandlerInfoProviderInterface::class]); - $mock->method('getProviderKey')->willReturn($key); + $mock->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: $key, name: $key)); $mock->method('isActive')->willReturn($active); $mock->method('getHandledDomains')->willReturn(["$key.com", "test.$key.de"]); diff --git a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php index 63e1b452..bce0a2cf 100644 --- a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php +++ b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php @@ -27,6 +27,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\Providers\LCSCProvider; use App\Services\InfoProviderSystem\Providers\ProviderCapabilities; @@ -56,18 +57,10 @@ final class LCSCProviderTest extends TestCase { $info = $this->provider->getProviderInfo(); - $this->assertIsArray($info); - $this->assertArrayHasKey('name', $info); - $this->assertArrayHasKey('description', $info); - $this->assertArrayHasKey('url', $info); - $this->assertArrayHasKey('disabled_help', $info); - $this->assertEquals('LCSC', $info['name']); - $this->assertEquals('https://www.lcsc.com/', $info['url']); - } - - public function testGetProviderKey(): void - { - $this->assertSame('lcsc', $this->provider->getProviderKey()); + $this->assertInstanceOf(ProviderInfoDTO::class, $info); + $this->assertSame('lcsc', $info->key); + $this->assertEquals('LCSC', $info->name); + $this->assertEquals('https://www.lcsc.com/', $info->url); } public function testIsActiveWhenEnabled(): void @@ -86,7 +79,7 @@ final class LCSCProviderTest extends TestCase public function testGetCapabilities(): void { - $capabilities = $this->provider->getCapabilities(); + $capabilities = $this->provider->getProviderInfo()->capabilities; $this->assertIsArray($capabilities); $this->assertContains(ProviderCapabilities::BASIC, $capabilities); diff --git a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php index 20fba523..be4629e5 100644 --- a/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php +++ b/tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php @@ -24,6 +24,7 @@ namespace App\Tests\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\ProviderCapabilities; @@ -212,17 +213,10 @@ final class TMEProviderTest extends TestCase { $info = $this->provider->getProviderInfo(); - $this->assertIsArray($info); - $this->assertArrayHasKey('name', $info); - $this->assertArrayHasKey('description', $info); - $this->assertArrayHasKey('url', $info); - $this->assertEquals('TME', $info['name']); - $this->assertEquals('https://tme.eu/', $info['url']); - } - - public function testGetProviderKey(): void - { - $this->assertSame('tme', $this->provider->getProviderKey()); + $this->assertInstanceOf(ProviderInfoDTO::class, $info); + $this->assertSame('tme', $info->key); + $this->assertEquals('TME', $info->name); + $this->assertEquals('https://tme.eu/', $info->url); } public function testIsActiveWithCredentials(): void @@ -239,7 +233,7 @@ final class TMEProviderTest extends TestCase public function testGetCapabilities(): void { - $capabilities = $this->provider->getCapabilities(); + $capabilities = $this->provider->getProviderInfo()->capabilities; $this->assertIsArray($capabilities); $this->assertContains(ProviderCapabilities::BASIC, $capabilities); diff --git a/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php b/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php index 07fcdc27..2f6e61ba 100644 --- a/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php +++ b/tests/State/Mcp/GetInfoProviderPartDetailsProcessorTest.php @@ -26,6 +26,7 @@ namespace App\Tests\State\Mcp; use ApiPlatform\Metadata\Get; use App\Mcp\DTO\InfoProviderPartDetailsInput; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOtoEntityConverter; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; @@ -45,14 +46,14 @@ final class GetInfoProviderPartDetailsProcessorTest extends TestCase protected function setUp(): void { $activeProvider = $this->createMock(InfoProviderInterface::class); - $activeProvider->method('getProviderKey')->willReturn('test1'); + $activeProvider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'test1', name: 'test1')); $activeProvider->method('isActive')->willReturn(true); $activeProvider->method('getDetails')->willReturn( new PartDetailDTO(provider_key: 'test1', provider_id: '42', name: 'Element 42', description: 'desc') ); $inactiveProvider = $this->createMock(InfoProviderInterface::class); - $inactiveProvider->method('getProviderKey')->willReturn('test2'); + $inactiveProvider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'test2', name: 'test2')); $inactiveProvider->method('isActive')->willReturn(false); $providerRegistry = new ProviderRegistry([$activeProvider, $inactiveProvider]); diff --git a/tests/State/Mcp/ListInfoProvidersProcessorTest.php b/tests/State/Mcp/ListInfoProvidersProcessorTest.php index a255aee3..b37e85f4 100644 --- a/tests/State/Mcp/ListInfoProvidersProcessorTest.php +++ b/tests/State/Mcp/ListInfoProvidersProcessorTest.php @@ -25,7 +25,7 @@ namespace App\Tests\State\Mcp; use ApiPlatform\Metadata\Get; use App\Mcp\DTO\ListInfoProvidersInput; -use App\Services\InfoProviderSystem\DTOs\InfoProviderDTO; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use App\Services\InfoProviderSystem\Providers\ProviderCapabilities; use App\Services\InfoProviderSystem\ProviderRegistry; @@ -37,20 +37,18 @@ final class ListInfoProvidersProcessorTest extends TestCase public function testOnlyActiveProvidersAreListed(): void { $active = $this->createMock(InfoProviderInterface::class); - $active->method('getProviderKey')->willReturn('active_provider'); $active->method('isActive')->willReturn(true); - $active->method('getProviderInfo')->willReturn([ - 'name' => 'Active Provider', - 'description' => 'A provider that is active', - 'url' => 'https://example.com', - ]); - $active->method('getCapabilities')->willReturn([ProviderCapabilities::BASIC, ProviderCapabilities::PRICE]); + $active->method('getProviderInfo')->willReturn(new ProviderInfoDTO( + key: 'active_provider', + name: 'Active Provider', + description: 'A provider that is active', + url: 'https://example.com', + capabilities: [ProviderCapabilities::BASIC, ProviderCapabilities::PRICE], + )); $disabled = $this->createMock(InfoProviderInterface::class); - $disabled->method('getProviderKey')->willReturn('disabled_provider'); $disabled->method('isActive')->willReturn(false); - $disabled->method('getProviderInfo')->willReturn(['name' => 'Disabled Provider']); - $disabled->method('getCapabilities')->willReturn([]); + $disabled->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'disabled_provider', name: 'Disabled Provider')); $registry = new ProviderRegistry([$active, $disabled]); $processor = new ListInfoProvidersProcessor($registry); @@ -58,11 +56,11 @@ final class ListInfoProvidersProcessorTest extends TestCase $result = $processor->process(new ListInfoProvidersInput(), new Get()); $this->assertCount(1, $result); - $this->assertInstanceOf(InfoProviderDTO::class, $result[0]); + $this->assertInstanceOf(ProviderInfoDTO::class, $result[0]); $this->assertSame('active_provider', $result[0]->key); $this->assertSame('Active Provider', $result[0]->name); $this->assertSame('A provider that is active', $result[0]->description); $this->assertSame('https://example.com', $result[0]->url); - $this->assertSame(['BASIC', 'PRICE'], $result[0]->capabilities); + $this->assertSame([ProviderCapabilities::BASIC, ProviderCapabilities::PRICE], $result[0]->capabilities); } } diff --git a/tests/State/Mcp/SearchInfoProvidersProcessorTest.php b/tests/State/Mcp/SearchInfoProvidersProcessorTest.php index 2a56a9a2..52c24832 100644 --- a/tests/State/Mcp/SearchInfoProvidersProcessorTest.php +++ b/tests/State/Mcp/SearchInfoProvidersProcessorTest.php @@ -25,6 +25,7 @@ namespace App\Tests\State\Mcp; use ApiPlatform\Metadata\Get; use App\Mcp\DTO\InfoProviderSearchInput; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\DTOtoEntityConverter; use App\Services\InfoProviderSystem\PartInfoRetriever; @@ -69,8 +70,8 @@ final class SearchInfoProvidersProcessorTest extends TestCase private function getMockProvider(string $key, bool $active = true): InfoProviderInterface { $mock = $this->createMock(InfoProviderInterface::class); - $mock->method('getProviderKey')->willReturn($key); $mock->method('isActive')->willReturn($active); + $mock->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: $key, name: $key)); $mock->method('searchByKeyword')->willReturn([ new SearchResultDTO(provider_key: $key, provider_id: '1', name: 'Element 1', description: 'desc'), ]); From 73941707b5a405ae95362ce4059b0de2bcb2b993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jul 2026 14:07:23 +0200 Subject: [PATCH 22/25] Mark some info providers as expensive, to hint that to AI providers --- .../DTOs/ProviderInfoDTO.php | 19 +++++++++++++------ .../Providers/AIWebProvider.php | 1 + .../Providers/CanopyProvider.php | 1 + .../Providers/OctopartProvider.php | 1 + 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php index 1e51e5c6..248bf997 100644 --- a/src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ProviderInfoDTO.php @@ -44,22 +44,22 @@ use Symfony\Component\Serializer\Annotation\Groups; description: 'An info provider which can be used to search for parts and retrieve part details.', operations: [ new GetCollection( + openapi: new Operation(summary: 'List the info providers which are currently active and can be used for searching parts.'), security: 'is_granted("@info_providers.create_parts")', provider: ListInfoProvidersProcessor::class, - openapi: new Operation(summary: 'List the info providers which are currently active and can be used for searching parts.'), ), ], - paginationEnabled: false, normalizationContext: ['groups' => ['info_provider:read']], + paginationEnabled: false, mcp: [ 'list_info_providers' => new McpToolCollection( title: 'List available info providers', - description: 'List the info providers (e.g. distributors like Digikey, Mouser, LCSC) which are currently active and can be used with search_info_providers and get_info_provider_part_details.', + description: 'List the info providers (e.g. distributors like Digikey, Mouser, LCSC) which are currently active and can be used with search_info_providers and get_info_provider_part_details. Ask for the user\'s confirmation before using expensive providers (e.g. distributors with strict rate limits or which cost money to use).', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false], - input: ListInfoProvidersInput::class, - security: 'is_granted("@info_providers.create_parts")', - processor: ListInfoProvidersProcessor::class, normalizationContext: ['groups' => ['info_provider:read']], + security: 'is_granted("@info_providers.create_parts")', + input: ListInfoProvidersInput::class, + processor: ListInfoProvidersProcessor::class, ), ], )] @@ -92,6 +92,13 @@ readonly class ProviderInfoDTO */ #[Groups(['info_provider:read'])] public array $capabilities = [], + /** + * @var bool True if this provider is considered "expensive" (e.g. it has a strict rate limit or costs money to use), false otherwise. + * Users should be asked for confirmation before using an expensive provider, when making multiple requests (e.g. when searching for multiple parts at once), + * and more careful caching should be used for expensive providers. + */ + #[Groups(['info_provider:read'])] + public bool $expensive = false, ) { } } diff --git a/src/Services/InfoProviderSystem/Providers/AIWebProvider.php b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php index 7f977036..feb94384 100644 --- a/src/Services/InfoProviderSystem/Providers/AIWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php @@ -89,6 +89,7 @@ final class AIWebProvider implements InfoProviderInterface ProviderCapabilities::PRICE, ProviderCapabilities::PARAMETERS, ], + expensive: true, ); } diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php index 2f0f355a..3390bd98 100644 --- a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -68,6 +68,7 @@ class CanopyProvider implements InfoProviderInterface ProviderCapabilities::PICTURE, ProviderCapabilities::PRICE, ], + expensive: true, ); } diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php index b26df2bb..3e5c0040 100644 --- a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php @@ -183,6 +183,7 @@ class OctopartProvider implements InfoProviderInterface ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, ], + expensive: true, ); } From 6a4d816545b319b761cf174ee1c154c0eebf09ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jul 2026 14:12:49 +0200 Subject: [PATCH 23/25] Show the expensive tag in info providers list --- .../info_providers/providers.macro.html.twig | 12 +++++++++--- translations/messages.en.xlf | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig index 62a32502..ad03f20a 100644 --- a/templates/info_providers/providers.macro.html.twig +++ b/templates/info_providers/providers.macro.html.twig @@ -11,12 +11,12 @@ {% if provider.providerInfo.url is not empty %} {{ provider.providerInfo.name }} {% else %} - {{ provider.providerInfo.name | trans }} + {{ provider.providerInfo.name }} {% endif %}

{% if provider.providerInfo.description is not null %} - {{ provider.providerInfo.description | trans }} + {{ provider.providerInfo.description }} {% endif %}
@@ -27,6 +27,12 @@ title="{% trans %}info_providers.settings.title{% endtrans %}" > {% endif %} + {% if provider.providerInfo.expensive %} + + + {% trans %}info_providers.expensive{% endtrans %} + + {% endif %} {% for capability in provider.providerInfo.capabilities|sort((a, b) => a.orderIndex <=> b.orderIndex) %} {# @var capability \App\Services\InfoProviderSystem\Providers\ProviderCapabilities #} @@ -46,7 +52,7 @@ {% trans %}info_providers.providers_list.disabled{% endtrans %} {% if provider.providerInfo.disabledHelp is not empty %}
- {{ provider.providerInfo.disabledHelp|trans }} + {{ provider.providerInfo.disabledHelp }} {% endif %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ccf977e3..597816e1 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13805,5 +13805,23 @@ Buerklin-API Authentication server: MCP endpoint + + + info_providers.capabilities.parameters + Parameters + + + + + info_providers.expensive + Expensive + + + + + info_providers.expensive.tooltip + Using this info provider can cause additional costs, or has very strict rate limits. + + From ef690fe37f316efbaf99754d66552ba9154b53c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jul 2026 14:16:47 +0200 Subject: [PATCH 24/25] Hint the parameter capabilities for more info providers --- src/Services/InfoProviderSystem/Providers/BuerklinProvider.php | 1 + src/Services/InfoProviderSystem/Providers/ConradProvider.php | 1 + src/Services/InfoProviderSystem/Providers/DigikeyProvider.php | 1 + src/Services/InfoProviderSystem/Providers/Element14Provider.php | 2 ++ src/Services/InfoProviderSystem/Providers/LCSCProvider.php | 1 + .../InfoProviderSystem/Providers/OEMSecretsProvider.php | 1 + src/Services/InfoProviderSystem/Providers/OctopartProvider.php | 1 + src/Services/InfoProviderSystem/Providers/PollinProvider.php | 1 + src/Services/InfoProviderSystem/Providers/ReicheltProvider.php | 1 + src/Services/InfoProviderSystem/Providers/TMEProvider.php | 1 + 10 files changed, 11 insertions(+) diff --git a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php index fe110971..9197df48 100644 --- a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php @@ -192,6 +192,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv //ProviderCapabilities::DATASHEET, // currently not implemented ProviderCapabilities::PRICE, ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PARAMETERS ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 9af1513f..0fb1525f 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -69,6 +69,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr ProviderCapabilities::PRICE, ProviderCapabilities::FOOTPRINT, ProviderCapabilities::GTIN, + ProviderCapabilities::PARAMETERS ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index 4bd29a5c..fedda225 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -90,6 +90,7 @@ class DigikeyProvider implements InfoProviderInterface ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::PARAMETERS ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 179c2fb6..ba8a7370 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -75,6 +75,8 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider ProviderCapabilities::BASIC, ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ProviderCapabilities::PARAMETERS, ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index ab96ec0d..de1906cc 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -62,6 +62,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PARAMETERS ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php index 4d3780da..edeb5f71 100644 --- a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php @@ -243,6 +243,7 @@ class OEMSecretsProvider implements InfoProviderInterface ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::PARAMETERS ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php index 3e5c0040..14fdbfa5 100644 --- a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php @@ -182,6 +182,7 @@ class OctopartProvider implements InfoProviderInterface ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::PARAMETERS ], expensive: true, ); diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php index bf7ac732..919db66b 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -61,6 +61,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt ProviderCapabilities::PICTURE, ProviderCapabilities::PRICE, ProviderCapabilities::DATASHEET, + ProviderCapabilities::PARAMETERS ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php index b92a8e4c..901956e1 100644 --- a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php @@ -65,6 +65,7 @@ class ReicheltProvider implements InfoProviderInterface ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, ProviderCapabilities::GTIN, + ProviderCapabilities::PARAMETERS ], ); } diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 0ffc8bb7..3d230a26 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -65,6 +65,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::PARAMETERS ], ); } From 51ce453d1a2d725fe61184fcb87a95b813e6d241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jul 2026 18:45:50 +0200 Subject: [PATCH 25/25] Removed MCP Schema Factory decorator as fix was merge and the upstream version actually contained more fixes --- src/Mcp/JsonSchema/FixedSchemaFactory.php | 162 ---------------------- 1 file changed, 162 deletions(-) delete mode 100644 src/Mcp/JsonSchema/FixedSchemaFactory.php diff --git a/src/Mcp/JsonSchema/FixedSchemaFactory.php b/src/Mcp/JsonSchema/FixedSchemaFactory.php deleted file mode 100644 index c851c394..00000000 --- a/src/Mcp/JsonSchema/FixedSchemaFactory.php +++ /dev/null @@ -1,162 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Mcp\JsonSchema; - -use ApiPlatform\JsonSchema\Schema; -use ApiPlatform\JsonSchema\SchemaFactoryInterface; -use ApiPlatform\Metadata\Operation; -use Symfony\Component\DependencyInjection\Attribute\AsAlias; -use Symfony\Component\DependencyInjection\Attribute\AsDecorator; -use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; - -/** - * Overwrite the default JSON Schema factory to resolve $ref and allOf into a flat schema. - * This is a workaround until https://github.com/api-platform/core/pull/7962 is merged - */ -#[AsAlias('api_platform.mcp.json_schema.schema_factory')] -readonly class FixedSchemaFactory implements SchemaFactoryInterface -{ - public function __construct( - private readonly SchemaFactoryInterface $decorated, - ) { - } - - public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema - { - $schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); - - $definitions = []; - foreach ($schema->getDefinitions() as $key => $definition) { - $definitions[$key] = $definition instanceof \ArrayObject ? $definition->getArrayCopy() : (array) $definition; - } - - $rootKey = $schema->getRootDefinitionKey(); - if (null !== $rootKey) { - $root = $definitions[$rootKey] ?? []; - } else { - // Collection schemas (and others) put allOf/type directly on the root - $root = $schema->getArrayCopy(false); - } - - $flat = self::resolveNode($root, $definitions); - - $flatSchema = new Schema(Schema::VERSION_JSON_SCHEMA); - unset($flatSchema['$schema']); - foreach ($flat as $key => $value) { - $flatSchema[$key] = $value; - } - - return $flatSchema; - } - - /** - * Recursively resolve $ref, allOf, and nested structures into a flat schema node. - * - * @param array $resolving Tracks the current $ref resolution chain to detect circular references - */ - public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array - { - if ($node instanceof \ArrayObject) { - $node = $node->getArrayCopy(); - } - - if (isset($node['$ref'])) { - $refKey = str_replace('#/definitions/', '', $node['$ref']); - if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) { - return ['type' => 'object']; - } - $resolving[$refKey] = true; - $resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving); - unset($resolving[$refKey]); - - return $resolved; - } - - if (isset($node['allOf'])) { - $merged = ['type' => 'object', 'properties' => []]; - $requiredSets = []; - foreach ($node['allOf'] as $entry) { - $resolved = self::resolveNode($entry, $definitions, $resolving); - if (isset($resolved['properties'])) { - foreach ($resolved['properties'] as $k => $v) { - $merged['properties'][$k] = $v; - } - } - if (isset($resolved['required'])) { - $requiredSets[] = $resolved['required']; - } - } - - if ($requiredSets) { - $merged['required'] = array_merge(...$requiredSets); - } - if ([] === $merged['properties']) { - unset($merged['properties']); - } - if (isset($node['description'])) { - $merged['description'] = $node['description']; - } - - return self::resolveDeep($merged, $definitions, $resolving); - } - - // oneOf/anyOf nodes must not receive a type fallback — their type is expressed - // through the sub-schemas. Adding 'type: object' here would break schemas like - // HydraItemBaseSchema's @context, which is oneOf: [string, object]. - if (isset($node['oneOf']) || isset($node['anyOf'])) { - return self::resolveDeep($node, $definitions, $resolving); - } - - if (!isset($node['type'])) { - $node['type'] = 'object'; - } - - return self::resolveDeep($node, $definitions, $resolving); - } - - /** - * Recursively resolve nested properties and array items. - */ - private static function resolveDeep(array $node, array $definitions, array &$resolving): array - { - if (isset($node['items'])) { - $node['items'] = self::resolveNode( - $node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'], - $definitions, - $resolving, - ); - } - - if (isset($node['properties']) && \is_array($node['properties'])) { - foreach ($node['properties'] as $propName => $propSchema) { - $node['properties'][$propName] = self::resolveNode( - $propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema, - $definitions, - $resolving, - ); - } - } - - return $node; - } -}