From 4f6a9a782aa674e65eea4b5232e8ee87f32fadd8 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Wed, 18 Mar 2026 18:13:49 +0100 Subject: [PATCH 1/7] feat(mcp-server): migrated to mcp/sdk # Conflicts: # composer.lock --- composer.json | 4 +- composer.lock | 1436 ++++++++++------- docs/mcp-server.md | 62 +- .../src/phpMyFAQ/Command/McpServerCommand.php | 4 +- .../Service/McpServer/FaqSearchTool.php | 191 +++ .../McpServer/FaqSearchToolExecutor.php | 148 -- .../McpServer/FaqSearchToolMetadata.php | 104 -- .../Service/McpServer/McpSdkRuntime.php | 108 ++ .../McpServer/McpServerRuntimeInterface.php | 33 + .../Service/McpServer/McpToolDefinition.php | 38 + .../McpServer/McpToolExecutorInterface.php | 31 + .../Service/McpServer/PhpMyFaqMcpServer.php | 57 +- phpmyfaq/src/services.php | 29 + .../phpMyFAQ/Command/McpServerCommandTest.php | 6 +- .../McpServer/FaqSearchToolExecutorTest.php | 106 -- .../McpServer/FaqSearchToolMetadataTest.php | 56 - .../Service/McpServer/McpSdkRuntimeTest.php | 120 ++ .../McpServer/PhpMyFaqMcpServerTest.php | 107 +- 18 files changed, 1476 insertions(+), 1164 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php delete mode 100644 phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolExecutor.php delete mode 100644 phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolMetadata.php create mode 100644 phpmyfaq/src/phpMyFAQ/Service/McpServer/McpSdkRuntime.php create mode 100644 phpmyfaq/src/phpMyFAQ/Service/McpServer/McpServerRuntimeInterface.php create mode 100644 phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolDefinition.php create mode 100644 phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolExecutorInterface.php delete mode 100644 tests/phpMyFAQ/Service/McpServer/FaqSearchToolExecutorTest.php delete mode 100644 tests/phpMyFAQ/Service/McpServer/FaqSearchToolMetadataTest.php create mode 100644 tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php diff --git a/composer.json b/composer.json index 7863815aa4..cc62504729 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "guzzlehttp/guzzle": "^7.5", "league/commonmark": "^2.4", "league/oauth2-server": "^9.2", + "mcp/sdk": "^0.4.0", "minishlink/web-push": "^10.0", "monolog/monolog": "^3.3", "myclabs/deep-copy": "~1.0", @@ -52,7 +53,6 @@ "symfony/http-foundation": "^8.0", "symfony/http-kernel": "^8.0", "symfony/mailer": "^8.0", - "symfony/mcp-sdk": "dev-main", "symfony/rate-limiter": "^8.0", "symfony/routing": "^8.0", "symfony/uid": "^8.0", @@ -65,7 +65,7 @@ "carthage-software/mago": "^1.0.1", "doctrine/instantiator": "2.*", "mikey179/vfsstream": "^1.6", - "phpdocumentor/reflection-docblock": "6.*", + "phpdocumentor/reflection-docblock": "^5.6", "phpunit/phpunit": "^12.3", "rector/rector": "^2", "symfony/browser-kit": "^8.0", diff --git a/composer.lock b/composer.lock index ac35f05984..6fba992779 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": "d34dd00c2f06422b12335651065d6353", + "content-hash": "4cab2d5e382f65d2c6b241fb5e99ffea", "packages": [ { "name": "2tvenom/cborencode", @@ -107,16 +107,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.373.5", + "version": "3.373.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "978964f417f3617a6ced691110af6fc5d496fb4e" + "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/978964f417f3617a6ced691110af6fc5d496fb4e", - "reference": "978964f417f3617a6ced691110af6fc5d496fb4e", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/483fba51c28b3a0c0647bf5100e0edca82090b18", + "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18", "shasum": "" }, "require": { @@ -198,9 +198,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.373.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.373.2" }, - "time": "2026-03-18T18:22:37+00:00" + "time": "2026-03-13T18:08:30+00:00" }, { "name": "bacon/bacon-qr-code", @@ -509,6 +509,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/lexer", "version": "3.0.1", @@ -1940,6 +1988,82 @@ ], "time": "2026-03-08T20:05:35+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": "minishlink/web-push", "version": "v10.0.3", @@ -2612,6 +2736,196 @@ }, "time": "2026-01-16T21:44:15+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": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -2920,41 +3234,31 @@ "time": "2024-03-15T13:55:21+00:00" }, { - "name": "phpseclib/phpseclib", - "version": "3.0.50", + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "shasum": "" }, "require": { - "paragonie/constant_time_encoding": "^1|^2|^3", - "paragonie/random_compat": "^1.4|^2.0|^9.99.99", - "php": ">=5.6.1" - }, - "require-dev": { - "phpunit/phpunit": "*" - }, - "suggest": { - "ext-dom": "Install the DOM extension to load XML formatted public keys.", - "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", - "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", - "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", - "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + "php": "^7.2 || ^8.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, "autoload": { - "files": [ - "phpseclib/bootstrap.php" - ], "psr-4": { - "phpseclib3\\": "phpseclib/" + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2963,10 +3267,195 @@ ], "authors": [ { - "name": "Jim Wigginton", - "email": "terrafrost@php.net", - "role": "Lead Developer" - }, + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.6", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "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.6" + }, + "time": "2025-12-22T21:13:58+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "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" + }, + "time": "2025-11-21T15:09:14+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.49", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, { "name": "Patrick Monnerat", "email": "pm@datasphere.ch", @@ -3011,7 +3500,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" }, "funding": [ { @@ -3027,7 +3516,54 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-01-27T09:17:28+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" }, { "name": "psr/cache", @@ -4803,28 +5339,29 @@ "time": "2026-02-25T16:59:43+00:00" }, { - "name": "symfony/html-sanitizer", - "version": "v8.0.7", + "name": "symfony/finder", + "version": "v8.0.6", "source": { "type": "git", - "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2" + "url": "https://github.com/symfony/finder.git", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/555b37caeee3d07af33471e02377d5ff561f8ac2", - "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", "shasum": "" }, "require": { - "ext-dom": "*", - "league/uri": "^6.5|^7.0", "php": ">=8.4" }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\HtmlSanitizer\\": "" + "Symfony\\Component\\Finder\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -4836,23 +5373,18 @@ ], "authors": [ { - "name": "Titouan Galopin", - "email": "galopintitouan@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", - "keywords": [ - "Purifier", - "html", - "sanitizer" - ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v8.0.7" + "source": "https://github.com/symfony/finder/tree/v8.0.6" }, "funding": [ { @@ -4872,24 +5404,96 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:40+00:00" + "time": "2026-01-29T09:41:02+00:00" }, { - "name": "symfony/http-client", + "name": "symfony/html-sanitizer", "version": "v8.0.7", "source": { "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "ade9bd433450382f0af154661fc8e72758b4de36" + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/ade9bd433450382f0af154661fc8e72758b4de36", - "reference": "ade9bd433450382f0af154661fc8e72758b4de36", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/555b37caeee3d07af33471e02377d5ff561f8ac2", + "reference": "555b37caeee3d07af33471e02377d5ff561f8ac2", "shasum": "" }, "require": { - "php": ">=8.4", + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v8.0.7" + }, + "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-03-06T13:17:40+00:00" + }, + { + "name": "symfony/http-client", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "ade9bd433450382f0af154661fc8e72758b4de36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/ade9bd433450382f0af154661fc8e72758b4de36", + "reference": "ade9bd433450382f0af154661fc8e72758b4de36", + "shasum": "" + }, + "require": { + "php": ">=8.4", "psr/log": "^1|^2|^3", "symfony/http-client-contracts": "~3.4.4|^3.5.2", "symfony/service-contracts": "^2.5|^3" @@ -5401,80 +6005,6 @@ ], "time": "2026-02-25T16:59:43+00:00" }, - { - "name": "symfony/mcp-sdk", - "version": "dev-main", - "source": { - "type": "git", - "url": "https://github.com/symfony/mcp-sdk.git", - "reference": "19464d5ce91b969c156d68ec7aa942e39ade5118" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/mcp-sdk/zipball/19464d5ce91b969c156d68ec7aa942e39ade5118", - "reference": "19464d5ce91b969c156d68ec7aa942e39ade5118", - "shasum": "" - }, - "require": { - "php": "^8.2", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/uid": "^7.3|^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^11.5", - "psr/cache": "^3.0", - "symfony/console": "^7.3|^8.0" - }, - "suggest": { - "psr/cache": "To use CachePoolStore with SSE Transport", - "symfony/console": "To use SymfonyConsoleTransport for STDIO" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\AI\\McpSdk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christopher Hertel", - "email": "mail@christopher-hertel.de" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - } - ], - "description": "Model Context Protocol SDK for Client and Server applications in PHP", - "support": { - "source": "https://github.com/symfony/mcp-sdk/tree/main" - }, - "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": "2025-09-18T06:25:22+00:00" - }, { "name": "symfony/mime", "version": "v8.0.7", @@ -7233,7 +7763,7 @@ }, { "name": "twig/intl-extra", - "version": "v3.24.0", + "version": "v3.23.0", "source": { "type": "git", "url": "https://github.com/twigphp/intl-extra.git", @@ -7281,7 +7811,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/intl-extra/tree/v3.24.0" + "source": "https://github.com/twigphp/intl-extra/tree/v3.23.0" }, "funding": [ { @@ -7297,16 +7827,16 @@ }, { "name": "twig/twig", - "version": "v3.24.0", + "version": "v3.23.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", "shasum": "" }, "require": { @@ -7316,8 +7846,7 @@ "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "php-cs-fixer/shim": "^3.0@stable", - "phpstan/phpstan": "^2.0@stable", + "phpstan/phpstan": "^2.0", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -7361,7 +7890,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + "source": "https://github.com/twigphp/Twig/tree/v3.23.0" }, "funding": [ { @@ -7373,7 +7902,7 @@ "type": "tidelift" } ], - "time": "2026-03-17T21:31:11+00:00" + "time": "2026-01-23T21:00:41+00:00" }, { "name": "web-token/jwt-library", @@ -7463,6 +7992,68 @@ } ], "time": "2025-12-18T14:27:35+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.6", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.6" + }, + "time": "2026-02-27T10:28:38+00:00" } ], "packages-dev": [ @@ -7522,54 +8113,6 @@ ], "time": "2026-03-14T00:50:47+00:00" }, - { - "name": "doctrine/deprecations", - "version": "1.1.6", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", - "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" - }, - "time": "2026-02-07T07:09:04+00:00" - }, { "name": "doctrine/instantiator", "version": "2.1.0", @@ -7744,351 +8287,128 @@ "php" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" - }, - "time": "2025-12-06T11:56:16+00:00" - }, - { - "name": "phar-io/manifest", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" - }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2024-03-03T12:33:53+00:00" - }, - { - "name": "phar-io/version", - "version": "3.2.1", - "source": { - "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Library for handling version information and constraints", - "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" - }, - "time": "2022-02-21T01:04:05+00:00" - }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "6.0.3", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.1", - "ext-filter": "*", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^2.0", - "phpstan/phpdoc-parser": "^2.0", - "webmozart/assert": "^1.9.1 || ^2" - }, - "require-dev": { - "mockery/mockery": "~1.3.5 || ~1.6.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "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" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - }, - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "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" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2026-03-18T20:49:53+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { - "name": "phpdocumentor/type-resolver", - "version": "2.0.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^2.0" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^4" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev", - "dev-2.x": "2.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2026-01-06T21:53:42+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { "name": "phpstan/phpstan", @@ -9877,74 +10197,6 @@ ], "time": "2026-02-17T13:07:04+00:00" }, - { - "name": "symfony/finder", - "version": "v8.0.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "symfony/filesystem": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "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": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.6" - }, - "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-01-29T09:41:02+00:00" - }, { "name": "symfony/type-info", "version": "v8.0.7", @@ -10077,68 +10329,6 @@ ], "time": "2025-12-08T11:19:18+00:00" }, - { - "name": "webmozart/assert", - "version": "2.1.6", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" - }, - "time": "2026-02-27T10:28:38+00:00" - }, { "name": "zircote/swagger-php", "version": "6.0.6", @@ -10232,9 +10422,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "symfony/mcp-sdk": 20 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/docs/mcp-server.md b/docs/mcp-server.md index d86fbd018c..993f90121b 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -4,9 +4,11 @@ This document describes the Model Context Protocol (MCP) server implementation f ## 12.1 Overview -The phpMyFAQ MCP Server allows Large Language Models (LLMs) to query the phpMyFAQ installation through the Model +The phpMyFAQ MCP Server allows Large Language Models (LLMs) to query the phpMyFAQ installation through the Model Context Protocol. This enables AI assistants to provide contextually relevant answers based on your FAQ. +The current implementation uses the official PHP package `mcp/sdk` and exposes the server over STDIO. + ## 12.2 Usage ### 12.2.1 Starting the Server @@ -19,6 +21,8 @@ php bin/console phpmyfaq:mcp:server --info php bin/console phpmyfaq:mcp:server ``` +The command starts a STDIO MCP server. It is intended to be launched by an MCP-capable client or by the MCP Inspector. + ### 12.2.2 Available Tools #### faq_search @@ -44,15 +48,34 @@ Search through the phpMyFAQ knowledge base to find relevant FAQ entries. ``` **Response Format:** -The tool returns formatted text with FAQ entries including: +The tool returns JSON with: - Question and answer content - FAQ ID and language - Relevance score - Direct URL to the FAQ entry +Example result shape: + +```json +{ + "results": [ + { + "id": 42, + "language": "en", + "question": "How do I reset my password?", + "answer": "Use the password reset form on the login page.", + "category_id": 1, + "relevance_score": 0.95, + "url": "https://example.com/content/42/en" + } + ], + "total_found": 1 +} +``` + ## 12.3 Integration with LLM Clients -Once the MCP server is running, LLM clients can connect to it and use the `faq_search` tool to query your phpMyFAQ +Once the MCP server is running, LLM clients can connect to it and use the `faq_search` tool to query your phpMyFAQ database. The server follows the MCP specification and provides: - Tool discovery via `tools/list` @@ -84,7 +107,23 @@ You can then access the MCP Inspector at the provided URL to interact with the s ## 12.4 Configuration -No additional configuration is required beyond having a working phpMyFAQ installation. +No additional MCP-specific configuration is required beyond having a working phpMyFAQ installation. + +The MCP server uses the same phpMyFAQ runtime configuration as the rest of the application, including: + +- database configuration +- language configuration +- URL configuration used for generated FAQ links +- the standard logger + +## 12.4.1 Dependency Notes + +The server runtime depends on: + +- `mcp/sdk` +- `symfony/console` + +If you update dependencies, make sure `mcp/sdk` remains installable with the project Composer constraints. ## 12.5 Error Handling @@ -103,4 +142,17 @@ The server includes comprehensive error handling: ### 12.7.1 Debugging -Enable debug logging by checking the Monolog output when running the server. The logger outputs to stdout by default. +Enable debug logging by checking the Monolog output when running the server. + +### 12.7.2 Runtime Issues + +If the server does not start: + +- verify Composer dependencies are installed +- verify `mcp/sdk` is present in the installed packages +- run `php bin/console phpmyfaq:mcp:server --info` to confirm the command is wired correctly +- test the server with MCP Inspector before connecting a custom client + +### 12.7.3 Transport Notes + +The phpMyFAQ MCP server currently uses STDIO only. It is not exposed as HTTP transport by default. diff --git a/phpmyfaq/src/phpMyFAQ/Command/McpServerCommand.php b/phpmyfaq/src/phpMyFAQ/Command/McpServerCommand.php index 3cf345a3ac..7cb4be7fcd 100644 --- a/phpmyfaq/src/phpMyFAQ/Command/McpServerCommand.php +++ b/phpmyfaq/src/phpMyFAQ/Command/McpServerCommand.php @@ -20,7 +20,7 @@ namespace phpMyFAQ\Command; use Exception; -use phpMyFAQ\Service\McpServer\PhpMyFaqMcpServer; +use phpMyFAQ\Service\McpServer\McpServerRuntimeInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -39,7 +39,7 @@ class McpServerCommand extends Command { public function __construct( - private readonly PhpMyFaqMcpServer $phpMyFaqMcpServer, + private readonly McpServerRuntimeInterface $phpMyFaqMcpServer, ) { parent::__construct(); } diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php new file mode 100644 index 0000000000..c5e953d2ee --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php @@ -0,0 +1,191 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-03-16 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Service\McpServer; + +use Exception; +use phpMyFAQ\Category; +use phpMyFAQ\Configuration; +use phpMyFAQ\Faq; +use phpMyFAQ\Search; + +readonly class FaqSearchTool implements McpToolExecutorInterface +{ + public function __construct( + private Configuration $configuration, + private Search $search, + private Faq $faq, + ) { + } + + public function getDefinition(): McpToolDefinition + { + return new McpToolDefinition( + name: 'faq_search', + description: 'Search through the phpMyFAQ knowledge base to find relevant FAQ entries that can answer questions. ' + . 'This tool searches both questions and answers in the FAQ database to provide comprehensive results.', + title: 'FAQ Search', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'type' => 'string', + 'description' => 'The search query or question to find relevant FAQ entries for', + ], + 'category_id' => [ + 'type' => 'integer', + 'description' => 'Optional category ID to limit search to a specific FAQ category', + 'minimum' => 1, + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum number of results to return (default: 10)', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 50, + ], + 'all_languages' => [ + 'type' => 'boolean', + 'description' => 'Whether to search in all languages or just the current language (default: false)', + 'default' => false, + ], + ], + 'required' => ['query'], + ], + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'results' => [ + 'type' => 'array', + 'description' => 'Array of FAQ search results', + ], + 'total_found' => [ + 'type' => 'integer', + 'description' => 'Total number of FAQ entries found', + ], + ], + ], + ); + } + + public function execute(array $arguments): array + { + $query = $arguments['query'] ?? ''; + $categoryId = $arguments['category_id'] ?? null; + $limit = $arguments['limit'] ?? 10; + $allLanguages = $arguments['all_languages'] ?? false; + + if (trim((string) $query) === '') { + return $this->createResult('Error: Search query cannot be empty.'); + } + + try { + $this->faq->setUser(-1); + $this->faq->setGroups([-1]); + + $category = new Category($this->configuration, [-1]); + $category->setUser(-1); + $this->search->setCategory($category); + + if ($categoryId !== null) { + $this->search->setCategoryId((int) $categoryId); + } + + $searchResults = $this->search->search($query, (bool) $allLanguages); + + if ($searchResults === []) { + return $this->createResult($this->formatResultsAsJson([])); + } + + $validResults = []; + foreach ($searchResults as $searchResult) { + $this->configuration->getLogger()->info(var_export($searchResult, return: true)); + + $validResults[] = [ + 'id' => $searchResult->id, + 'language' => $searchResult->lang, + 'question' => $searchResult->question ?? '', + 'answer' => $searchResult->answer ?? '', + 'category_id' => $searchResult->category_id ?? null, + 'relevance_score' => $searchResult->score ?? 0.0, + 'url' => $this->buildFaqUrl($searchResult->id, $searchResult->lang), + ]; + } + + $limitedResults = array_slice($validResults, 0, (int) $limit); + + if ($limitedResults === []) { + return $this->createResult('No accessible FAQ entries found for the given query.'); + } + + return $this->createResult($this->formatResultsAsJson($limitedResults)); + } catch (Exception $exception) { + return $this->createResult('Error searching FAQ database: ' . $exception->getMessage()); + } + } + + public function getSearch(): Search + { + return $this->search; + } + + public function getFaq(): Faq + { + return $this->faq; + } + + /** + * @param array> $results + */ + private function formatResultsAsJson(array $results): string + { + if ($results === []) { + $jsonData = [ + 'results' => [], + 'total_found' => 0, + ]; + + return json_encode($jsonData); + } + + $jsonData = [ + 'results' => $results, + 'total_found' => count($results), + ]; + + return json_encode($jsonData, JSON_PRETTY_PRINT); + } + + private function buildFaqUrl(int $faqId, string $language): string + { + return $this->configuration->getDefaultUrl() . 'content/' . $faqId . '/' . $language; + } + + /** + * @return array{content: string, type: string, mimeType: string} + */ + private function createResult(string $content): array + { + return [ + 'content' => $content, + 'type' => 'text', + 'mimeType' => 'application/json', + ]; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolExecutor.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolExecutor.php deleted file mode 100644 index faf6ada6dc..0000000000 --- a/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolExecutor.php +++ /dev/null @@ -1,148 +0,0 @@ - - * @copyright 2025 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2025-08-16 - */ - -declare(strict_types=1); - -namespace phpMyFAQ\Service\McpServer; - -use Exception; -use phpMyFAQ\Category; -use phpMyFAQ\Configuration; -use phpMyFAQ\Faq; -use phpMyFAQ\Search; -use Symfony\AI\McpSdk\Capability\Tool\IdentifierInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; -use Symfony\AI\McpSdk\Capability\Tool\ToolCallResult; -use Symfony\AI\McpSdk\Capability\Tool\ToolExecutorInterface; - -/** - * Class FaqSearchToolExecutor - * - * Executes the FAQ search functionality for the MCP server. - * This class uses phpMyFAQ's Search and Faq classes to search - * through the knowledge base and return relevant FAQ entries. - */ -readonly class FaqSearchToolExecutor implements ToolExecutorInterface, IdentifierInterface -{ - public function __construct( - private Configuration $configuration, - private Search $search, - private Faq $faq, - ) { - } - - public function getName(): string - { - return 'faq_search'; - } - - /** - * @throws Exception - */ - public function call(ToolCall $toolCall): ToolCallResult - { - $query = $toolCall->arguments['query'] ?? ''; - $categoryId = $toolCall->arguments['category_id'] ?? null; - $limit = $toolCall->arguments['limit'] ?? 10; - $allLanguages = $toolCall->arguments['all_languages'] ?? false; - - if (trim((string) $query) === '') { - return new ToolCallResult('Error: Search query cannot be empty.', 'text', 'application/json'); - } - - try { - $this->faq->setUser(-1); - $this->faq->setGroups([-1]); - - // Set the category class - $category = new Category($this->configuration, [-1]); - $category->setUser(-1); - $this->search->setCategory($category); - - // Set category filter if provided - if ($categoryId !== null) { - $this->search->setCategoryId((int) $categoryId); - } - - // Perform the search - $searchResults = $this->search->search($query, (bool) $allLanguages); - - if ($searchResults === []) { - $emptyResult = $this->formatResultsAsJson([]); - return new ToolCallResult($emptyResult, 'text', 'application/json'); - } - - // Format the results - $validResults = []; - foreach ($searchResults as $searchResult) { - $this->configuration->getLogger()->info(var_export($searchResult, return: true)); - - $validResults[] = [ - 'id' => $searchResult->id, - 'language' => $searchResult->lang, - 'question' => $searchResult->question ?? '', - 'answer' => $searchResult->answer ?? '', - 'category_id' => $searchResult->category_id ?? null, - 'relevance_score' => $searchResult->score ?? 0.0, - 'url' => $this->buildFaqUrl($searchResult->id, $searchResult->lang), - ]; - } - - // Limit results - $limitedResults = array_slice($validResults, offset: 0, length: (int) $limit); - - if ($limitedResults === []) { - return new ToolCallResult( - 'No accessible FAQ entries found for the given query.', - 'text', - 'application/json', - ); - } - - $resultJson = $this->formatResultsAsJson($limitedResults); - return new ToolCallResult($resultJson, 'text', 'application/json'); - } catch (Exception $exception) { - return new ToolCallResult( - 'Error searching FAQ database: ' . $exception->getMessage(), - 'text', - 'application/json', - ); - } - } - - private function formatResultsAsJson(array $results): string - { - if ($results === []) { - return json_encode([ - 'results' => [], - 'total_found' => 0, - ]); - } - - $jsonData = [ - 'results' => $results, - 'total_found' => count($results), - ]; - - return json_encode($jsonData, JSON_PRETTY_PRINT); - } - - private function buildFaqUrl(int $faqId, string $language): string - { - return $this->configuration->getDefaultUrl() . 'content/' . $faqId . '/' . $language; - } -} diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolMetadata.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolMetadata.php deleted file mode 100644 index 6409ca6c2e..0000000000 --- a/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchToolMetadata.php +++ /dev/null @@ -1,104 +0,0 @@ - - * @copyright 2025 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2025-08-16 - */ - -declare(strict_types=1); - -namespace phpMyFAQ\Service\McpServer; - -use Symfony\AI\McpSdk\Capability\Tool\MetadataInterface; -use Symfony\AI\McpSdk\Capability\Tool\ToolAnnotationsInterface; - -/** - * Class FaqSearchToolMetadata - * - * Defines the metadata for the FAQ search tool in the MCP server. - * This tool allows LLMs to search through phpMyFAQ's knowledge base - * to find relevant FAQ entries based on user questions. - */ -class FaqSearchToolMetadata implements MetadataInterface -{ - public function getName(): string - { - return 'faq_search'; - } - - public function getDescription(): ?string - { - return ( - 'Search through the phpMyFAQ knowledge base to find relevant FAQ entries that can answer questions. ' - . 'This tool searches both questions and answers in the FAQ database to provide comprehensive results.' - ); - } - - public function getTitle(): ?string - { - return 'FAQ Search'; - } - - public function getInputSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'query' => [ - 'type' => 'string', - 'description' => 'The search query or question to find relevant FAQ entries for', - ], - 'category_id' => [ - 'type' => 'integer', - 'description' => 'Optional category ID to limit search to a specific FAQ category', - 'minimum' => 1, - ], - 'limit' => [ - 'type' => 'integer', - 'description' => 'Maximum number of results to return (default: 10)', - 'default' => 10, - 'minimum' => 1, - 'maximum' => 50, - ], - 'all_languages' => [ - 'type' => 'boolean', - 'description' => 'Whether to search in all languages or just the current language (default: false)', - 'default' => false, - ], - ], - 'required' => ['query'], - ]; - } - - public function getOutputSchema(): ?array - { - return [ - 'type' => 'object', - 'properties' => [ - 'results' => [ - 'type' => 'array', - 'description' => 'Array of FAQ search results', - ], - 'total_found' => [ - 'type' => 'integer', - 'description' => 'Total number of FAQ entries found', - ], - ], - ]; - } - - public function getAnnotations(): ?ToolAnnotationsInterface - { - return null; - } -} diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpSdkRuntime.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpSdkRuntime.php new file mode 100644 index 0000000000..a9c0c6f164 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpSdkRuntime.php @@ -0,0 +1,108 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-03-18 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Service\McpServer; + +use phpMyFAQ\Configuration; +use RuntimeException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +final class McpSdkRuntime implements McpServerRuntimeInterface +{ + /** + * @param array $serverInfo + */ + public function __construct( + private readonly Configuration $configuration, + private readonly FaqSearchTool $faqSearchTool, + private readonly array $serverInfo, + ) { + } + + public function runConsole(InputInterface $input, OutputInterface $output): void + { + $this->buildServer()->run(new \Mcp\Server\Transport\StdioTransport()); + } + + public function getServerInfo(): array + { + return $this->serverInfo; + } + + /** + * Adapter method for mcp/sdk manual tool registration. + * + * Returns the decoded JSON payload for successful searches and a string for + * user-facing errors, matching the intent of the current Symfony MCP runtime. + * + * @return array|string + */ + public function faqSearch( + string $query, + ?int $category_id = null, + int $limit = 10, + bool $all_languages = false, + ): array|string { + $result = $this->faqSearchTool->execute([ + 'query' => $query, + 'category_id' => $category_id, + 'limit' => $limit, + 'all_languages' => $all_languages, + ]); + + $decoded = json_decode($result['content'], true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $result['content']; + } + + private function buildServer(): \Mcp\Server + { + if (!class_exists(\Mcp\Server::class) || !class_exists(\Mcp\Server\Transport\StdioTransport::class)) { + throw new RuntimeException( + 'The mcp/sdk package is not installed or does not expose the expected server classes.', + ); + } + + $definition = $this->faqSearchTool->getDefinition(); + + return \Mcp\Server::builder() + ->setServerInfo( + (string) $this->serverInfo['name'], + (string) $this->serverInfo['version'], + (string) ($this->serverInfo['description'] ?? null), + ) + ->addTool( + fn( + string $query, + ?int $category_id = null, + int $limit = 10, + bool $all_languages = false, + ): array|string => $this->faqSearch($query, $category_id, $limit, $all_languages), + $definition->name, + $definition->description, + null, + $definition->inputSchema, + null, + null, + $definition->outputSchema, + ) + ->build(); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpServerRuntimeInterface.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpServerRuntimeInterface.php new file mode 100644 index 0000000000..0c6de57fb2 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpServerRuntimeInterface.php @@ -0,0 +1,33 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-03-16 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Service\McpServer; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +interface McpServerRuntimeInterface +{ + public function runConsole(InputInterface $input, OutputInterface $output): void; + + /** + * @return array + */ + public function getServerInfo(): array; +} diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolDefinition.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolDefinition.php new file mode 100644 index 0000000000..e06af53a6f --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolDefinition.php @@ -0,0 +1,38 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-03-16 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Service\McpServer; + +readonly class McpToolDefinition +{ + /** + * @param array $inputSchema + * @param array|null $outputSchema + * @param array $annotations + */ + public function __construct( + public string $name, + public string $description, + public ?string $title, + public array $inputSchema, + public ?array $outputSchema = null, + public array $annotations = [], + ) { + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolExecutorInterface.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolExecutorInterface.php new file mode 100644 index 0000000000..c427a7b357 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/McpToolExecutorInterface.php @@ -0,0 +1,31 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-03-16 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Service\McpServer; + +interface McpToolExecutorInterface +{ + public function getDefinition(): McpToolDefinition; + + /** + * @param array $arguments + * @return array{content: string, type: string, mimeType: string} + */ + public function execute(array $arguments): array; +} diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php index 2db0c7f190..3126dfa84c 100644 --- a/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php @@ -23,14 +23,6 @@ use phpMyFAQ\Faq; use phpMyFAQ\Language; use phpMyFAQ\Search; -use Symfony\AI\McpSdk\Capability\ToolChain; -use Symfony\AI\McpSdk\Message\Factory; -use Symfony\AI\McpSdk\Server; -use Symfony\AI\McpSdk\Server\JsonRpcHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\InitializeHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolCallHandler; -use Symfony\AI\McpSdk\Server\RequestHandler\ToolListHandler; -use Symfony\AI\McpSdk\Server\Transport\Stdio\SymfonyConsoleTransport; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -41,9 +33,9 @@ * with FAQ search capabilities. This allows LLM models to query the phpMyFAQ knowledge base * through the MCP protocol. */ -class PhpMyFaqMcpServer +class PhpMyFaqMcpServer implements McpServerRuntimeInterface { - private JsonRpcHandler $jsonRpcHandler; + private McpServerRuntimeInterface $runtime; private const string MCP_SERVER_NAME = 'phpMyFAQ MCP Server'; @@ -54,44 +46,29 @@ public function __construct( Language $language, private readonly Search $search, private readonly Faq $faq, + ?McpServerRuntimeInterface $runtime = null, ) { $detectionEnabled = (bool) $this->configuration->get(item: 'main.languageDetection'); $configLang = (string) $this->configuration->get(item: 'main.language'); if ($detectionEnabled) { $language->setLanguageWithDetection($configLang); $this->configuration->setLanguage($language); - $this->initializeServer(); + $this->initializeServer($runtime); return; } $language->setLanguageFromConfiguration($configLang); $this->configuration->setLanguage($language); - $this->initializeServer(); + $this->initializeServer($runtime); } - private function initializeServer(): void + private function initializeServer(?McpServerRuntimeInterface $runtime = null): void { - $toolChain = new ToolChain([ - new FaqSearchToolMetadata(), - new FaqSearchToolExecutor($this->configuration, $this->search, $this->faq), - ]); - - $messageFactory = new Factory(); - - $requestHandlers = [ - new InitializeHandler(self::MCP_SERVER_NAME, self::MCP_SERVER_VERSION), - new ToolListHandler($toolChain), - new ToolCallHandler($toolChain), - ]; - - $notificationHandlers = []; - - $this->jsonRpcHandler = new JsonRpcHandler( - $messageFactory, - $requestHandlers, - $notificationHandlers, - $this->configuration->getLogger(), + $this->runtime = $runtime ?? new McpSdkRuntime( + $this->configuration, + new FaqSearchTool($this->configuration, $this->search, $this->faq), + $this->createServerInfo(), ); } @@ -100,23 +77,21 @@ private function initializeServer(): void */ public function runConsole(InputInterface $input, OutputInterface $output): void { - $symfonyConsoleTransport = new SymfonyConsoleTransport($input, $output); - $server = new Server($this->jsonRpcHandler, $this->configuration->getLogger()); - $server->connect($symfonyConsoleTransport); + $this->runtime->runConsole($input, $output); } /** - * Get the configured JSON-RPC handler + * Get server information for debugging */ - public function getJsonRpcHandler(): JsonRpcHandler + public function getServerInfo(): array { - return $this->jsonRpcHandler; + return $this->runtime->getServerInfo(); } /** - * Get server information for debugging + * @return array */ - public function getServerInfo(): array + private function createServerInfo(): array { return [ 'name' => self::MCP_SERVER_NAME, diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index 7bed5ca92f..869e7ec80d 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -179,6 +179,9 @@ use phpMyFAQ\Seo\SeoRepository; use phpMyFAQ\Seo\SitemapXmlService; use phpMyFAQ\Service\Gravatar; +use phpMyFAQ\Service\McpServer\FaqSearchTool; +use phpMyFAQ\Service\McpServer\McpServerRuntimeInterface; +use phpMyFAQ\Service\McpServer\McpSdkRuntime; use phpMyFAQ\Service\McpServer\PhpMyFaqMcpServer; use phpMyFAQ\Session\SessionWrapper; use phpMyFAQ\Session\Token; @@ -681,11 +684,37 @@ service('phpmyfaq.configuration'), ]); + $services->set('phpmyfaq.service.mcp-server.faq-search-tool', FaqSearchTool::class)->args([ + service('phpmyfaq.configuration'), + service('phpmyfaq.search'), + service('phpmyfaq.faq'), + ]); + + $services->set('phpmyfaq.service.mcp-server.runtime', McpSdkRuntime::class)->args([ + service('phpmyfaq.configuration'), + service('phpmyfaq.service.mcp-server.faq-search-tool'), + [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + 'description' => 'Model Context Protocol server for phpMyFAQ installations', + 'capabilities' => ['tools' => true], + 'tools' => [ + [ + 'name' => 'faq_search', + 'description' => 'Search through phpMyFAQ installations', + ], + ], + ], + ]); + + $services->alias(McpServerRuntimeInterface::class, 'phpmyfaq.service.mcp-server.runtime'); + $services->set('phpmyfaq.service.mcp-server', PhpMyFaqMcpServer::class)->args([ service('phpmyfaq.configuration'), service('phpmyfaq.language'), service('phpmyfaq.search'), service('phpmyfaq.faq'), + service('phpmyfaq.service.mcp-server.runtime'), ]); $services->set(CreateHashesCommand::class, CreateHashesCommand::class)->args([ diff --git a/tests/phpMyFAQ/Command/McpServerCommandTest.php b/tests/phpMyFAQ/Command/McpServerCommandTest.php index 2e0bfec6f8..6bcee38671 100644 --- a/tests/phpMyFAQ/Command/McpServerCommandTest.php +++ b/tests/phpMyFAQ/Command/McpServerCommandTest.php @@ -2,7 +2,7 @@ namespace phpMyFAQ\Command; -use phpMyFAQ\Service\McpServer\PhpMyFaqMcpServer; +use phpMyFAQ\Service\McpServer\McpServerRuntimeInterface; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -12,12 +12,12 @@ #[AllowMockObjectsWithoutExpectations] class McpServerCommandTest extends TestCase { - private PhpMyFaqMcpServer $serverMock; + private McpServerRuntimeInterface $serverMock; private McpServerCommand $command; protected function setUp(): void { - $this->serverMock = $this->createMock(PhpMyFaqMcpServer::class); + $this->serverMock = $this->createMock(McpServerRuntimeInterface::class); $this->command = new McpServerCommand($this->serverMock); } diff --git a/tests/phpMyFAQ/Service/McpServer/FaqSearchToolExecutorTest.php b/tests/phpMyFAQ/Service/McpServer/FaqSearchToolExecutorTest.php deleted file mode 100644 index 5757009ad5..0000000000 --- a/tests/phpMyFAQ/Service/McpServer/FaqSearchToolExecutorTest.php +++ /dev/null @@ -1,106 +0,0 @@ -createStub(Configuration::class); - $this->searchMock = $this->createStub(Search::class); - $this->faqMock = $this->createStub(Faq::class); - - $configMock->method('getDefaultUrl')->willReturn('https://example.com/'); - - $this->executor = new FaqSearchToolExecutor($configMock, $this->searchMock, $this->faqMock); - } - - public function testGetName(): void - { - $this->assertSame('faq_search', $this->executor->getName()); - } - - /** - * @throws Exception - */ - public function testCallWithEmptyQueryReturnsError(): void - { - $toolCall = new ToolCall('test-id', 'faq_search', ['query' => '']); - $result = $this->executor->call($toolCall); - - $this->assertInstanceOf(ToolCallResult::class, $result); - $this->assertStringContainsString('Search query cannot be empty', $result->result); - } - - /** - * @throws Exception - */ - public function testCallWithNoResults(): void - { - $toolCall = new ToolCall('test-id', 'faq_search', ['query' => 'test']); - $this->searchMock->method('search')->willReturn([]); - - $result = $this->executor->call($toolCall); - - $jsonData = json_decode($result->result, true); - $this->assertIsArray($jsonData); - $this->assertArrayHasKey('results', $jsonData); - $this->assertArrayHasKey('total_found', $jsonData); - $this->assertEmpty($jsonData['results']); - $this->assertSame(0, $jsonData['total_found']); - } - - /** - * @throws Exception - */ - public function testCallWithResults(): void - { - $toolCall = new ToolCall('test-id', 'faq_search', ['query' => 'test', 'limit' => 1]); - $searchResult = (object) [ - 'id' => 42, - 'lang' => 'en', - 'question' => 'What is phpMyFAQ?', - 'answer' => 'phpMyFAQ is an open source FAQ system.', - 'category_id' => 1, - 'score' => 0.95, - ]; - $this->searchMock->method('search')->willReturn([$searchResult]); - $this->faqMock->method('getFaqResult')->willReturn(['id' => 42]); - - $result = $this->executor->call($toolCall); - - $jsonData = json_decode($result->result, true); - $this->assertIsArray($jsonData); - $this->assertArrayHasKey('results', $jsonData); - $this->assertArrayHasKey('total_found', $jsonData); - $this->assertSame(1, $jsonData['total_found']); - $this->assertCount(1, $jsonData['results']); - $this->assertSame(42, $jsonData['results'][0]['id']); - $this->assertSame('What is phpMyFAQ?', $jsonData['results'][0]['question']); - $this->assertStringContainsString('https://example.com/content/42/en', $jsonData['results'][0]['url']); - } - - public function testCallWithException(): void - { - $toolCall = new ToolCall('test-id', 'faq_search', ['query' => 'test']); - $this->searchMock->method('search')->willThrowException(new Exception('DB error')); - - $result = $this->executor->call($toolCall); - - $this->assertStringContainsString('Error searching FAQ database', $result->result); - } -} diff --git a/tests/phpMyFAQ/Service/McpServer/FaqSearchToolMetadataTest.php b/tests/phpMyFAQ/Service/McpServer/FaqSearchToolMetadataTest.php deleted file mode 100644 index 1b599bb148..0000000000 --- a/tests/phpMyFAQ/Service/McpServer/FaqSearchToolMetadataTest.php +++ /dev/null @@ -1,56 +0,0 @@ -metadata = new FaqSearchToolMetadata(); - } - - public function testGetName(): void - { - $this->assertSame('faq_search', $this->metadata->getName()); - } - - public function testGetDescription(): void - { - $desc = $this->metadata->getDescription(); - $this->assertIsString($desc); - $this->assertStringContainsString('Search through the phpMyFAQ knowledge base', $desc); - } - - public function testGetTitle(): void - { - $this->assertSame('FAQ Search', $this->metadata->getTitle()); - } - - public function testGetInputSchema(): void - { - $schema = $this->metadata->getInputSchema(); - $this->assertIsArray($schema); - $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('query', $schema['properties']); - $this->assertContains('query', $schema['required']); - } - - public function testGetOutputSchema(): void - { - $schema = $this->metadata->getOutputSchema(); - $this->assertIsArray($schema); - $this->assertArrayHasKey('results', $schema['properties']); - $this->assertArrayHasKey('total_found', $schema['properties']); - } - - public function testGetAnnotations(): void - { - $this->assertNull($this->metadata->getAnnotations()); - } -} diff --git a/tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php b/tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php new file mode 100644 index 0000000000..f49b198fb1 --- /dev/null +++ b/tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php @@ -0,0 +1,120 @@ +createConfigurationMock(), + new FaqSearchTool( + $this->createConfigurationMock(), + $this->createMock(Search::class), + $this->createMock(Faq::class), + ), + [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + 'description' => 'Model Context Protocol server for phpMyFAQ installations', + 'capabilities' => ['tools' => true], + 'tools' => [['name' => 'faq_search', 'description' => 'Search through phpMyFAQ installations']], + ], + ); + + $this->assertSame('phpMyFAQ MCP Server', $runtime->getServerInfo()['name']); + } + + public function testFaqSearchReturnsDecodedJsonPayload(): void + { + $configuration = $this->createConfigurationMock(); + $search = $this->createMock(Search::class); + $faq = $this->createMock(Faq::class); + + $faq->method('setUser'); + $faq->method('setGroups'); + $search->method('setCategory'); + $search->method('search')->willReturn([]); + + $runtime = new McpSdkRuntime($configuration, new FaqSearchTool($configuration, $search, $faq), [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + 'description' => 'Model Context Protocol server for phpMyFAQ installations', + 'capabilities' => ['tools' => true], + 'tools' => [['name' => 'faq_search', 'description' => 'Search through phpMyFAQ installations']], + ]); + + $result = $runtime->faqSearch('test'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('results', $result); + $this->assertArrayHasKey('total_found', $result); + } + + public function testBuildServerReturnsMcpSdkServerInstance(): void + { + $runtime = new McpSdkRuntime( + $this->createConfigurationMock(), + new FaqSearchTool( + $this->createConfigurationMock(), + $this->createMock(Search::class), + $this->createMock(Faq::class), + ), + [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + 'description' => 'Model Context Protocol server for phpMyFAQ installations', + 'capabilities' => ['tools' => true], + 'tools' => [['name' => 'faq_search', 'description' => 'Search through phpMyFAQ installations']], + ], + ); + + $method = new ReflectionMethod($runtime, 'buildServer'); + $server = $method->invoke($runtime); + + $this->assertInstanceOf(\Mcp\Server::class, $server); + } + + public function testRunConsoleMethodAcceptsConsoleInterfaces(): void + { + $runtime = new McpSdkRuntime( + $this->createConfigurationMock(), + new FaqSearchTool( + $this->createConfigurationMock(), + $this->createMock(Search::class), + $this->createMock(Faq::class), + ), + [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + 'description' => 'Model Context Protocol server for phpMyFAQ installations', + 'capabilities' => ['tools' => true], + 'tools' => [['name' => 'faq_search', 'description' => 'Search through phpMyFAQ installations']], + ], + ); + + $this->assertTrue(method_exists($runtime, 'runConsole')); + $this->assertInstanceOf(InputInterface::class, $this->createMock(InputInterface::class)); + $this->assertInstanceOf(OutputInterface::class, $this->createMock(OutputInterface::class)); + } + + private function createConfigurationMock(): Configuration + { + $configuration = $this->createMock(Configuration::class); + $configuration->method('getDefaultUrl')->willReturn('https://example.com/'); + $configuration->method('getLogger')->willReturn($this->createMock(Logger::class)); + + return $configuration; + } +} diff --git a/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php b/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php index b9d097504c..1cbb837064 100644 --- a/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php +++ b/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php @@ -2,7 +2,6 @@ namespace phpMyFAQ\Service\McpServer; -use Exception; use Monolog\Logger; use phpMyFAQ\Configuration; use phpMyFAQ\Faq; @@ -10,30 +9,28 @@ use phpMyFAQ\Search; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; -use Symfony\AI\McpSdk\Capability\Tool\ToolCall; -use Symfony\AI\McpSdk\Server\JsonRpcHandler; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; #[AllowMockObjectsWithoutExpectations] class PhpMyFaqMcpServerTest extends TestCase { private PhpMyFaqMcpServer $server; private Configuration $configMock; - private Search $searchMock; - private Faq $faqMock; + private McpServerRuntimeInterface $runtimeMock; protected function setUp(): void { $this->configMock = $this->createMock(Configuration::class); $languageMock = $this->createMock(Language::class); - $this->searchMock = $this->createMock(Search::class); - $this->faqMock = $this->createMock(Faq::class); + $searchMock = $this->createMock(Search::class); + $faqMock = $this->createMock(Faq::class); + $this->runtimeMock = $this->createMock(McpServerRuntimeInterface::class); $loggerMock = $this->createMock(Logger::class); $this->configMock->method('getLogger')->willReturn($loggerMock); $this->configMock->method('setLanguage'); - $this->configMock->method('getDefaultUrl')->willReturn('https://example.com'); - // Mock the configuration values needed by Language::setLanguage() $this->configMock ->method('get') ->willReturnMap([ @@ -41,17 +38,33 @@ protected function setUp(): void ['main.language', 'en'], ]); - $this->server = new PhpMyFaqMcpServer($this->configMock, $languageMock, $this->searchMock, $this->faqMock); + $this->server = new PhpMyFaqMcpServer( + $this->configMock, + $languageMock, + $searchMock, + $faqMock, + $this->runtimeMock, + ); } - public function testJsonRpcHandlerIsInitialized(): void + public function testServerImplementsRuntimeInterface(): void { - $handler = $this->server->getJsonRpcHandler(); - $this->assertInstanceOf(JsonRpcHandler::class, $handler); + $this->assertInstanceOf(McpServerRuntimeInterface::class, $this->server); } public function testGetServerInfoReturnsExpectedArray(): void { + $this->runtimeMock + ->expects($this->once()) + ->method('getServerInfo') + ->willReturn([ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + 'description' => 'Model Context Protocol server for phpMyFAQ installations', + 'capabilities' => ['tools' => true], + 'tools' => [['name' => 'faq_search', 'description' => 'Search through phpMyFAQ installations']], + ]); + $info = $this->server->getServerInfo(); $this->assertIsArray($info); $this->assertSame('phpMyFAQ MCP Server', $info['name']); @@ -62,68 +75,16 @@ public function testGetServerInfoReturnsExpectedArray(): void $this->assertSame('faq_search', $info['tools'][0]['name']); } - /** - * @throws Exception - */ - public function testFaqSearchToolExecutorReturnsValidJsonFormat(): void + public function testRunConsoleDelegatesToRuntime(): void { - $executor = new FaqSearchToolExecutor($this->configMock, $this->searchMock, $this->faqMock); - - // Mock search results - $searchResults = [ - (object) [ - 'id' => 1, - 'lang' => 'en', - 'question' => 'Test question?', - 'answer' => 'Test answer', - 'category_id' => 1, - 'score' => 0.95, - ], - ]; - - $this->searchMock->method('search')->willReturn($searchResults); - $this->searchMock->expects($this->once())->method('setCategory'); - - $toolCall = new ToolCall('test-id', 'faq_search', ['query' => 'test']); - $result = $executor->call($toolCall); - - $this->assertSame('application/json', $result->mimeType); - - $jsonData = json_decode($result->result, true); - $this->assertIsArray($jsonData); - $this->assertArrayHasKey('results', $jsonData); - $this->assertArrayHasKey('total_found', $jsonData); - $this->assertIsArray($jsonData['results']); - $this->assertIsInt($jsonData['total_found']); - } - - public function testFaqSearchToolExecutorHandlesEmptyQuery(): void - { - $executor = new FaqSearchToolExecutor($this->configMock, $this->searchMock, $this->faqMock); - - $toolCall = new ToolCall('test-id', 'faq_search', ['query' => '']); - $result = $executor->call($toolCall); - - $this->assertSame('application/json', $result->mimeType); - $this->assertStringContainsString('Search query cannot be empty', $result->result); - } - - public function testFaqSearchToolExecutorHandlesNoResults(): void - { - $executor = new FaqSearchToolExecutor($this->configMock, $this->searchMock, $this->faqMock); - - $this->searchMock->method('search')->willReturn([]); - $this->searchMock->expects($this->once())->method('setCategory'); - - $toolCall = new ToolCall('test-id', 'faq_search', ['query' => 'nonexistent']); - $result = $executor->call($toolCall); + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); - $this->assertSame('application/json', $result->mimeType); + $this->runtimeMock + ->expects($this->once()) + ->method('runConsole') + ->with($input, $output); - $jsonData = json_decode($result->result, true); - $this->assertArrayHasKey('results', $jsonData); - $this->assertArrayHasKey('total_found', $jsonData); - $this->assertEmpty($jsonData['results']); - $this->assertSame(0, $jsonData['total_found']); + $this->server->runConsole($input, $output); } } From bfa0b303ee35f40bf1857ea31f800fe6fdbe1bd0 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Wed, 18 Mar 2026 18:42:07 +0100 Subject: [PATCH 2/7] test: added more tests --- .husky/pre-commit | 2 +- .husky/pre-push | 2 +- package.json | 2 +- .../Service/McpServer/FaqSearchToolTest.php | 285 ++++++++++++++++++ .../Service/McpServer/McpSdkRuntimeTest.php | 82 +++++ .../McpServer/McpToolDefinitionTest.php | 59 ++++ .../McpServer/PhpMyFaqMcpServerTest.php | 52 ++++ 7 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 tests/phpMyFAQ/Service/McpServer/FaqSearchToolTest.php create mode 100644 tests/phpMyFAQ/Service/McpServer/McpToolDefinitionTest.php diff --git a/.husky/pre-commit b/.husky/pre-commit index f41d0d5c22..aa6c6d25c7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -mago format --check && composer validate && composer test && pnpm pretty-quick --staged && pnpm eslint . && pnpm run tsc && pnpm test +mago format --check && composer validate && composer test && pnpm pretty-quick --staged && pnpm run eslint && pnpm run tsc && pnpm test diff --git a/.husky/pre-push b/.husky/pre-push index 32998bfe67..7656dec3bf 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -composer validate && composer test && pnpm pretty-quick --staged && pnpm eslint . && pnpm run tsc && pnpm test +composer validate && composer test && pnpm pretty-quick --staged && pnpm run eslint && pnpm run tsc && pnpm test diff --git a/package.json b/package.json index 0dabb5c210..01e3dd8b5d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build": "vite build", "build:watch": "vite build --watch", "build:prod": "vite build", - "eslint": "eslint .", + "eslint": "eslint phpmyfaq/assets/src phpmyfaq/admin/assets/src", "lint": "prettier --check .", "lint:fix": "prettier --write .", "prepare": "husky", diff --git a/tests/phpMyFAQ/Service/McpServer/FaqSearchToolTest.php b/tests/phpMyFAQ/Service/McpServer/FaqSearchToolTest.php new file mode 100644 index 0000000000..ead32585ba --- /dev/null +++ b/tests/phpMyFAQ/Service/McpServer/FaqSearchToolTest.php @@ -0,0 +1,285 @@ +configMock = $this->createMock(Configuration::class); + $this->configMock->method('getDefaultUrl')->willReturn('https://example.com/'); + $this->configMock->method('getLogger')->willReturn($this->createMock(Logger::class)); + + $this->searchMock = $this->createMock(Search::class); + $this->faqMock = $this->createMock(Faq::class); + + $this->tool = new FaqSearchTool($this->configMock, $this->searchMock, $this->faqMock); + } + + public function testImplementsMcpToolExecutorInterface(): void + { + $this->assertInstanceOf(McpToolExecutorInterface::class, $this->tool); + } + + public function testGetDefinitionReturnsCorrectToolDefinition(): void + { + $definition = $this->tool->getDefinition(); + + $this->assertInstanceOf(McpToolDefinition::class, $definition); + $this->assertSame('faq_search', $definition->name); + $this->assertSame('FAQ Search', $definition->title); + $this->assertStringContainsString('Search through the phpMyFAQ knowledge base', $definition->description); + } + + public function testGetDefinitionInputSchemaHasRequiredQueryProperty(): void + { + $definition = $this->tool->getDefinition(); + + $this->assertSame('object', $definition->inputSchema['type']); + $this->assertArrayHasKey('query', $definition->inputSchema['properties']); + $this->assertSame(['query'], $definition->inputSchema['required']); + } + + public function testGetDefinitionInputSchemaHasOptionalProperties(): void + { + $definition = $this->tool->getDefinition(); + $properties = $definition->inputSchema['properties']; + + $this->assertArrayHasKey('category_id', $properties); + $this->assertSame('integer', $properties['category_id']['type']); + + $this->assertArrayHasKey('limit', $properties); + $this->assertSame(10, $properties['limit']['default']); + $this->assertSame(1, $properties['limit']['minimum']); + $this->assertSame(50, $properties['limit']['maximum']); + + $this->assertArrayHasKey('all_languages', $properties); + $this->assertSame('boolean', $properties['all_languages']['type']); + $this->assertFalse($properties['all_languages']['default']); + } + + public function testGetDefinitionHasOutputSchema(): void + { + $definition = $this->tool->getDefinition(); + + $this->assertNotNull($definition->outputSchema); + $this->assertArrayHasKey('results', $definition->outputSchema['properties']); + $this->assertArrayHasKey('total_found', $definition->outputSchema['properties']); + } + + public function testExecuteWithEmptyQueryReturnsError(): void + { + $result = $this->tool->execute(['query' => '']); + + $this->assertSame('Error: Search query cannot be empty.', $result['content']); + $this->assertSame('text', $result['type']); + $this->assertSame('application/json', $result['mimeType']); + } + + public function testExecuteWithWhitespaceOnlyQueryReturnsError(): void + { + $result = $this->tool->execute(['query' => ' ']); + + $this->assertSame('Error: Search query cannot be empty.', $result['content']); + } + + public function testExecuteWithMissingQueryReturnsError(): void + { + $result = $this->tool->execute([]); + + $this->assertSame('Error: Search query cannot be empty.', $result['content']); + } + + public function testExecuteWithNoResultsReturnsEmptyJson(): void + { + $this->searchMock->method('search')->willReturn([]); + + $result = $this->tool->execute(['query' => 'nonexistent']); + + $decoded = json_decode($result['content'], true); + $this->assertSame([], $decoded['results']); + $this->assertSame(0, $decoded['total_found']); + } + + public function testExecuteWithResultsReturnsFormattedJson(): void + { + $searchResult = new stdClass(); + $searchResult->id = 42; + $searchResult->lang = 'en'; + $searchResult->question = 'How to install?'; + $searchResult->answer = 'Run the installer.'; + $searchResult->category_id = 5; + $searchResult->score = 0.95; + + $this->searchMock->method('search')->willReturn([$searchResult]); + + $result = $this->tool->execute(['query' => 'install']); + + $decoded = json_decode($result['content'], true); + $this->assertSame(1, $decoded['total_found']); + $this->assertCount(1, $decoded['results']); + + $faq = $decoded['results'][0]; + $this->assertSame(42, $faq['id']); + $this->assertSame('en', $faq['language']); + $this->assertSame('How to install?', $faq['question']); + $this->assertSame('Run the installer.', $faq['answer']); + $this->assertSame(5, $faq['category_id']); + $this->assertSame(0.95, $faq['relevance_score']); + $this->assertSame('https://example.com/content/42/en', $faq['url']); + } + + public function testExecuteWithCategoryIdSetsCategoryOnSearch(): void + { + $this->searchMock + ->expects($this->once()) + ->method('setCategoryId') + ->with(7); + $this->searchMock->method('search')->willReturn([]); + + $this->tool->execute(['query' => 'test', 'category_id' => 7]); + } + + public function testExecuteWithoutCategoryIdDoesNotSetCategoryId(): void + { + $this->searchMock + ->expects($this->never()) + ->method('setCategoryId'); + $this->searchMock->method('search')->willReturn([]); + + $this->tool->execute(['query' => 'test']); + } + + public function testExecuteRespectsLimitParameter(): void + { + $results = []; + for ($i = 1; $i <= 5; $i++) { + $obj = new stdClass(); + $obj->id = $i; + $obj->lang = 'en'; + $obj->question = "Question $i"; + $obj->answer = "Answer $i"; + $obj->category_id = 1; + $obj->score = 1.0 - ($i * 0.1); + $results[] = $obj; + } + + $this->searchMock->method('search')->willReturn($results); + + $result = $this->tool->execute(['query' => 'test', 'limit' => 2]); + + $decoded = json_decode($result['content'], true); + $this->assertSame(2, $decoded['total_found']); + $this->assertCount(2, $decoded['results']); + $this->assertSame(1, $decoded['results'][0]['id']); + $this->assertSame(2, $decoded['results'][1]['id']); + } + + public function testExecutePassesAllLanguagesFlag(): void + { + $this->searchMock + ->expects($this->once()) + ->method('search') + ->with('test', true) + ->willReturn([]); + + $this->tool->execute(['query' => 'test', 'all_languages' => true]); + } + + public function testExecuteHandlesExceptionGracefully(): void + { + $this->searchMock + ->method('search') + ->willThrowException(new Exception('Database connection failed')); + + $result = $this->tool->execute(['query' => 'test']); + + $this->assertSame('Error searching FAQ database: Database connection failed', $result['content']); + $this->assertSame('text', $result['type']); + $this->assertSame('application/json', $result['mimeType']); + } + + public function testExecuteHandlesResultsWithNullFields(): void + { + $searchResult = new stdClass(); + $searchResult->id = 1; + $searchResult->lang = 'de'; + $searchResult->question = null; + $searchResult->answer = null; + $searchResult->category_id = null; + $searchResult->score = null; + + $this->searchMock->method('search')->willReturn([$searchResult]); + + $result = $this->tool->execute(['query' => 'test']); + + $decoded = json_decode($result['content'], true); + $this->assertSame(1, $decoded['total_found']); + $faq = $decoded['results'][0]; + $this->assertSame('', $faq['question']); + $this->assertSame('', $faq['answer']); + $this->assertNull($faq['category_id']); + $this->assertSame(0, $faq['relevance_score']); + } + + public function testExecuteDefaultLimitIsTen(): void + { + $results = []; + for ($i = 1; $i <= 15; $i++) { + $obj = new stdClass(); + $obj->id = $i; + $obj->lang = 'en'; + $obj->question = "Q$i"; + $obj->answer = "A$i"; + $obj->category_id = 1; + $obj->score = 0.5; + $results[] = $obj; + } + + $this->searchMock->method('search')->willReturn($results); + + $result = $this->tool->execute(['query' => 'test']); + + $decoded = json_decode($result['content'], true); + $this->assertSame(10, $decoded['total_found']); + $this->assertCount(10, $decoded['results']); + } + + public function testGetSearchReturnsSearchInstance(): void + { + $this->assertSame($this->searchMock, $this->tool->getSearch()); + } + + public function testGetFaqReturnsFaqInstance(): void + { + $this->assertSame($this->faqMock, $this->tool->getFaq()); + } + + public function testExecuteResultStructure(): void + { + $this->searchMock->method('search')->willReturn([]); + + $result = $this->tool->execute(['query' => 'test']); + + $this->assertArrayHasKey('content', $result); + $this->assertArrayHasKey('type', $result); + $this->assertArrayHasKey('mimeType', $result); + $this->assertSame('text', $result['type']); + $this->assertSame('application/json', $result['mimeType']); + } +} \ No newline at end of file diff --git a/tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php b/tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php index f49b198fb1..6ac45637d1 100644 --- a/tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php +++ b/tests/phpMyFAQ/Service/McpServer/McpSdkRuntimeTest.php @@ -109,6 +109,88 @@ public function testRunConsoleMethodAcceptsConsoleInterfaces(): void $this->assertInstanceOf(OutputInterface::class, $this->createMock(OutputInterface::class)); } + public function testImplementsMcpServerRuntimeInterface(): void + { + $runtime = new McpSdkRuntime( + $this->createConfigurationMock(), + new FaqSearchTool( + $this->createConfigurationMock(), + $this->createMock(Search::class), + $this->createMock(Faq::class), + ), + [], + ); + + $this->assertInstanceOf(McpServerRuntimeInterface::class, $runtime); + } + + public function testGetServerInfoReturnsFullPayload(): void + { + $serverInfo = [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + 'description' => 'Model Context Protocol server for phpMyFAQ installations', + 'capabilities' => ['tools' => true], + 'tools' => [['name' => 'faq_search', 'description' => 'Search through phpMyFAQ installations']], + ]; + + $runtime = new McpSdkRuntime( + $this->createConfigurationMock(), + new FaqSearchTool( + $this->createConfigurationMock(), + $this->createMock(Search::class), + $this->createMock(Faq::class), + ), + $serverInfo, + ); + + $this->assertSame($serverInfo, $runtime->getServerInfo()); + } + + public function testFaqSearchReturnsStringOnNonJsonContent(): void + { + $configuration = $this->createConfigurationMock(); + $search = $this->createMock(Search::class); + $faq = $this->createMock(Faq::class); + + $search->method('search')->willThrowException(new \Exception('DB error')); + + $runtime = new McpSdkRuntime($configuration, new FaqSearchTool($configuration, $search, $faq), [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + ]); + + $result = $runtime->faqSearch('test'); + + $this->assertIsString($result); + $this->assertStringContainsString('Error searching FAQ database', $result); + } + + public function testFaqSearchPassesParametersToTool(): void + { + $configuration = $this->createConfigurationMock(); + $search = $this->createMock(Search::class); + $faq = $this->createMock(Faq::class); + + $search->expects($this->once()) + ->method('setCategoryId') + ->with(3); + $search->expects($this->once()) + ->method('search') + ->with('install', true) + ->willReturn([]); + + $runtime = new McpSdkRuntime($configuration, new FaqSearchTool($configuration, $search, $faq), [ + 'name' => 'phpMyFAQ MCP Server', + 'version' => '0.1.0-dev', + ]); + + $result = $runtime->faqSearch('install', category_id: 3, limit: 5, all_languages: true); + + $this->assertIsArray($result); + $this->assertSame(0, $result['total_found']); + } + private function createConfigurationMock(): Configuration { $configuration = $this->createMock(Configuration::class); diff --git a/tests/phpMyFAQ/Service/McpServer/McpToolDefinitionTest.php b/tests/phpMyFAQ/Service/McpServer/McpToolDefinitionTest.php new file mode 100644 index 0000000000..4c2b8df869 --- /dev/null +++ b/tests/phpMyFAQ/Service/McpServer/McpToolDefinitionTest.php @@ -0,0 +1,59 @@ + 'object', 'properties' => ['query' => ['type' => 'string']]]; + $outputSchema = ['type' => 'object', 'properties' => ['results' => ['type' => 'array']]]; + $annotations = ['readOnlyHint' => true]; + + $definition = new McpToolDefinition( + name: 'test_tool', + description: 'A test tool', + title: 'Test Tool', + inputSchema: $inputSchema, + outputSchema: $outputSchema, + annotations: $annotations, + ); + + $this->assertSame('test_tool', $definition->name); + $this->assertSame('A test tool', $definition->description); + $this->assertSame('Test Tool', $definition->title); + $this->assertSame($inputSchema, $definition->inputSchema); + $this->assertSame($outputSchema, $definition->outputSchema); + $this->assertSame($annotations, $definition->annotations); + } + + public function testConstructorWithDefaultOptionalParameters(): void + { + $definition = new McpToolDefinition( + name: 'minimal_tool', + description: 'Minimal tool', + title: null, + inputSchema: ['type' => 'object'], + ); + + $this->assertSame('minimal_tool', $definition->name); + $this->assertNull($definition->title); + $this->assertNull($definition->outputSchema); + $this->assertSame([], $definition->annotations); + } + + public function testIsReadonly(): void + { + $definition = new McpToolDefinition( + name: 'tool', + description: 'desc', + title: null, + inputSchema: [], + ); + + $reflection = new \ReflectionClass($definition); + $this->assertTrue($reflection->isReadOnly()); + } +} \ No newline at end of file diff --git a/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php b/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php index 1cbb837064..d9bc53f20d 100644 --- a/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php +++ b/tests/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServerTest.php @@ -87,4 +87,56 @@ public function testRunConsoleDelegatesToRuntime(): void $this->server->runConsole($input, $output); } + + public function testConstructorWithLanguageDetectionDisabled(): void + { + $configMock = $this->createMock(Configuration::class); + $languageMock = $this->createMock(Language::class); + $searchMock = $this->createMock(Search::class); + $faqMock = $this->createMock(Faq::class); + $runtimeMock = $this->createMock(McpServerRuntimeInterface::class); + + $configMock->method('getLogger')->willReturn($this->createMock(Logger::class)); + $configMock->method('setLanguage'); + $configMock->method('get')->willReturnMap([ + ['main.languageDetection', false], + ['main.language', 'de'], + ]); + + $languageMock->expects($this->once()) + ->method('setLanguageFromConfiguration') + ->with('de'); + $languageMock->expects($this->never()) + ->method('setLanguageWithDetection'); + + $server = new PhpMyFaqMcpServer($configMock, $languageMock, $searchMock, $faqMock, $runtimeMock); + + $this->assertInstanceOf(McpServerRuntimeInterface::class, $server); + } + + public function testConstructorWithLanguageDetectionEnabled(): void + { + $configMock = $this->createMock(Configuration::class); + $languageMock = $this->createMock(Language::class); + $searchMock = $this->createMock(Search::class); + $faqMock = $this->createMock(Faq::class); + $runtimeMock = $this->createMock(McpServerRuntimeInterface::class); + + $configMock->method('getLogger')->willReturn($this->createMock(Logger::class)); + $configMock->method('setLanguage'); + $configMock->method('get')->willReturnMap([ + ['main.languageDetection', true], + ['main.language', 'en'], + ]); + + $languageMock->expects($this->once()) + ->method('setLanguageWithDetection') + ->with('en'); + $languageMock->expects($this->never()) + ->method('setLanguageFromConfiguration'); + + $server = new PhpMyFaqMcpServer($configMock, $languageMock, $searchMock, $faqMock, $runtimeMock); + + $this->assertInstanceOf(McpServerRuntimeInterface::class, $server); + } } From c1017842dc5d170912361b55df72dea21f775197 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Wed, 18 Mar 2026 19:21:18 +0100 Subject: [PATCH 3/7] fix: corrected review notes --- package.json | 2 +- .../SystemInformationController.php | 1 + .../Service/McpServer/FaqSearchTool.php | 2 -- .../Service/McpServer/PhpMyFaqMcpServer.php | 5 ++- phpmyfaq/src/phpMyFAQ/System.php | 13 +++++++ phpmyfaq/src/services.php | 2 +- .../Service/McpServer/FaqSearchToolTest.php | 10 ++---- .../Service/McpServer/McpSdkRuntimeTest.php | 9 ++--- .../McpServer/McpToolDefinitionTest.php | 9 ++--- .../McpServer/PhpMyFaqMcpServerTest.php | 36 +++++++++---------- 10 files changed, 42 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 01e3dd8b5d..0dabb5c210 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build": "vite build", "build:watch": "vite build --watch", "build:prod": "vite build", - "eslint": "eslint phpmyfaq/assets/src phpmyfaq/admin/assets/src", + "eslint": "eslint .", "lint": "prettier --check .", "lint:fix": "prettier --write .", "prepare": "husky", diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php index 857b4f7e95..3f08cd0323 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php @@ -107,6 +107,7 @@ public function index(Request $request): Response 'phpMyFAQ Version' => $faqSystem->getVersion(), 'phpMyFAQ API Version' => $faqSystem->getApiVersion(), 'phpMyFAQ Plugin API Version' => $faqSystem->getPluginVersion(), + 'phpMyFAQ MCP Server Version' => $faqSystem->getMcpServerVersion(), 'phpMyFAQ Installation Path' => dirname((string) $request->server->get('SCRIPT_FILENAME'), levels: 2), 'Web server software' => $request->server->get('SERVER_SOFTWARE'), 'Web server document root' => $request->server->get('DOCUMENT_ROOT'), diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php index c5e953d2ee..94fe142bea 100644 --- a/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/FaqSearchTool.php @@ -115,8 +115,6 @@ public function execute(array $arguments): array $validResults = []; foreach ($searchResults as $searchResult) { - $this->configuration->getLogger()->info(var_export($searchResult, return: true)); - $validResults[] = [ 'id' => $searchResult->id, 'language' => $searchResult->lang, diff --git a/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php b/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php index 3126dfa84c..b874e76b88 100644 --- a/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php +++ b/phpmyfaq/src/phpMyFAQ/Service/McpServer/PhpMyFaqMcpServer.php @@ -23,6 +23,7 @@ use phpMyFAQ\Faq; use phpMyFAQ\Language; use phpMyFAQ\Search; +use phpMyFAQ\System; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -39,8 +40,6 @@ class PhpMyFaqMcpServer implements McpServerRuntimeInterface private const string MCP_SERVER_NAME = 'phpMyFAQ MCP Server'; - private const string MCP_SERVER_VERSION = '0.1.0-dev'; - public function __construct( private readonly Configuration $configuration, Language $language, @@ -95,7 +94,7 @@ private function createServerInfo(): array { return [ 'name' => self::MCP_SERVER_NAME, - 'version' => self::MCP_SERVER_VERSION, + 'version' => System::getMcpServerVersion(), 'description' => 'Model Context Protocol server for phpMyFAQ installations', 'capabilities' => [ 'tools' => true, diff --git a/phpmyfaq/src/phpMyFAQ/System.php b/phpmyfaq/src/phpMyFAQ/System.php index 90f27940b8..db52f6017b 100644 --- a/phpmyfaq/src/phpMyFAQ/System.php +++ b/phpmyfaq/src/phpMyFAQ/System.php @@ -66,6 +66,11 @@ class System */ private const string PLUGIN_VERSION = '0.2.0'; + /** + * MCP Server version + */ + private const string MCP_SERVER_VERSION = '0.1.0'; + /** * Minimum required PHP version. */ @@ -189,6 +194,14 @@ public static function getPluginVersion(): string return self::PLUGIN_VERSION; } + /** + * Returns the current MCP Server version of phpMyFAQ + */ + public static function getMcpServerVersion(): string + { + return self::MCP_SERVER_VERSION; + } + public static function getPoweredByString(): string { return 'powered with ❤️ and ☕️ by answer = "Answer $i"; + $obj->question = 'Question ' . $i; + $obj->answer = 'Answer ' . $i; $obj->category_id = 1; $obj->score = 1.0 - ($i * 0.1); $results[] = $obj; @@ -240,8 +240,8 @@ public function testExecuteDefaultLimitIsTen(): void $obj = new stdClass(); $obj->id = $i; $obj->lang = 'en'; - $obj->question = "Q$i"; - $obj->answer = "A$i"; + $obj->question = 'Q' . $i; + $obj->answer = 'A' . $i; $obj->category_id = 1; $obj->score = 0.5; $results[] = $obj; From 5f5454bb188b12186f3efa962ca17d6485254223 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Wed, 18 Mar 2026 19:39:13 +0100 Subject: [PATCH 5/7] docs: updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf243e032..690027b2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - migrated codebase using PHP 8.4 language features (Thorsten) - migrated routes using PHP 8+ #[Route] attributes (Thorsten) - migrated to Vite v8 (Thorsten) +- migrated experimental MCP Server to mcp/sdk (Thorsten) ### phpMyFAQ v4.1.1 - unreleased From 89b5645718c1c811f9850086a5243007201802d7 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Wed, 18 Mar 2026 19:46:07 +0100 Subject: [PATCH 6/7] fix: corrected risky tests --- tests/phpMyFAQ/KernelTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpMyFAQ/KernelTest.php b/tests/phpMyFAQ/KernelTest.php index dde103a9c5..31e0573f2e 100644 --- a/tests/phpMyFAQ/KernelTest.php +++ b/tests/phpMyFAQ/KernelTest.php @@ -16,6 +16,7 @@ use phpMyFAQ\Routing\AttributeRouteLoader; use phpMyFAQ\Routing\RouteCacheManager; use phpMyFAQ\Routing\RouteCollectionBuilder; +use phpMyFAQ\System; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; @@ -47,6 +48,7 @@ #[UsesClass(RouteCollectionBuilder::class)] #[UsesClass(PdoSqlite::class)] #[UsesClass(RateLimiter::class)] +#[UsesClass(System::class)] #[AllowMockObjectsWithoutExpectations] class KernelTest extends TestCase { From f67ad6456041a92f5924ec797c7cbf35480296ed Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Thu, 19 Mar 2026 17:59:16 +0100 Subject: [PATCH 7/7] fix: corrected review notes --- phpmyfaq/src/phpMyFAQ/Search.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpmyfaq/src/phpMyFAQ/Search.php b/phpmyfaq/src/phpMyFAQ/Search.php index 4566dbb294..a50846538d 100755 --- a/phpmyfaq/src/phpMyFAQ/Search.php +++ b/phpmyfaq/src/phpMyFAQ/Search.php @@ -54,7 +54,7 @@ public function __construct( /** * Setter for category. * - * @param int $categoryId Entity ID + * @param int|null $categoryId Entity ID */ public function setCategoryId(?int $categoryId): void {