diff --git a/composer.json b/composer.json index 5d620843..181bca75 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "require": { "firebase/php-jwt": "v7.0.*", - "webonyx/graphql-php": "^15.13" + "webonyx/graphql-php": "^15.13", + "mcp/sdk": "^0.4" } } diff --git a/composer.lock b/composer.lock index 717323e5..c0b53a3c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,68 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c5ee15cb98a3d7b53a5298a4a0692672", + "content-hash": "e4af8fec8ebb6ba45904bed618df1aa0", "packages": [ + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "firebase/php-jwt", - "version": "v7.0.4", + "version": "v7.0.5", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "e41f1bd7dbe3c5455c3f72d4338cfeb083b71931" + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/e41f1bd7dbe3c5455c3f72d4338cfeb083b71931", - "reference": "e41f1bd7dbe3c5455c3f72d4338cfeb083b71931", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, "require": { @@ -65,23 +113,1303 @@ "php" ], "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.4" + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, + { + "name": "mcp/sdk", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/php-sdk.git", + "reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024", + "reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "php": "^8.1", + "php-http/discovery": "^1.20", + "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", + "psr/container": "^1.0 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "require-dev": { + "laminas/laminas-httphandlerrunner": "^2.12", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "phar-io/composer-distributor": "^1.0.2", + "php-cs-fixer/shim": "^3.91", + "phpdocumentor/shim": "^3", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "psr/simple-cache": "^2.0 || ^3.0", + "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "Model Context Protocol SDK for Client and Server applications in PHP", + "support": { + "issues": "https://github.com/modelcontextprotocol/php-sdk/issues", + "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.4.0" + }, + "time": "2026-02-23T21:42:54+00:00" + }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, + { + "name": "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.7", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "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.7" + }, + "time": "2026-03-18T20:47:46+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": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8da41214757b87d97f181e3d14a4179286151007" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "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.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "f63fa6096a24147283bce4d29327d285326438e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/f63fa6096a24147283bce4d29327d285326438e0", + "reference": "f63fa6096a24147283bce4d29327d285326438e0", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^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/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2026-03-27T21:17:19+00:00" + "time": "2026-04-11T10:33:05+00:00" }, { "name": "webonyx/graphql-php", - "version": "v15.31.3", + "version": "v15.32.3", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "c20acbef1cb4af427ef6797512bfb2e651a85db9" + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/c20acbef1cb4af427ef6797512bfb2e651a85db9", - "reference": "c20acbef1cb4af427ef6797512bfb2e651a85db9", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145", "shasum": "" }, "require": { @@ -90,16 +1418,16 @@ "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", + "amphp/amp": "^2.6 || ^3", + "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.94.2", + "friendsofphp/php-cs-fixer": "3.95.1", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.43", + "phpstan/phpstan": "2.1.51", "phpstan/phpstan-phpunit": "2.0.16", "phpstan/phpstan-strict-rules": "2.0.10", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", @@ -110,9 +1438,10 @@ "symfony/polyfill-php81": "^1.23", "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", "thecodingmachine/safe": "^1.3 || ^2 || ^3", - "ticketswap/phpstan-error-formatter": "1.2.6" + "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" @@ -135,7 +1464,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.31.3" + "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3" }, "funding": [ { @@ -147,7 +1476,7 @@ "type": "open_collective" } ], - "time": "2026-03-29T18:22:41+00:00" + "time": "2026-04-24T13:49:35+00:00" } ], "packages-dev": [], diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 00000000..4a8fe13c --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,237 @@ +# Model Context Protocol (MCP) + +The package includes a fully-dynamic [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that +exposes every REST API operation as an MCP tool. MCP is a JSON-RPC 2.0 based protocol designed to give large language +models (LLMs) and AI agents a standardized way to discover and invoke tools, fetch resources and exchange context with +external systems. The MCP server in this package is built on top of the official [`mcp/sdk`](https://packagist.org/packages/mcp/sdk) +PHP SDK and is generated dynamically from the same Endpoint and Model classes that power the REST and GraphQL APIs, so +any new Endpoint added to the package is automatically exposed as an MCP tool. + +!!! Tips + If you are new to APIs in general, it is recommended to start with the REST API first. The MCP server is intended + primarily for AI agents/LLM clients (e.g. Claude Desktop, Cursor, custom agent runtimes) that already speak MCP. + +## Enabling the MCP Server + +The MCP server is **disabled by default** and must be explicitly enabled before use. You can enable it by setting the +`mcp_enabled` field on the REST API settings: + +=== "REST" + + ```http + PATCH /api/v2/system/restapi/settings + Content-Type: application/json + + { "mcp_enabled": true } + ``` + +=== "GraphQL" + + ```graphql + mutation { + updateRestapiSettings(mcp_enabled: true) { + mcp_enabled + } + } + ``` + +When `mcp_enabled` is `false`, all calls to `/api/v2/mcp` return a JSON-RPC error envelope with a `response_id` of +`MCP_SERVER_NOT_ENABLED`. + +## Authentication and Authorization + +The MCP server uses the same authentication methods and privileges as the REST API. For more information on +authentication, please refer to the [Authentication and Authorization](AUTHENTICATION_AND_AUTHORIZATION.md) documentation. + +!!! Important + The user must at least have the `api-v2-mcp-post` privilege to access the MCP server. From there, the user must + also have the relevant privileges for the underlying REST API operation each MCP tool maps to. For example, + invoking the `createFirewallAlias` MCP tool requires the same privilege as performing a `POST` request to + `/api/v2/firewall/alias`. + +## Endpoint and Transport + +| Property | Value | +|-----------------|----------------------------------------| +| URL | `/api/v2/mcp` | +| Method | `POST` | +| Content-Type | `application/json` | +| Transport | Stateless HTTP request/response | +| Protocol | JSON-RPC 2.0 (MCP) | + +Every MCP request is a single HTTP `POST` containing a JSON-RPC 2.0 envelope. The server returns a single JSON-RPC +response in the body. Notifications (requests without an `id`) receive an empty response. The MCP server intentionally +does **not** use Streamable HTTP / SSE transports — every interaction is a single request/response cycle. + +## Supported MCP Methods + +The MCP server implements the following standard MCP methods: + +| Method | Description | +|---------------|------------------------------------------------------------------------------| +| `initialize` | Returns server capabilities and identification information. | +| `ping` | Returns an empty result. Useful for liveness checks. | +| `tools/list` | Returns every tool dynamically generated from this API's Endpoints. | +| `tools/call` | Invokes a specific tool with the supplied arguments. | + +## Tool Naming Convention + +To stay consistent with the GraphQL API, MCP tool names use the same naming convention as GraphQL operations: + +| Operation | Pattern | Example | +|-----------------|--------------------------------------|-------------------------------| +| Read | `read{ModelName}` | `readFirewallAlias` | +| Query (many) | `query{ModelName}s` | `queryFirewallAliases` | +| Create | `create{ModelName}` | `createFirewallAlias` | +| Update | `update{ModelName}` | `updateFirewallAlias` | +| Delete | `delete{ModelName}` | `deleteFirewallAlias` | +| Replace All | `replaceAll{ModelName}s` | `replaceAllFirewallAliases` | +| Delete Many | `deleteMany{ModelName}s` | `deleteManyFirewallAliases` | +| Delete All | `deleteAll{ModelName}s` | `deleteAllFirewallAliases` | + +The complete list of tools available on your pfSense instance can always be obtained by issuing a `tools/list` request. + +## Examples + +### `initialize` + +```http +POST /api/v2/mcp +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2025-06-18" } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": true }, + "serverInfo": { "name": "pfSense-pkg-RESTAPI MCP Server", "version": "..." }, + "instructions": "This MCP server exposes the pfSense REST API as dynamically generated tools..." + } +} +``` + +### `tools/list` + +```http +POST /api/v2/mcp +Content-Type: application/json + +{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" } +``` + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "readFirewallAlias", + "description": "Reads a single Firewall Alias object. Equivalent to GET /api/v2/firewall/alias.", + "inputSchema": { + "type": "object", + "properties": { "id": { "type": "integer", "description": "..." } }, + "required": ["id"] + } + } + ] + } +} +``` + +### `tools/call` + +```http +POST /api/v2/mcp +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "createFirewallAlias", + "arguments": { + "name": "example", + "type": "host", + "address": ["1.1.1.1"], + "descr": "Created via MCP" + } + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "isError": false, + "content": [ + { "type": "text", "text": "{\n \"id\": 0,\n \"name\": \"example\",\n \"type\": \"host\",\n \"address\": [\"1.1.1.1\"]\n}" } + ] + } +} +``` + +## Error Handling + +The MCP server returns errors using two distinct mechanisms, depending on where the failure originated: + +- **Protocol-level errors** (unknown method, invalid params, internal error, MCP server disabled, authorization + failure) are returned as a JSON-RPC `error` envelope: + + ```json + { + "jsonrpc": "2.0", + "id": 3, + "error": { + "code": -32601, + "message": "Unknown MCP method 'foo'." + } + } + ``` + +- **Tool-level errors** (a Model validation error during a `tools/call`, for example) are returned inside a successful + `CallToolResult` with `isError` set to `true`. This is per the MCP specification so the LLM can see the error and + self-correct. + + ```json + { + "jsonrpc": "2.0", + "id": 3, + "result": { + "isError": true, + "content": [{ "type": "text", "text": "{\n \"message\": \"...\",\n \"response_id\": \"...\"\n}" }] + } + } + ``` + +All MCP responses always return HTTP 200, regardless of whether the request succeeded — exactly like the GraphQL API. + +## Differences from REST and GraphQL + +- All MCP requests are made to the `/api/v2/mcp` endpoint via a `POST`. +- Tools mirror the GraphQL operation names so the same vocabulary is used between MCP and GraphQL. +- The MCP server is disabled by default and must be enabled in the REST API settings. +- The MCP server itself is excluded from the `tools/list` response — it cannot expose itself as a tool. +- Privileges enforced by MCP tool calls are pulled directly from the corresponding REST API Endpoint, ensuring an + MCP tool call requires the exact same privileges as its REST API equivalent. + +## Why Use MCP? + +MCP is an emerging standard supported by a growing number of LLM clients and agent runtimes. Exposing the pfSense +REST API as MCP tools allows AI agents to discover and operate on your firewall using a standard, well-defined +protocol — without you needing to write any glue code. Because the tool catalog is generated dynamically from the +Endpoint and Model classes, every new feature added to the REST API is automatically available to MCP clients. + diff --git a/mkdocs.yml b/mkdocs.yml index 2c2a8f54..bfb6a980 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ nav: - Working with HATEOAS: WORKING_WITH_HATEOAS.md - Common Control Parameters: COMMON_CONTROL_PARAMETERS.md - GraphQL: GRAPHQL.md + - Model Context Protocol (MCP): MCP.md - Securing API Access: SECURING_API_ACCESS.md - Limitations & FAQs: LIMITATIONS_AND_FAQS.md - API Reference: https://pfrest.org/api-docs/ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/MCPEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/MCPEndpoint.inc new file mode 100644 index 00000000..f4d4d90b --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/MCPEndpoint.inc @@ -0,0 +1,55 @@ +url = '/api/v2/mcp'; + $this->model_name = 'MCP'; + $this->request_method_options = ['POST']; + $this->response_types = ['MCPResponse']; + + # Set help text for this Endpoint + $this->post_help_text = + 'Dispatch a Model Context Protocol (MCP) JSON-RPC 2.0 request. The MCP server is dynamically generated ' . + 'from this API\'s Endpoints and Models, exposing each REST API operation as an MCP tool. The MCP server ' . + 'is disabled by default and must be enabled via the REST API settings before use.'; + + # Construct the parent Endpoint object + parent::__construct(); + } + + /** + * A custom handler used to wrap REST API responses into MCP JSON-RPC envelopes. Successful MCP responses + * are returned as-is; non-200 responses are wrapped into MCP error envelopes so MCP clients always receive + * a JSON-RPC compliant response. + * @param Response $response The incoming response object. + * @return Response The response object to return to the client. + */ + public function response_handler(Response $response): Response { + if ($response->code !== 200) { + return MCPResponse::to_mcp_response($response); + } + + return new MCPResponse(data: $this->model); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/MCP/InvalidParamsException.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/MCP/InvalidParamsException.inc new file mode 100644 index 00000000..4e8975ac --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/MCP/InvalidParamsException.inc @@ -0,0 +1,10 @@ +check_privs(resolver: 'query', auth: $auth); + + $query = $this->model->query( + query_params: $args['query_params'] ?? [], + limit: $args['limit'] ?? 0, + offset: $args['offset'] ?? 0, + reverse: $args['reverse'] ?? false, + sort_by: $args['sort_by'] ?? [], + sort_order: $args['sort_order'] ?? SORT_ASC, + ); + + return $query->to_representation(); + } + + /** + * Resolver that maps an MCP tool call to the Model's 'read' method. + * @param array $args The arguments passed to the MCP tool call. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The read object in array representation. + */ + public function read(array $args, Auth $auth): array { + $this->check_privs(resolver: 'read', auth: $auth); + $model = new $this->model(id: $args['id'] ?? null, parent_id: $args['parent_id'] ?? null); + return $model->to_representation(); + } + + /** + * Resolver that maps an MCP tool call to the Model's 'create' method. + * @param array $args The arguments passed to the MCP tool call. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The created object in array representation. + */ + public function create(array $args, Auth $auth): array { + $this->check_privs(resolver: 'create', auth: $auth); + $model = new $this->model(data: $args); + return $model->create()->to_representation(); + } + + /** + * Resolver that maps an MCP tool call to the Model's 'update' method. + * @param array $args The arguments passed to the MCP tool call. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The updated object in array representation. + */ + public function update(array $args, Auth $auth): array { + $this->check_privs(resolver: 'update', auth: $auth); + $model = new $this->model(data: $args); + return $model->update()->to_representation(); + } + + /** + * Resolver that maps an MCP tool call to the Model's 'delete' method. + * @param array $args The arguments passed to the MCP tool call. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The deleted object in array representation. + */ + public function delete(array $args, Auth $auth): array { + $this->check_privs(resolver: 'delete', auth: $auth); + $model = new $this->model(data: $args); + return $model->delete()->to_representation(); + } + + /** + * Resolver that maps an MCP tool call to the Model's 'replace_all' method. + * @param array $args The arguments passed to the MCP tool call. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The replaced objects in array representation. + */ + public function replace_all(array $args, Auth $auth): array { + $this->check_privs(resolver: 'replace_all', auth: $auth); + $model = new $this->model(); + return $model->replace_all(data: $args['objects'] ?? [])->to_representation(); + } + + /** + * Resolver that maps an MCP tool call to the Model's 'delete_many' method. + * @param array $args The arguments passed to the MCP tool call. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The deleted objects in array representation. + */ + public function delete_many(array $args, Auth $auth): array { + $this->check_privs(resolver: 'delete_many', auth: $auth); + $model = new $this->model(); + $deleted_objects = $model->delete_many( + query_params: $args['query_params'] ?? [], + limit: $args['limit'] ?? 0, + offset: $args['offset'] ?? 0, + ); + return $deleted_objects->to_representation(); + } + + /** + * Resolver that maps an MCP tool call to the Model's 'delete_all' method. + * @param array $args The arguments passed to the MCP tool call. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The deleted objects in array representation. + */ + public function delete_all(array $args, Auth $auth): array { + $this->check_privs(resolver: 'delete_all', auth: $auth); + $model = new $this->model(); + return $model->delete_all()->to_representation(); + } + + /** + * Checks that the authenticated client has the privileges necessary to perform the requested resolver action. + * The privileges enforced here are pulled directly from the corresponding REST API Endpoint to guarantee that + * MCP tool calls require the exact same privileges as their REST API equivalents. + * @param string $resolver The resolver method to check permissions for. + * @param Auth $auth The Auth object to use for checking permissions. + * @throws ForbiddenError If the user does not have the required privileges. + */ + public function check_privs(string $resolver, Auth $auth): void { + # Obtain the endpoint that corresponds with the Model and resolve method + switch ($resolver) { + case 'query': + $endpoint = $this->model->get_related_endpoint(many: true); + $auth->required_privileges = $endpoint->get_privileges; + break; + case 'read': + $endpoint = $this->model->get_related_endpoint(many: false); + $auth->required_privileges = $endpoint->get_privileges; + break; + case 'create': + $endpoint = $this->model->get_related_endpoint(many: false); + $auth->required_privileges = $endpoint->post_privileges; + break; + case 'update': + $endpoint = $this->model->get_related_endpoint(many: false); + $auth->required_privileges = $endpoint->patch_privileges; + break; + case 'delete': + $endpoint = $this->model->get_related_endpoint(many: false); + $auth->required_privileges = $endpoint->delete_privileges; + break; + case 'replace_all': + $endpoint = $this->model->get_related_endpoint(many: true); + $auth->required_privileges = $endpoint->put_privileges; + break; + case 'delete_all': + case 'delete_many': + $endpoint = $this->model->get_related_endpoint(many: true); + $auth->required_privileges = $endpoint->delete_privileges; + break; + default: + throw new ForbiddenError( + message: 'Unknown MCP resolver action requested.', + response_id: 'MCP_RESOLVER_UNKNOWN_CHECK_PRIVS_ACTION', + ); + } + + # Throw an error if the user does not have the required privileges + if (!$auth->authorize()) { + throw new ForbiddenError( + message: 'Authorization failed. You do not have sufficient privileges to access this resource.', + response_id: 'MCP_RESOLVER_UNAUTHORIZED', + ); + } + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/MCP/Server.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/MCP/Server.inc new file mode 100644 index 00000000..99159fb1 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/MCP/Server.inc @@ -0,0 +1,234 @@ +schema = $schema ?? new MCPSchema(); + $this->server_info = new Implementation( + name: 'pfSense-pkg-RESTAPI MCP Server', + version: $this->get_api_version(), + description: 'A dynamic Model Context Protocol server for the pfSense REST API. ' . + 'All REST API operations are exposed as MCP tools.', + ); + $this->capabilities = new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: false, + resourcesSubscribe: false, + resourcesListChanged: false, + prompts: false, + promptsListChanged: false, + logging: false, + completions: false, + ); + } + + /** + * Dispatches a JSON-RPC 2.0 request to the appropriate MCP method handler. + * @param array $request The decoded JSON-RPC request. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return array The JSON-RPC envelope (result or error) to send back to the client. + */ + public function handle(array $request, Auth $auth): array { + # Notifications (requests without an `id`) receive no response per the JSON-RPC spec. + $id = $request['id'] ?? null; + $is_notification = !array_key_exists('id', $request); + + # Validate the JSON-RPC envelope. + if (($request['jsonrpc'] ?? null) !== MessageInterface::JSONRPC_VERSION) { + return $is_notification ? [] : $this->error_envelope( + JsonRpcError::forInvalidRequest('Invalid or missing "jsonrpc" version.', $id ?? ''), + ); + } + if (!isset($request['method']) or !is_string($request['method'])) { + return $is_notification ? [] : $this->error_envelope( + JsonRpcError::forInvalidRequest('Invalid or missing "method".', $id ?? ''), + ); + } + + $method = $request['method']; + $params = $request['params'] ?? []; + + # Notifications carry no response. Acknowledge by returning an empty array. + if ($is_notification) { + return []; + } + + try { + $result = match ($method) { + 'initialize' => $this->handle_initialize($params), + 'ping' => new EmptyResult(), + 'tools/list' => $this->handle_tools_list($params), + 'tools/call' => $this->handle_tools_call($params, $auth), + default => throw new MethodNotFoundException("Unknown MCP method '$method'."), + }; + } catch (MethodNotFoundException $e) { + return $this->error_envelope(JsonRpcError::forMethodNotFound($e->getMessage(), $id)); + } catch (InvalidParamsException $e) { + return $this->error_envelope(JsonRpcError::forInvalidParams($e->getMessage(), $id)); + } catch (Response $e) { + # Surface RESTAPI Response exceptions (e.g. ForbiddenError) as JSON-RPC errors. + return $this->error_envelope( + new JsonRpcError( + id: $id, + code: $e->code === 403 ? JsonRpcError::INVALID_REQUEST : JsonRpcError::SERVER_ERROR, + message: $e->message, + data: ['response_id' => $e->response_id, 'http_code' => $e->code], + ), + ); + } catch (Throwable $e) { + return $this->error_envelope(JsonRpcError::forInternalError($e->getMessage(), $id)); + } + + return [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $id, + 'result' => $result, + ]; + } + + /** + * Handles the MCP `initialize` request and returns the server's capabilities. + * @param array $params The parameters from the JSON-RPC request. + * @return InitializeResult The InitializeResult that describes this server. + */ + public function handle_initialize(array $params): InitializeResult { + # Use the protocol version requested by the client when supported, otherwise fall back to the latest one. + $requested = isset($params['protocolVersion']) ? ProtocolVersion::tryFrom($params['protocolVersion']) : null; + $protocol_version = $requested ?? ProtocolVersion::V2025_06_18; + + return new InitializeResult( + capabilities: $this->capabilities, + serverInfo: $this->server_info, + instructions: 'This MCP server exposes the pfSense REST API as dynamically generated tools. Each tool ' . + 'corresponds to a REST API operation and uses the same authentication, authorization and ' . + 'privileges as the REST API. Use tools/list to discover the available tools.', + protocolVersion: $protocol_version, + ); + } + + /** + * Handles the MCP `tools/list` request and returns every tool generated by the MCPSchema. + * @param array $params The parameters from the JSON-RPC request (currently unused). + * @return ListToolsResult The ListToolsResult containing all generated tools. + */ + public function handle_tools_list(array $params): ListToolsResult { + return new ListToolsResult(tools: array_values($this->schema->get_tools())); + } + + /** + * Handles the MCP `tools/call` request by routing to the appropriate Resolver method. + * @param array $params The parameters from the JSON-RPC request, must contain a `name` and may contain `arguments`. + * @param Auth $auth The Auth object representing the authenticated MCP client. + * @return CallToolResult The CallToolResult containing the tool result or an error. + * @throws InvalidParamsException If `name` is missing or unknown. + */ + public function handle_tools_call(array $params, Auth $auth): CallToolResult { + if (!isset($params['name']) or !is_string($params['name'])) { + throw new InvalidParamsException('Missing or invalid "name" in tools/call params.'); + } + + $name = $params['name']; + $arguments = $params['arguments'] ?? []; + if (!is_array($arguments)) { + throw new InvalidParamsException('"arguments" must be an object/array.'); + } + + # Look up the tool handler by name. The handler array is keyed by tool name and contains a Resolver method. + $handlers = $this->schema->get_tool_handlers(); + if (!array_key_exists($name, $handlers)) { + throw new InvalidParamsException("Unknown MCP tool '$name'."); + } + + $handler = $handlers[$name]; + $resolver = $handler['resolver']; + $action = $handler['action']; + + try { + $data = $resolver->$action($arguments, $auth); + return CallToolResult::success([new TextContent(text: $data)]); + } catch (Response $e) { + # Tool-level errors are returned inside the CallToolResult per the MCP spec so the client can show + # the error to the model. Protocol-level errors are still surfaced via JSON-RPC errors above. + return CallToolResult::error([ + new TextContent( + text: [ + 'message' => $e->message, + 'response_id' => $e->response_id, + 'http_code' => $e->code, + ], + ), + ]); + } + } + + /** + * Builds a JSON-RPC error envelope from a given Mcp\Schema\JsonRpc\Error. + * @param JsonRpcError $error The JsonRpcError object describing the error. + * @return array The serialized JSON-RPC error envelope. + */ + public function error_envelope(JsonRpcError $error): array { + return $error->jsonSerialize(); + } + + /** + * Returns the current pfSense REST API version, used as the server version in the initialize handshake. + * @return string The current REST API version string, or 'dev' when unavailable. + */ + private function get_api_version(): string { + try { + $version = new \RESTAPI\Models\RESTAPIVersion(); + return (string) ($version->current_version->value ?? 'dev'); + } catch (Throwable) { + return 'dev'; + } + } +} + + + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/MCP.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/MCP.inc new file mode 100644 index 00000000..7c49b652 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/MCP.inc @@ -0,0 +1,118 @@ +internal_callable = 'get_internal'; + $this->verbose_name = 'MCP'; + $this->many = false; + $this->always_apply = true; + + # Set model fields. These represent the JSON-RPC 2.0 envelope sent by an MCP client. + $this->jsonrpc = new StringField( + default: '2.0', + allow_empty: true, + verbose_name: 'JSON-RPC Version', + help_text: 'The JSON-RPC protocol version. This must be "2.0" for compliance with the MCP specification.', + ); + $this->method = new StringField( + default: '', + allow_empty: true, + verbose_name: 'Method', + help_text: 'The MCP method to invoke. Common values are "initialize", "tools/list" and "tools/call".', + ); + $this->params = new ObjectField( + default: [], + allow_empty: true, + verbose_name: 'Parameters', + help_text: 'The parameters object passed to the requested MCP method.', + ); + $this->id = new StringField( + default: '', + allow_empty: true, + verbose_name: 'Request ID', + help_text: 'The JSON-RPC request ID. Notifications without an ID will not receive a response.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Pseudo-method that acts as an internal callable. The MCP model has no internal config so this always + * returns an empty array. + * @return array An empty array. + */ + public function get_internal(): array { + return []; + } + + /** + * Dispatches the assigned JSON-RPC request to the MCP Server. When the MCP server is disabled in + * RESTAPISettings, this method will throw a ServiceUnavailableError. + * @throws ServiceUnavailableError When the MCP server is not enabled. + */ + public function _create(): void { + # Bail early when the MCP server is not enabled. + $settings = new RESTAPISettings(); + if (!$settings->mcp_enabled->value) { + throw new ServiceUnavailableError( + message: 'The MCP server is not enabled. Enable it in the REST API settings before using /api/v2/mcp.', + response_id: 'MCP_SERVER_NOT_ENABLED', + ); + } + + # Reconstruct the original JSON-RPC envelope and dispatch it through the MCP Server. + $request = [ + 'jsonrpc' => $this->jsonrpc->value ?: '2.0', + 'method' => $this->method->value, + 'params' => $this->params->value, + ]; + # Only include the `id` when one was provided so notifications behave correctly. + if ($this->id->value !== '' and $this->id->value !== null) { + $id = $this->id->value; + $request['id'] = is_numeric($id) ? (int) $id : $id; + } + + $server = new Server(); + $this->result = $server->handle($request, $this->client); + } + + /** + * Prevents the `result` property from being included in serialization. This property may contain non- + * serializable data (e.g. mcp/sdk schema objects) and would otherwise cause errors when the Model + * sets the `initial_object` property. + * @return array The list of property names to serialize. + */ + public function __sleep(): array { + $properties = get_object_vars($this); + unset($properties['result']); + return array_keys($properties); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc index d38ba8d2..ffe3ecf4 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc @@ -35,6 +35,7 @@ class RESTAPISettings extends Model { public BooleanField $allow_pre_releases; public BooleanField $hateoas; public BooleanField $expose_sensitive_fields; + public BooleanField $mcp_enabled; public StringField $override_sensitive_fields; public InterfaceField $allowed_interfaces; public StringField $represent_interfaces_as; @@ -141,6 +142,16 @@ class RESTAPISettings extends Model { help_text: 'Enables or disables exposing sensitive fields in API responses. When enabled, sensitive fields ' . 'such as passwords, private keys, and other sensitive data will be included in API responses.', ); + $this->mcp_enabled = new BooleanField( + default: false, + indicates_true: 'enabled', + indicates_false: 'disabled', + verbose_name: 'enable MCP server', + help_text: 'Enables or disables the Model Context Protocol (MCP) server at /api/v2/mcp. When enabled, ' . + 'AI agents and other MCP clients can dynamically discover and invoke REST API operations as MCP ' . + 'tools using the same authentication, authorization and privileges as the standard REST API. The ' . + 'MCP server is disabled by default and must be explicitly enabled here.', + ); $this->override_sensitive_fields = new StringField( default: [], choices_callable: 'get_override_sensitive_fields_choices', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/MCPResponse.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/MCPResponse.inc new file mode 100644 index 00000000..ab13b0b5 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/MCPResponse.inc @@ -0,0 +1,137 @@ +data instanceof MCP and is_array($this->data->result)) { + return $this->data->result; + } + return []; + } + + /** + * Converts a standard Response object into an MCPResponse. This is used by the MCPEndpoint to wrap REST API + * errors (e.g. authentication failures) into a JSON-RPC error envelope so MCP clients receive a consistent + * response format regardless of where the failure originated. + * @param Response $response The Response object to convert. + * @return MCPResponse The wrapped MCPResponse. + */ + public static function to_mcp_response(Response $response): MCPResponse { + $mcp = new MCP(); + + if ($response->code === 200) { + # Successful responses are echoed through the JSON-RPC envelope. + $mcp->result = [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => null, + 'result' => $response->data instanceof Model ? $response->data->to_representation() : [], + ]; + } else { + # Map common HTTP error codes to JSON-RPC error codes per the MCP specification. + $code = match (true) { + $response->code === 401, $response->code === 403 => JsonRpcError::INVALID_REQUEST, + $response->code === 404 => JsonRpcError::METHOD_NOT_FOUND, + $response->code >= 400 and $response->code < 500 => JsonRpcError::INVALID_PARAMS, + default => JsonRpcError::SERVER_ERROR, + }; + $error = new JsonRpcError( + id: '', + code: $code, + message: $response->message, + data: ['response_id' => $response->response_id, 'http_code' => $response->code], + ); + $mcp->result = $error->jsonSerialize(); + } + + return new MCPResponse(data: $mcp); + } + + /** + * Returns the OpenAPI schema describing the JSON-RPC envelope produced by the MCP server. + */ + public function to_openapi_schema(): array { + return [ + 'type' => 'object', + 'properties' => [ + 'jsonrpc' => [ + 'description' => 'The JSON-RPC protocol version. Always "2.0".', + 'type' => 'string', + ], + 'id' => [ + 'description' => 'The request ID echoed from the original JSON-RPC request.', + 'type' => ['string', 'integer', 'null'], + ], + 'result' => [ + 'description' => 'The MCP method result. Present on successful responses.', + 'type' => 'object', + ], + 'error' => [ + 'description' => 'The MCP error object. Present on failed responses.', + 'type' => 'object', + 'properties' => [ + 'code' => ['description' => 'The JSON-RPC error code.', 'type' => 'integer'], + 'message' => ['description' => 'A short description of the error.', 'type' => 'string'], + 'data' => [ + 'description' => 'Additional information about the error including the originating ' . + 'pfSense REST API response_id and HTTP code.', + 'type' => 'object', + ], + ], + ], + ], + ]; + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/MCPSchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/MCPSchema.inc new file mode 100644 index 00000000..2b83b6fe --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/MCPSchema.inc @@ -0,0 +1,424 @@ + $tools An associative array of MCP Tool objects keyed by tool name. + */ + public array $tools = []; + + /** + * @var array $tool_handlers + * A handler map keyed by tool name. Each entry contains the Resolver, the resolver method to invoke and the + * Endpoint the tool was generated from. + */ + public array $tool_handlers = []; + + /** + * Constructs a new MCPSchema and eagerly builds the tool catalog. + */ + public function __construct() { + $this->build_tools(); + parent::__construct(); + } + + /** + * Returns every MCP Tool object generated from this API's Endpoints. + * @return array The MCP Tool objects keyed by tool name. + */ + public function get_tools(): array { + return $this->tools; + } + + /** + * Returns the handler map used to dispatch MCP `tools/call` requests to the correct Resolver method. + * @return array The handler map. + */ + public function get_tool_handlers(): array { + return $this->tool_handlers; + } + + /** + * @inheritDoc + */ + public function get_schema_str(): string { + return json_encode( + ['tools' => array_values($this->tools)], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES, + ); + } + + /** + * Builds the catalog of MCP Tool objects by walking every Endpoint in the RESTAPI\Endpoints namespace. + * The MCPEndpoint itself is intentionally excluded so the MCP server cannot expose itself as a tool. + */ + public function build_tools(): void { + $this->tools = []; + $this->tool_handlers = []; + + foreach (get_classes_from_namespace('\\RESTAPI\\Endpoints\\') as $endpoint_class) { + # Avoid recursive exposure of the MCP endpoint itself. + if ($endpoint_class === '\\RESTAPI\\Endpoints\\MCPEndpoint') { + continue; + } + # The GraphQL endpoint is also excluded since it is itself a tool dispatcher. + if ($endpoint_class === '\\RESTAPI\\Endpoints\\GraphQLEndpoint') { + continue; + } + + $endpoint = new $endpoint_class(); + $this->add_tools_for_endpoint($endpoint); + } + } + + /** + * Adds every MCP Tool that corresponds to a supported operation on the given Endpoint. The supported + * operations mirror the REST API + GraphQL operations: read/query for GET, create for POST, update for + * PATCH, delete for DELETE, and replaceAll/deleteMany/deleteAll for the `many` variants. + * @param Endpoint $endpoint The Endpoint to generate MCP tools from. + */ + public function add_tools_for_endpoint(Endpoint $endpoint): void { + $resolver = new Resolver($endpoint->model); + + # GET-based operations + if (in_array('GET', $endpoint->request_method_options)) { + $endpoint->many ? $this->add_query_tool($endpoint, $resolver) : $this->add_read_tool($endpoint, $resolver); + } + + # Non-many mutations + if (!$endpoint->many and in_array('POST', $endpoint->request_method_options)) { + $this->add_create_tool($endpoint, $resolver); + } + if (!$endpoint->many and in_array('PATCH', $endpoint->request_method_options)) { + $this->add_update_tool($endpoint, $resolver); + } + if (!$endpoint->many and in_array('DELETE', $endpoint->request_method_options)) { + $this->add_delete_tool($endpoint, $resolver); + } + + # Many-only mutations + if ($endpoint->many and in_array('PUT', $endpoint->request_method_options)) { + $this->add_replace_all_tool($endpoint, $resolver); + } + if ($endpoint->many and in_array('DELETE', $endpoint->request_method_options)) { + $this->add_delete_many_tool($endpoint, $resolver); + $this->add_delete_all_tool($endpoint, $resolver); + } + } + + /** + * Builds the MCP operation name that corresponds with a given Endpoint and operation prefix. The naming + * convention is identical to the GraphQL operation naming so MCP and GraphQL share the same vocabulary. + * @param string $operation The operation prefix (e.g. read, query, create). + * @param Endpoint $endpoint The Endpoint to derive the operation name from. + * @return string The MCP tool name (e.g. readFirewallAlias, queryFirewallAliases). + */ + public function endpoint_to_operation_name(string $operation, Endpoint $endpoint): string { + $operation = lcfirst($operation); + $name = str_replace('/api/v2/', '', $endpoint->url); + $name = str_replace('/', ' ', $name); + return $operation . to_upper_camel_case($name); + } + + /** + * Registers a single MCP Tool and its handler in the schema. + * @param Endpoint $endpoint The Endpoint the tool is generated from. + * @param Resolver $resolver The Resolver to use when dispatching the tool call. + * @param string $operation The operation prefix used to name the tool. + * @param string $action The Resolver method to invoke when the tool is called. + * @param array $properties The JSON Schema `properties` map for the tool's input. + * @param array $required The required property names for the tool's input. + * @param string $description The human-readable description for the tool. + */ + private function register_tool( + Endpoint $endpoint, + Resolver $resolver, + string $operation, + string $action, + array $properties, + array $required, + string $description, + ): void { + $name = $this->endpoint_to_operation_name(operation: $operation, endpoint: $endpoint); + $this->tools[$name] = new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => empty($properties) ? new \stdClass() : $properties, + 'required' => $required, + ], + description: $description, + annotations: null, + ); + $this->tool_handlers[$name] = ['resolver' => $resolver, 'action' => $action, 'endpoint' => $endpoint]; + } + + /** + * Adds a `query{ModelName}` tool that maps to the Resolver::query method. + */ + private function add_query_tool(Endpoint $endpoint, Resolver $resolver): void { + $properties = [ + 'query_params' => [ + 'type' => 'object', + 'description' => 'An object containing the query parameters used to filter the results.', + 'additionalProperties' => true, + 'default' => new \stdClass(), + ], + 'limit' => ['type' => 'integer', 'description' => 'The maximum number of objects to return.', 'default' => 0], + 'offset' => ['type' => 'integer', 'description' => 'The offset to start returning objects from.', 'default' => 0], + 'reverse' => ['type' => 'boolean', 'description' => 'Reverse the order of returned objects.', 'default' => false], + 'sort_by' => [ + 'type' => 'array', + 'description' => 'The fields to sort the returned objects by.', + 'items' => ['type' => 'string'], + 'default' => [], + ], + 'sort_order' => ['type' => 'integer', 'description' => 'The order to use when sorting.', 'default' => SORT_ASC], + ]; + $description = "Queries multiple {$endpoint->model->verbose_name} objects. Equivalent to GET {$endpoint->url}."; + $this->register_tool($endpoint, $resolver, 'query', 'query', $properties, [], $description); + } + + /** + * Adds a `read{ModelName}` tool that maps to the Resolver::read method. + */ + private function add_read_tool(Endpoint $endpoint, Resolver $resolver): void { + $properties = []; + $required = []; + if ($endpoint->model->many) { + $properties['id'] = $this->id_property($endpoint->model->id_type, 'The ID of the object to read.'); + $required[] = 'id'; + } + if ($endpoint->model->parent_model_class) { + $properties['parent_id'] = $this->id_property( + $endpoint->model->parent_id_type, + 'The parent ID of the object to read.', + ); + $required[] = 'parent_id'; + } + $description = "Reads a single {$endpoint->model->verbose_name} object. Equivalent to GET {$endpoint->url}."; + $this->register_tool($endpoint, $resolver, 'read', 'read', $properties, $required, $description); + } + + /** + * Adds a `create{ModelName}` tool that maps to the Resolver::create method. + */ + private function add_create_tool(Endpoint $endpoint, Resolver $resolver): void { + $args = $this->model_to_input_properties( + model: $endpoint->model, + require_id: false, + ignore_required: false, + exclude_fields: ['append', 'delete'], + ); + $description = "Creates a new {$endpoint->model->verbose_name} object. Equivalent to POST {$endpoint->url}."; + $this->register_tool($endpoint, $resolver, 'create', 'create', $args['properties'], $args['required'], $description); + } + + /** + * Adds an `update{ModelName}` tool that maps to the Resolver::update method. + */ + private function add_update_tool(Endpoint $endpoint, Resolver $resolver): void { + $args = $this->model_to_input_properties( + model: $endpoint->model, + require_id: $endpoint->model->many, + ignore_required: true, + ); + $description = "Updates an existing {$endpoint->model->verbose_name} object. Equivalent to PATCH {$endpoint->url}."; + $this->register_tool($endpoint, $resolver, 'update', 'update', $args['properties'], $args['required'], $description); + } + + /** + * Adds a `delete{ModelName}` tool that maps to the Resolver::delete method. + */ + private function add_delete_tool(Endpoint $endpoint, Resolver $resolver): void { + $args = $this->model_to_input_properties( + model: $endpoint->model, + require_id: $endpoint->model->many, + only_id: true, + ); + $description = "Deletes an existing {$endpoint->model->verbose_name} object. Equivalent to DELETE {$endpoint->url}."; + $this->register_tool($endpoint, $resolver, 'delete', 'delete', $args['properties'], $args['required'], $description); + } + + /** + * Adds a `replaceAll{ModelName}s` tool that maps to the Resolver::replace_all method. + */ + private function add_replace_all_tool(Endpoint $endpoint, Resolver $resolver): void { + $properties = [ + 'objects' => [ + 'type' => 'array', + 'description' => 'The objects to replace all existing objects with.', + 'items' => $this->model_to_object_schema($endpoint->model, ignore_required: true), + ], + ]; + $description = "Replaces every {$endpoint->model->verbose_name} object. Equivalent to PUT {$endpoint->url}."; + $this->register_tool($endpoint, $resolver, 'replaceAll', 'replace_all', $properties, ['objects'], $description); + } + + /** + * Adds a `deleteMany{ModelName}s` tool that maps to the Resolver::delete_many method. + */ + private function add_delete_many_tool(Endpoint $endpoint, Resolver $resolver): void { + $properties = [ + 'query_params' => [ + 'type' => 'object', + 'description' => 'An object containing the query parameters used to filter which objects to delete.', + 'additionalProperties' => true, + ], + 'limit' => ['type' => 'integer', 'description' => 'The maximum number of objects to delete.', 'default' => 0], + 'offset' => ['type' => 'integer', 'description' => 'The offset to start deleting objects from.', 'default' => 0], + ]; + $description = "Deletes many {$endpoint->model->verbose_name} objects matching a query. Equivalent to DELETE {$endpoint->url}."; + $this->register_tool($endpoint, $resolver, 'deleteMany', 'delete_many', $properties, ['query_params'], $description); + } + + /** + * Adds a `deleteAll{ModelName}s` tool that maps to the Resolver::delete_all method. + */ + private function add_delete_all_tool(Endpoint $endpoint, Resolver $resolver): void { + $description = "Deletes every {$endpoint->model->verbose_name} object. Equivalent to DELETE {$endpoint->url}?all=true."; + $this->register_tool($endpoint, $resolver, 'deleteAll', 'delete_all', [], [], $description); + } + + /** + * Builds an MCP/JSON-Schema input properties map for a given Model. Handles ID fields, parent IDs, regular + * fields and nested model fields. + * @param Model $model The Model whose Fields should be converted into JSON Schema properties. + * @param bool $require_id Whether to require an `id` property. + * @param bool $only_id Whether to include only the ID/parent_id properties. + * @param bool $ignore_required Whether to ignore the Field::$required attribute. + * @param array $exclude_fields A list of field names to exclude from the schema. + * @return array{properties: array, required: array} + */ + public function model_to_input_properties( + Model $model, + bool $require_id = true, + bool $only_id = false, + bool $ignore_required = false, + array $exclude_fields = [], + ): array { + $properties = []; + $required = []; + + if ($require_id) { + $properties['id'] = $this->id_property($model->id_type, 'The ID of the object.'); + $required[] = 'id'; + } + if ($model->parent_model_class) { + $properties['parent_id'] = $this->id_property($model->parent_id_type, 'The parent ID of the object.'); + $required[] = 'parent_id'; + } + + if ($only_id) { + return ['properties' => $properties, 'required' => $required]; + } + + foreach ($model->get_fields() as $field) { + if (in_array($field, $exclude_fields)) { + continue; + } + $properties[$field] = $this->field_to_property($model->$field); + if ($model->$field->required and !$model->$field->conditions and !$ignore_required) { + $required[] = $field; + } + } + + return ['properties' => $properties, 'required' => $required]; + } + + /** + * Builds a JSON Schema object representation of a Model, used as the `items` schema for replace_all tools. + * @param Model $model The Model to represent as a JSON Schema object. + * @param bool $ignore_required Whether to ignore the Field::$required attribute. + * @return array The JSON Schema for the Model. + */ + public function model_to_object_schema(Model $model, bool $ignore_required = false): array { + $args = $this->model_to_input_properties($model, require_id: false, ignore_required: $ignore_required); + return [ + 'type' => 'object', + 'description' => $model->verbose_name, + 'properties' => empty($args['properties']) ? new \stdClass() : $args['properties'], + 'required' => $args['required'], + ]; + } + + /** + * Converts a single Field object into a JSON Schema property. + * @param Field $field The Field to convert. + * @return array The JSON Schema property definition. + */ + public function field_to_property(Field $field): array { + # Nested model fields recursively expose the nested Model's schema. + if ($field instanceof NestedModelField) { + $nested = new $field->model_class(skip_init: true); + $object = $this->model_to_object_schema($nested); + if ($field->many) { + return [ + 'type' => 'array', + 'description' => $field->help_text, + 'items' => $object, + ]; + } + $object['description'] = $field->help_text; + return $object; + } + + $base = match ($field->type) { + 'boolean' => ['type' => 'boolean'], + 'integer' => ['type' => 'integer'], + 'double' => ['type' => 'number'], + 'string' => ['type' => 'string'], + 'array' => ['type' => 'object', 'additionalProperties' => true], + default => ['type' => 'string'], + }; + + if ($field->choices) { + $base = ['type' => $base['type'], 'enum' => array_values($field->choices)]; + } + + if ($field->many) { + $base = ['type' => 'array', 'items' => $base]; + } + + if ($field->help_text) { + $base['description'] = $field->help_text; + } + + return $base; + } + + /** + * Builds a JSON Schema property for an ID/parent_id value with the appropriate type. + * @param string $id_type The Model's id_type ('integer' or 'string'). + * @param string $description A human-readable description for the property. + * @return array The JSON Schema property definition. + */ + private function id_property(string $id_type, string $description): array { + return ['type' => $id_type === 'integer' ? 'integer' : 'string', 'description' => $description]; + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsMCPEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsMCPEndpointTestCase.inc new file mode 100644 index 00000000..503e15ab --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsMCPEndpointTestCase.inc @@ -0,0 +1,95 @@ +assert_is_true($endpoint->response_handler($not_found_resp) instanceof MCPResponse); + $this->assert_is_true($endpoint->response_handler($bad_request_resp) instanceof MCPResponse); + $this->assert_is_true($endpoint->response_handler($success_resp) instanceof MCPResponse); + } + + /** + * Ensures the MCPEndpoint declares the expected URL, model, request methods and response types so it + * stays in sync with the documented contract. + */ + public function test_endpoint_attributes(): void { + $endpoint = new MCPEndpoint(); + $this->assert_equals($endpoint->url, '/api/v2/mcp'); + $this->assert_equals($endpoint->model_name, 'MCP'); + $this->assert_equals($endpoint->request_method_options, ['POST']); + $this->assert_equals($endpoint->response_types, ['MCPResponse']); + } + + /** + * Make a POST request to /api/v2/mcp with the MCP server enabled to ensure it works end-to-end. The MCP + * server is enabled before the request and reset to disabled afterwards by the TestCase config restore. + */ + public function test_endpoint_e2e(): void { + # Enable the MCP server so the endpoint will respond. + $settings = new RESTAPISettings(async: false); + $settings->mcp_enabled->value = true; + $settings->update(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: 'https://localhost/api/v2/mcp', + method: 'POST', + data: ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp, associative: true); + + $this->assert_equals($resp['jsonrpc'], '2.0'); + $this->assert_is_true(isset($resp['result']['tools'])); + $tool_names = array_column($resp['result']['tools'], 'name'); + $this->assert_is_true(in_array('readSystemHostname', $tool_names)); + } + + /** + * Make a POST request to /api/v2/mcp while the MCP server is disabled to ensure the endpoint returns the + * expected MCP error envelope rather than executing tools. + */ + public function test_endpoint_e2e_disabled(): void { + # Explicitly disable the MCP server. + $settings = new RESTAPISettings(async: false); + $settings->mcp_enabled->value = false; + $settings->update(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: 'https://localhost/api/v2/mcp', + method: 'POST', + data: ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp, associative: true); + + # The endpoint should wrap the ServiceUnavailableError into a JSON-RPC error envelope. + $this->assert_is_true(isset($resp['error'])); + $this->assert_equals($resp['error']['data']['response_id'], 'MCP_SERVER_NOT_ENABLED'); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIMCPResolverTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIMCPResolverTestCase.inc new file mode 100644 index 00000000..6a3b6905 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIMCPResolverTestCase.inc @@ -0,0 +1,129 @@ +admin_auth = new Auth(); + $this->admin_auth->username = 'admin'; + + # An auth object whose username does not exist will fail authorization for every privilege. + $this->no_priv_auth = new Auth(); + $this->no_priv_auth->username = 'mcp_no_such_user'; + + # Seed a single alias to operate on. + $alias = new FirewallAlias(); + $alias->replace_all([ + ['name' => 'mcp_resolver_test', 'type' => 'host', 'address' => ['1.1.1.1'], 'detail' => ['x']], + ]); + } + + /** + * Cleans up test data after the tests are complete. + */ + public function teardown(): void { + (new FirewallAlias())->delete_all(); + } + + /** + * Ensures the resolver pulls per-Endpoint privileges from the related Endpoint and matches the privileges + * required by the equivalent REST API method. This is critical for ensuring MCP tool calls require the + * exact same privileges as their REST counterparts. + */ + public function test_check_privs_uses_endpoint_privileges(): void { + $resolver = new Resolver(new FirewallAlias()); + + # Read uses GET privileges. + $resolver->check_privs(resolver: 'read', auth: $this->admin_auth); + $endpoint = (new FirewallAlias())->get_related_endpoint(many: false); + $this->assert_equals($this->admin_auth->required_privileges, $endpoint->get_privileges); + + # Create uses POST privileges. + $resolver->check_privs(resolver: 'create', auth: $this->admin_auth); + $this->assert_equals($this->admin_auth->required_privileges, $endpoint->post_privileges); + + # Update uses PATCH privileges. + $resolver->check_privs(resolver: 'update', auth: $this->admin_auth); + $this->assert_equals($this->admin_auth->required_privileges, $endpoint->patch_privileges); + + # Delete uses DELETE privileges. + $resolver->check_privs(resolver: 'delete', auth: $this->admin_auth); + $this->assert_equals($this->admin_auth->required_privileges, $endpoint->delete_privileges); + + # Many-resolver privileges come from the many endpoint. + $many_endpoint = (new FirewallAlias())->get_related_endpoint(many: true); + $resolver->check_privs(resolver: 'query', auth: $this->admin_auth); + $this->assert_equals($this->admin_auth->required_privileges, $many_endpoint->get_privileges); + $resolver->check_privs(resolver: 'replace_all', auth: $this->admin_auth); + $this->assert_equals($this->admin_auth->required_privileges, $many_endpoint->put_privileges); + $resolver->check_privs(resolver: 'delete_many', auth: $this->admin_auth); + $this->assert_equals($this->admin_auth->required_privileges, $many_endpoint->delete_privileges); + $resolver->check_privs(resolver: 'delete_all', auth: $this->admin_auth); + $this->assert_equals($this->admin_auth->required_privileges, $many_endpoint->delete_privileges); + } + + /** + * Ensures the resolver throws a ForbiddenError when the authenticated client does not have the required + * privileges to perform the requested action. + */ + public function test_check_privs_throws_when_unauthorized(): void { + $resolver = new Resolver(new FirewallAlias()); + + $this->assert_throws_response( + response_id: 'MCP_RESOLVER_UNAUTHORIZED', + code: 403, + callable: function () use ($resolver) { + $resolver->check_privs(resolver: 'create', auth: $this->no_priv_auth); + }, + ); + } + + /** + * Ensures unknown resolver actions throw an MCP_RESOLVER_UNKNOWN_CHECK_PRIVS_ACTION ForbiddenError. + */ + public function test_check_privs_unknown_action(): void { + $resolver = new Resolver(new FirewallAlias()); + + $this->assert_throws_response( + response_id: 'MCP_RESOLVER_UNKNOWN_CHECK_PRIVS_ACTION', + code: 403, + callable: function () use ($resolver) { + $resolver->check_privs(resolver: 'totally_unknown', auth: $this->admin_auth); + }, + ); + } + + /** + * Ensures resolver methods correctly delegate to the underlying Model and return array representations. + */ + public function test_resolver_methods_return_representations(): void { + $resolver = new Resolver(new FirewallAlias()); + + $read = $resolver->read(['id' => 0], $this->admin_auth); + $this->assert_equals($read['name'], 'mcp_resolver_test'); + + $query = $resolver->query([], $this->admin_auth); + $this->assert_equals(count($query), 1); + $this->assert_equals($query[0]['name'], 'mcp_resolver_test'); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsMCPTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsMCPTestCase.inc new file mode 100644 index 00000000..98c2f0d9 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsMCPTestCase.inc @@ -0,0 +1,348 @@ +mcp_enabled->value = true; + $settings->update(); + + # Use an admin Auth object so per-tool privilege checks succeed. + $this->auth = new Auth(); + $this->auth->username = 'admin'; + + # Create a few FirewallAlias models to query against. + $alias = new FirewallAlias(); + $alias->replace_all([ + [ + 'name' => 'mcp_test1', + 'type' => 'host', + 'descr' => 'MCP test alias 1', + 'address' => ['1.2.3.4'], + 'detail' => ['mcp detail 1'], + ], + [ + 'name' => 'mcp_test2', + 'type' => 'network', + 'descr' => 'MCP test alias 2', + 'address' => ['1.2.3.4/32'], + 'detail' => ['mcp detail 2'], + ], + ]); + } + + /** + * Clean up FirewallAlias models after testing. + */ + public function teardown(): void { + $alias = new FirewallAlias(); + $alias->delete_all(); + } + + /** + * Ensures that an MCP `initialize` request returns a JSON-RPC envelope with the server capabilities. + */ + public function test_initialize(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'initialize'; + $mcp->params->value = ['protocolVersion' => '2025-06-18']; + $mcp->id->value = '1'; + $mcp->create(); + + $this->assert_equals($mcp->result['jsonrpc'], '2.0'); + $this->assert_equals($mcp->result['id'], 1); + $this->assert_is_true(isset($mcp->result['result'])); + } + + /** + * Ensures that an MCP `tools/list` request returns a list of tools that includes representative tools for + * a known Endpoint (FirewallAlias). + */ + public function test_tools_list_includes_known_tools(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/list'; + $mcp->id->value = '2'; + $mcp->create(); + + $this->assert_equals($mcp->result['jsonrpc'], '2.0'); + $tools = json_decode(json_encode($mcp->result['result']['tools']), true); + $tool_names = array_column($tools, 'name'); + + $this->assert_is_true(in_array('readFirewallAlias', $tool_names)); + $this->assert_is_true(in_array('queryFirewallAliases', $tool_names)); + $this->assert_is_true(in_array('createFirewallAlias', $tool_names)); + $this->assert_is_true(in_array('updateFirewallAlias', $tool_names)); + $this->assert_is_true(in_array('deleteFirewallAlias', $tool_names)); + $this->assert_is_true(in_array('replaceAllFirewallAliases', $tool_names)); + $this->assert_is_true(in_array('deleteManyFirewallAliases', $tool_names)); + $this->assert_is_true(in_array('deleteAllFirewallAliases', $tool_names)); + } + + /** + * Ensures that the `tools/list` response excludes the MCP endpoint itself to prevent recursive exposure. + */ + public function test_tools_list_excludes_mcp_endpoint(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/list'; + $mcp->id->value = '3'; + $mcp->create(); + + $tools = json_decode(json_encode($mcp->result['result']['tools']), true); + $tool_names = array_column($tools, 'name'); + + # The MCP endpoint should never expose itself as a tool. + $this->assert_is_false(in_array('createMcp', $tool_names)); + $this->assert_is_false(in_array('readMcp', $tool_names)); + } + + /** + * Ensures the `tools/call` resolver can read an existing object via the dynamically generated read tool. + */ + public function test_tools_call_read(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = ['name' => 'readFirewallAlias', 'arguments' => ['id' => 0]]; + $mcp->id->value = '4'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + $payload = json_decode($result['content'][0]['text'], associative: true); + $this->assert_equals($payload['name'], 'mcp_test1'); + $this->assert_equals($payload['type'], 'host'); + } + + /** + * Ensures the `tools/call` resolver can query objects via the dynamically generated query tool. + */ + public function test_tools_call_query(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = ['name' => 'queryFirewallAliases', 'arguments' => []]; + $mcp->id->value = '5'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + $payload = json_decode($result['content'][0]['text'], associative: true); + $this->assert_equals(count($payload), 2); + $this->assert_equals($payload[0]['name'], 'mcp_test1'); + $this->assert_equals($payload[1]['name'], 'mcp_test2'); + } + + /** + * Ensures the `tools/call` resolver can create a new object via the dynamically generated create tool. + */ + public function test_tools_call_create(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = [ + 'name' => 'createFirewallAlias', + 'arguments' => [ + 'name' => 'mcp_test3', + 'type' => 'host', + 'descr' => 'MCP test alias 3', + 'address' => ['9.9.9.9'], + 'detail' => ['mcp detail 3'], + ], + ]; + $mcp->id->value = '6'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + + # Ensure the alias was actually created. + $alias_q = FirewallAlias::query(name: 'mcp_test3'); + $this->assert_is_true($alias_q->exists()); + $alias_q->first()->delete(); + } + + /** + * Ensures the `tools/call` resolver can update an object via the dynamically generated update tool. + */ + public function test_tools_call_update(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = [ + 'name' => 'updateFirewallAlias', + 'arguments' => ['id' => 0, 'descr' => 'updated by mcp'], + ]; + $mcp->id->value = '7'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + + $alias = new FirewallAlias(id: 0); + $this->assert_equals($alias->descr->value, 'updated by mcp'); + } + + /** + * Ensures the `tools/call` resolver can delete an object via the dynamically generated delete tool. + */ + public function test_tools_call_delete(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = ['name' => 'deleteFirewallAlias', 'arguments' => ['id' => 0]]; + $mcp->id->value = '8'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + $this->assert_is_false(FirewallAlias::query(name: 'mcp_test1')->exists()); + } + + /** + * Ensures the `tools/call` resolver can replace all objects via the dynamically generated replace_all tool. + */ + public function test_tools_call_replace_all(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = [ + 'name' => 'replaceAllFirewallAliases', + 'arguments' => [ + 'objects' => [ + [ + 'name' => 'mcp_replaced', + 'type' => 'host', + 'descr' => 'replaced', + 'address' => ['8.8.8.8'], + 'detail' => ['replaced'], + ], + ], + ], + ]; + $mcp->id->value = '9'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + $aliases = FirewallAlias::read_all(); + $this->assert_equals($aliases->count(), 1); + $this->assert_equals($aliases->first()->name->value, 'mcp_replaced'); + } + + /** + * Ensures the `tools/call` resolver can delete many objects via the dynamically generated delete_many tool. + */ + public function test_tools_call_delete_many(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = [ + 'name' => 'deleteManyFirewallAliases', + 'arguments' => ['query_params' => ['name' => 'mcp_test1']], + ]; + $mcp->id->value = '10'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + $this->assert_is_false(FirewallAlias::query(name: 'mcp_test1')->exists()); + $this->assert_is_true(FirewallAlias::query(name: 'mcp_test2')->exists()); + } + + /** + * Ensures the `tools/call` resolver can delete all objects via the dynamically generated delete_all tool. + */ + public function test_tools_call_delete_all(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = ['name' => 'deleteAllFirewallAliases', 'arguments' => []]; + $mcp->id->value = '11'; + $mcp->create(); + + $result = $mcp->result['result']; + $this->assert_is_false($result['isError']); + $this->assert_equals(FirewallAlias::read_all()->count(), 0); + } + + /** + * Ensures unknown tool names return a JSON-RPC InvalidParams error. + */ + public function test_tools_call_unknown_tool(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/call'; + $mcp->params->value = ['name' => 'thisToolDoesNotExist', 'arguments' => []]; + $mcp->id->value = '12'; + $mcp->create(); + + $this->assert_is_true(isset($mcp->result['error'])); + $this->assert_equals($mcp->result['error']['code'], -32602); + } + + /** + * Ensures unknown JSON-RPC methods return a JSON-RPC MethodNotFound error. + */ + public function test_unknown_method(): void { + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'unknown/method'; + $mcp->id->value = '13'; + $mcp->create(); + + $this->assert_is_true(isset($mcp->result['error'])); + $this->assert_equals($mcp->result['error']['code'], -32601); + } + + /** + * Ensures the MCP model throws a ServiceUnavailableError when the MCP server is disabled. + */ + public function test_mcp_disabled_throws_service_unavailable(): void { + # Disable the MCP server for this test. + $settings = new RESTAPISettings(async: false); + $settings->mcp_enabled->value = false; + $settings->update(); + + $mcp = new MCP(client: $this->auth); + $mcp->method->value = 'tools/list'; + $mcp->id->value = '14'; + + $this->assert_throws_response( + response_id: 'MCP_SERVER_NOT_ENABLED', + code: 503, + callable: function () use ($mcp) { + $mcp->create(); + }, + ); + } + + /** + * Ensures the MCP Model's internal callable returns an empty array. + */ + public function test_internal_callable(): void { + $mcp = new MCP(client: $this->auth); + $this->assert_equals($mcp->get_internal(), []); + } + + /** + * Ensures the MCP `result` property is excluded from serialization to avoid breaking the Model's + * `initial_object` property. + */ + public function test_result_property_excluded_from_serialization(): void { + $mcp = new MCP(client: $this->auth); + $this->assert_is_true(!in_array('result', $mcp->__sleep())); + $this->assert_does_not_throw( + callable: function () use ($mcp) { + serialize($mcp); + }, + ); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIResponsesMCPResponseTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIResponsesMCPResponseTestCase.inc new file mode 100644 index 00000000..b084a845 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIResponsesMCPResponseTestCase.inc @@ -0,0 +1,96 @@ +assert_equals($resp->code, 200); + } + + /** + * Ensures MCPResponse::to_representation() returns the JSON-RPC envelope from the embedded MCP model. + */ + public function test_to_representation_returns_envelope(): void { + $mcp = new MCP(); + $mcp->result = ['jsonrpc' => '2.0', 'id' => 1, 'result' => ['ok' => true]]; + $resp = new MCPResponse(data: $mcp); + $this->assert_equals($resp->to_representation(), ['jsonrpc' => '2.0', 'id' => 1, 'result' => ['ok' => true]]); + } + + /** + * Ensures `to_mcp_response()` converts a 401 error into an MCP JSON-RPC envelope with the + * INVALID_REQUEST error code. + */ + public function test_to_mcp_response_authentication_error(): void { + $auth_err = new AuthenticationError(message: 'no auth', response_id: 'TEST_AUTH'); + $resp = MCPResponse::to_mcp_response($auth_err); + $envelope = $resp->to_representation(); + + $this->assert_equals($envelope['jsonrpc'], '2.0'); + $this->assert_equals($envelope['error']['code'], -32600); + $this->assert_equals($envelope['error']['message'], 'no auth'); + $this->assert_equals($envelope['error']['data']['response_id'], 'TEST_AUTH'); + $this->assert_equals($envelope['error']['data']['http_code'], 401); + } + + /** + * Ensures `to_mcp_response()` converts a 404 error into an MCP JSON-RPC envelope with the METHOD_NOT_FOUND + * error code. + */ + public function test_to_mcp_response_not_found(): void { + $err = new NotFoundError(message: 'gone', response_id: 'TEST_NF'); + $resp = MCPResponse::to_mcp_response($err); + $envelope = $resp->to_representation(); + + $this->assert_equals($envelope['error']['code'], -32601); + $this->assert_equals($envelope['error']['data']['http_code'], 404); + } + + /** + * Ensures `to_mcp_response()` converts a generic 4xx validation error into an INVALID_PARAMS error. + */ + public function test_to_mcp_response_validation_error(): void { + $err = new ValidationError(message: 'bad', response_id: 'TEST_VAL'); + $resp = MCPResponse::to_mcp_response($err); + $envelope = $resp->to_representation(); + + $this->assert_equals($envelope['error']['code'], -32602); + } + + /** + * Ensures `to_mcp_response()` converts a 5xx server error into a SERVER_ERROR JSON-RPC error. + */ + public function test_to_mcp_response_server_error(): void { + $err = new ServerError(message: 'kaboom', response_id: 'TEST_SE'); + $resp = MCPResponse::to_mcp_response($err); + $envelope = $resp->to_representation(); + + $this->assert_equals($envelope['error']['code'], -32000); + } + + /** + * Ensures the OpenAPI schema generated by MCPResponse describes both the success and error envelope shapes. + */ + public function test_to_openapi_schema_describes_envelope(): void { + $resp = new MCPResponse(); + $schema = $resp->to_openapi_schema(); + $this->assert_equals($schema['type'], 'object'); + foreach (['jsonrpc', 'id', 'result', 'error'] as $expected) { + $this->assert_is_true(array_key_exists($expected, $schema['properties'])); + } + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APISchemasMCPSchemaTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APISchemasMCPSchemaTestCase.inc new file mode 100644 index 00000000..f439cc2b --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APISchemasMCPSchemaTestCase.inc @@ -0,0 +1,142 @@ +get_tools(); + + $this->assert_is_true(!empty($tools)); + $this->assert_is_true(array_key_exists('readFirewallAlias', $tools)); + $this->assert_is_true($tools['readFirewallAlias'] instanceof Tool); + } + + /** + * Ensures the MCPSchema reuses GraphQL operation naming so MCP and GraphQL use the same vocabulary. + */ + public function test_operation_naming_matches_graphql(): void { + $schema = new MCPSchema(); + $endpoint = new FirewallAliasEndpoint(); + $endpoint_many = new FirewallAliasesEndpoint(); + + $this->assert_equals( + $schema->endpoint_to_operation_name(operation: 'read', endpoint: $endpoint), + 'readFirewallAlias', + ); + $this->assert_equals( + $schema->endpoint_to_operation_name(operation: 'create', endpoint: $endpoint), + 'createFirewallAlias', + ); + $this->assert_equals( + $schema->endpoint_to_operation_name(operation: 'query', endpoint: $endpoint_many), + 'queryFirewallAliases', + ); + $this->assert_equals( + $schema->endpoint_to_operation_name(operation: 'replaceAll', endpoint: $endpoint_many), + 'replaceAllFirewallAliases', + ); + } + + /** + * Ensures every supported CRUD operation produces a tool for the FirewallAlias endpoints. + */ + public function test_endpoint_tool_coverage(): void { + $schema = new MCPSchema(); + $tools = $schema->get_tools(); + + # Single-object endpoints + $this->assert_is_true(array_key_exists('readFirewallAlias', $tools)); + $this->assert_is_true(array_key_exists('createFirewallAlias', $tools)); + $this->assert_is_true(array_key_exists('updateFirewallAlias', $tools)); + $this->assert_is_true(array_key_exists('deleteFirewallAlias', $tools)); + + # Many-object endpoints + $this->assert_is_true(array_key_exists('queryFirewallAliases', $tools)); + $this->assert_is_true(array_key_exists('replaceAllFirewallAliases', $tools)); + $this->assert_is_true(array_key_exists('deleteManyFirewallAliases', $tools)); + $this->assert_is_true(array_key_exists('deleteAllFirewallAliases', $tools)); + } + + /** + * Ensures the MCPEndpoint and GraphQLEndpoint are excluded from the generated tool catalog so the MCP + * server cannot recursively expose itself or the GraphQL dispatcher. + */ + public function test_excludes_mcp_and_graphql_endpoints(): void { + $schema = new MCPSchema(); + $tools = $schema->get_tools(); + $names = array_keys($tools); + + foreach ($names as $name) { + $this->assert_is_false(str_contains(strtolower($name), 'graphql')); + $this->assert_is_false(str_contains(strtolower($name), 'mcp')); + } + } + + /** + * Ensures the input schema for a representative read tool requires an `id` field. + */ + public function test_read_tool_requires_id(): void { + $schema = new MCPSchema(); + $tool = $schema->get_tools()['readFirewallAlias']; + + $this->assert_equals($tool->inputSchema['type'], 'object'); + $this->assert_is_true(in_array('id', $tool->inputSchema['required'])); + $this->assert_equals($tool->inputSchema['properties']['id']['type'], 'integer'); + } + + /** + * Ensures the input schema for a representative create tool exposes Field properties as JSON Schema. + */ + public function test_create_tool_includes_field_properties(): void { + $schema = new MCPSchema(); + $tool = $schema->get_tools()['createFirewallAlias']; + $properties = $tool->inputSchema['properties']; + + $this->assert_is_true(array_key_exists('name', $properties)); + $this->assert_is_true(array_key_exists('type', $properties)); + $this->assert_equals($properties['name']['type'], 'string'); + + # The `type` field has choices, so it should be exposed as an enum. + $this->assert_is_true(array_key_exists('enum', $properties['type'])); + } + + /** + * Ensures that a handler is registered for every generated tool so `tools/call` requests can be dispatched. + */ + public function test_tool_handlers_registered(): void { + $schema = new MCPSchema(); + $tools = $schema->get_tools(); + $handlers = $schema->get_tool_handlers(); + + foreach ($tools as $name => $_tool) { + $this->assert_is_true(array_key_exists($name, $handlers)); + $this->assert_is_true(in_array( + $handlers[$name]['action'], + ['query', 'read', 'create', 'update', 'delete', 'replace_all', 'delete_many', 'delete_all'], + )); + } + } + + /** + * Ensures the schema string is valid JSON containing a `tools` array, allowing it to be persisted to disk. + */ + public function test_get_schema_str_returns_valid_json(): void { + $schema = new MCPSchema(); + $decoded = json_decode($schema->get_schema_str(), associative: true); + $this->assert_type($decoded, 'array'); + $this->assert_is_true(array_key_exists('tools', $decoded)); + $this->assert_type($decoded['tools'], 'array'); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc index 63dd6e66..a92828c7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc @@ -39,6 +39,7 @@ const RESTAPI_LIBRARIES = [ '/usr/local/pkg/RESTAPI/ContentHandlers/', '/usr/local/pkg/RESTAPI/Schemas/', '/usr/local/pkg/RESTAPI/GraphQL/', + '/usr/local/pkg/RESTAPI/MCP/', '/usr/local/pkg/RESTAPI/Auth', '/usr/local/pkg/RESTAPI/Endpoints/', '/usr/local/pkg/RESTAPI/Forms/',