From aebb49602280eb25951fa6057365dbc83ce41df4 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 00:12:45 +0000 Subject: [PATCH 01/42] Add Model Context Protocol (MCP) server support --- composer.json | 6 +- composer.lock | 698 ++++++++++++++++-- config/application.config.php | 1 + .../LowerCaseServiceNameTrait.php | 2 +- module/VuFindApi/config/module.config.php | 16 + .../VuFindApi/Controller/McpController.php | 90 +++ .../Controller/McpControllerFactory.php | 78 ++ .../src/VuFindApi/Mcp/Capabilities.php | 58 ++ .../src/VuFindApi/Mcp/ServerProvider.php | 63 ++ .../VuFindApi/Mcp/ServerProviderFactory.php | 83 +++ 10 files changed, 1010 insertions(+), 85 deletions(-) create mode 100644 module/VuFindApi/src/VuFindApi/Controller/McpController.php create mode 100644 module/VuFindApi/src/VuFindApi/Controller/McpControllerFactory.php create mode 100644 module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php create mode 100644 module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php create mode 100644 module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php diff --git a/composer.json b/composer.json index dcbabc1e73c..edd35e74ecd 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "process-timeout": 0, "allow-plugins": { "composer/package-versions-deprecated": true, - "wikimedia/composer-merge-plugin": true + "wikimedia/composer-merge-plugin": true, + "php-http/discovery": true } }, "autoload": { @@ -121,7 +122,8 @@ "webfontkit/open-sans": "^1.0", "webmozart/glob": "^4.7", "wikimedia/composer-merge-plugin": "2.1.0", - "yajra/laravel-pdo-via-oci8": "3.7.2" + "yajra/laravel-pdo-via-oci8": "3.7.2", + "mcp/sdk": "^0.2.2" }, "require-dev": { "behat/mink": "1.12.0", diff --git a/composer.lock b/composer.lock index 145c7e47990..cbe681a726d 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": "0cf6cc66e7a4c0d3ae4e22508049a336", "packages": [ { "name": "ahand/mobileesp", @@ -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/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php b/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php index 020b687e70b..b16e69d1f7f 100644 --- a/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php +++ b/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php @@ -63,7 +63,7 @@ public function get($name, ?array $options = null) * * @return bool */ - public function has($id) + public function has($id): bool { return parent::has($this->getNormalizedServiceName($id)); } diff --git a/module/VuFindApi/config/module.config.php b/module/VuFindApi/config/module.config.php index 091231d1a9b..a4e56ae47c5 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,18 @@ ], ], ], + 'mcpApiv1' => [ + 'type' => 'Laminas\Router\Http\Literal', + // 'verb' => 'get,post,options', + 'verb' => 'post', + '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..4b23c2b5e09 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Controller/McpController.php @@ -0,0 +1,90 @@ +. + * + * @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\Psr7Bridge\Psr7Response; +use Laminas\ServiceManager\ServiceLocatorInterface; +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 +{ + // protected $accessPermission = 'access.mcp'; + + /** + * Constructor + * + * @param ServiceLocatorInterface $sm Service manager + * @param Server $server MCP Server + */ + public function __construct(ServiceLocatorInterface $sm, protected Server $server) + { + return parent::__construct($sm); + } + + /** + * MCP action + * + * @return \Laminas\Http\Response + */ + public function mcpAction() + { + // 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.'); + } +} 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.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php new file mode 100644 index 00000000000..0808ffdae0a --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.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; + +use Mcp\Capability\Attribute\McpTool; + +/** + * 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 Capabilities +{ + /** + * 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/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php new file mode 100644 index 00000000000..32b9cbe9054 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -0,0 +1,63 @@ +. + * + * @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\Server; + +/** + * 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 +{ + /** + * Constructor + * + * @param Server $server MCP server + */ + public function __construct(protected Server $server) + { + } + + /** + * Return the MCP Server instance. + * + * @return Server + */ + public function getServer() + { + return $this->server; + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php new file mode 100644 index 00000000000..8fd8c5b6e95 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php @@ -0,0 +1,83 @@ +. + * + * @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 Mcp\Server; +use Mcp\Server\Session\FileSessionStore; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; + +/** + * 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 +{ + /** + * 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.'); + } + + $server = Server::builder() + ->setServerInfo('VuFind Server', '0.0.1') + ->setDiscovery(__DIR__, ['../Mcp']) + ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) + ->build(); + return new $requestedName( + $server, + ); + } +} From 6e17e1e2b6331a806d559501b3a8c6f606dc9599 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 00:37:08 +0000 Subject: [PATCH 02/42] Fix a test --- module/VuFind/src/VuFindTest/Container/MockContainerTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php b/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php index 4b9fe9fdca6..3bfcd5512ea 100644 --- a/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php +++ b/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php @@ -140,7 +140,7 @@ public function get($rawId, ?array $options = []) * * @return bool */ - public function has($rawId) + public function has($rawId): bool { $id = $this->mockAliases[$rawId] ?? $rawId; // Assume every service exists unless explicitly disabled From 57fd82e02db8ae93f7d1443ca4ecdfb2fb45fc41 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 13:16:08 +0000 Subject: [PATCH 03/42] Remove unneeded factory --- module/VuFindApi/config/module.config.php | 2 +- .../src/VuFindApi/Mcp/ServerProvider.php | 15 +++- .../VuFindApi/Mcp/ServerProviderFactory.php | 83 ------------------- 3 files changed, 13 insertions(+), 87 deletions(-) delete mode 100644 module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php diff --git a/module/VuFindApi/config/module.config.php b/module/VuFindApi/config/module.config.php index a4e56ae47c5..bb07e627883 100644 --- a/module/VuFindApi/config/module.config.php +++ b/module/VuFindApi/config/module.config.php @@ -27,7 +27,7 @@ 'VuFindApi\Formatter\RecordFormatter' => 'VuFindApi\Formatter\RecordFormatterFactory', 'VuFindApi\Formatter\Search2RecordFormatter' => 'VuFindApi\Formatter\Search2RecordFormatterFactory', 'VuFindApi\Formatter\WebRecordFormatter' => 'VuFindApi\Formatter\WebRecordFormatterFactory', - 'VuFindApi\Mcp\ServerProvider' => 'VuFindApi\Mcp\ServerProviderFactory', + 'VuFindApi\Mcp\ServerProvider' => 'Laminas\ServiceManager\Factory\InvokableFactory', ], ], 'vufind_api' => [ diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index 32b9cbe9054..da21ec713ce 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -30,6 +30,7 @@ namespace VuFindApi\Mcp; use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; /** * ServerProvider for Model Context Protocol (MCP) @@ -42,13 +43,21 @@ */ class ServerProvider { + /** + * MCP Server + */ + private Server $server; + /** * Constructor - * - * @param Server $server MCP server */ - public function __construct(protected Server $server) + public function __construct() { + $this->server = Server::builder() + ->setServerInfo('VuFind Server', '0.0.1') + ->setDiscovery(__DIR__, ['../Mcp']) + ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) + ->build(); } /** diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php deleted file mode 100644 index 8fd8c5b6e95..00000000000 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php +++ /dev/null @@ -1,83 +0,0 @@ -. - * - * @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 Mcp\Server; -use Mcp\Server\Session\FileSessionStore; -use Psr\Container\ContainerExceptionInterface as ContainerException; -use Psr\Container\ContainerInterface; - -/** - * 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 -{ - /** - * 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.'); - } - - $server = Server::builder() - ->setServerInfo('VuFind Server', '0.0.1') - ->setDiscovery(__DIR__, ['../Mcp']) - ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) - ->build(); - return new $requestedName( - $server, - ); - } -} From 7bb40d0529068e2232170671ad8d012714065dcc Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 14:59:19 +0000 Subject: [PATCH 04/42] Add resource template for record by ID --- module/VuFindApi/config/module.config.php | 5 +- .../src/VuFindApi/Mcp/Capabilities.php | 41 ++++++++++ .../src/VuFindApi/Mcp/ServerProvider.php | 13 +++- .../VuFindApi/Mcp/ServerProviderFactory.php | 75 +++++++++++++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php diff --git a/module/VuFindApi/config/module.config.php b/module/VuFindApi/config/module.config.php index bb07e627883..4ea356209d5 100644 --- a/module/VuFindApi/config/module.config.php +++ b/module/VuFindApi/config/module.config.php @@ -27,7 +27,7 @@ 'VuFindApi\Formatter\RecordFormatter' => 'VuFindApi\Formatter\RecordFormatterFactory', 'VuFindApi\Formatter\Search2RecordFormatter' => 'VuFindApi\Formatter\Search2RecordFormatterFactory', 'VuFindApi\Formatter\WebRecordFormatter' => 'VuFindApi\Formatter\WebRecordFormatterFactory', - 'VuFindApi\Mcp\ServerProvider' => 'Laminas\ServiceManager\Factory\InvokableFactory', + 'VuFindApi\Mcp\ServerProvider' => 'VuFindApi\Mcp\ServerProviderFactory', ], ], 'vufind_api' => [ @@ -87,8 +87,7 @@ ], 'mcpApiv1' => [ 'type' => 'Laminas\Router\Http\Literal', - // 'verb' => 'get,post,options', - 'verb' => 'post', + 'verb' => 'get,post,options', 'options' => [ 'route' => '/api/v1/mcp', 'defaults' => [ diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php index 0808ffdae0a..70bf2fc669e 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php @@ -29,7 +29,13 @@ namespace VuFindApi\Mcp; +use Exception; +use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\ResourceReadException; +use VuFind\Record\Loader; +use VuFindApi\Formatter\RecordFormatter; /** * Capabilities (stub) for Model Context Protocol (MCP) @@ -42,6 +48,10 @@ */ class Capabilities { + public function __construct(protected Loader $recordLoader, protected RecordFormatter $recordFormatter) + { + } + /** * Add two numbers. It's AI-powered magic! * @@ -55,4 +65,35 @@ public function add(int $a, int $b): int { return $a + $b; } + + /** + * Retrieve a record by record ID. + * + * @param string $recordId The record ID + * + * @return array The record + */ + #[McpResourceTemplate( + uriTemplate: 'record://{recordId}', + name: 'record', + description: 'Get a catalog record by its ID.', + mimeType: 'application/json' + )] + public function getRecord(string $recordId): array + { + if (!$recordId) { + throw new InvalidArgumentException('Record ID required.'); + } + + try { + $searchClassId = 'Solr'; + $record = $this->recordLoader->load($recordId, $searchClassId); + } catch (Exception $e) { + throw new ResourceReadException(message: "Record not found for ID: {$recordId}", previous: $e); + } + + $fields = ['title', 'authors', 'publicationDates']; + $formattedRecord = $this->recordFormatter->format([$record], $fields)[0]; + return $formattedRecord; + } } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index da21ec713ce..09b5bf83b73 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -29,6 +29,8 @@ namespace VuFindApi\Mcp; +use Laminas\ServiceManager\ServiceLocatorInterface; +use Mcp\Capability\Registry\Container; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; @@ -51,12 +53,21 @@ class ServerProvider /** * Constructor */ - public function __construct() + public function __construct(protected ServiceLocatorInterface $serviceLocator) { + $container = new Container(); + + $recordLoader = $serviceLocator->get(\VuFind\Record\Loader::class); + $container->set(\VuFind\Record\Loader::class, $recordLoader); + + $recordFormatter = $serviceLocator->get(\VuFindApi\Formatter\RecordFormatter::class); + $container->set(\VuFindApi\Formatter\RecordFormatter::class, $recordFormatter); + $this->server = Server::builder() ->setServerInfo('VuFind Server', '0.0.1') ->setDiscovery(__DIR__, ['../Mcp']) ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) + ->setContainer($container) ->build(); } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php new file mode 100644 index 00000000000..cac4d68c6e9 --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php @@ -0,0 +1,75 @@ +. + * + * @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; + +/** + * 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 +{ + /** + * 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.'); + } + return new $requestedName( + $container, + ); + } +} From 525fa24fe6de483782100800095513143c862364 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 16:46:47 +0000 Subject: [PATCH 05/42] Add resource template to search by keywords --- .../src/VuFindApi/Mcp/Capabilities.php | 82 +++++++++++++++++-- .../src/VuFindApi/Mcp/ServerProvider.php | 18 ++-- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php index 70bf2fc669e..6bfa4bb0e76 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php @@ -33,8 +33,10 @@ use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use VuFind\Record\Loader; +use VuFind\Search\SearchRunner; use VuFindApi\Formatter\RecordFormatter; /** @@ -48,8 +50,33 @@ */ class Capabilities { - public function __construct(protected Loader $recordLoader, protected RecordFormatter $recordFormatter) - { + /** + * Search class Id + */ + protected string $searchClassId = 'Solr'; + + /** + * Record fields to return + */ + protected array $recordFields = ['title', 'authors', 'publicationDates']; + + /** + * Limit for searches + */ + protected $limit = 50; + + /** + * Constructor + * + * @param Loader $recordLoader Record loader + * @param RecordFormatter $recordFormatter Record formatter + * @param SearchRunner $searchRunner Search runner + */ + public function __construct( + protected Loader $recordLoader, + protected RecordFormatter $recordFormatter, + protected SearchRunner $searchRunner + ) { } /** @@ -66,6 +93,47 @@ public function add(int $a, int $b): int return $a + $b; } + /** + * Search records by keywords + * + * @param string $keywords Keywords + * + * @return array The records found + */ + #[McpResourceTemplate( + uriTemplate: 'catalog://record/{keywords}', + name: 'searchRecords', + description: 'Search catalog records by keywords.', + mimeType: 'application/json' + )] + public function searchRecords($keywords) + { + $limit = $this->limit; + $results = $this->searchRunner->run( + ['lookfor' => urldecode($keywords)], + $this->searchClassId, + function ( + $runner, + $params, + $searchId, + $results + ) use ( + $limit + ): void { + $results->overrideStartRecord(1); + $params->setLimit($limit); + } + ); + if ($results instanceof \VuFind\Search\EmptySet\Results) { + throw new ResourceNotFoundException('No records found'); + } + $records = $this->recordFormatter->format( + $results->getResults(), + $this->recordFields + ); + return $records; + } + /** * Retrieve a record by record ID. * @@ -74,8 +142,8 @@ public function add(int $a, int $b): int * @return array The record */ #[McpResourceTemplate( - uriTemplate: 'record://{recordId}', - name: 'record', + uriTemplate: 'catalog://record/{recordId}', + name: 'getRecord', description: 'Get a catalog record by its ID.', mimeType: 'application/json' )] @@ -86,14 +154,12 @@ public function getRecord(string $recordId): array } try { - $searchClassId = 'Solr'; - $record = $this->recordLoader->load($recordId, $searchClassId); + $record = $this->recordLoader->load($recordId, $this->searchClassId); } catch (Exception $e) { throw new ResourceReadException(message: "Record not found for ID: {$recordId}", previous: $e); } - $fields = ['title', 'authors', 'publicationDates']; - $formattedRecord = $this->recordFormatter->format([$record], $fields)[0]; + $formattedRecord = $this->recordFormatter->format([$record], $this->recordFields)[0]; return $formattedRecord; } } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index 09b5bf83b73..f410cd68eb8 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -52,16 +52,22 @@ class ServerProvider /** * Constructor + * + * @param ServiceLocatorInterface $serviceLocator Service locator */ public function __construct(protected ServiceLocatorInterface $serviceLocator) { $container = new Container(); - - $recordLoader = $serviceLocator->get(\VuFind\Record\Loader::class); - $container->set(\VuFind\Record\Loader::class, $recordLoader); - - $recordFormatter = $serviceLocator->get(\VuFindApi\Formatter\RecordFormatter::class); - $container->set(\VuFindApi\Formatter\RecordFormatter::class, $recordFormatter); + foreach ( + [ + \VuFind\Record\Loader::class, + \VuFindApi\Formatter\RecordFormatter::class, + \VuFind\Search\SearchRunner::class, + ] as $class + ) { + // Provide these services to each capability class constructor + $container->set($class, $serviceLocator->get($class)); + } $this->server = Server::builder() ->setServerInfo('VuFind Server', '0.0.1') From c02b19014d9305e3467c597aa07a12d13e1f26ad Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 19:26:07 +0000 Subject: [PATCH 06/42] Change the catalog search resource template to an MCP tool --- module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php | 10 ++++------ module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php index 6bfa4bb0e76..9ae301b994b 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php @@ -96,15 +96,13 @@ public function add(int $a, int $b): int /** * Search records by keywords * - * @param string $keywords Keywords + * @param string $keywords Keywords to search for * - * @return array The records found + * @return array The records found for this search */ - #[McpResourceTemplate( - uriTemplate: 'catalog://record/{keywords}', + #[McpTool( name: 'searchRecords', - description: 'Search catalog records by keywords.', - mimeType: 'application/json' + description: 'Search library catalog records (books, videos and more) by keywords.' )] public function searchRecords($keywords) { diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index f410cd68eb8..541a9959f5b 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -70,7 +70,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) } $this->server = Server::builder() - ->setServerInfo('VuFind Server', '0.0.1') + ->setServerInfo(name: 'VuFind Server', version: '0.0.1', description: 'The library catalog') ->setDiscovery(__DIR__, ['../Mcp']) ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) ->setContainer($container) From d09f2a45804a1576501ce2d5f2ae226f7dc33a6c Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 21:10:19 +0000 Subject: [PATCH 07/42] Use forked mcp/sdk (for now) that supports psr/container 1.x --- .../src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php | 2 +- module/VuFind/src/VuFindTest/Container/MockContainerTrait.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php b/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php index b16e69d1f7f..020b687e70b 100644 --- a/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php +++ b/module/VuFind/src/VuFind/ServiceManager/LowerCaseServiceNameTrait.php @@ -63,7 +63,7 @@ public function get($name, ?array $options = null) * * @return bool */ - public function has($id): bool + public function has($id) { return parent::has($this->getNormalizedServiceName($id)); } diff --git a/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php b/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php index 3bfcd5512ea..4b9fe9fdca6 100644 --- a/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php +++ b/module/VuFind/src/VuFindTest/Container/MockContainerTrait.php @@ -140,7 +140,7 @@ public function get($rawId, ?array $options = []) * * @return bool */ - public function has($rawId): bool + public function has($rawId) { $id = $this->mockAliases[$rawId] ?? $rawId; // Assume every service exists unless explicitly disabled From 7dc73c480c471013ca8d02cf879dab04afa93379 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 21:18:00 +0000 Subject: [PATCH 08/42] Fix constructor --- module/VuFindApi/src/VuFindApi/Controller/McpController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Controller/McpController.php b/module/VuFindApi/src/VuFindApi/Controller/McpController.php index 4b23c2b5e09..023eb836a55 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/McpController.php +++ b/module/VuFindApi/src/VuFindApi/Controller/McpController.php @@ -58,7 +58,7 @@ class McpController extends AbstractBase */ public function __construct(ServiceLocatorInterface $sm, protected Server $server) { - return parent::__construct($sm); + parent::__construct($sm); } /** From eaf8f5715e8384180296784c9f1e7bdfdbb88958 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 5 Dec 2025 22:16:06 +0000 Subject: [PATCH 09/42] Alphabetize composer.json and avoid the comma problem --- composer.json | 8 ++++---- composer.lock | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index edd35e74ecd..1e84b760dad 100644 --- a/composer.json +++ b/composer.json @@ -15,8 +15,8 @@ "process-timeout": 0, "allow-plugins": { "composer/package-versions-deprecated": true, - "wikimedia/composer-merge-plugin": true, - "php-http/discovery": true + "php-http/discovery": true, + "wikimedia/composer-merge-plugin": true } }, "autoload": { @@ -95,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", @@ -122,8 +123,7 @@ "webfontkit/open-sans": "^1.0", "webmozart/glob": "^4.7", "wikimedia/composer-merge-plugin": "2.1.0", - "yajra/laravel-pdo-via-oci8": "3.7.2", - "mcp/sdk": "^0.2.2" + "yajra/laravel-pdo-via-oci8": "3.7.2" }, "require-dev": { "behat/mink": "1.12.0", diff --git a/composer.lock b/composer.lock index cbe681a726d..ba89156c5cc 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": "0cf6cc66e7a4c0d3ae4e22508049a336", + "content-hash": "d0f353a137f2cbbd12219f81e1c49b3e", "packages": [ { "name": "ahand/mobileesp", From 5d7eea0932ef8d123450ca1ca24e6674a87bdf87 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 9 Dec 2025 16:57:24 +0000 Subject: [PATCH 10/42] Allow filtering on content type, and introduce config --- config/vufind/ModelContextProtocol.yaml | 54 +++++++++++++++++ config/vufind/SearchApiRecordFields.yaml | 4 ++ .../VuFindApi/Controller/McpController.php | 13 +++- .../VuFindApi/Formatter/RecordFormatter.php | 1 + .../src/VuFindApi/Mcp/Capabilities.php | 43 ++++++++++---- .../src/VuFindApi/Mcp/ServerProvider.php | 59 ++++++++++++++++--- 6 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 config/vufind/ModelContextProtocol.yaml diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml new file mode 100644 index 00000000000..69d7802026d --- /dev/null +++ b/config/vufind/ModelContextProtocol.yaml @@ -0,0 +1,54 @@ +General: + + enabled: true + +Tools: + + searchRecordsAnyType: + description: Search library catalog records of all content types by keywords. + class: VuFindApi\Mcp\Capabilities + 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 + function: searchRecords + inputSchema: + type: object + properties: + keywords: + type: string + description: Keywords to search for + contentType: + type: string + description: A type of library content + enum: + - books + - ebooks + - videos + required: + - keywords + - contentType + +ContentTypes: + books: + filter: (format:"Print Book" OR format:eBook OR format:"Streaming Audio" OR format:Serial) + ebooks: + filter: format:eBook + videos: + filter: (format:DVD OR format:VHS OR format:"Blu-ray Disc" OR format:Filmstrip OR format:"Streaming Video") + +ResponseFields: + - recordPage + - title + - authors + - publicationDates + - formats diff --git a/config/vufind/SearchApiRecordFields.yaml b/config/vufind/SearchApiRecordFields.yaml index ee95e38b17b..50732a784c4 100644 --- a/config/vufind/SearchApiRecordFields.yaml +++ b/config/vufind/SearchApiRecordFields.yaml @@ -303,6 +303,10 @@ recordPageAbsoluteLink: vufind.method: "Formatter::getRecordPageAbsoluteLink" description: Link to the record page from external sites with an absolute link type: string +recordPageFullUrl: + vufind.method: "Formatter::getRecordPageFullUrl" + description: Link to the record page from external sites with a fully qualified URL + type: string relationshipNotes: vufind.method: getRelationshipNotes description: Notes describing relationships to other items diff --git a/module/VuFindApi/src/VuFindApi/Controller/McpController.php b/module/VuFindApi/src/VuFindApi/Controller/McpController.php index 023eb836a55..3dfd2684ed4 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/McpController.php +++ b/module/VuFindApi/src/VuFindApi/Controller/McpController.php @@ -32,6 +32,7 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Psr7Bridge\Psr7Response; use Laminas\ServiceManager\ServiceLocatorInterface; +use Mcp\Exception\ServiceNotFoundException; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; use Psr\Http\Message\ResponseInterface; @@ -48,15 +49,17 @@ */ class McpController extends AbstractBase { + use ApiTrait; + // protected $accessPermission = 'access.mcp'; /** * Constructor * * @param ServiceLocatorInterface $sm Service manager - * @param Server $server MCP Server + * @param ?Server $server MCP Server */ - public function __construct(ServiceLocatorInterface $sm, protected Server $server) + public function __construct(ServiceLocatorInterface $sm, protected ?Server $server) { parent::__construct($sm); } @@ -68,6 +71,12 @@ public function __construct(ServiceLocatorInterface $sm, protected Server $serve */ public function mcpAction() { + $this->determineOutputMode(); + + if (!$this->server) { + throw new ServiceNotFoundException('This MCP server is not enabled.'); + } + // Adapting: https://github.com/modelcontextprotocol/php-sdk/blob/main/docs/transports.md // and https://github.com/vufind-org/vufind/pull/4672/files#diff-89cf777c1454a4e7f97e51f800ca68001e874a555cb19ec27135779b76ccd8f4 diff --git a/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php b/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php index 5e1baeafb17..f0184e01d9f 100644 --- a/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php +++ b/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php @@ -34,6 +34,7 @@ use VuFind\I18n\TranslatableString; use VuFindApi\Controller\ApiException; +use function in_array; use function is_object; /** diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php index 9ae301b994b..4ffe9c4d44e 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php @@ -35,6 +35,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; +use VuFind\Config\YamlReader; use VuFind\Record\Loader; use VuFind\Search\SearchRunner; use VuFindApi\Formatter\RecordFormatter; @@ -58,25 +59,40 @@ class Capabilities /** * Record fields to return */ - protected array $recordFields = ['title', 'authors', 'publicationDates']; + protected array $responseFields = ['recordPageFullUrl', 'title', 'authors']; /** * Limit for searches */ - protected $limit = 50; + protected int $limit = 50; + + /** + * 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'); + + $this->responseFields = $this->config['ResponseFields'] ?? $this->responseFields; } /** @@ -94,21 +110,23 @@ public function add(int $a, int $b): int } /** - * Search records by keywords + * 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 $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 */ - #[McpTool( - name: 'searchRecords', - description: 'Search library catalog records (books, videos and more) by keywords.' - )] - public function searchRecords($keywords) + public function searchRecords(string $keywords, ?string $contentType = null): array { $limit = $this->limit; + $rawRequest = ['lookfor' => urldecode($keywords)]; + if ($filter = $this->config['ContentTypes'][$contentType]['filter'] ?? null) { + $rawRequest['filter'] = $filter; + } $results = $this->searchRunner->run( - ['lookfor' => urldecode($keywords)], + $rawRequest, $this->searchClassId, function ( $runner, @@ -125,9 +143,10 @@ function ( if ($results instanceof \VuFind\Search\EmptySet\Results) { throw new ResourceNotFoundException('No records found'); } + $records = $this->recordFormatter->format( $results->getResults(), - $this->recordFields + $this->responseFields ); return $records; } @@ -157,7 +176,7 @@ public function getRecord(string $recordId): array throw new ResourceReadException(message: "Record not found for ID: {$recordId}", previous: $e); } - $formattedRecord = $this->recordFormatter->format([$record], $this->recordFields)[0]; + $formattedRecord = $this->recordFormatter->format([$record], $this->responseFields)[0]; return $formattedRecord; } } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index 541a9959f5b..770958bdd0e 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -32,7 +32,9 @@ use Laminas\ServiceManager\ServiceLocatorInterface; use Mcp\Capability\Registry\Container; use Mcp\Server; +use Mcp\Server\Builder; use Mcp\Server\Session\FileSessionStore; +use VuFind\Config\YamlReader; /** * ServerProvider for Model Context Protocol (MCP) @@ -50,6 +52,16 @@ class ServerProvider */ private Server $server; + /** + * Config name + */ + private string $configName = 'ModelContextProtocol'; + + /** + * Config array + */ + protected array $config; + /** * Constructor * @@ -57,9 +69,17 @@ class ServerProvider */ public function __construct(protected ServiceLocatorInterface $serviceLocator) { + $yamlReader = $serviceLocator->get(YamlReader::class); + $this->config = $yamlReader->get($this->configName . '.yaml'); + + if (!($this->config['General']['enabled'] ?? false)) { + return; + } + $container = new Container(); foreach ( [ + \VuFind\Config\YamlReader::class, \VuFind\Record\Loader::class, \VuFindApi\Formatter\RecordFormatter::class, \VuFind\Search\SearchRunner::class, @@ -69,21 +89,44 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) $container->set($class, $serviceLocator->get($class)); } - $this->server = Server::builder() + $builder = Server::builder() ->setServerInfo(name: 'VuFind Server', version: '0.0.1', description: 'The library catalog') - ->setDiscovery(__DIR__, ['../Mcp']) ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) - ->setContainer($container) - ->build(); + ->setContainer($container); + $this->addTools($builder); + $this->server = $builder->build(); + } + + /** + * Add tools from config to the Server Builder. + * + * @param Builder $builder The server builder + * + * @return void + */ + protected function addTools(Builder $builder) + { + foreach (($this->config['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 + ); + } } /** - * Return the MCP Server instance. + * Return the MCP Server instance, or null if the server is not enabled. * - * @return Server + * @return ?Server */ - public function getServer() + public function getServer(): ?Server { - return $this->server; + return $this->server ?? null; } } From 86b6515c9ea504efa17d9110695e0dbd29f3fd07 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 9 Dec 2025 17:28:05 +0000 Subject: [PATCH 11/42] Fix url formatting --- config/vufind/ModelContextProtocol.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index 69d7802026d..e5b9e903fc1 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -47,7 +47,7 @@ ContentTypes: filter: (format:DVD OR format:VHS OR format:"Blu-ray Disc" OR format:Filmstrip OR format:"Streaming Video") ResponseFields: - - recordPage + - recordPageFullUrl - title - authors - publicationDates From 3b44874baec4341269e0b775366a538763e0c4ce Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 9 Dec 2025 17:30:27 +0000 Subject: [PATCH 12/42] Allow configuration of resource templates --- config/vufind/ModelContextProtocol.yaml | 7 +++++++ .../VuFindApi/Formatter/RecordFormatter.php | 1 - .../src/VuFindApi/Mcp/Capabilities.php | 3 +-- .../src/VuFindApi/Mcp/ServerProvider.php | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index e5b9e903fc1..b58c55b0aa5 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -2,6 +2,13 @@ General: enabled: true +ResourceTemplates: + + getRecord: + class: VuFindApi\Mcp\Capabilities + function: getRecord + uriTemplate: 'catalog://record/{recordId}' + Tools: searchRecordsAnyType: diff --git a/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php b/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php index f0184e01d9f..5e1baeafb17 100644 --- a/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php +++ b/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php @@ -34,7 +34,6 @@ use VuFind\I18n\TranslatableString; use VuFindApi\Controller\ApiException; -use function in_array; use function is_object; /** diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php index 4ffe9c4d44e..8728310a22e 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php @@ -152,14 +152,13 @@ function ( } /** - * Retrieve a record by record ID. + * Retrieve a record by record ID. Uri and other parameters are defined in config. * * @param string $recordId The record ID * * @return array The record */ #[McpResourceTemplate( - uriTemplate: 'catalog://record/{recordId}', name: 'getRecord', description: 'Get a catalog record by its ID.', mimeType: 'application/json' diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index 770958bdd0e..63d1d32e9a5 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -93,10 +93,31 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) ->setServerInfo(name: 'VuFind Server', version: '0.0.1', description: 'The library catalog') ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) ->setContainer($container); + $this->addResourceTemplates($builder); $this->addTools($builder); $this->server = $builder->build(); } + /** + * Add resource templates from config to the Server Builder. + * + * @param Builder $builder The server builder + * + * @return void + */ + protected function addResourceTemplates(Builder $builder) + { + foreach (($this->config['ResourceTemplates'] ?? []) as $name => $resourceTemplate) { + $className = $resourceTemplate['class']; + $functionName = $resourceTemplate['function']; + $uriTemplate = $resourceTemplate['uriTemplate']; + $builder->addResourceTemplate( + [$className, $functionName], + uriTemplate: $uriTemplate, + ); + } + } + /** * Add tools from config to the Server Builder. * From 358e94c1b9e3378f62d681c60a453ee3787f3a69 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 9 Dec 2025 17:42:15 +0000 Subject: [PATCH 13/42] Fix template definition --- module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php | 1 + 1 file changed, 1 insertion(+) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php index 8728310a22e..6d417ab4102 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php @@ -159,6 +159,7 @@ function ( * @return array The record */ #[McpResourceTemplate( + uriTemplate: 'catalog://record/{recordId}', name: 'getRecord', description: 'Get a catalog record by its ID.', mimeType: 'application/json' From 9c654229ee19e3df61fce9c4793d093d002d42ff Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 12 Dec 2025 15:06:29 +0000 Subject: [PATCH 14/42] Fix style --- module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index 63d1d32e9a5..f81bed17cf8 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -107,7 +107,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) */ protected function addResourceTemplates(Builder $builder) { - foreach (($this->config['ResourceTemplates'] ?? []) as $name => $resourceTemplate) { + foreach (($this->config['ResourceTemplates'] ?? []) as $resourceTemplate) { $className = $resourceTemplate['class']; $functionName = $resourceTemplate['function']; $uriTemplate = $resourceTemplate['uriTemplate']; From c93710607dc00b42bb292f92b7204b36faa398ad Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 12 Dec 2025 21:07:41 +0000 Subject: [PATCH 15/42] Add basic authorization checks via permissions.ini --- config/vufind/permissions.ini | 7 +++ .../VuFindApi/Controller/McpController.php | 63 +++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) 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/src/VuFindApi/Controller/McpController.php b/module/VuFindApi/src/VuFindApi/Controller/McpController.php index 3dfd2684ed4..198b1cc12c9 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/McpController.php +++ b/module/VuFindApi/src/VuFindApi/Controller/McpController.php @@ -30,9 +30,12 @@ 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; @@ -49,9 +52,20 @@ */ class McpController extends AbstractBase { - use ApiTrait; + /** + * Permission required for all MCP endpoints + * + * @var string + */ + protected $baseAccessPermission = 'access.mcp'; - // protected $accessPermission = '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 @@ -71,12 +85,18 @@ public function __construct(ServiceLocatorInterface $sm, protected ?Server $serv */ public function mcpAction() { - $this->determineOutputMode(); - 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'] ?? ''; + 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 @@ -96,4 +116,39 @@ public function mcpAction() } 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; + } } From 27b45e888b6ec22faf39a8dd61698f1187700537 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 12 Dec 2025 21:07:52 +0000 Subject: [PATCH 16/42] Add some typing --- module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index f81bed17cf8..e40853182f3 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -105,7 +105,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) * * @return void */ - protected function addResourceTemplates(Builder $builder) + protected function addResourceTemplates(Builder $builder): void { foreach (($this->config['ResourceTemplates'] ?? []) as $resourceTemplate) { $className = $resourceTemplate['class']; @@ -125,7 +125,7 @@ protected function addResourceTemplates(Builder $builder) * * @return void */ - protected function addTools(Builder $builder) + protected function addTools(Builder $builder): void { foreach (($this->config['Tools'] ?? []) as $name => $tool) { $description = $tool['description']; From b71ffe4a0e30edcd47e48105436e6f59274806c0 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 12 Dec 2025 21:15:24 +0000 Subject: [PATCH 17/42] Fix messageId --- module/VuFindApi/src/VuFindApi/Controller/McpController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Controller/McpController.php b/module/VuFindApi/src/VuFindApi/Controller/McpController.php index 198b1cc12c9..480c7bb2389 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/McpController.php +++ b/module/VuFindApi/src/VuFindApi/Controller/McpController.php @@ -93,7 +93,8 @@ public function mcpAction() // $mcpMethod = $content['method'] ?? ''; $mcpMethod = null; if ($this->isAccessDenied($mcpMethod)) { - $messageId = $content['messageId'] ?? ''; + // $messageId = $content['messageId'] ?? ''; + $messageId = ''; return $this->outputAuthError($messageId); } From 435147de9fa506ade54e6fcac2ab64a1b063da63 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 30 Dec 2025 16:05:40 +0000 Subject: [PATCH 18/42] Validate contentType --- module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php index 6d417ab4102..3c1a2fb94ab 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php @@ -122,9 +122,15 @@ public function searchRecords(string $keywords, ?string $contentType = null): ar { $limit = $this->limit; $rawRequest = ['lookfor' => urldecode($keywords)]; - if ($filter = $this->config['ContentTypes'][$contentType]['filter'] ?? null) { - $rawRequest['filter'] = $filter; + 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->searchClassId, From 7a870c82e19320b6c9d5f610c20189c9bac0c2c3 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 30 Dec 2025 16:07:32 +0000 Subject: [PATCH 19/42] Document .yaml file --- config/vufind/ModelContextProtocol.yaml | 117 +++++++++++++----------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index b58c55b0aa5..d83549fa178 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -1,61 +1,74 @@ -General: +# Model Context Protocol (MCP) server configuration. +# +# See (a wiki page -- I don't have wiki permissions to create a page) - enabled: true +General: + # Enable the MCP Server and register all capabilities defined below. Disabled by default. + # Access must also be granted in permissions.ini. + # enabled: true +# 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 - function: getRecord - uriTemplate: 'catalog://record/{recordId}' + # getRecord: + # class: VuFindApi\Mcp\Capabilities + # 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 - 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 - function: searchRecords - inputSchema: - type: object - properties: - keywords: - type: string - description: Keywords to search for - contentType: - type: string - description: A type of library content - enum: - - books - - ebooks - - videos - required: - - keywords - - contentType + # searchRecordsAnyType: + # description: Search library catalog records of all content types by keywords. + # class: VuFindApi\Mcp\Capabilities + # 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 + # 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:"Print Book" OR format:eBook OR format:"Streaming Audio" OR format:Serial) - ebooks: - filter: format:eBook - videos: - filter: (format:DVD OR format:VHS OR format:"Blu-ray Disc" OR format:Filmstrip OR format:"Streaming Video") - -ResponseFields: - - recordPageFullUrl - - title - - authors - - publicationDates - - formats + # 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: +# - recordPageFullUrl +# - title +# - authors From 21f8bc5294277f73af026f6344b12ef6cccd23f6 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 30 Dec 2025 17:50:13 +0000 Subject: [PATCH 20/42] Refactor capabilities and support auto-discovery --- config/vufind/ModelContextProtocol.yaml | 23 +++++- .../Mcp/Capabilities/AbstractCapabilities.php | 74 +++++++++++++++++++ .../AbstractSearch.php} | 55 +++++--------- .../AutoDiscovery/ExampleCapabilities.php | 58 +++++++++++++++ .../VuFindApi/Mcp/Capabilities/SearchSolr.php | 52 +++++++++++++ .../src/VuFindApi/Mcp/ServerProvider.php | 22 ++++++ 6 files changed, 246 insertions(+), 38 deletions(-) create mode 100644 module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractCapabilities.php rename module/VuFindApi/src/VuFindApi/Mcp/{Capabilities.php => Capabilities/AbstractSearch.php} (82%) create mode 100644 module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AutoDiscovery/ExampleCapabilities.php create mode 100644 module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index d83549fa178..b6f7e252584 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -7,13 +7,30 @@ General: # Access must also be granted in permissions.ini. # enabled: true +# 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 + # class: VuFindApi\Mcp\Capabilities\SearchSolr # function: getRecord # uriTemplate: 'catalog://record/{recordId}' @@ -27,7 +44,7 @@ Tools: # searchRecordsAnyType: # description: Search library catalog records of all content types by keywords. - # class: VuFindApi\Mcp\Capabilities + # class: VuFindApi\Mcp\Capabilities\SearchSolr # function: searchRecords # inputSchema: # type: object @@ -40,7 +57,7 @@ Tools: # searchRecordsByContentType: # description: Search library catalog records for a specific content type by keywords. - # class: VuFindApi\Mcp\Capabilities + # class: VuFindApi\Mcp\Capabilities\SearchSolr # function: searchRecords # inputSchema: # type: object 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.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php similarity index 82% rename from module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php rename to module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 3c1a2fb94ab..687c1a6c267 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -1,7 +1,7 @@ config = $this->yamlReader->get($this->configName . '.yaml'); - + parent::__construct($yamlReader, $recordLoader, $recordFormatter, $searchRunner); $this->responseFields = $this->config['ResponseFields'] ?? $this->responseFields; } /** - * Add two numbers. It's AI-powered magic! + * Get the search class ID. * - * @param int $a One of those super interesting numbers - * @param int $b A second really fantastic number + * @return string + */ + abstract protected function getSearchClassId(); + + /** + * Return the request parameter name. * - * @return int An even more amazing number that magically combines the first two!!! + * @return string */ - #[McpTool] - public function add(int $a, int $b): int + protected function getRequestParam() { - return $a + $b; + return 'lookfor'; } /** @@ -121,19 +107,18 @@ public function add(int $a, int $b): int public function searchRecords(string $keywords, ?string $contentType = null): array { $limit = $this->limit; - $rawRequest = ['lookfor' => urldecode($keywords)]; + $rawRequest = [$this->getRequestParam() => urldecode($keywords)]; if ($contentType) { if ($filter = $this->config['ContentTypes'][$contentType]['filter'] ?? null) { $rawRequest['filter'] = $filter; - } - else { + } else { throw new ResourceNotFoundException('Unknown content type: ' . $contentType); } } - + $results = $this->searchRunner->run( $rawRequest, - $this->searchClassId, + $this->getSearchClassId(), function ( $runner, $params, @@ -177,7 +162,7 @@ public function getRecord(string $recordId): array } try { - $record = $this->recordLoader->load($recordId, $this->searchClassId); + $record = $this->recordLoader->load($recordId, $this->getSearchClassId()); } catch (Exception $e) { throw new ResourceReadException(message: "Record not found for ID: {$recordId}", previous: $e); } 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..f8deef662df --- /dev/null +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php @@ -0,0 +1,52 @@ +. + * + * @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() + { + return 'Solr'; + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index e40853182f3..acc15d442d0 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -95,6 +95,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) ->setContainer($container); $this->addResourceTemplates($builder); $this->addTools($builder); + $this->addAutoDiscovery($builder); $this->server = $builder->build(); } @@ -141,6 +142,27 @@ protected function addTools(Builder $builder): void } } + /** + * 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->config['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. * From 7db23b74fe2c5530f7fafd0fcedb3d78e957541b Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Tue, 30 Dec 2025 18:51:06 +0000 Subject: [PATCH 21/42] Link to wiki --- config/vufind/ModelContextProtocol.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index b6f7e252584..54c2ce02615 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -1,6 +1,6 @@ # Model Context Protocol (MCP) server configuration. # -# See (a wiki page -- I don't have wiki permissions to create a page) +# See https://vufind.org/wiki/configuration:model_context_protocol General: # Enable the MCP Server and register all capabilities defined below. Disabled by default. From a4e1e5c9927781b29b42e4c3cf44887f2a129104 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Wed, 21 Jan 2026 15:22:35 +0000 Subject: [PATCH 22/42] Include a search results page URL with the results --- .../VuFindApi/Mcp/Capabilities/AbstractSearch.php | 14 +++++++++++--- .../VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 687c1a6c267..768fba671e4 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -35,6 +35,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use VuFind\Config\YamlReader; +use VuFind\Http\ServerUrlHelper; use VuFind\Record\Loader; use VuFind\Search\SearchRunner; use VuFindApi\Formatter\RecordFormatter; @@ -72,7 +73,8 @@ public function __construct( protected YamlReader $yamlReader, protected Loader $recordLoader, protected RecordFormatter $recordFormatter, - protected SearchRunner $searchRunner + protected SearchRunner $searchRunner, + protected ServerUrlHelper $serverUrlHelper ) { parent::__construct($yamlReader, $recordLoader, $recordFormatter, $searchRunner); $this->responseFields = $this->config['ResponseFields'] ?? $this->responseFields; @@ -102,7 +104,7 @@ protected function getRequestParam() * @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 + * @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 { @@ -139,7 +141,13 @@ function ( $results->getResults(), $this->responseFields ); - return $records; + // TODO how to do this correctly, with real base path, route mapping to path, and filters. + $resultsPage = $this->serverUrlHelper->getBaseUrl() . + '/vufind/Search/Results?' . $this->getRequestParam() . '=' . urlencode($keywords); + return [ + 'search_results' => $records, + 'search_results_page' => $resultsPage, + ]; } /** diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index acc15d442d0..b033a8bef45 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -83,6 +83,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) \VuFind\Record\Loader::class, \VuFindApi\Formatter\RecordFormatter::class, \VuFind\Search\SearchRunner::class, + \VuFind\Http\ServerUrlHelper::class ] as $class ) { // Provide these services to each capability class constructor From d35d4cc5945a28bcc51679c01e9a38f99b5dc049 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Wed, 21 Jan 2026 15:27:38 +0000 Subject: [PATCH 23/42] Fix styles --- .../VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 1 + module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 768fba671e4..b06975d7dd1 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -68,6 +68,7 @@ abstract class AbstractSearch extends AbstractCapabilities * @param Loader $recordLoader Record loader * @param RecordFormatter $recordFormatter Record formatter * @param SearchRunner $searchRunner Search runner + * @param ServerUrlHelper $serverUrlHelper Server URL helper */ public function __construct( protected YamlReader $yamlReader, diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index b033a8bef45..faef28faf7b 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -83,7 +83,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) \VuFind\Record\Loader::class, \VuFindApi\Formatter\RecordFormatter::class, \VuFind\Search\SearchRunner::class, - \VuFind\Http\ServerUrlHelper::class + \VuFind\Http\ServerUrlHelper::class, ] as $class ) { // Provide these services to each capability class constructor From af71a1b6c712184fc60a870bade74bcad918828d Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 15:50:01 +0000 Subject: [PATCH 24/42] Fix formatting of disabled 'enabled' setting --- config/vufind/ModelContextProtocol.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index 54c2ce02615..268522e2725 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -5,7 +5,7 @@ General: # Enable the MCP Server and register all capabilities defined below. Disabled by default. # Access must also be granted in permissions.ini. - # enabled: true + #enabled: false # Capabilities may be auto-discovered in the defined directories, if they are properly # defined with the appropriate PHP attributes #[McpTool], #[McpResource], etc. From 43ca8fd4f509df62a95ef4302ea189dc10138d76 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 15:58:01 +0000 Subject: [PATCH 25/42] Remove local $limit var --- .../src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index b06975d7dd1..cdeffe09db8 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -59,7 +59,7 @@ abstract class AbstractSearch extends AbstractCapabilities /** * Limit for searches */ - protected int $limit = 50; + protected int $limit = 20; /** * Constructor @@ -109,7 +109,6 @@ protected function getRequestParam() */ public function searchRecords(string $keywords, ?string $contentType = null): array { - $limit = $this->limit; $rawRequest = [$this->getRequestParam() => urldecode($keywords)]; if ($contentType) { if ($filter = $this->config['ContentTypes'][$contentType]['filter'] ?? null) { @@ -127,11 +126,9 @@ function ( $params, $searchId, $results - ) use ( - $limit ): void { $results->overrideStartRecord(1); - $params->setLimit($limit); + $params->setLimit($this->limit); } ); if ($results instanceof \VuFind\Search\EmptySet\Results) { From 650907be8237e023cd7a324bb833c94de3430ca7 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 16:02:51 +0000 Subject: [PATCH 26/42] Remove unneeded urldecode --- .../VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index cdeffe09db8..f0550c6d4da 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -109,7 +109,7 @@ protected function getRequestParam() */ public function searchRecords(string $keywords, ?string $contentType = null): array { - $rawRequest = [$this->getRequestParam() => urldecode($keywords)]; + $rawRequest = [$this->getRequestParam() => $keywords]; if ($contentType) { if ($filter = $this->config['ContentTypes'][$contentType]['filter'] ?? null) { $rawRequest['filter'] = $filter; From b6b09b211ce0a2f70fd53851bf5f39538416aba9 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 16:13:41 +0000 Subject: [PATCH 27/42] Change private to protected --- module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index faef28faf7b..c6377b034df 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -50,12 +50,12 @@ class ServerProvider /** * MCP Server */ - private Server $server; + protected Server $server; /** * Config name */ - private string $configName = 'ModelContextProtocol'; + protected string $configName = 'ModelContextProtocol'; /** * Config array From d342ac64b400cd4fa93028a68b158aed1eb146db Mon Sep 17 00:00:00 2001 From: Maccabee Levine <31278545+maccabeelevine@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:21:00 -0500 Subject: [PATCH 28/42] Add typing and indendation Co-authored-by: Demian Katz --- .../src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 4 ++-- .../src/VuFindApi/Mcp/Capabilities/SearchSolr.php | 2 +- module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index f0550c6d4da..540c50194d3 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -86,14 +86,14 @@ public function __construct( * * @return string */ - abstract protected function getSearchClassId(); + abstract protected function getSearchClassId(): string; /** * Return the request parameter name. * * @return string */ - protected function getRequestParam() + protected function getRequestParam(): string { return 'lookfor'; } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php index f8deef662df..a9fe1052abe 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php @@ -45,7 +45,7 @@ class SearchSolr extends AbstractSearch * * @return string */ - protected function getSearchClassId() + protected function getSearchClassId(): string { return 'Solr'; } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index c6377b034df..cc8be8fab71 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -79,11 +79,11 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) $container = new Container(); foreach ( [ - \VuFind\Config\YamlReader::class, - \VuFind\Record\Loader::class, - \VuFindApi\Formatter\RecordFormatter::class, - \VuFind\Search\SearchRunner::class, - \VuFind\Http\ServerUrlHelper::class, + \VuFind\Config\YamlReader::class, + \VuFind\Record\Loader::class, + \VuFindApi\Formatter\RecordFormatter::class, + \VuFind\Search\SearchRunner::class, + \VuFind\Http\ServerUrlHelper::class, ] as $class ) { // Provide these services to each capability class constructor From f101eff64ef6f54be42dab972346feb1232dfbcb Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 16:57:55 +0000 Subject: [PATCH 29/42] Change recordPageFullUrl to recordPageAbsoluteLink after merging #4954 --- config/vufind/ModelContextProtocol.yaml | 2 +- config/vufind/SearchApiRecordFields.yaml | 4 ---- .../src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index 268522e2725..2a64d62c652 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -86,6 +86,6 @@ ContentTypes: # Response fields for capabilities that return records. For valid field names and format functions, # see SearchApiRecordFields.yaml. Default is below. # ResponseFields: -# - recordPageFullUrl +# - recordPageAbsoluteLink # - title # - authors diff --git a/config/vufind/SearchApiRecordFields.yaml b/config/vufind/SearchApiRecordFields.yaml index 50732a784c4..ee95e38b17b 100644 --- a/config/vufind/SearchApiRecordFields.yaml +++ b/config/vufind/SearchApiRecordFields.yaml @@ -303,10 +303,6 @@ recordPageAbsoluteLink: vufind.method: "Formatter::getRecordPageAbsoluteLink" description: Link to the record page from external sites with an absolute link type: string -recordPageFullUrl: - vufind.method: "Formatter::getRecordPageFullUrl" - description: Link to the record page from external sites with a fully qualified URL - type: string relationshipNotes: vufind.method: getRelationshipNotes description: Notes describing relationships to other items diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 540c50194d3..7487b8bc950 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -54,7 +54,7 @@ abstract class AbstractSearch extends AbstractCapabilities /** * Record fields to return */ - protected array $responseFields = ['recordPageFullUrl', 'title', 'authors']; + protected array $responseFields = ['recordPageAbsoluteLink', 'title', 'authors']; /** * Limit for searches From d0b264daea384197f99c28f6eac2a94edd9e2521 Mon Sep 17 00:00:00 2001 From: Maccabee Levine <31278545+maccabeelevine@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:53:48 -0500 Subject: [PATCH 30/42] Add copyright symbol Co-authored-by: Demian Katz --- module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index cc8be8fab71..e74a83eac57 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -91,7 +91,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) } $builder = Server::builder() - ->setServerInfo(name: 'VuFind Server', version: '0.0.1', description: 'The library catalog') + ->setServerInfo(name: 'VuFind® Server', version: '0.0.1', description: 'The library catalog') ->setSession(new FileSessionStore(LOCAL_CACHE_DIR . '/mcp/session')) ->setContainer($container); $this->addResourceTemplates($builder); From 55e95e031965696ae68f0d6b2be310cb254f47c7 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 20:53:42 +0000 Subject: [PATCH 31/42] Create URL Helper to generate route URL --- module/VuFind/config/module.config.php | 1 + module/VuFind/src/VuFind/Http/UrlHelper.php | 86 +++++++++++++++++++ .../src/VuFind/Http/UrlHelperFactory.php | 78 +++++++++++++++++ .../Mcp/Capabilities/AbstractSearch.php | 11 ++- .../src/VuFindApi/Mcp/ServerProvider.php | 1 + 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 module/VuFind/src/VuFind/Http/UrlHelper.php create mode 100644 module/VuFind/src/VuFind/Http/UrlHelperFactory.php diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php index aab5a0da408..a20738d34fc 100644 --- a/module/VuFind/config/module.config.php +++ b/module/VuFind/config/module.config.php @@ -491,6 +491,7 @@ 'VuFind\Http\GuzzleService' => 'VuFind\Http\GuzzleServiceFactory', 'VuFind\Http\PhpEnvironment\Request' => 'Laminas\ServiceManager\Factory\InvokableFactory', 'VuFind\Http\ServerUrlHelper' => 'VuFind\Http\ServerUrlHelperFactory', + 'VuFind\Http\UrlHelper' => 'VuFind\Http\UrlHelperFactory', 'VuFind\I18n\Locale\LocaleSettings' => 'VuFind\Service\ServiceWithConfigIniFactory', 'VuFind\I18n\Sorter' => 'VuFind\I18n\SorterFactory', 'VuFind\IdentifierLinker\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory', diff --git a/module/VuFind/src/VuFind/Http/UrlHelper.php b/module/VuFind/src/VuFind/Http/UrlHelper.php new file mode 100644 index 00000000000..f4cd35093f6 --- /dev/null +++ b/module/VuFind/src/VuFind/Http/UrlHelper.php @@ -0,0 +1,86 @@ +. + * + * @category VuFind + * @package Http + * @author Demian Katz + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org + */ + +namespace VuFind\Http; + +use Closure; + +/** + * URL Helper class. Wrapper around Laminas UrlHelper. + * + * @category VuFind + * @package Http + * @author Demian Katz + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org + */ +class UrlHelper +{ + /** + * Constructor. + * + * @param Closure $urlHelper URL helper function + * + * @return void + */ + public function __construct(protected Closure $urlHelper) + { + } + + /** + * Generates a url given the name of a route. + * + * @param string $name Name of the route + * @param array $params Parameters for the link + * @param array|\Traversable $options Options for the route + * @param bool $reuseMatchedParams Whether to reuse matched + * parameters + * + * @see \Laminas\Router\RouteInterface::assemble() + * + * @throws \Laminas\View\Exception\RuntimeException If no RouteStackInterface was provided + * @throws \Laminas\View\Exception\RuntimeException If no RouteMatch was provided + * @throws \Laminas\View\Exception\RuntimeException If RouteMatch didn't contain a matched + * route name + * @throws \Laminas\View\Exception\InvalidArgumentException If the params object was not an + * array or Traversable object. + * + * @return self|string Url For the link href attribute + */ + public function generateUrl( + $name = null, + $params = [], + $options = [], + $reuseMatchedParams = false + ): string { + return ($this->urlHelper)($name, $params, $options, $reuseMatchedParams); + } +} diff --git a/module/VuFind/src/VuFind/Http/UrlHelperFactory.php b/module/VuFind/src/VuFind/Http/UrlHelperFactory.php new file mode 100644 index 00000000000..8799acd64ec --- /dev/null +++ b/module/VuFind/src/VuFind/Http/UrlHelperFactory.php @@ -0,0 +1,78 @@ +. + * + * @category VuFind + * @package Http + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFind\Http; + +use Closure; +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; + +/** + * URL Helper factory. + * + * @category VuFind + * @package Http + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class UrlHelperFactory 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.'); + } + + $viewRenderer = $container->get('ViewRenderer'); + return new $requestedName( + Closure::fromCallable($viewRenderer->plugin('url')) + ); + } +} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 7487b8bc950..c2f44c5c6ef 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -36,6 +36,7 @@ use Mcp\Exception\ResourceReadException; use VuFind\Config\YamlReader; use VuFind\Http\ServerUrlHelper; +use VuFind\Http\UrlHelper; use VuFind\Record\Loader; use VuFind\Search\SearchRunner; use VuFindApi\Formatter\RecordFormatter; @@ -69,13 +70,15 @@ abstract class AbstractSearch extends AbstractCapabilities * @param RecordFormatter $recordFormatter Record formatter * @param SearchRunner $searchRunner Search runner * @param ServerUrlHelper $serverUrlHelper Server URL helper + * @param UrlHelper $urlHelper Server URL helper */ public function __construct( protected YamlReader $yamlReader, protected Loader $recordLoader, protected RecordFormatter $recordFormatter, protected SearchRunner $searchRunner, - protected ServerUrlHelper $serverUrlHelper + protected ServerUrlHelper $serverUrlHelper, + protected UrlHelper $urlHelper ) { parent::__construct($yamlReader, $recordLoader, $recordFormatter, $searchRunner); $this->responseFields = $this->config['ResponseFields'] ?? $this->responseFields; @@ -141,7 +144,11 @@ function ( ); // TODO how to do this correctly, with real base path, route mapping to path, and filters. $resultsPage = $this->serverUrlHelper->getBaseUrl() . - '/vufind/Search/Results?' . $this->getRequestParam() . '=' . urlencode($keywords); + $this->urlHelper->generateUrl( + 'search-results', + [], + ['query' => [$this->getRequestParam() => urlencode($keywords)]] + ); return [ 'search_results' => $records, 'search_results_page' => $resultsPage, diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index e74a83eac57..19e29148f5e 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -84,6 +84,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) \VuFindApi\Formatter\RecordFormatter::class, \VuFind\Search\SearchRunner::class, \VuFind\Http\ServerUrlHelper::class, + \VuFind\Http\UrlHelper::class, ] as $class ) { // Provide these services to each capability class constructor From 1327ce8c603888ce3a0bcf2038898c14ebc39fca Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 21:00:23 +0000 Subject: [PATCH 32/42] Refactor route name --- .../src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 9 ++++++++- .../src/VuFindApi/Mcp/Capabilities/SearchSolr.php | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index c2f44c5c6ef..517b13e2c0f 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -91,6 +91,13 @@ public function __construct( */ 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. * @@ -145,7 +152,7 @@ function ( // TODO how to do this correctly, with real base path, route mapping to path, and filters. $resultsPage = $this->serverUrlHelper->getBaseUrl() . $this->urlHelper->generateUrl( - 'search-results', + $this->getSearchActionRoute(), [], ['query' => [$this->getRequestParam() => urlencode($keywords)]] ); diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php index a9fe1052abe..3f245349d4b 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/SearchSolr.php @@ -49,4 +49,14 @@ protected function getSearchClassId(): string { return 'Solr'; } + + /** + * Get the route name to perform a search. + * + * @return string + */ + protected function getSearchActionRoute(): string + { + return 'search-results'; + } } From ae7e3e21b1bd2ecd2f3f2b5fbe2384faa50e2eab Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 29 Jan 2026 21:38:42 +0000 Subject: [PATCH 33/42] Set the server info metadata based on config --- config/vufind/ModelContextProtocol.yaml | 6 +++ .../src/VuFindApi/Mcp/ServerProvider.php | 42 +++++++++++++++---- .../VuFindApi/Mcp/ServerProviderFactory.php | 5 +++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index 2a64d62c652..ed10c5c29be 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -6,6 +6,12 @@ General: # Enable the MCP Server and register all capabilities defined below. Disabled by default. # Access must also be granted 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. diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index 19e29148f5e..75686a04406 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -34,6 +34,7 @@ use Mcp\Server; use Mcp\Server\Builder; use Mcp\Server\Session\FileSessionStore; +use VuFind\Config\Config; use VuFind\Config\YamlReader; /** @@ -60,19 +61,22 @@ class ServerProvider /** * Config array */ - protected array $config; + protected array $mcpConfig; /** * Constructor * + * @param Config $topConfig config.ini * @param ServiceLocatorInterface $serviceLocator Service locator */ - public function __construct(protected ServiceLocatorInterface $serviceLocator) - { + public function __construct( + protected Config $topConfig, + protected ServiceLocatorInterface $serviceLocator + ) { $yamlReader = $serviceLocator->get(YamlReader::class); - $this->config = $yamlReader->get($this->configName . '.yaml'); + $this->mcpConfig = $yamlReader->get($this->configName . '.yaml'); - if (!($this->config['General']['enabled'] ?? false)) { + if (!($this->mcpConfig['General']['enabled'] ?? false)) { return; } @@ -92,15 +96,35 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) } $builder = Server::builder() - ->setServerInfo(name: 'VuFind® Server', version: '0.0.1', description: 'The library catalog') ->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. * @@ -110,7 +134,7 @@ public function __construct(protected ServiceLocatorInterface $serviceLocator) */ protected function addResourceTemplates(Builder $builder): void { - foreach (($this->config['ResourceTemplates'] ?? []) as $resourceTemplate) { + foreach (($this->mcpConfig['ResourceTemplates'] ?? []) as $resourceTemplate) { $className = $resourceTemplate['class']; $functionName = $resourceTemplate['function']; $uriTemplate = $resourceTemplate['uriTemplate']; @@ -130,7 +154,7 @@ protected function addResourceTemplates(Builder $builder): void */ protected function addTools(Builder $builder): void { - foreach (($this->config['Tools'] ?? []) as $name => $tool) { + foreach (($this->mcpConfig['Tools'] ?? []) as $name => $tool) { $description = $tool['description']; $className = $tool['class']; $functionName = $tool['function']; @@ -153,7 +177,7 @@ protected function addTools(Builder $builder): void */ protected function addAutoDiscovery(Builder $builder): void { - if ($discovery = ($this->config['AutoDiscovery'] ?? [])) { + if ($discovery = ($this->mcpConfig['AutoDiscovery'] ?? [])) { $params = [$discovery['basePath'] ?? __DIR__]; if ($scanDirs = $discovery['scanDirs'] ?? []) { $params['scanDirs'] = $scanDirs; diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php index cac4d68c6e9..d78691b4209 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php @@ -68,7 +68,12 @@ public function __invoke( if (!empty($options)) { throw new \Exception('Unexpected options passed to factory.'); } + + $configManager = $container->get(\VuFind\Config\ConfigManagerInterface::class); + $topConfig = $configManager->getConfigObject('config'); + return new $requestedName( + $topConfig, $container, ); } From 2a5659ff5ecda7bb27eb39622f954841ea91bf30 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 30 Jan 2026 14:32:39 +0000 Subject: [PATCH 34/42] Remove redundant attribute description for getRecord template --- .../src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 517b13e2c0f..718d0d3ffe0 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -169,12 +169,6 @@ function ( * * @return array The record */ - #[McpResourceTemplate( - uriTemplate: 'catalog://record/{recordId}', - name: 'getRecord', - description: 'Get a catalog record by its ID.', - mimeType: 'application/json' - )] public function getRecord(string $recordId): array { if (!$recordId) { From 6f7a462091096203f9646f42033476e134bb54ab Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 30 Jan 2026 14:35:04 +0000 Subject: [PATCH 35/42] Fix styles --- .../VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 1 - 1 file changed, 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 718d0d3ffe0..43de4f2f4d2 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -30,7 +30,6 @@ namespace VuFindApi\Mcp\Capabilities; use Exception; -use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; From 7088e910a6598b9c7a697b0caa65336051d81003 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 30 Jan 2026 14:37:37 +0000 Subject: [PATCH 36/42] Reference permissions.ini more specifically --- config/vufind/ModelContextProtocol.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/vufind/ModelContextProtocol.yaml b/config/vufind/ModelContextProtocol.yaml index ed10c5c29be..026f326447c 100644 --- a/config/vufind/ModelContextProtocol.yaml +++ b/config/vufind/ModelContextProtocol.yaml @@ -4,7 +4,7 @@ General: # Enable the MCP Server and register all capabilities defined below. Disabled by default. - # Access must also be granted in permissions.ini. + # See also "Model Context Protocol permissions" in permissions.ini. #enabled: false # name: Default is "VuFind® Server" #name: MyLibrary VuFind® Server From f8340278112b7f99b532bf20d5e2ced8d155f871 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 30 Jan 2026 14:51:08 +0000 Subject: [PATCH 37/42] Don't allow php-http/discovery plugin, matching the mcp/sdk package --- composer.json | 2 +- composer.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 1e84b760dad..0e3a1b34356 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "process-timeout": 0, "allow-plugins": { "composer/package-versions-deprecated": true, - "php-http/discovery": true, + "php-http/discovery": false, "wikimedia/composer-merge-plugin": true } }, diff --git a/composer.lock b/composer.lock index ba89156c5cc..ef7ab1c8c61 100644 --- a/composer.lock +++ b/composer.lock @@ -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", From 2e99ac89b99980ebdc41869f7f369bfa0a25bc3a Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 30 Jan 2026 15:34:54 +0000 Subject: [PATCH 38/42] Move MCP config loading to ServerProviderFactory --- .../src/VuFindApi/Mcp/ServerProvider.php | 16 ++-------------- .../src/VuFindApi/Mcp/ServerProviderFactory.php | 10 ++++++++++ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index 75686a04406..cb6fe42d583 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -35,7 +35,6 @@ use Mcp\Server\Builder; use Mcp\Server\Session\FileSessionStore; use VuFind\Config\Config; -use VuFind\Config\YamlReader; /** * ServerProvider for Model Context Protocol (MCP) @@ -53,29 +52,18 @@ class ServerProvider */ protected Server $server; - /** - * Config name - */ - protected string $configName = 'ModelContextProtocol'; - - /** - * Config array - */ - protected array $mcpConfig; - /** * Constructor * + * @param array $mcpConfig MCP configuration * @param Config $topConfig config.ini * @param ServiceLocatorInterface $serviceLocator Service locator */ public function __construct( + protected array $mcpConfig, protected Config $topConfig, protected ServiceLocatorInterface $serviceLocator ) { - $yamlReader = $serviceLocator->get(YamlReader::class); - $this->mcpConfig = $yamlReader->get($this->configName . '.yaml'); - if (!($this->mcpConfig['General']['enabled'] ?? false)) { return; } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php index d78691b4209..83d7e9234f4 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php @@ -34,6 +34,7 @@ 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) @@ -46,6 +47,11 @@ */ class ServerProviderFactory implements FactoryInterface { + /** + * MCP Config name + */ + protected string $mcpConfigName = 'ModelContextProtocol'; + /** * Create an object * @@ -69,10 +75,14 @@ public function __invoke( 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'); return new $requestedName( + $mcpConfig, $topConfig, $container, ); From b7f188fec143ca110a161c44d95c4cfe8fd3df3e Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 30 Jan 2026 15:45:16 +0000 Subject: [PATCH 39/42] Retrieve services for capabilities within ServerProviderFactory --- .../src/VuFindApi/Mcp/ServerProvider.php | 22 +++++-------------- .../VuFindApi/Mcp/ServerProviderFactory.php | 14 +++++++++++- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php index cb6fe42d583..ef58d461f7c 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProvider.php @@ -29,7 +29,6 @@ namespace VuFindApi\Mcp; -use Laminas\ServiceManager\ServiceLocatorInterface; use Mcp\Capability\Registry\Container; use Mcp\Server; use Mcp\Server\Builder; @@ -55,32 +54,23 @@ class ServerProvider /** * Constructor * - * @param array $mcpConfig MCP configuration - * @param Config $topConfig config.ini - * @param ServiceLocatorInterface $serviceLocator Service locator + * @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, - protected ServiceLocatorInterface $serviceLocator + array $services ) { if (!($this->mcpConfig['General']['enabled'] ?? false)) { return; } $container = new Container(); - foreach ( - [ - \VuFind\Config\YamlReader::class, - \VuFind\Record\Loader::class, - \VuFindApi\Formatter\RecordFormatter::class, - \VuFind\Search\SearchRunner::class, - \VuFind\Http\ServerUrlHelper::class, - \VuFind\Http\UrlHelper::class, - ] as $class - ) { + foreach ($services as $service) { // Provide these services to each capability class constructor - $container->set($class, $serviceLocator->get($class)); + $container->set($service::class, $service); } $builder = Server::builder() diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php index 83d7e9234f4..a606215b370 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php @@ -81,10 +81,22 @@ public function __invoke( $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\ServerUrlHelper::class, + \VuFind\Http\UrlHelper::class, + ] + ); + return new $requestedName( $mcpConfig, $topConfig, - $container, + $services, ); } } From 1c8fec6197ddb1a6abff57920dc38ac8618212e6 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Mon, 2 Feb 2026 19:45:18 +0000 Subject: [PATCH 40/42] Simplify URL helper a bit --- module/VuFind/src/VuFind/Http/UrlHelper.php | 17 +++++++---------- .../Mcp/Capabilities/AbstractSearch.php | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/module/VuFind/src/VuFind/Http/UrlHelper.php b/module/VuFind/src/VuFind/Http/UrlHelper.php index f4cd35093f6..58a6838403c 100644 --- a/module/VuFind/src/VuFind/Http/UrlHelper.php +++ b/module/VuFind/src/VuFind/Http/UrlHelper.php @@ -58,11 +58,9 @@ public function __construct(protected Closure $urlHelper) /** * Generates a url given the name of a route. * - * @param string $name Name of the route - * @param array $params Parameters for the link - * @param array|\Traversable $options Options for the route - * @param bool $reuseMatchedParams Whether to reuse matched - * parameters + * @param string $name Name of the route + * @param array $linkParams Parameters for the link + * @param array|\Traversable $routeOptions Options for the route * * @see \Laminas\Router\RouteInterface::assemble() * @@ -75,12 +73,11 @@ public function __construct(protected Closure $urlHelper) * * @return self|string Url For the link href attribute */ - public function generateUrl( + public function getUrlFromRoute( $name = null, - $params = [], - $options = [], - $reuseMatchedParams = false + $linkParams = [], + $routeOptions = [] ): string { - return ($this->urlHelper)($name, $params, $options, $reuseMatchedParams); + return ($this->urlHelper)($name, $linkParams, $routeOptions); } } diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 43de4f2f4d2..6e69cb7ec89 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -150,7 +150,7 @@ function ( ); // TODO how to do this correctly, with real base path, route mapping to path, and filters. $resultsPage = $this->serverUrlHelper->getBaseUrl() . - $this->urlHelper->generateUrl( + $this->urlHelper->getUrlFromRoute( $this->getSearchActionRoute(), [], ['query' => [$this->getRequestParam() => urlencode($keywords)]] From b39739b2c59bbdfc7a08d086e31c6daf2b7b6e7f Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 5 Feb 2026 20:09:45 +0000 Subject: [PATCH 41/42] Use new RouteHelper from #5049 --- module/VuFind/config/module.config.php | 1 - module/VuFind/src/VuFind/Http/UrlHelper.php | 83 ------------------- .../src/VuFind/Http/UrlHelperFactory.php | 78 ----------------- .../Mcp/Capabilities/AbstractSearch.php | 12 +-- .../VuFindApi/Mcp/ServerProviderFactory.php | 2 +- 5 files changed, 7 insertions(+), 169 deletions(-) delete mode 100644 module/VuFind/src/VuFind/Http/UrlHelper.php delete mode 100644 module/VuFind/src/VuFind/Http/UrlHelperFactory.php diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php index 3e388b200c1..6172c93e979 100644 --- a/module/VuFind/config/module.config.php +++ b/module/VuFind/config/module.config.php @@ -496,7 +496,6 @@ 'VuFind\Http\PhpEnvironment\Request' => 'Laminas\ServiceManager\Factory\InvokableFactory', 'VuFind\Http\RouteHelper' => 'VuFind\Http\RouteHelperFactory', 'VuFind\Http\ServerUrlHelper' => 'VuFind\Http\ServerUrlHelperFactory', - 'VuFind\Http\UrlHelper' => 'VuFind\Http\UrlHelperFactory', 'VuFind\I18n\Locale\LocaleSettings' => 'VuFind\Service\ServiceWithConfigIniFactory', 'VuFind\I18n\Sorter' => 'VuFind\I18n\SorterFactory', 'VuFind\IdentifierLinker\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory', diff --git a/module/VuFind/src/VuFind/Http/UrlHelper.php b/module/VuFind/src/VuFind/Http/UrlHelper.php deleted file mode 100644 index 58a6838403c..00000000000 --- a/module/VuFind/src/VuFind/Http/UrlHelper.php +++ /dev/null @@ -1,83 +0,0 @@ -. - * - * @category VuFind - * @package Http - * @author Demian Katz - * @author Maccabee Levine - * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License - * @link https://vufind.org - */ - -namespace VuFind\Http; - -use Closure; - -/** - * URL Helper class. Wrapper around Laminas UrlHelper. - * - * @category VuFind - * @package Http - * @author Demian Katz - * @author Maccabee Levine - * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License - * @link https://vufind.org - */ -class UrlHelper -{ - /** - * Constructor. - * - * @param Closure $urlHelper URL helper function - * - * @return void - */ - public function __construct(protected Closure $urlHelper) - { - } - - /** - * Generates a url given the name of a route. - * - * @param string $name Name of the route - * @param array $linkParams Parameters for the link - * @param array|\Traversable $routeOptions Options for the route - * - * @see \Laminas\Router\RouteInterface::assemble() - * - * @throws \Laminas\View\Exception\RuntimeException If no RouteStackInterface was provided - * @throws \Laminas\View\Exception\RuntimeException If no RouteMatch was provided - * @throws \Laminas\View\Exception\RuntimeException If RouteMatch didn't contain a matched - * route name - * @throws \Laminas\View\Exception\InvalidArgumentException If the params object was not an - * array or Traversable object. - * - * @return self|string Url For the link href attribute - */ - public function getUrlFromRoute( - $name = null, - $linkParams = [], - $routeOptions = [] - ): string { - return ($this->urlHelper)($name, $linkParams, $routeOptions); - } -} diff --git a/module/VuFind/src/VuFind/Http/UrlHelperFactory.php b/module/VuFind/src/VuFind/Http/UrlHelperFactory.php deleted file mode 100644 index 8799acd64ec..00000000000 --- a/module/VuFind/src/VuFind/Http/UrlHelperFactory.php +++ /dev/null @@ -1,78 +0,0 @@ -. - * - * @category VuFind - * @package Http - * @author Maccabee Levine - * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License - * @link https://vufind.org/wiki/development Wiki - */ - -namespace VuFind\Http; - -use Closure; -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; - -/** - * URL Helper factory. - * - * @category VuFind - * @package Http - * @author Maccabee Levine - * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License - * @link https://vufind.org/wiki/development Wiki - */ -class UrlHelperFactory 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.'); - } - - $viewRenderer = $container->get('ViewRenderer'); - return new $requestedName( - Closure::fromCallable($viewRenderer->plugin('url')) - ); - } -} diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index 6e69cb7ec89..d4e90d707ff 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -34,8 +34,8 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use VuFind\Config\YamlReader; +use VuFind\Http\RouteHelper; use VuFind\Http\ServerUrlHelper; -use VuFind\Http\UrlHelper; use VuFind\Record\Loader; use VuFind\Search\SearchRunner; use VuFindApi\Formatter\RecordFormatter; @@ -68,16 +68,16 @@ abstract class AbstractSearch extends AbstractCapabilities * @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 - * @param UrlHelper $urlHelper Server URL helper */ public function __construct( protected YamlReader $yamlReader, protected Loader $recordLoader, protected RecordFormatter $recordFormatter, protected SearchRunner $searchRunner, - protected ServerUrlHelper $serverUrlHelper, - protected UrlHelper $urlHelper + protected RouteHelper $routeHelper, + protected ServerUrlHelper $serverUrlHelper ) { parent::__construct($yamlReader, $recordLoader, $recordFormatter, $searchRunner); $this->responseFields = $this->config['ResponseFields'] ?? $this->responseFields; @@ -150,10 +150,10 @@ function ( ); // TODO how to do this correctly, with real base path, route mapping to path, and filters. $resultsPage = $this->serverUrlHelper->getBaseUrl() . - $this->urlHelper->getUrlFromRoute( + $this->routeHelper->getUrlFromRoute( $this->getSearchActionRoute(), [], - ['query' => [$this->getRequestParam() => urlencode($keywords)]] + [$this->getRequestParam() => urlencode($keywords)] ); return [ 'search_results' => $records, diff --git a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php index a606215b370..df6cb531d98 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/ServerProviderFactory.php @@ -88,8 +88,8 @@ public function __invoke( \VuFind\Record\Loader::class, \VuFindApi\Formatter\RecordFormatter::class, \VuFind\Search\SearchRunner::class, + \VuFind\Http\RouteHelper::class, \VuFind\Http\ServerUrlHelper::class, - \VuFind\Http\UrlHelper::class, ] ); From 6a70f2a76577172dcc8647e9dea051cf81f7ec24 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Thu, 5 Feb 2026 20:54:43 +0000 Subject: [PATCH 42/42] Remove TODO done --- .../VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php | 1 - 1 file changed, 1 deletion(-) diff --git a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php index d4e90d707ff..ee1c9b40dd7 100644 --- a/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php +++ b/module/VuFindApi/src/VuFindApi/Mcp/Capabilities/AbstractSearch.php @@ -148,7 +148,6 @@ function ( $results->getResults(), $this->responseFields ); - // TODO how to do this correctly, with real base path, route mapping to path, and filters. $resultsPage = $this->serverUrlHelper->getBaseUrl() . $this->routeHelper->getUrlFromRoute( $this->getSearchActionRoute(),