diff --git a/composer.json b/composer.json index dcbabc1e73c..0e3a1b34356 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "process-timeout": 0, "allow-plugins": { "composer/package-versions-deprecated": true, + "php-http/discovery": false, "wikimedia/composer-merge-plugin": true } }, @@ -94,6 +95,7 @@ "lm-commons/lmc-rbac": "^2.2.1", "lm-commons/lmc-rbac-mvc": "4.1.1", "matthiasmullie/minify": "1.3.75", + "mcp/sdk": "^0.2.2", "monolog/monolog": "^3.9", "mpdf/mpdf": "v8.2.6", "paytrail/paytrail-php-sdk": "2.7.5", diff --git a/composer.lock b/composer.lock index 145c7e47990..ef7ab1c8c61 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": "14cc659b2556cd10a0f10d12107a2938", + "content-hash": "d0f353a137f2cbbd12219f81e1c49b3e", "packages": [ { "name": "ahand/mobileesp", @@ -122,16 +122,16 @@ }, { "name": "brick/math", - "version": "0.14.1", + "version": "0.14.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "55c950aa71a2cabc1d8f2bec1f8a7020bd244aa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/55c950aa71a2cabc1d8f2bec1f8a7020bd244aa2", + "reference": "55c950aa71a2cabc1d8f2bec1f8a7020bd244aa2", "shasum": "" }, "require": { @@ -170,7 +170,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.14.2" }, "funding": [ { @@ -178,7 +178,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-01-30T14:03:11+00:00" }, { "name": "brick/varexporter", @@ -1522,16 +1522,16 @@ }, { "name": "doctrine/event-manager", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "c07799fcf5ad362050960a0fd068dded40b1e312" + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312", - "reference": "c07799fcf5ad362050960a0fd068dded40b1e312", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", "shasum": "" }, "require": { @@ -1593,7 +1593,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.1.0" + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" }, "funding": [ { @@ -1609,7 +1609,7 @@ "type": "tidelift" } ], - "time": "2026-01-17T22:40:21+00:00" + "time": "2026-01-29T07:11:08+00:00" }, { "name": "doctrine/inflector", @@ -3337,22 +3337,22 @@ }, { "name": "laminas/laminas-config", - "version": "3.10.1", + "version": "3.11.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-config.git", - "reference": "0f50adbf2b2e01e0fe99c13e37d3a6c1ef645185" + "reference": "b816433bd36ddc4ba93e190eaac18497b0b6948c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-config/zipball/0f50adbf2b2e01e0fe99c13e37d3a6c1ef645185", - "reference": "0f50adbf2b2e01e0fe99c13e37d3a6c1ef645185", + "url": "https://api.github.com/repos/laminas/laminas-config/zipball/b816433bd36ddc4ba93e190eaac18497b0b6948c", + "reference": "b816433bd36ddc4ba93e190eaac18497b0b6948c", "shasum": "" }, "require": { "ext-json": "*", "laminas/laminas-stdlib": "^3.6", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "^8.1.0", "psr/container": "^1.0" }, "conflict": { @@ -3402,7 +3402,7 @@ } ], "abandoned": true, - "time": "2024-12-05T14:32:05+00:00" + "time": "2026-01-29T12:14:07+00:00" }, { "name": "laminas/laminas-diactoros", @@ -6968,6 +6968,78 @@ ], "time": "2022-11-10T09:28:49+00:00" }, + { + "name": "mcp/sdk", + "version": "v0.2.2", + "source": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/php-sdk.git", + "reference": "2f29b1dbe5e540162cde4d09e4fea48e83d4c01f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/2f29b1dbe5e540162cde4d09e4fea48e83d4c01f", + "reference": "2f29b1dbe5e540162cde4d09e4fea48e83d4c01f", + "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/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", + "php-cs-fixer/shim": "^3.91", + "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": [ + "MIT" + ], + "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.2.2" + }, + "time": "2025-12-28T06:32:37+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -8430,6 +8502,307 @@ ], "time": "2025-07-25T18:55:19+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+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": "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": "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": "ppito/laminas-whoops", "version": "2.3.0", @@ -10039,6 +10412,74 @@ ], "time": "2025-11-26T14:43:45+00:00" }, + { + "name": "symfony/finder", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^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/v7.4.5" + }, + "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-26T15:07:59+00:00" + }, { "name": "symfony/mailer", "version": "v7.3.5", @@ -10934,6 +11375,89 @@ ], "time": "2025-06-24T13:30:11+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/rate-limiter", "version": "v7.4.5", @@ -11184,6 +11708,84 @@ ], "time": "2025-11-21T18:03:05+00:00" }, + { + "name": "symfony/uid", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.4" + }, + "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-03T23:30:35+00:00" + }, { "name": "symfony/var-dumper", "version": "v7.3.5", @@ -15661,74 +16263,6 @@ ], "time": "2026-01-27T16:16:02+00:00" }, - { - "name": "symfony/finder", - "version": "v7.4.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^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/v7.4.5" - }, - "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-26T15:07:59+00:00" - }, { "name": "symfony/polyfill-php81", "version": "v1.33.0", @@ -16086,5 +16620,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/config/application.config.php b/config/application.config.php index c82a50c537e..a8c680fb817 100644 --- a/config/application.config.php +++ b/config/application.config.php @@ -11,6 +11,7 @@ 'Laminas\Cache\Storage\Adapter\Filesystem', 'Laminas\Cache\Storage\Adapter\Memcached', 'Laminas\Cache\Storage\Adapter\Memory', + 'Laminas\Diactoros', 'Laminas\Form', 'Laminas\Router', 'Lmc\Rbac', diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml new file mode 100644 index 00000000000..026f326447c --- /dev/null +++ b/config/vufind/ModelContextProtocol.yaml @@ -0,0 +1,97 @@ +# Model Context Protocol (MCP) server configuration. +# +# See https://vufind.org/wiki/configuration:model_context_protocol + +General: + # Enable the MCP Server and register all capabilities defined below. Disabled by default. + # See also "Model Context Protocol permissions" in permissions.ini. + #enabled: false + # name: Default is "VuFind® Server" + #name: MyLibrary VuFind® Server + # versionSuffix: Appended to the VuFind release string. If used, start with punctuation. + #versionSuffix: -a + # description: Default is config.ini -> Site -> title. + #description: The library catalog, offering search capabilities + +# Capabilities may be auto-discovered in the defined directories, if they are properly +# defined with the appropriate PHP attributes #[McpTool], #[McpResource], etc. +# Alternately, capabilities can be manually configured below. +# Auto-discovery may be useful for custom-defined capabilities, and is less verbose. +# But manual configuration here makes it easier to reuse shared config (i.e. ContentTypes) +# and to internationalize. So auto-discovery is not recommended for anything that will be +# shared back upstream. +# AutoDiscovery: +# # basePath: Leave blank for the VuFindApi/Mcp base path +# basePath: +# # scanDirs: Default is ['.', 'src'] +# scanDirs: +# # There is an example capability in this folder. +# # - Capabilities/AutoDiscovery +# # excludeDirs: default is [] +# excludeDirs: + +# Resource Templates to register with the MCP server. Define / uncomment to enable. +# - class and function identify the implementation +# - uriTemplate must match the implementing function's annotation +ResourceTemplates: + + # getRecord: + # class: VuFindApi\Mcp\Capabilities\SearchSolr + # function: getRecord + # uriTemplate: 'catalog://record/{recordId}' + +# Tools to register with the MCP server. +# - description will be sent back to the LLM, and may be relevant to discovery +# - class and function identify the implementation +# - inputSchema is a JSON Schema definition of the input requested from the LLM. +# Note that multiple tools may be defined using the same implementing function +# but different input schema, via default parameter values. +Tools: + + # searchRecordsAnyType: + # description: Search library catalog records of all content types by keywords. + # class: VuFindApi\Mcp\Capabilities\SearchSolr + # function: searchRecords + # inputSchema: + # type: object + # properties: + # keywords: + # type: string + # description: Keywords to search for + # required: + # - keywords + + # searchRecordsByContentType: + # description: Search library catalog records for a specific content type by keywords. + # class: VuFindApi\Mcp\Capabilities\SearchSolr + # function: searchRecords + # inputSchema: + # type: object + # properties: + # keywords: + # type: string + # description: Keywords to search for + # contentType: + # type: string + # description: A type of library content + # # At least one enum value is required and must be defined below in ContentTypes. + # enum: + # - books + # - videos + # required: + # - keywords + # - contentType + +# Content Type filters supporting capabilities above that use them +ContentTypes: + # books: + # filter: (format:Book OR format:eBook OR format:"Streaming Audio" OR format:Serial) + # videos: + # filter: (format:DVD OR format:VHS OR format:"Blu-ray Disc" OR format:Filmstrip OR format:"Streaming Video") + +# Response fields for capabilities that return records. For valid field names and format functions, +# see SearchApiRecordFields.yaml. Default is below. +# ResponseFields: +# - recordPageAbsoluteLink +# - title +# - authors diff --git a/config/vufind/permissions.ini b/config/vufind/permissions.ini index d56875442fa..f50528c2632 100644 --- a/config/vufind/permissions.ini +++ b/config/vufind/permissions.ini @@ -206,6 +206,13 @@ role = loggedin ;ipRange[] = '127.0.0.1' ;ipRange[] = '::1' +; Model Context Protocol permissions +;[mcp] +;permission[] = access.mcp +;require = ANY +;ipRange[] = '127.0.0.1' +;ipRange[] = '::1' + ; Example permission for Alma webbooks ;[alma.Webhooks] ;permission[] = "access.alma.webhook.user" diff --git a/module/VuFindApi/config/module.config.php b/module/VuFindApi/config/module.config.php index 091231d1a9b..4ea356209d5 100644 --- a/module/VuFindApi/config/module.config.php +++ b/module/VuFindApi/config/module.config.php @@ -7,6 +7,7 @@ 'factories' => [ 'VuFindApi\Controller\AdminApiController' => 'VuFindApi\Controller\AdminApiControllerFactory', 'VuFindApi\Controller\ApiController' => 'VuFindApi\Controller\ApiControllerFactory', + 'VuFindApi\Controller\McpController' => 'VuFindApi\Controller\McpControllerFactory', 'VuFindApi\Controller\SearchApiController' => 'VuFindApi\Controller\SearchApiControllerFactory', 'VuFindApi\Controller\Search2ApiController' => 'VuFindApi\Controller\Search2ApiControllerFactory', 'VuFindApi\Controller\WebApiController' => 'VuFindApi\Controller\WebApiControllerFactory', @@ -14,6 +15,7 @@ 'aliases' => [ 'AdminApi' => 'VuFindApi\Controller\AdminApiController', 'Api' => 'VuFindApi\Controller\ApiController', + 'Mcp' => 'VuFindApi\Controller\McpController', 'SearchApi' => 'VuFindApi\Controller\SearchApiController', 'Search2Api' => 'VuFindApi\Controller\Search2ApiController', 'WebApi' => 'VuFindApi\Controller\WebApiController', @@ -25,11 +27,13 @@ 'VuFindApi\Formatter\RecordFormatter' => 'VuFindApi\Formatter\RecordFormatterFactory', 'VuFindApi\Formatter\Search2RecordFormatter' => 'VuFindApi\Formatter\Search2RecordFormatterFactory', 'VuFindApi\Formatter\WebRecordFormatter' => 'VuFindApi\Formatter\WebRecordFormatterFactory', + 'VuFindApi\Mcp\ServerProvider' => 'VuFindApi\Mcp\ServerProviderFactory', ], ], 'vufind_api' => [ 'register_controllers' => [ \VuFindApi\Controller\AdminApiController::class, + // \VuFindApi\Controller\McpController::class, \VuFindApi\Controller\SearchApiController::class, \VuFindApi\Controller\Search2ApiController::class, \VuFindApi\Controller\WebApiController::class, @@ -81,6 +85,17 @@ ], ], ], + 'mcpApiv1' => [ + 'type' => 'Laminas\Router\Http\Literal', + 'verb' => 'get,post,options', + 'options' => [ + 'route' => '/api/v1/mcp', + 'defaults' => [ + 'controller' => 'Mcp', + 'action' => 'mcp', + ], + ], + ], 'search2Apiv1' => [ 'type' => 'Laminas\Router\Http\Literal', 'verb' => 'get,post,options', diff --git a/module/VuFindApi/src/VuFindApi/Controller/McpController.php b/module/VuFindApi/src/VuFindApi/Controller/McpController.php new file mode 100644 index 00000000000..480c7bb2389 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Controller/McpController.php @@ -0,0 +1,155 @@ +. + * + * @category VuFind + * @package Controller + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Controller; + +use Laminas\Diactoros\ServerRequestFactory; +use Laminas\Http\Header\ContentType; +use Laminas\Http\Response; +use Laminas\Psr7Bridge\Psr7Response; +use Laminas\ServiceManager\ServiceLocatorInterface; +use Mcp\Exception\ServiceNotFoundException; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Psr\Http\Message\ResponseInterface; +use VuFind\Controller\AbstractBase; + +/** + * Controller for Model Context Protocol (MCP) + * + * @category VuFind + * @package Controller + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class McpController extends AbstractBase +{ + /** + * Permission required for all MCP endpoints + * + * @var string + */ + protected $baseAccessPermission = 'access.mcp'; + + /** + * JSON schema response code to represent an authorization error. This + * is not defined by any standard, except that the 32xxx range is app-specific. + * + * @var int + */ + protected int $AUTH_ERROR = -32003; + + /** + * Constructor + * + * @param ServiceLocatorInterface $sm Service manager + * @param ?Server $server MCP Server + */ + public function __construct(ServiceLocatorInterface $sm, protected ?Server $server) + { + parent::__construct($sm); + } + + /** + * MCP action + * + * @return \Laminas\Http\Response + */ + public function mcpAction() + { + if (!$this->server) { + throw new ServiceNotFoundException('This MCP server is not enabled.'); + } + + // $content = json_decode($this->params()->getController()->getRequest()->getContent(), true); + // $mcpMethod = $content['method'] ?? ''; + $mcpMethod = null; + if ($this->isAccessDenied($mcpMethod)) { + // $messageId = $content['messageId'] ?? ''; + $messageId = ''; + return $this->outputAuthError($messageId); + } + + // Adapting: https://github.com/modelcontextprotocol/php-sdk/blob/main/docs/transports.md + // and https://github.com/vufind-org/vufind/pull/4672/files#diff-89cf777c1454a4e7f97e51f800ca68001e874a555cb19ec27135779b76ccd8f4 + + // Convert to PSR-7 request + $psrRequest = ServerRequestFactory::fromGlobals(); + foreach ($this->params()->fromRoute() as $routeParam => $value) { + $psrRequest = $psrRequest->withAttribute($routeParam, $value); + } + + // Process with MCP + $transport = new StreamableHttpTransport($psrRequest); + $psrResponse = $this->server->run($transport); + + // Convert back to Laminas response + if ($psrResponse instanceof ResponseInterface) { + return Psr7Response::toLaminas($psrResponse); + } + throw new \Exception('Unexpected state reached.'); + } + + /** + * Check whether access is denied based on the MCP method. + * + * @param string $mcpMethod MCP method + * + * @return bool + */ + protected function isAccessDenied($mcpMethod): bool + { + $auth = $this->getService(\Lmc\Rbac\Mvc\Service\AuthorizationService::class); + return $mcpMethod + ? !$auth->isGranted($this->baseAccessPermission . '.' . $mcpMethod) + : !$auth->isGranted($this->baseAccessPermission); + } + + /** + * Output an authorization error. + * + * @param string $messageId The MCP message Id. + * + * @return Response + */ + protected function outputAuthError(string $messageId): Response + { + $error = new Error($messageId, $this->AUTH_ERROR, 'Access denied'); + $response = $this->getResponse(); + $response->setStatusCode(403); + $contentTypeHeader = new ContentType(); + $contentTypeHeader->setMediaType('application/json'); + $headers = $response->getHeaders(); + $headers->addHeader($contentTypeHeader); + $response->setContent(json_encode($error->jsonSerialize())); + return $response; + } +} diff --git a/module/VuFindApi/src/VuFindApi/Controller/McpControllerFactory.php b/module/VuFindApi/src/VuFindApi/Controller/McpControllerFactory.php new file mode 100644 index 00000000000..f532e305f93 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Controller/McpControllerFactory.php @@ -0,0 +1,78 @@ +. + * + * @category VuFind + * @package Controller + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Controller; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; + +/** + * Controller Factory for Model Context Protocol (MCP) + * + * @category VuFind + * @package Controller + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class McpControllerFactory implements FactoryInterface +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + if (!empty($options)) { + throw new \Exception('Unexpected options passed to factory.'); + } + + $serverProvider = $container->get(\VuFindApi\Mcp\ServerProvider::class); + return new $requestedName( + $container, + $serverProvider->getServer(), + ); + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractCapabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractCapabilities.php new file mode 100644 index 00000000000..531662b7551 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractCapabilities.php @@ -0,0 +1,74 @@ +. + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Mcp\Capabilities; + +use VuFind\Config\YamlReader; +use VuFind\Record\Loader; +use VuFind\Search\SearchRunner; +use VuFindApi\Formatter\RecordFormatter; + +/** + * Abstract capability provider for Model Context Protocol (MCP) + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +abstract class AbstractCapabilities +{ + /** + * Config filename + */ + protected string $configName = 'ModelContextProtocol'; + + /** + * Config for MCP + */ + protected array $config; + + /** + * Constructor + * + * @param YamlReader $yamlReader YAML reader + * @param Loader $recordLoader Record loader + * @param RecordFormatter $recordFormatter Record formatter + * @param SearchRunner $searchRunner Search runner + */ + public function __construct( + protected YamlReader $yamlReader, + protected Loader $recordLoader, + protected RecordFormatter $recordFormatter, + protected SearchRunner $searchRunner + ) { + $this->config = $this->yamlReader->get($this->configName . '.yaml'); + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php new file mode 100644 index 00000000000..ee1c9b40dd7 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -0,0 +1,185 @@ +. + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Mcp\Capabilities; + +use Exception; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ResourceReadException; +use VuFind\Config\YamlReader; +use VuFind\Http\RouteHelper; +use VuFind\Http\ServerUrlHelper; +use VuFind\Record\Loader; +use VuFind\Search\SearchRunner; +use VuFindApi\Formatter\RecordFormatter; + +/** + * Abstract search capability provider for Model Context Protocol (MCP) + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +abstract class AbstractSearch extends AbstractCapabilities +{ + /** + * Record fields to return + */ + protected array $responseFields = ['recordPageAbsoluteLink', 'title', 'authors']; + + /** + * Limit for searches + */ + protected int $limit = 20; + + /** + * Constructor + * + * @param YamlReader $yamlReader YAML reader + * @param Loader $recordLoader Record loader + * @param RecordFormatter $recordFormatter Record formatter + * @param SearchRunner $searchRunner Search runner + * @param RouteHelper $routeHelper Route helper + * @param ServerUrlHelper $serverUrlHelper Server URL helper + */ + public function __construct( + protected YamlReader $yamlReader, + protected Loader $recordLoader, + protected RecordFormatter $recordFormatter, + protected SearchRunner $searchRunner, + protected RouteHelper $routeHelper, + protected ServerUrlHelper $serverUrlHelper + ) { + parent::__construct($yamlReader, $recordLoader, $recordFormatter, $searchRunner); + $this->responseFields = $this->config['ResponseFields'] ?? $this->responseFields; + } + + /** + * Get the search class ID. + * + * @return string + */ + abstract protected function getSearchClassId(): string; + + /** + * Get the route name to perform a search. + * + * @return string + */ + abstract protected function getSearchActionRoute(): string; + + /** + * Return the request parameter name. + * + * @return string + */ + protected function getRequestParam(): string + { + return 'lookfor'; + } + + /** + * Search records by keywords and content type. Input schema and response fields are defined + * in config file. + * + * @param string $keywords Keywords to search for + * @param ?string $contentType A content type from the resources defined in the schema. + * + * @return array The records found for this search, and a URL to the search results page + */ + public function searchRecords(string $keywords, ?string $contentType = null): array + { + $rawRequest = [$this->getRequestParam() => $keywords]; + if ($contentType) { + if ($filter = $this->config['ContentTypes'][$contentType]['filter'] ?? null) { + $rawRequest['filter'] = $filter; + } else { + throw new ResourceNotFoundException('Unknown content type: ' . $contentType); + } + } + + $results = $this->searchRunner->run( + $rawRequest, + $this->getSearchClassId(), + function ( + $runner, + $params, + $searchId, + $results + ): void { + $results->overrideStartRecord(1); + $params->setLimit($this->limit); + } + ); + if ($results instanceof \VuFind\Search\EmptySet\Results) { + throw new ResourceNotFoundException('No records found'); + } + + $records = $this->recordFormatter->format( + $results->getResults(), + $this->responseFields + ); + $resultsPage = $this->serverUrlHelper->getBaseUrl() . + $this->routeHelper->getUrlFromRoute( + $this->getSearchActionRoute(), + [], + [$this->getRequestParam() => urlencode($keywords)] + ); + return [ + 'search_results' => $records, + 'search_results_page' => $resultsPage, + ]; + } + + /** + * Retrieve a record by record ID. Uri and other parameters are defined in config. + * + * @param string $recordId The record ID + * + * @return array The record + */ + public function getRecord(string $recordId): array + { + if (!$recordId) { + throw new InvalidArgumentException('Record ID required.'); + } + + try { + $record = $this->recordLoader->load($recordId, $this->getSearchClassId()); + } catch (Exception $e) { + throw new ResourceReadException(message: "Record not found for ID: {$recordId}", previous: $e); + } + + $formattedRecord = $this->recordFormatter->format([$record], $this->responseFields)[0]; + return $formattedRecord; + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AutoDiscovery/ExampleCapabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AutoDiscovery/ExampleCapabilities.php new file mode 100644 index 00000000000..7f5695ec193 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AutoDiscovery/ExampleCapabilities.php @@ -0,0 +1,58 @@ +. + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Mcp\Capabilities\AutoDiscovery; + +use Mcp\Capability\Attribute\McpTool; + +/** + * Example capabilities provider for Model Context Protocol (MCP) + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class ExampleCapabilities +{ + /** + * Add two numbers. It's AI-powered magic! + * + * @param int $a One of those super interesting numbers + * @param int $b A second really fantastic number + * + * @return int An even more amazing number that magically combines the first two!!! + */ + #[McpTool] + public function add(int $a, int $b): int + { + return $a + $b; + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php new file mode 100644 index 00000000000..3f245349d4b --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php @@ -0,0 +1,62 @@ +. + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Mcp\Capabilities; + +/** + * Capabilities (stub) for Model Context Protocol (MCP) + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class SearchSolr extends AbstractSearch +{ + /** + * Get the search class ID. + * + * @return string + */ + protected function getSearchClassId(): string + { + return 'Solr'; + } + + /** + * Get the route name to perform a search. + * + * @return string + */ + protected function getSearchActionRoute(): string + { + return 'search-results'; + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php new file mode 100644 index 00000000000..ef58d461f7c --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -0,0 +1,179 @@ +. + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Mcp; + +use Mcp\Capability\Registry\Container; +use Mcp\Server; +use Mcp\Server\Builder; +use Mcp\Server\Session\FileSessionStore; +use VuFind\Config\Config; + +/** + * ServerProvider for Model Context Protocol (MCP) + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class ServerProvider +{ + /** + * MCP Server + */ + protected Server $server; + + /** + * Constructor + * + * @param array $mcpConfig MCP configuration + * @param Config $topConfig config.ini + * @param array $services Services to register with the MCP container + */ + public function __construct( + protected array $mcpConfig, + protected Config $topConfig, + array $services + ) { + if (!($this->mcpConfig['General']['enabled'] ?? false)) { + return; + } + + $container = new Container(); + foreach ($services as $service) { + // Provide these services to each capability class constructor + $container->set($service::class, $service); + } + + $builder = Server::builder() + ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) + ->setContainer($container); + $this->setServerInfo($builder); + $this->addResourceTemplates($builder); + $this->addTools($builder); + $this->addAutoDiscovery($builder); + $this->server = $builder->build(); + } + + /** + * Add server info metadata to the Server Builder. + * + * @param Builder $builder The server builder + * + * @return void + */ + protected function setServerInfo(Builder $builder): void + { + $name = $this->mcpConfig['General']['name'] ?? 'VuFind® Server'; + + $baseVersion = $this->topConfig['Site']['generator']; + $baseVersion = str_replace('VuFind ', '', $baseVersion); + $version = $baseVersion . ($this->mcpConfig['General']['versionSuffix'] ?? ''); + + $description = $this->mcpConfig['General']['description'] ?? $this->topConfig['Site']['title']; + + $builder->setServerInfo(name: $name, version: $version, description: $description); + } + + /** + * Add resource templates from config to the Server Builder. + * + * @param Builder $builder The server builder + * + * @return void + */ + protected function addResourceTemplates(Builder $builder): void + { + foreach (($this->mcpConfig['ResourceTemplates'] ?? []) as $resourceTemplate) { + $className = $resourceTemplate['class']; + $functionName = $resourceTemplate['function']; + $uriTemplate = $resourceTemplate['uriTemplate']; + $builder->addResourceTemplate( + [$className, $functionName], + uriTemplate: $uriTemplate, + ); + } + } + + /** + * Add tools from config to the Server Builder. + * + * @param Builder $builder The server builder + * + * @return void + */ + protected function addTools(Builder $builder): void + { + foreach (($this->mcpConfig['Tools'] ?? []) as $name => $tool) { + $description = $tool['description']; + $className = $tool['class']; + $functionName = $tool['function']; + $inputSchema = $tool['inputSchema']; + $builder->addTool( + [$className, $functionName], + name: $name, + description: $description, + inputSchema: $inputSchema + ); + } + } + + /** + * Set the server builder to auto-discover capabilities in configured folders. + * + * @param Builder $builder The server builder + * + * @return void + */ + protected function addAutoDiscovery(Builder $builder): void + { + if ($discovery = ($this->mcpConfig['AutoDiscovery'] ?? [])) { + $params = [$discovery['basePath'] ?? __DIR__]; + if ($scanDirs = $discovery['scanDirs'] ?? []) { + $params['scanDirs'] = $scanDirs; + } + if ($excludeDirs = $discovery['excludeDirs'] ?? []) { + $params['excludeDirs'] = $excludeDirs; + } + $builder->setDiscovery(...$params); + } + } + + /** + * Return the MCP Server instance, or null if the server is not enabled. + * + * @return ?Server + */ + public function getServer(): ?Server + { + return $this->server ?? null; + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php new file mode 100644 index 00000000000..df6cb531d98 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php @@ -0,0 +1,102 @@ +. + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindApi\Mcp; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; +use VuFind\Config\YamlReader; + +/** + * ServerProviderFactory for Model Context Protocol (MCP) + * + * @category VuFind + * @package Mcp + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class ServerProviderFactory implements FactoryInterface +{ + /** + * MCP Config name + */ + protected string $mcpConfigName = 'ModelContextProtocol'; + + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + if (!empty($options)) { + throw new \Exception('Unexpected options passed to factory.'); + } + + $yamlReader = $container->get(YamlReader::class); + $mcpConfig = $yamlReader->get($this->mcpConfigName . '.yaml'); + + $configManager = $container->get(\VuFind\Config\ConfigManagerInterface::class); + $topConfig = $configManager->getConfigObject('config'); + + $services = array_map( + fn ($className) => $container->get($className), + [ + \VuFind\Config\YamlReader::class, + \VuFind\Record\Loader::class, + \VuFindApi\Formatter\RecordFormatter::class, + \VuFind\Search\SearchRunner::class, + \VuFind\Http\RouteHelper::class, + \VuFind\Http\ServerUrlHelper::class, + ] + ); + + return new $requestedName( + $mcpConfig, + $topConfig, + $services, + ); + } +}