From a03bf40113596ac6610b23216c4f40240a7870ad Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 13:47:08 +0200 Subject: [PATCH 01/49] Rebuild the 2.x package skeleton: metadata, MIT license and test harness --- .gitignore | 8 +- LICENSE | 21 +++++ bin/mcp-server | 24 ------ composer.json | 55 ++++++++++-- config/bootstrap.php | 57 ------------ phpcs.xml | 9 ++ phpstan.neon | 6 ++ phpunit.xml.dist | 17 ++++ src/McpPlugin.php | 77 +---------------- src/Server/CakeServer.php | 50 ----------- tests/TestCase/McpPluginTest.php | 19 ++++ tests/bootstrap.php | 52 +++++++++++ tests/test_app/src/Application.php | 86 +++++++++++++++++++ tests/test_app/src/AuthorizationSpy.php | 21 +++++ tests/test_app/src/Mcp/.gitkeep | 0 .../Middleware/AuthorizationSpyMiddleware.php | 25 ++++++ tests/test_app/src/TestTokens.php | 28 ++++++ 17 files changed, 337 insertions(+), 218 deletions(-) create mode 100644 LICENSE delete mode 100755 bin/mcp-server delete mode 100644 config/bootstrap.php create mode 100644 phpcs.xml create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist delete mode 100644 src/Server/CakeServer.php create mode 100644 tests/TestCase/McpPluginTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/test_app/src/Application.php create mode 100644 tests/test_app/src/AuthorizationSpy.php create mode 100644 tests/test_app/src/Mcp/.gitkeep create mode 100644 tests/test_app/src/Middleware/AuthorizationSpyMiddleware.php create mode 100644 tests/test_app/src/TestTokens.php diff --git a/.gitignore b/.gitignore index 4f38912..b0abe95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -.idea -vendor -composer.lock +/vendor/ +/composer.lock +/.phpunit.cache/ +/tests/test_app/tmp/ +.phpunit.result.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..500dd79 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025-2026 Cake Development Corporation (https://www.cakedc.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/bin/mcp-server b/bin/mcp-server deleted file mode 100755 index df5b343..0000000 --- a/bin/mcp-server +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env php -info('Starting CakePHP MCP Server...'); - -$root = dirname(__DIR__, 4); -$server = \CakeDC\Mcp\Server\CakeServer::builder(new \App\Application($root . '/config')) - ->setServerInfo('CakePHP MCP Server', '1.0.0', 'CakePHP MCP Server') - ->setContainer(container()) - ->setLogger(logger()) - ->setDiscovery($root, ['./src']) - ->build(); - -$transport = new StdioTransport(logger: logger()); - -$server->connect($transport); - -$transport->listen(); -logger()->info('Server listener has been successfully stopped.'); diff --git a/composer.json b/composer.json index ed8f909..3ba6f16 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,56 @@ { "name": "cakedc/cakephp-mcp", - "description": "MCP server for CakePHP", + "description": "Model Context Protocol (MCP) server for CakePHP: expose tools, resources and prompts to Claude and other MCP clients over HTTP or stdio", + "type": "cakephp-plugin", + "license": "MIT", + "keywords": ["cakephp", "mcp", "model context protocol", "ai", "claude", "llm"], + "authors": [ + { + "name": "Cake Development Corporation", + "homepage": "https://www.cakedc.com" + } + ], + "require": { + "php": ">=8.2", + "cakephp/cakephp": "^5.0", + "mcp/sdk": "dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "cakedc/auth": "^10.1 || ^11.0", + "cakephp/authentication": "^3.3", + "cakephp/cakephp-codesniffer": "^5.1", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5.5 || ^11.1.3 || ^12.1 || ^13.0" + }, + "suggest": { + "cakephp/authentication": "Identifies the caller of the MCP endpoint (for example with a Bearer token authenticator)", + "cakedc/auth": "Enables RbacToolAuthorizer, per-tool permissions in the config/permissions.php format" + }, "autoload": { "psr-4": { "CakeDC\\Mcp\\": "src/" } }, - "require": { - "php": ">8.2", - "mcp/sdk": "dev-main#4b91567", - "cakephp/cakephp": "^5.0" + "autoload-dev": { + "psr-4": { + "CakeDC\\Mcp\\Test\\": "tests/", + "TestApp\\": "tests/test_app/src/" + } + }, + "scripts": { + "test": "phpunit", + "cs-check": "phpcs --colors -p", + "cs-fix": "phpcbf --colors -p", + "stan": "phpstan analyse --no-progress" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "php-http/discovery": false + } }, - "bin": [ - "bin/mcp-server" - ] + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/config/bootstrap.php b/config/bootstrap.php deleted file mode 100644 index 1e93bdd..0000000 --- a/config/bootstrap.php +++ /dev/null @@ -1,57 +0,0 @@ -critical('Uncaught exception: '.$t->getMessage(), ['exception' => $t]); - - exit(1); -}); - -function logger(): LoggerInterface -{ - return new class extends AbstractLogger { - public function log($level, Stringable|string $message, array $context = []): void - { - $debug = $_SERVER['DEBUG'] ?? false; - - if (!$debug && 'debug' === $level) { - return; - } - - $logMessage = sprintf( - "[%s] %s %s\n", - strtoupper($level), - $message, - ([] === $context || !$debug) ? '' : json_encode($context), - ); - - if (($_SERVER['FILE_LOG'] ?? false) || !defined('STDERR')) { - file_put_contents('dev.log', $logMessage, \FILE_APPEND); - } else { - fwrite(\STDERR, $logMessage); - } - } - }; -} - -function container(): Container -{ - $container = new Container(); - $container->set(LoggerInterface::class, logger()); - - return $container; -} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..7cd6e78 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,9 @@ + + + + + src/ + tests/ + + tests/test_app/tmp/* + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..12855a9 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 8 + paths: + - src/ + bootstrapFiles: + - tests/bootstrap.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..eb79a0c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/TestCase/ + + + + + src/ + + + diff --git a/src/McpPlugin.php b/src/McpPlugin.php index 2652e2b..829dac0 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -3,86 +3,11 @@ namespace CakeDC\Mcp; -use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; -use Cake\Core\ContainerInterface; -use Cake\Core\PluginApplicationInterface; -use Cake\Http\MiddlewareQueue; -use Cake\Routing\RouteBuilder; /** - * Plugin for Mcp + * Exposes an MCP (Model Context Protocol) server from a CakePHP application. */ class McpPlugin extends BasePlugin { - /** - * Load all the plugin configuration and bootstrap logic. - * - * The host application is provided as an argument. This allows you to load - * additional plugin dependencies, or attach events. - * - * @param \Cake\Core\PluginApplicationInterface $app The host application - * @return void - */ - public function bootstrap(PluginApplicationInterface $app): void - { - // remove this method hook if you don't need it - } - - /** - * Add routes for the plugin. - * - * If your plugin has many routes and you would like to isolate them into a separate file, - * you can create `$plugin/config/routes.php` and delete this method. - * - * @param \Cake\Routing\RouteBuilder $routes The route builder to update. - * @return void - */ - public function routes(RouteBuilder $routes): void - { - parent::routes($routes); - } - - /** - * Add middleware for the plugin. - * - * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. - * @return \Cake\Http\MiddlewareQueue - */ - public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue - { - // Add your middlewares here - // remove this method hook if you don't need it - - return $middlewareQueue; - } - - /** - * Add commands for the plugin. - * - * @param \Cake\Console\CommandCollection $commands The command collection to update. - * @return \Cake\Console\CommandCollection - */ - public function console(CommandCollection $commands): CommandCollection - { - // Add your commands here - // remove this method hook if you don't need it - - $commands = parent::console($commands); - - return $commands; - } - - /** - * Register application container services. - * - * @param \Cake\Core\ContainerInterface $container The Container to update. - * @return void - * @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection - */ - public function services(ContainerInterface $container): void - { - // Add your services here - // remove this method hook if you don't need it - } } diff --git a/src/Server/CakeServer.php b/src/Server/CakeServer.php deleted file mode 100644 index 7290a4d..0000000 --- a/src/Server/CakeServer.php +++ /dev/null @@ -1,50 +0,0 @@ -bootstrap(); - - if (!Configure::read('App.base')) { - Configure::write('App.base', ''); - } - - self::$application->pluginBootstrap(); - } - - public static function builder(BaseApplication $application): Builder - { - self::$application = $application; - self::bootstrap(); - - return Server::builder(); - } - - public function connect(TransportInterface $transport): void - { - $this->server->connect($transport); - } -} diff --git a/tests/TestCase/McpPluginTest.php b/tests/TestCase/McpPluginTest.php new file mode 100644 index 0000000..56642dc --- /dev/null +++ b/tests/TestCase/McpPluginTest.php @@ -0,0 +1,19 @@ +bootstrap(); + $app->pluginBootstrap(); + + $this->assertTrue($app->getPlugins()->has('CakeDC/Mcp')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b210919 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,52 @@ + 'TestApp', + 'encoding' => 'UTF-8', + 'base' => false, + 'baseUrl' => false, + 'dir' => APP_DIR, + 'webroot' => 'webroot', + 'wwwRoot' => WWW_ROOT, + 'fullBaseUrl' => 'http://localhost', + 'paths' => [ + 'plugins' => [ROOT . 'plugins' . DS], + 'templates' => [ROOT . 'templates' . DS], + ], +]); + +Security::setSalt('cakephp-mcp-test-salt-cakephp-mcp-test-salt-0123456789'); + +Cache::setConfig([ + '_cake_core_' => ['engine' => 'Array'], + '_cake_translations_' => ['engine' => 'Array'], + '_cake_model_' => ['engine' => 'Array'], + '_cake_routes_' => ['engine' => 'Array'], +]); diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php new file mode 100644 index 0000000..a8bc3aa --- /dev/null +++ b/tests/test_app/src/Application.php @@ -0,0 +1,86 @@ +addPlugin(McpPlugin::class); + } + + /** + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The queue. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + $csrf = new CsrfProtectionMiddleware(); + $csrf->skipCheckCallback(fn(ServerRequest $request): bool => str_starts_with($request->getPath(), '/mcp')); + + return $middlewareQueue + ->add(new ErrorHandlerMiddleware()) + ->add(new RoutingMiddleware($this)) + ->add(new BodyParserMiddleware()) + ->add($csrf) + ->add(new AuthenticationMiddleware($this)) + ->add(new AuthorizationSpyMiddleware()); + } + + /** + * @param \Cake\Routing\RouteBuilder $routes The builder. + * @return void + */ + public function routes(RouteBuilder $routes): void + { + $routes->scope('/', function (RouteBuilder $builder): void { + $builder->fallbacks(); + }); + } + + /** + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @return \Authentication\AuthenticationServiceInterface + */ + public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface + { + $service = new AuthenticationService(); + $service->loadAuthenticator('Authentication.Token', [ + 'header' => 'Authorization', + 'tokenPrefix' => 'Bearer', + 'identifier' => [ + 'Authentication.Callback' => [ + 'callback' => fn(array $data): ?ArrayObject => TestTokens::identity($data['token'] ?? null), + ], + ], + ]); + + return $service; + } +} diff --git a/tests/test_app/src/AuthorizationSpy.php b/tests/test_app/src/AuthorizationSpy.php new file mode 100644 index 0000000..507ff69 --- /dev/null +++ b/tests/test_app/src/AuthorizationSpy.php @@ -0,0 +1,21 @@ +handle($request->withAttribute('authorization', new AuthorizationSpy())); + } +} diff --git a/tests/test_app/src/TestTokens.php b/tests/test_app/src/TestTokens.php new file mode 100644 index 0000000..a6cfc98 --- /dev/null +++ b/tests/test_app/src/TestTokens.php @@ -0,0 +1,28 @@ +|null + */ + public static function identity(?string $token): ?ArrayObject + { + return match ($token) { + self::EDITOR => new ArrayObject(['id' => 1, 'role' => 'editor', 'name' => 'Ed']), + self::ADMIN => new ArrayObject(['id' => 2, 'role' => 'admin', 'name' => 'Ada']), + default => null, + }; + } +} From 395a9a37689491cd907447d3460fd4b8b31f7a7e Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 13:49:13 +0200 Subject: [PATCH 02/49] Add McpConfig defaults, McpLogger and default cache configs --- src/Authorization/AllowAllToolAuthorizer.php | 21 ++++ src/Authorization/ToolAuthorizerInterface.php | 17 +++ src/Log/McpLogger.php | 29 +++++ src/McpConfig.php | 105 ++++++++++++++++++ src/McpPlugin.php | 25 +++++ tests/TestCase/Log/McpLoggerTest.php | 36 ++++++ tests/TestCase/McpConfigTest.php | 71 ++++++++++++ tests/TestCase/McpPluginTest.php | 45 ++++++++ 8 files changed, 349 insertions(+) create mode 100644 src/Authorization/AllowAllToolAuthorizer.php create mode 100644 src/Authorization/ToolAuthorizerInterface.php create mode 100644 src/Log/McpLogger.php create mode 100644 src/McpConfig.php create mode 100644 tests/TestCase/Log/McpLoggerTest.php create mode 100644 tests/TestCase/McpConfigTest.php diff --git a/src/Authorization/AllowAllToolAuthorizer.php b/src/Authorization/AllowAllToolAuthorizer.php new file mode 100644 index 0000000..fb27ae7 --- /dev/null +++ b/src/Authorization/AllowAllToolAuthorizer.php @@ -0,0 +1,21 @@ + $context Context; `scope` is extended with `mcp`. + * @return void + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + $context['scope'] = array_merge((array)($context['scope'] ?? []), [self::SCOPE]); + Log::write(is_string($level) ? $level : 'info', $message, $context); + } +} diff --git a/src/McpConfig.php b/src/McpConfig.php new file mode 100644 index 0000000..ec69564 --- /dev/null +++ b/src/McpConfig.php @@ -0,0 +1,105 @@ + + */ + public const DEFAULTS = [ + 'route' => '/mcp', + 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null], + 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], + 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], + 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], + 'identity' => ['required' => true, 'attribute' => 'identity', 'scopes' => null], + 'authorization' => ['authorizer' => AllowAllToolAuthorizer::class, 'rbac' => []], + 'stdio' => ['identity' => null, 'trusted' => true], + ]; + + /** + * @param string|null $path Dot path inside the `Mcp` config, or null for all of it. + * @return mixed + */ + public static function get(?string $path = null): mixed + { + $config = self::merge(self::DEFAULTS, (array)Configure::read('Mcp')); + + return $path === null ? $config : Hash::get($config, $path); + } + + /** + * The cache config used for the discovery cache, or null when it is disabled. + * Always null in debug mode, so new tools show up without clearing caches. + * + * @return string|null + */ + public static function discoveryCache(): ?string + { + $name = self::get('discovery.cache'); + if (Configure::read('debug') || !is_string($name) || $name === '') { + return null; + } + + return $name; + } + + /** + * Hosts the DNS-rebinding protection accepts. Defaults to the host of + * `App.fullBaseUrl` plus localhost; the SDK default (localhost only) would + * reject every request to a real domain. + * + * @return list + */ + public static function allowedHosts(): array + { + $hosts = self::get('http.allowedHosts'); + if (is_array($hosts)) { + return array_values(array_map(fn(mixed $host): string => strtolower((string)$host), $hosts)); + } + + $allowed = ['localhost', '127.0.0.1', '[::1]']; + $host = parse_url((string)Configure::read('App.fullBaseUrl'), PHP_URL_HOST); + if (is_string($host) && $host !== '') { + array_unshift($allowed, strtolower($host)); + } + + return array_values(array_unique($allowed)); + } + + /** + * Recursive merge where associative arrays merge and lists are replaced. + * + * @param array $base The defaults. + * @param array $override The host values. + * @return array + */ + private static function merge(array $base, array $override): array + { + foreach ($override as $key => $value) { + if ( + is_array($value) && isset($base[$key]) && is_array($base[$key]) + && !array_is_list($value) && !array_is_list($base[$key]) + ) { + $base[$key] = self::merge($base[$key], $value); + + continue; + } + $base[$key] = $value; + } + + return $base; + } +} diff --git a/src/McpPlugin.php b/src/McpPlugin.php index 829dac0..f20a965 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -3,11 +3,36 @@ namespace CakeDC\Mcp; +use Cake\Cache\Cache; +use Cake\Cache\Engine\FileEngine; use Cake\Core\BasePlugin; +use Cake\Core\PluginApplicationInterface; /** * Exposes an MCP (Model Context Protocol) server from a CakePHP application. */ class McpPlugin extends BasePlugin { + /** + * Defines the discovery and session cache configs when the host did not. + * + * @param \Cake\Core\PluginApplicationInterface $app The host application. + * @return void + */ + public function bootstrap(PluginApplicationInterface $app): void + { + parent::bootstrap($app); + + foreach ([McpConfig::get('discovery.cache'), McpConfig::get('session.cache')] as $name) { + if (!is_string($name) || $name === '' || Cache::getConfig($name) !== null) { + continue; + } + Cache::setConfig($name, [ + 'className' => FileEngine::class, + 'path' => CACHE . 'mcp' . DS, + 'prefix' => $name . '_', + 'duration' => '+1 hours', + ]); + } + } } diff --git a/tests/TestCase/Log/McpLoggerTest.php b/tests/TestCase/Log/McpLoggerTest.php new file mode 100644 index 0000000..ff2c6d2 --- /dev/null +++ b/tests/TestCase/Log/McpLoggerTest.php @@ -0,0 +1,36 @@ + ArrayLog::class, 'scopes' => ['mcp']]); + } + + protected function tearDown(): void + { + Log::drop('mcp_test'); + parent::tearDown(); + } + + public function testWritesToCakeLogUnderTheMcpScope(): void + { + (new McpLogger())->warning('Tool {name} failed', ['name' => 'echo']); + + $engine = Log::engine('mcp_test'); + $this->assertInstanceOf(ArrayLog::class, $engine); + $lines = $engine->read(); + $this->assertCount(1, $lines); + $this->assertStringContainsString('warning', $lines[0]); + $this->assertStringContainsString('Tool echo failed', $lines[0]); + } +} diff --git a/tests/TestCase/McpConfigTest.php b/tests/TestCase/McpConfigTest.php new file mode 100644 index 0000000..c4015c0 --- /dev/null +++ b/tests/TestCase/McpConfigTest.php @@ -0,0 +1,71 @@ +assertSame('/mcp', McpConfig::get('route')); + $this->assertSame(['src/Mcp'], McpConfig::get('discovery.scanDirs')); + $this->assertTrue(McpConfig::get('identity.required')); + $this->assertSame(AllowAllToolAuthorizer::class, McpConfig::get('authorization.authorizer')); + $this->assertTrue(McpConfig::get('stdio.trusted')); + } + + public function testHostValuesOverrideAndKeepSiblingDefaults(): void + { + Configure::write('Mcp', ['server' => ['name' => 'Host Server']]); + + $this->assertSame('Host Server', McpConfig::get('server.name')); + $this->assertSame('1.0.0', McpConfig::get('server.version')); + } + + public function testListsAreReplacedNotAppended(): void + { + Configure::write('Mcp.discovery.scanDirs', ['src/Tools']); + + $this->assertSame(['src/Tools'], McpConfig::get('discovery.scanDirs')); + } + + public function testRouteCanBeDisabledWithFalse(): void + { + Configure::write('Mcp.route', false); + + $this->assertFalse(McpConfig::get('route')); + } + + public function testDiscoveryCacheIsOffInDebugAndNamedOtherwise(): void + { + Configure::write('debug', true); + $this->assertNull(McpConfig::discoveryCache()); + + Configure::write('debug', false); + $this->assertSame('_cake_mcp_', McpConfig::discoveryCache()); + + Configure::write('Mcp.discovery.cache', false); + $this->assertNull(McpConfig::discoveryCache()); + } + + public function testAllowedHostsDefaultToFullBaseUrlHostPlusLocalhost(): void + { + Configure::write('App.fullBaseUrl', 'https://MCP.Example.com:8443'); + + $this->assertSame(['mcp.example.com', 'localhost', '127.0.0.1', '[::1]'], McpConfig::allowedHosts()); + } + + public function testExplicitAllowedHostsAreUsedAsGivenLowercased(): void + { + Configure::write('Mcp.http.allowedHosts', ['API.example.com']); + + $this->assertSame(['api.example.com'], McpConfig::allowedHosts()); + } +} diff --git a/tests/TestCase/McpPluginTest.php b/tests/TestCase/McpPluginTest.php index 56642dc..7e6c67b 100644 --- a/tests/TestCase/McpPluginTest.php +++ b/tests/TestCase/McpPluginTest.php @@ -3,11 +3,34 @@ namespace CakeDC\Mcp\Test\TestCase; +use Cake\Cache\Cache; +use Cake\Cache\Engine\ArrayEngine; +use Cake\Cache\Engine\FileEngine; +use Cake\Core\Configure; use Cake\TestSuite\TestCase; use TestApp\Application; class McpPluginTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->tearDownCaches(); + } + + protected function tearDown(): void + { + $this->tearDownCaches(); + parent::tearDown(); + } + + private function tearDownCaches(): void + { + Cache::drop('_cake_mcp_'); + Cache::drop('mcp_sessions'); + Cache::drop('host_sessions'); + } + public function testTheTestAppLoadsThePlugin(): void { $app = new Application(CONFIG); @@ -16,4 +39,26 @@ public function testTheTestAppLoadsThePlugin(): void $this->assertTrue($app->getPlugins()->has('CakeDC/Mcp')); } + + public function testBootstrapDefinesMissingCacheConfigs(): void + { + $app = new Application(CONFIG); + $app->bootstrap(); + $app->pluginBootstrap(); + + $this->assertInstanceOf(FileEngine::class, Cache::pool('_cake_mcp_')); + $this->assertInstanceOf(FileEngine::class, Cache::pool('mcp_sessions')); + } + + public function testBootstrapKeepsCacheConfigsTheHostDefined(): void + { + Configure::write('Mcp.session.cache', 'host_sessions'); + Cache::setConfig('host_sessions', ['className' => ArrayEngine::class]); + + $app = new Application(CONFIG); + $app->bootstrap(); + $app->pluginBootstrap(); + + $this->assertInstanceOf(ArrayEngine::class, Cache::pool('host_sessions')); + } } From 3fa1d36dc4d9f8426f4b28893450931d0a8148b3 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 13:51:08 +0200 Subject: [PATCH 03/49] Add an autowiring PSR-11 bridge over Cake's container --- src/Container/ContainerBridge.php | 130 ++++++++++++++++++ src/Container/ContainerException.php | 11 ++ src/Container/NotFoundException.php | 11 ++ .../Container/ContainerBridgeTest.php | 76 ++++++++++ tests/test_app/src/Service/Clock.php | 12 ++ tests/test_app/src/Service/CycleA.php | 11 ++ tests/test_app/src/Service/CycleB.php | 11 ++ tests/test_app/src/Service/Greeter.php | 11 ++ tests/test_app/src/Service/NeedsScalar.php | 11 ++ 9 files changed, 284 insertions(+) create mode 100644 src/Container/ContainerBridge.php create mode 100644 src/Container/ContainerException.php create mode 100644 src/Container/NotFoundException.php create mode 100644 tests/TestCase/Container/ContainerBridgeTest.php create mode 100644 tests/test_app/src/Service/Clock.php create mode 100644 tests/test_app/src/Service/CycleA.php create mode 100644 tests/test_app/src/Service/CycleB.php create mode 100644 tests/test_app/src/Service/Greeter.php create mode 100644 tests/test_app/src/Service/NeedsScalar.php diff --git a/src/Container/ContainerBridge.php b/src/Container/ContainerBridge.php new file mode 100644 index 0000000..8d2b741 --- /dev/null +++ b/src/Container/ContainerBridge.php @@ -0,0 +1,130 @@ + + */ + private array $instances = []; + + /** + * @var array + */ + private array $building = []; + + /** + * @param \Psr\Container\ContainerInterface $container The host container. + */ + public function __construct(private readonly ContainerInterface $container) + { + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + return $this->container->has($id) || class_exists($id); + } + + /** + * @inheritDoc + */ + public function get(string $id): mixed + { + if ($this->container->has($id)) { + return $this->container->get($id); + } + if (isset($this->instances[$id])) { + return $this->instances[$id]; + } + if (!class_exists($id)) { + throw new NotFoundException(sprintf('`%s` is not registered in the container and is not a class.', $id)); + } + + return $this->instances[$id] = $this->build($id); + } + + /** + * @param class-string $class The class to build. + * @return object + */ + private function build(string $class): object + { + if (isset($this->building[$class])) { + throw new ContainerException(sprintf('Circular dependency while resolving `%s`.', $class)); + } + $reflection = new ReflectionClass($class); + if (!$reflection->isInstantiable()) { + throw new ContainerException(sprintf('`%s` is not instantiable.', $class)); + } + + $this->building[$class] = true; + try { + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return $reflection->newInstance(); + } + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $arguments[] = $this->resolveParameter($parameter, $class); + } + + return $reflection->newInstanceArgs($arguments); + } finally { + unset($this->building[$class]); + } + } + + /** + * @param \ReflectionParameter $parameter The constructor parameter. + * @param string $class The class being built (for error messages). + * @return mixed + */ + private function resolveParameter(ReflectionParameter $parameter, string $class): mixed + { + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $name = $type->getName(); + if ($this->container->has($name) || isset($this->building[$name]) || $this->isInstantiable($name)) { + return $this->get($name); + } + } + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + if ($parameter->allowsNull()) { + return null; + } + + throw new ContainerException(sprintf( + 'Cannot resolve parameter `$%s` of `%s`.', + $parameter->getName(), + $class, + )); + } + + /** + * @param string $name A class or interface name. + * @return bool + */ + private function isInstantiable(string $name): bool + { + return class_exists($name) && (new ReflectionClass($name))->isInstantiable(); + } +} diff --git a/src/Container/ContainerException.php b/src/Container/ContainerException.php new file mode 100644 index 0000000..27d8a44 --- /dev/null +++ b/src/Container/ContainerException.php @@ -0,0 +1,11 @@ +addShared(Clock::class, $clock); + + $this->assertSame($clock, (new ContainerBridge($container))->get(Clock::class)); + } + + public function testAutowiresUnregisteredClassesWithDefaults(): void + { + $greeter = (new ContainerBridge(new Container()))->get(Greeter::class); + + $this->assertInstanceOf(Greeter::class, $greeter); + $this->assertInstanceOf(Clock::class, $greeter->clock); + $this->assertSame('Hello', $greeter->greeting); + } + + public function testAutowiredClassesReceiveRegisteredDependencies(): void + { + $container = new Container(); + $clock = new Clock(); + $container->addShared(Clock::class, $clock); + + $greeter = (new ContainerBridge($container))->get(Greeter::class); + + $this->assertSame($clock, $greeter->clock); + } + + public function testAutowiredInstancesAreReusedByTheSameBridge(): void + { + $bridge = new ContainerBridge(new Container()); + + $this->assertSame($bridge->get(Greeter::class), $bridge->get(Greeter::class)); + } + + public function testUnknownIdIsNotFound(): void + { + $bridge = new ContainerBridge(new Container()); + + $this->assertFalse($bridge->has('No\\Such\\Thing')); + $this->expectException(NotFoundException::class); + $bridge->get('No\\Such\\Thing'); + } + + public function testUnresolvableScalarFails(): void + { + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('$required'); + (new ContainerBridge(new Container()))->get(NeedsScalar::class); + } + + public function testCircularDependencyFails(): void + { + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('Circular'); + (new ContainerBridge(new Container()))->get(CycleA::class); + } +} diff --git a/tests/test_app/src/Service/Clock.php b/tests/test_app/src/Service/Clock.php new file mode 100644 index 0000000..509f7db --- /dev/null +++ b/tests/test_app/src/Service/Clock.php @@ -0,0 +1,12 @@ + Date: Thu, 15 Oct 2026 13:52:56 +0200 Subject: [PATCH 04/49] Add CurrentIdentity and the identity-to-AccessToken middleware --- src/Identity/CurrentIdentity.php | 68 +++++++++ src/Middleware/IdentityMiddleware.php | 101 +++++++++++++ .../TestCase/Identity/CurrentIdentityTest.php | 41 +++++ .../Middleware/IdentityMiddlewareTest.php | 143 ++++++++++++++++++ 4 files changed, 353 insertions(+) create mode 100644 src/Identity/CurrentIdentity.php create mode 100644 src/Middleware/IdentityMiddleware.php create mode 100644 tests/TestCase/Identity/CurrentIdentityTest.php create mode 100644 tests/TestCase/Middleware/IdentityMiddlewareTest.php diff --git a/src/Identity/CurrentIdentity.php b/src/Identity/CurrentIdentity.php new file mode 100644 index 0000000..a6681f1 --- /dev/null +++ b/src/Identity/CurrentIdentity.php @@ -0,0 +1,68 @@ +identity = $identity; + } + + /** + * @return mixed + */ + public function get(): mixed + { + return $this->identity; + } + + /** + * @return bool + */ + public function has(): bool + { + return $this->identity !== null; + } + + /** + * @return string|int|null + */ + public function getIdentifier(): string|int|null + { + return self::identifierOf($this->identity); + } + + /** + * The identifier of any identity shape: objects with `getIdentifier()` + * (cakephp/authentication), then arrays and `ArrayAccess` (entities, + * `ArrayObject`) through their `id`. + * + * @param mixed $identity The identity. + * @return string|int|null + */ + public static function identifierOf(mixed $identity): string|int|null + { + $id = null; + if (is_object($identity) && method_exists($identity, 'getIdentifier')) { + $id = $identity->getIdentifier(); + } elseif (is_array($identity) || $identity instanceof ArrayAccess) { + $id = $identity['id'] ?? null; + } + + return is_int($id) || is_string($id) ? $id : null; + } +} diff --git a/src/Middleware/IdentityMiddleware.php b/src/Middleware/IdentityMiddleware.php new file mode 100644 index 0000000..96d93ab --- /dev/null +++ b/src/Middleware/IdentityMiddleware.php @@ -0,0 +1,101 @@ +current->set(null); + + if ($request->getMethod() === 'OPTIONS') { + return $handler->handle($request); + } + + $identity = $request->getAttribute($this->attribute); + if ($identity === null) { + return $this->required ? $this->unauthorized() : $handler->handle($request); + } + + $this->current->set($identity); + $claims = []; + $identifier = CurrentIdentity::identifierOf($identity); + if ($identifier !== null) { + $claims['sub'] = (string)$identifier; + } + + return $handler->handle($request->withAttribute( + AccessToken::class, + new AccessToken($this->scopesFor($identity), $claims), + )); + } + + /** + * @param mixed $identity The identity. + * @return list + */ + private function scopesFor(mixed $identity): array + { + if ($this->scopes === null) { + return []; + } + $scopes = ($this->scopes)($identity); + + return is_array($scopes) ? array_values(array_filter($scopes, 'is_string')) : []; + } + + /** + * @return \Psr\Http\Message\ResponseInterface + */ + private function unauthorized(): ResponseInterface + { + $body = (string)json_encode([ + 'error' => 'unauthorized', + 'error_description' => 'Authentication required.', + ]); + + return $this->responseFactory->createResponse(401) + ->withHeader('WWW-Authenticate', 'Bearer') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + } +} diff --git a/tests/TestCase/Identity/CurrentIdentityTest.php b/tests/TestCase/Identity/CurrentIdentityTest.php new file mode 100644 index 0000000..a54f644 --- /dev/null +++ b/tests/TestCase/Identity/CurrentIdentityTest.php @@ -0,0 +1,41 @@ +assertSame(7, CurrentIdentity::identifierOf(['id' => 7])); + $this->assertSame('u-1', CurrentIdentity::identifierOf(new ArrayObject(['id' => 'u-1']))); + $this->assertSame(9, CurrentIdentity::identifierOf(new Entity(['id' => 9]))); + $this->assertSame(11, CurrentIdentity::identifierOf(new Identity(new ArrayObject(['id' => 11])))); + } + + public function testIdentifierIsNullForMissingOrOddValues(): void + { + $this->assertNull(CurrentIdentity::identifierOf(null)); + $this->assertNull(CurrentIdentity::identifierOf(['name' => 'no id'])); + $this->assertNull(CurrentIdentity::identifierOf(['id' => 1.5])); + $this->assertNull(CurrentIdentity::identifierOf('a string')); + } + + public function testHoldsTheIdentity(): void + { + $current = new CurrentIdentity(); + $this->assertFalse($current->has()); + + $current->set(['id' => 3]); + + $this->assertTrue($current->has()); + $this->assertSame(['id' => 3], $current->get()); + $this->assertSame(3, $current->getIdentifier()); + } +} diff --git a/tests/TestCase/Middleware/IdentityMiddlewareTest.php b/tests/TestCase/Middleware/IdentityMiddlewareTest.php new file mode 100644 index 0000000..ec518ea --- /dev/null +++ b/tests/TestCase/Middleware/IdentityMiddlewareTest.php @@ -0,0 +1,143 @@ +current = new CurrentIdentity(); + $this->handled = null; + } + + public function testMissingIdentityIsChallengedWhenRequired(): void + { + $response = $this->middleware()->process($this->request(), $this->handler()); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('Bearer', $response->getHeaderLine('WWW-Authenticate')); + $this->assertStringContainsString('unauthorized', (string)$response->getBody()); + $this->assertNull($this->handled); + } + + public function testMissingIdentityPassesWhenNotRequired(): void + { + $response = $this->middleware(required: false)->process($this->request(), $this->handler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($this->handled); + $this->assertNull($this->handled->getAttribute(AccessToken::class)); + $this->assertFalse($this->current->has()); + } + + public function testOptionsPassesWithoutIdentity(): void + { + $response = $this->middleware()->process($this->request('OPTIONS'), $this->handler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($this->handled); + } + + public function testIdentityBecomesAnAccessTokenAndTheCurrentIdentity(): void + { + $identity = ['id' => 7, 'role' => 'editor']; + + $this->middleware()->process($this->request()->withAttribute('identity', $identity), $this->handler()); + + $token = $this->handled?->getAttribute(AccessToken::class); + $this->assertInstanceOf(AccessToken::class, $token); + $this->assertSame('7', $token->getSubject()); + $this->assertSame([], $token->getScopes()); + $this->assertSame($identity, $this->current->get()); + } + + public function testScopesComeFromTheConfiguredCallable(): void + { + $scopes = fn(mixed $identity): array => ['posts:' . $identity['role'], 42]; + + $this->middleware(scopes: $scopes)->process( + $this->request()->withAttribute('identity', ['id' => 1, 'role' => 'editor']), + $this->handler(), + ); + + $token = $this->handled?->getAttribute(AccessToken::class); + $this->assertInstanceOf(AccessToken::class, $token); + $this->assertSame(['posts:editor'], $token->getScopes()); + } + + public function testCustomAttributeName(): void + { + $this->middleware(attribute: 'user')->process( + $this->request()->withAttribute('user', ['id' => 5]), + $this->handler(), + ); + + $this->assertSame(5, $this->current->getIdentifier()); + } + + public function testCurrentIdentityIsResetOnEveryRequest(): void + { + $this->current->set(['id' => 99]); + + $this->middleware(required: false)->process($this->request(), $this->handler()); + + $this->assertFalse($this->current->has()); + } + + private function middleware(bool $required = true, string $attribute = 'identity', ?Closure $scopes = null): IdentityMiddleware + { + return new IdentityMiddleware( + $this->current, + new ResponseFactory(), + new StreamFactory(), + $required, + $attribute, + $scopes, + ); + } + + private function request(string $method = 'POST'): ServerRequest + { + return new ServerRequest(['environment' => ['REQUEST_METHOD' => $method]]); + } + + private function handler(): RequestHandlerInterface + { + return new class ($this) implements RequestHandlerInterface { + public function __construct(private IdentityMiddlewareTest $test) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->test->record($request); + + return new Response(); + } + }; + } + + public function record(ServerRequestInterface $request): void + { + $this->handled = $request; + } +} From e5d85e2481e4e33ab864f0901fab3186d38dd726 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 13:55:10 +0200 Subject: [PATCH 05/49] Enforce per-tool authorization: call guard, list filter and PSR-14 dispatcher --- src/Authorization/ToolCallGuard.php | 46 +++++++++++++ src/Authorization/ToolGate.php | 34 ++++++++++ src/Event/EventDispatcher.php | 60 ++++++++++++++++ src/Event/ToolListFilter.php | 42 ++++++++++++ .../Authorization/ToolCallGuardTest.php | 50 ++++++++++++++ tests/TestCase/Authorization/ToolGateTest.php | 40 +++++++++++ tests/TestCase/Event/EventDispatcherTest.php | 52 ++++++++++++++ tests/TestCase/Event/ToolListFilterTest.php | 68 +++++++++++++++++++ 8 files changed, 392 insertions(+) create mode 100644 src/Authorization/ToolCallGuard.php create mode 100644 src/Authorization/ToolGate.php create mode 100644 src/Event/EventDispatcher.php create mode 100644 src/Event/ToolListFilter.php create mode 100644 tests/TestCase/Authorization/ToolCallGuardTest.php create mode 100644 tests/TestCase/Authorization/ToolGateTest.php create mode 100644 tests/TestCase/Event/EventDispatcherTest.php create mode 100644 tests/TestCase/Event/ToolListFilterTest.php diff --git a/src/Authorization/ToolCallGuard.php b/src/Authorization/ToolCallGuard.php new file mode 100644 index 0000000..43bd129 --- /dev/null +++ b/src/Authorization/ToolCallGuard.php @@ -0,0 +1,46 @@ + + */ +final class ToolCallGuard implements RequestHandlerInterface +{ + /** + * @param \CakeDC\Mcp\Authorization\ToolGate $gate The gate. + */ + public function __construct(private readonly ToolGate $gate) + { + } + + /** + * @inheritDoc + */ + public function supports(Request $request): bool + { + return $request instanceof CallToolRequest && !$this->gate->allows($request->name); + } + + /** + * @inheritDoc + */ + public function handle(Request $request, SessionInterface $session): Error + { + $name = $request instanceof CallToolRequest ? $request->name : ''; + + return Error::forInvalidParams(sprintf('Tool not found: "%s".', $name), $request->getId()); + } +} diff --git a/src/Authorization/ToolGate.php b/src/Authorization/ToolGate.php new file mode 100644 index 0000000..ffeb88c --- /dev/null +++ b/src/Authorization/ToolGate.php @@ -0,0 +1,34 @@ +trusted || $this->authorizer->canCall($this->identity->get(), $tool); + } +} diff --git a/src/Event/EventDispatcher.php b/src/Event/EventDispatcher.php new file mode 100644 index 0000000..696f433 --- /dev/null +++ b/src/Event/EventDispatcher.php @@ -0,0 +1,60 @@ +> + */ + private array $listeners = []; + + /** + * @param string $eventClass Class or interface name. + * @param callable $listener Receives the event. + * @return void + */ + public function listen(string $eventClass, callable $listener): void + { + $this->listeners[$eventClass][] = $listener; + } + + /** + * Yields the listeners whose registered class matches the event. + * + * @param object $event The event being dispatched. + * @return iterable + */ + public function getListenersForEvent(object $event): iterable + { + foreach ($this->listeners as $class => $listeners) { + if ($event instanceof $class) { + yield from $listeners; + } + } + } + + /** + * @inheritDoc + */ + public function dispatch(object $event): object + { + foreach ($this->getListenersForEvent($event) as $listener) { + if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { + break; + } + $listener($event); + } + + return $event; + } +} diff --git a/src/Event/ToolListFilter.php b/src/Event/ToolListFilter.php new file mode 100644 index 0000000..9b601d9 --- /dev/null +++ b/src/Event/ToolListFilter.php @@ -0,0 +1,42 @@ +getResponse(); + $result = $response->result; + if (!$result instanceof ListToolsResult) { + return; + } + + $tools = array_values(array_filter( + $result->tools, + fn(Tool $tool): bool => $this->gate->allows($tool->name), + )); + $event->setResponse(new Response($response->getId(), new ListToolsResult($tools, $result->nextCursor))); + } +} diff --git a/tests/TestCase/Authorization/ToolCallGuardTest.php b/tests/TestCase/Authorization/ToolCallGuardTest.php new file mode 100644 index 0000000..679f8df --- /dev/null +++ b/tests/TestCase/Authorization/ToolCallGuardTest.php @@ -0,0 +1,50 @@ +guard = new ToolCallGuard(new ToolGate($onlyEcho, new CurrentIdentity())); + } + + public function testStepsAsideForAllowedCallsAndOtherMethods(): void + { + $this->assertFalse($this->guard->supports((new CallToolRequest('echo', []))->withId(1))); + $this->assertFalse($this->guard->supports((new ListToolsRequest())->withId(2))); + } + + public function testAnswersDeniedCallsLikeAnUnknownTool(): void + { + $request = (new CallToolRequest('admin_only', []))->withId(7); + + $this->assertTrue($this->guard->supports($request)); + $error = $this->guard->handle($request, $this->createStub(SessionInterface::class)); + + $this->assertInstanceOf(Error::class, $error); + $this->assertSame(Error::INVALID_PARAMS, $error->code); + $this->assertSame('Tool not found: "admin_only".', $error->message); + $this->assertSame(7, $error->id); + } +} diff --git a/tests/TestCase/Authorization/ToolGateTest.php b/tests/TestCase/Authorization/ToolGateTest.php new file mode 100644 index 0000000..4e6abe0 --- /dev/null +++ b/tests/TestCase/Authorization/ToolGateTest.php @@ -0,0 +1,40 @@ +set(['id' => 1, 'role' => 'editor']); + $authorizer = new class implements ToolAuthorizerInterface { + public function canCall(mixed $identity, string $tool): bool + { + return $identity['role'] === 'editor' && $tool === 'echo'; + } + }; + $gate = new ToolGate($authorizer, $identity); + + $this->assertTrue($gate->allows('echo')); + $this->assertFalse($gate->allows('admin_only')); + } + + public function testTrustedGateAllowsEverything(): void + { + $denyAll = new class implements ToolAuthorizerInterface { + public function canCall(mixed $identity, string $tool): bool + { + return false; + } + }; + + $this->assertTrue((new ToolGate($denyAll, new CurrentIdentity(), true))->allows('anything')); + } +} diff --git a/tests/TestCase/Event/EventDispatcherTest.php b/tests/TestCase/Event/EventDispatcherTest.php new file mode 100644 index 0000000..36b877c --- /dev/null +++ b/tests/TestCase/Event/EventDispatcherTest.php @@ -0,0 +1,52 @@ +listen(ArrayObject::class, function () use (&$calls): void { + $calls[] = 'exact'; + }); + $dispatcher->listen(IteratorAggregate::class, function () use (&$calls): void { + $calls[] = 'interface'; + }); + $dispatcher->listen(stdClass::class, function () use (&$calls): void { + $calls[] = 'unrelated'; + }); + $event = new ArrayObject(); + + $this->assertSame($event, $dispatcher->dispatch($event)); + $this->assertSame(['exact', 'interface'], $calls); + } + + public function testStopsOnceAStoppableEventIsStopped(): void + { + $dispatcher = new EventDispatcher(); + $event = new class implements StoppableEventInterface { + public int $calls = 0; + + public function isPropagationStopped(): bool + { + return $this->calls > 0; + } + }; + $dispatcher->listen($event::class, fn(object $e) => $e->calls++); + $dispatcher->listen($event::class, fn(object $e) => $e->calls++); + + $dispatcher->dispatch($event); + + $this->assertSame(1, $event->calls); + } +} diff --git a/tests/TestCase/Event/ToolListFilterTest.php b/tests/TestCase/Event/ToolListFilterTest.php new file mode 100644 index 0000000..011ce11 --- /dev/null +++ b/tests/TestCase/Event/ToolListFilterTest.php @@ -0,0 +1,68 @@ +tool('echo'), $this->tool('admin_only')], 'cursor-2'); + $event = $this->event(new Response(3, $result)); + + ($this->filter())($event); + + $filtered = $event->getResponse()->result; + $this->assertInstanceOf(ListToolsResult::class, $filtered); + $this->assertSame(['echo'], array_map(fn(Tool $tool): string => $tool->name, $filtered->tools)); + $this->assertSame('cursor-2', $filtered->nextCursor); + $this->assertSame(3, $event->getResponse()->getId()); + } + + public function testLeavesOtherResponsesAlone(): void + { + $response = new Response(4, ['anything' => true]); + $event = $this->event($response); + + ($this->filter())($event); + + $this->assertSame($response, $event->getResponse()); + } + + private function filter(): ToolListFilter + { + $onlyEcho = new class implements ToolAuthorizerInterface { + public function canCall(mixed $identity, string $tool): bool + { + return $tool === 'echo'; + } + }; + + return new ToolListFilter(new ToolGate($onlyEcho, new CurrentIdentity())); + } + + /** + * @param \Mcp\Schema\JsonRpc\Response $response + */ + private function event(Response $response): ResponseEvent + { + return new ResponseEvent($response, (new ListToolsRequest())->withId(1), $this->createStub(SessionInterface::class)); + } + + private function tool(string $name): Tool + { + return new Tool($name, null, ['type' => 'object'], null, null); + } +} From 41c72366845b08d829b721ce233d6e7a4da84973 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 13:56:45 +0200 Subject: [PATCH 06/49] Add RbacToolAuthorizer on top of cakedc/auth Rbac --- src/Authorization/RbacToolAuthorizer.php | 55 ++++++++++++ .../Authorization/RbacToolAuthorizerTest.php | 86 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/Authorization/RbacToolAuthorizer.php create mode 100644 tests/TestCase/Authorization/RbacToolAuthorizerTest.php diff --git a/src/Authorization/RbacToolAuthorizer.php b/src/Authorization/RbacToolAuthorizer.php new file mode 100644 index 0000000..b943dbc --- /dev/null +++ b/src/Authorization/RbacToolAuthorizer.php @@ -0,0 +1,55 @@ + 'editor', 'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools', 'action' => ['create_draft']] + * + * Requires `cakedc/auth`. + */ +final class RbacToolAuthorizer implements ToolAuthorizerInterface +{ + public const PLUGIN = 'CakeDC/Mcp'; + public const CONTROLLER = 'Tools'; + + private RbacInterface $rbac; + + /** + * @param \CakeDC\Auth\Rbac\RbacInterface|array $rbac An Rbac instance, or the config for one. + */ + public function __construct(RbacInterface|array $rbac = []) + { + $this->rbac = $rbac instanceof RbacInterface ? $rbac : new Rbac($rbac); + } + + /** + * @inheritDoc + */ + public function canCall(mixed $identity, string $tool): bool + { + if (!is_array($identity) && !$identity instanceof ArrayAccess) { + return false; + } + + $request = (new ServerRequest(['url' => '/mcp']))->withAttribute('params', [ + 'plugin' => self::PLUGIN, + 'prefix' => null, + 'controller' => self::CONTROLLER, + 'action' => $tool, + '_ext' => null, + 'pass' => [], + ]); + + return $this->rbac->checkPermissions($identity, $request); + } +} diff --git a/tests/TestCase/Authorization/RbacToolAuthorizerTest.php b/tests/TestCase/Authorization/RbacToolAuthorizerTest.php new file mode 100644 index 0000000..43375a9 --- /dev/null +++ b/tests/TestCase/Authorization/RbacToolAuthorizerTest.php @@ -0,0 +1,86 @@ +> + */ + private function permissions(): array + { + return [ + [ + 'role' => 'editor', + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'Tools', + 'action' => ['echo', 'whoami'], + ], + [ + 'role' => 'admin', + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'Tools', + 'action' => '*', + ], + [ + 'role' => '*', + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'Tools', + 'action' => 'own_profile', + 'allowed' => fn($user, $role, $request): bool => ($user['id'] ?? null) === 1, + ], + ]; + } + + private function authorizer(): RbacToolAuthorizer + { + return new RbacToolAuthorizer(['permissions' => $this->permissions(), 'log' => false]); + } + + public function testRolesAllowTheirTools(): void + { + $editor = ['id' => 1, 'role' => 'editor']; + + $this->assertTrue($this->authorizer()->canCall($editor, 'echo')); + $this->assertFalse($this->authorizer()->canCall($editor, 'admin_only')); + $this->assertTrue($this->authorizer()->canCall(['id' => 2, 'role' => 'admin'], 'admin_only')); + } + + public function testCallableRulesSeeTheIdentity(): void + { + $this->assertTrue($this->authorizer()->canCall(['id' => 1, 'role' => 'viewer'], 'own_profile')); + $this->assertFalse($this->authorizer()->canCall(['id' => 2, 'role' => 'viewer'], 'own_profile')); + } + + public function testWorksWithAuthenticationIdentities(): void + { + $identity = new Identity(new ArrayObject(['id' => 1, 'role' => 'editor'])); + + $this->assertTrue($this->authorizer()->canCall($identity, 'whoami')); + } + + public function testNoIdentityIsDenied(): void + { + $this->assertFalse($this->authorizer()->canCall(null, 'echo')); + } + + public function testAcceptsAPreparedRbacInstanceAndCustomRoleField(): void + { + $rbac = new Rbac([ + 'role_field' => 'group', + 'log' => false, + 'permissions' => [ + ['role' => 'staff', 'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools', 'action' => 'echo'], + ], + ]); + + $this->assertTrue((new RbacToolAuthorizer($rbac))->canCall(['id' => 3, 'group' => 'staff'], 'echo')); + } +} From 0a6d8271617214a1c04de9153db937c1475b0f1c Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 13:58:54 +0200 Subject: [PATCH 07/49] Add ServerFactory: discovery, cache-backed sessions, container bridge and authorization --- src/Server/ServerFactory.php | 132 ++++++++++++++ tests/TestCase/Server/ServerFactoryTest.php | 180 ++++++++++++++++++++ tests/test_app/src/Mcp/.gitkeep | 0 tests/test_app/src/Mcp/AdminTools.php | 18 ++ tests/test_app/src/Mcp/EchoTools.php | 48 ++++++ tests/test_app/src/RecordingTransport.php | 44 +++++ 6 files changed, 422 insertions(+) create mode 100644 src/Server/ServerFactory.php create mode 100644 tests/TestCase/Server/ServerFactoryTest.php delete mode 100644 tests/test_app/src/Mcp/.gitkeep create mode 100644 tests/test_app/src/Mcp/AdminTools.php create mode 100644 tests/test_app/src/Mcp/EchoTools.php create mode 100644 tests/test_app/src/RecordingTransport.php diff --git a/src/Server/ServerFactory.php b/src/Server/ServerFactory.php new file mode 100644 index 0000000..1abdb16 --- /dev/null +++ b/src/Server/ServerFactory.php @@ -0,0 +1,132 @@ +identity; + } + + /** + * @param bool $trusted Skip per-tool authorization (stdio started by the developer). + * @return \Mcp\Server + */ + public function create(bool $trusted = false): Server + { + $gate = new ToolGate($this->authorizer(), $this->identity, $trusted); + $events = new EventDispatcher(); + $events->listen(ResponseEvent::class, new ToolListFilter($gate)); + + $builder = Server::builder() + ->setServerInfo((string)McpConfig::get('server.name'), (string)McpConfig::get('server.version')) + ->setContainer(new ContainerBridge($this->container)) + ->setLogger(new McpLogger()) + ->setEventDispatcher($events) + ->setSession(new Psr16SessionStore( + Cache::pool((string)McpConfig::get('session.cache')), + 'mcp-', + (int)McpConfig::get('session.ttl'), + )) + ->addRequestHandler(new ToolCallGuard($gate)); + + $instructions = McpConfig::get('server.instructions'); + if (is_string($instructions) && $instructions !== '') { + $builder->setInstructions($instructions); + } + + $scanDirs = $this->existingDirs((array)McpConfig::get('discovery.scanDirs')); + if ($scanDirs !== []) { + $cache = McpConfig::discoveryCache(); + $builder->setDiscovery( + rtrim(ROOT, DS), + $scanDirs, + array_values(array_filter((array)McpConfig::get('discovery.excludeDirs'), 'is_string')), + $cache !== null ? Cache::pool($cache) : null, + ); + } + + return $builder->build(); + } + + /** + * Keeps only the scan directories that exist, so a host that has not + * created its tools directory yet gets an empty tool list, not an error. + * + * @param array $dirs Directories relative to ROOT. + * @return list + */ + private function existingDirs(array $dirs): array + { + $existing = []; + foreach ($dirs as $dir) { + if (is_string($dir) && is_dir(rtrim(ROOT, DS) . DS . $dir)) { + $existing[] = $dir; + } + } + + return $existing; + } + + /** + * @return \CakeDC\Mcp\Authorization\ToolAuthorizerInterface + */ + private function authorizer(): ToolAuthorizerInterface + { + $authorizer = McpConfig::get('authorization.authorizer'); + if ($authorizer instanceof ToolAuthorizerInterface) { + return $authorizer; + } + if ($authorizer === RbacToolAuthorizer::class) { + return new RbacToolAuthorizer((array)McpConfig::get('authorization.rbac')); + } + if (is_string($authorizer) && is_subclass_of($authorizer, ToolAuthorizerInterface::class)) { + $instance = (new ContainerBridge($this->container))->get($authorizer); + if ($instance instanceof ToolAuthorizerInterface) { + return $instance; + } + } + + throw new InvalidArgumentException(sprintf( + '`Mcp.authorization.authorizer` must be an instance or class name of `%s`.', + ToolAuthorizerInterface::class, + )); + } +} diff --git a/tests/TestCase/Server/ServerFactoryTest.php b/tests/TestCase/Server/ServerFactoryTest.php new file mode 100644 index 0000000..2ba6420 --- /dev/null +++ b/tests/TestCase/Server/ServerFactoryTest.php @@ -0,0 +1,180 @@ + ArrayEngine::class]); + $container = new Container(); + $this->identity = new CurrentIdentity(); + $container->addShared(CurrentIdentity::class, $this->identity); + $this->factory = new ServerFactory($container, $this->identity); + } + + protected function tearDown(): void + { + Cache::drop('mcp_sessions'); + parent::tearDown(); + } + + public function testListsDiscoveredTools(): void + { + $this->assertSame(['admin_only', 'echo', 'explode', 'whoami'], $this->toolNames($this->runServer())); + } + + public function testCallsATool(): void + { + $transport = $this->runServer([$this->call(3, 'echo', ['text' => 'hello'])]); + + $this->assertSame('hello', $transport->responseFor(3)['result']['content'][0]['text'] ?? null); + } + + public function testToolsSeeTheCurrentIdentity(): void + { + $this->identity->set(['id' => 5]); + + $transport = $this->runServer([$this->call(3, 'whoami')]); + + $text = (string)($transport->responseFor(3)['result']['content'][0]['text'] ?? ''); + $this->assertSame(5, json_decode($text, true)['identifier'] ?? null); + } + + public function testToolExceptionsDoNotLeakTheirMessage(): void + { + $transport = $this->runServer([$this->call(3, 'explode')]); + + $response = (string)json_encode($transport->responseFor(3)); + $this->assertStringContainsString('-32603', $response); + $this->assertStringNotContainsString('secret details', $response); + } + + public function testDeniedToolsAreHiddenAndRejected(): void + { + Configure::write('Mcp.authorization.authorizer', $this->onlyEcho()); + + $transport = $this->runServer([$this->call(3, 'admin_only')]); + + $this->assertSame(['echo'], $this->toolNames($transport)); + $this->assertSame('Tool not found: "admin_only".', $transport->responseFor(3)['error']['message'] ?? null); + } + + public function testTrustedServersSkipTheAuthorizer(): void + { + Configure::write('Mcp.authorization.authorizer', $this->onlyEcho()); + + $this->assertContains('admin_only', $this->toolNames($this->runServer([], true))); + } + + public function testRbacAuthorizerFromConfig(): void + { + Configure::write('Mcp.authorization', [ + 'authorizer' => RbacToolAuthorizer::class, + 'rbac' => ['log' => false, 'permissions' => [ + ['role' => 'editor', 'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools', 'action' => ['echo']], + ]], + ]); + $this->identity->set(['id' => 1, 'role' => 'editor']); + + $this->assertSame(['echo'], $this->toolNames($this->runServer())); + } + + public function testMissingScanDirYieldsEmptyToolList(): void + { + Configure::write('Mcp.discovery.scanDirs', ['src/DoesNotExist']); + + $this->assertSame([], $this->toolNames($this->runServer())); + } + + public function testInstructionsAreAnnouncedOnInitialize(): void + { + Configure::write('Mcp.server', ['name' => 'Test Server', 'instructions' => 'Use echo to test.']); + + $result = $this->runServer()->responseFor(1)['result'] ?? []; + + $this->assertSame('Test Server', $result['serverInfo']['name'] ?? null); + $this->assertSame('Use echo to test.', $result['instructions'] ?? null); + } + + public function testRejectsAnInvalidAuthorizer(): void + { + Configure::write('Mcp.authorization.authorizer', 'NotAnAuthorizer'); + + $this->expectException(InvalidArgumentException::class); + $this->factory->create(); + } + + /** + * Runs initialize, notifications/initialized, tools/list (id 2), then $extra. + * + * @param list $extra More JSON-RPC lines. + */ + private function runServer(array $extra = [], bool $trusted = false): RecordingTransport + { + $transport = new RecordingTransport(array_merge([ + (string)json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1.0'], + ]]), + (string)json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']), + (string)json_encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list']), + ], $extra)); + $this->factory->create($trusted)->run($transport); + + return $transport; + } + + /** + * @param array $arguments + */ + private function call(int $id, string $tool, array $arguments = []): string + { + return (string)json_encode(['jsonrpc' => '2.0', 'id' => $id, 'method' => 'tools/call', 'params' => [ + 'name' => $tool, + 'arguments' => $arguments === [] ? new stdClass() : $arguments, + ]]); + } + + /** + * @return list + */ + private function toolNames(RecordingTransport $transport): array + { + $names = array_column($transport->responseFor(2)['result']['tools'] ?? [], 'name'); + sort($names); + + return $names; + } + + private function onlyEcho(): ToolAuthorizerInterface + { + return new class implements ToolAuthorizerInterface { + public function canCall(mixed $identity, string $tool): bool + { + return $tool === 'echo'; + } + }; + } +} diff --git a/tests/test_app/src/Mcp/.gitkeep b/tests/test_app/src/Mcp/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_app/src/Mcp/AdminTools.php b/tests/test_app/src/Mcp/AdminTools.php new file mode 100644 index 0000000..fb2fed8 --- /dev/null +++ b/tests/test_app/src/Mcp/AdminTools.php @@ -0,0 +1,18 @@ + + */ + #[McpTool(name: 'whoami')] + public function whoami(RequestContext $context): array + { + return [ + 'identifier' => $this->identity->getIdentifier(), + 'subject' => $context->getAccessToken()?->getSubject(), + ]; + } + + /** + * Always fails, to exercise error handling. + */ + #[McpTool(name: 'explode')] + public function explode(): string + { + throw new RuntimeException('boom with secret details'); + } +} diff --git a/tests/test_app/src/RecordingTransport.php b/tests/test_app/src/RecordingTransport.php new file mode 100644 index 0000000..7147ae6 --- /dev/null +++ b/tests/test_app/src/RecordingTransport.php @@ -0,0 +1,44 @@ +> + */ + public array $sent = []; + + /** + * @inheritDoc + */ + public function send(string $data, array $context): void + { + $decoded = json_decode($data, true); + if (is_array($decoded)) { + $this->sent[] = $decoded; + } + parent::send($data, $context); + } + + /** + * @param string|int $id JSON-RPC id. + * @return array|null + */ + public function responseFor(string|int $id): ?array + { + foreach ($this->sent as $message) { + if (($message['id'] ?? null) === $id) { + return $message; + } + } + + return null; + } +} From c7c731e1797f39af0289e0725dd92810b52795b6 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:01:12 +0200 Subject: [PATCH 08/49] Serve MCP over Streamable HTTP at a Cake route --- src/Controller/McpController.php | 131 ++++++++ src/McpPlugin.php | 33 ++ .../TestCase/Controller/McpControllerTest.php | 288 ++++++++++++++++++ tests/TestCase/McpPluginTest.php | 24 ++ 4 files changed, 476 insertions(+) create mode 100644 src/Controller/McpController.php create mode 100644 tests/TestCase/Controller/McpControllerTest.php diff --git a/src/Controller/McpController.php b/src/Controller/McpController.php new file mode 100644 index 0000000..04f410c --- /dev/null +++ b/src/Controller/McpController.php @@ -0,0 +1,131 @@ +skipHostAuthorization(); + $responseFactory = new ResponseFactory(); + $streamFactory = new StreamFactory(); + + try { + $origins = array_values(array_filter((array)McpConfig::get('http.allowedOrigins'), 'is_string')); + $scopes = McpConfig::get('identity.scopes'); + $transport = new StreamableHttpTransport( + $this->request, + $responseFactory, + $streamFactory, + new McpLogger(), + [ + new CorsMiddleware($origins), + new DnsRebindingProtectionMiddleware( + McpConfig::allowedHosts(), + $origins, + $responseFactory, + $streamFactory, + ), + new IdentityMiddleware( + $identity, + $responseFactory, + $streamFactory, + (bool)McpConfig::get('identity.required'), + (string)McpConfig::get('identity.attribute'), + is_callable($scopes) ? Closure::fromCallable($scopes) : null, + ), + ], + ); + $response = $factory->create()->run($transport); + } catch (Throwable $exception) { + Log::error(sprintf("MCP request failed.\n%s", $exception), ['scope' => [McpLogger::SCOPE]]); + + return $this->internalError(); + } + + return $this->toCakeResponse($response); + } + + /** + * cakephp/authorization (when the host uses it) would otherwise demand an + * authorization check for this action. Per-tool authorization is ours. + * + * @return void + */ + private function skipHostAuthorization(): void + { + $authorization = $this->request->getAttribute('authorization'); + if (is_object($authorization) && method_exists($authorization, 'skipAuthorization')) { + $authorization->skipAuthorization(); + } + } + + /** + * @param mixed $response What the transport returned. + * @return \Cake\Http\Response + */ + private function toCakeResponse(mixed $response): Response + { + if (!$response instanceof ResponseInterface) { + return $this->internalError(); + } + if (!$response instanceof Response) { + $converted = (new Response()) + ->withStatus($response->getStatusCode(), $response->getReasonPhrase()) + ->withBody($response->getBody()); + foreach ($response->getHeaders() as $name => $values) { + $converted = $converted->withHeader($name, $values); + } + $response = $converted; + } + if (str_starts_with($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + $response = $response->withHeader('X-Accel-Buffering', 'no'); + } + + return $response; + } + + /** + * Never Cake's HTML error page, never a stack trace, even in debug mode. + * + * @return \Cake\Http\Response + */ + private function internalError(): Response + { + return $this->response + ->withStatus(500) + ->withType('application/json') + ->withStringBody((string)json_encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => ['code' => -32603, 'message' => 'Internal error'], + ])); + } +} diff --git a/src/McpPlugin.php b/src/McpPlugin.php index f20a965..2e8d401 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -6,7 +6,11 @@ use Cake\Cache\Cache; use Cake\Cache\Engine\FileEngine; use Cake\Core\BasePlugin; +use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; +use Cake\Routing\RouteBuilder; +use CakeDC\Mcp\Identity\CurrentIdentity; +use CakeDC\Mcp\Server\ServerFactory; /** * Exposes an MCP (Model Context Protocol) server from a CakePHP application. @@ -35,4 +39,33 @@ public function bootstrap(PluginApplicationInterface $app): void ]); } } + + /** + * Connects the MCP endpoint at `Mcp.route` (nothing when it is false). + * + * @param \Cake\Routing\RouteBuilder $routes The route builder. + * @return void + */ + public function routes(RouteBuilder $routes): void + { + $path = McpConfig::get('route'); + if (!is_string($path) || $path === '') { + return; + } + + $routes->connect($path, ['plugin' => 'CakeDC/Mcp', 'controller' => 'Mcp', 'action' => 'handle']) + ->setMethods(['GET', 'POST', 'DELETE', 'OPTIONS']); + } + + /** + * @param \Cake\Core\ContainerInterface $container The host container. + * @return void + */ + public function services(ContainerInterface $container): void + { + $container->addShared(CurrentIdentity::class); + $container->addShared(ServerFactory::class) + ->addArgument($container) + ->addArgument(CurrentIdentity::class); + } } diff --git a/tests/TestCase/Controller/McpControllerTest.php b/tests/TestCase/Controller/McpControllerTest.php new file mode 100644 index 0000000..9ece208 --- /dev/null +++ b/tests/TestCase/Controller/McpControllerTest.php @@ -0,0 +1,288 @@ +mcp($this->initialize(1)); + + $this->assertResponseOk(); + $this->assertSame('CakePHP MCP Server', $this->json()['result']['serverInfo']['name'] ?? null); + $this->assertNotSame('', $this->_response->getHeaderLine('Mcp-Session-Id')); + } + + public function testHandshakeSessionPersistsAcrossRequests(): void + { + $this->mcp($this->initialize(1)); + $headers = [ + 'Mcp-Session-Id' => $this->_response->getHeaderLine('Mcp-Session-Id'), + 'MCP-Protocol-Version' => self::HANDSHAKE, + ]; + + $this->mcp(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], $headers); + $this->assertResponseCode(202); + + $this->mcp(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list'], $headers); + $this->assertResponseOk(); + $this->assertContains('echo', $this->toolNames()); + + $this->mcp($this->handshakeCall(3, 'echo', ['text' => 'over the session']), $headers); + $this->assertSame('over the session', $this->json()['result']['content'][0]['text'] ?? null); + } + + public function testModernEraListsAndCallsWithoutASession(): void + { + $this->mcp($this->modern(1, 'tools/list'), $this->modernHeaders('tools/list')); + $this->assertResponseOk(); + $this->assertContains('echo', $this->toolNames()); + + $this->mcp( + $this->modern(2, 'tools/call', ['name' => 'echo', 'arguments' => ['text' => 'stateless']]), + $this->modernHeaders('tools/call', 'echo'), + ); + $this->assertResponseOk(); + $this->assertSame('stateless', $this->json()['result']['content'][0]['text'] ?? null); + } + + public function testJsonBodyIsReadAfterBodyParserMiddleware(): void + { + $this->mcp( + $this->modern(1, 'tools/call', ['name' => 'echo', 'arguments' => ['text' => 'parsed twice']]), + $this->modernHeaders('tools/call', 'echo'), + ); + + $this->assertSame('parsed twice', $this->json()['result']['content'][0]['text'] ?? null); + } + + public function testToolsSeeTheAuthenticatedIdentity(): void + { + $this->mcp( + $this->modern(1, 'tools/call', ['name' => 'whoami', 'arguments' => new stdClass()]), + $this->modernHeaders('tools/call', 'whoami'), + ); + + $whoami = json_decode((string)($this->json()['result']['content'][0]['text'] ?? ''), true); + $this->assertSame(['identifier' => 1, 'subject' => '1'], $whoami); + } + + public function testMissingTokenIsChallenged(): void + { + $this->mcp($this->initialize(1), ['Authorization' => '']); + + $this->assertResponseCode(401); + $this->assertHeader('WWW-Authenticate', 'Bearer'); + } + + public function testInvalidTokenIsChallenged(): void + { + $this->mcp($this->initialize(1), ['Authorization' => 'Bearer nope']); + + $this->assertResponseCode(401); + } + + public function testSessionDeleteWithoutTokenIsChallenged(): void + { + $this->configRequest(['headers' => ['Host' => 'localhost', 'Mcp-Session-Id' => 'anything']]); + $this->delete('/mcp'); + + $this->assertResponseCode(401); + } + + public function testCorsPreflightIsNotChallenged(): void + { + $this->configRequest(['headers' => ['Host' => 'localhost']]); + $this->options('/mcp'); + + $this->assertResponseCode(204); + } + + public function testForeignHostIsRejected(): void + { + $this->mcp($this->initialize(1), ['Host' => 'evil.example']); + + $this->assertResponseCode(403); + } + + public function testFullBaseUrlHostIsAccepted(): void + { + Configure::write('App.fullBaseUrl', 'https://mcp.example.com'); + + $this->mcp($this->initialize(1), ['Host' => 'mcp.example.com']); + + $this->assertResponseOk(); + } + + public function testRbacHidesAndRejectsToolsPerRoleInBothEras(): void + { + Configure::write('Mcp.authorization', [ + 'authorizer' => RbacToolAuthorizer::class, + 'rbac' => ['log' => false, 'permissions' => [ + ['role' => 'editor', 'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools', 'action' => ['echo', 'whoami']], + ['role' => 'admin', 'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools', 'action' => '*'], + ]], + ]); + + $this->mcp($this->modern(1, 'tools/list'), $this->modernHeaders('tools/list')); + $this->assertNotContains('admin_only', $this->toolNames()); + + $this->mcp( + $this->modern(2, 'tools/call', ['name' => 'admin_only', 'arguments' => new stdClass()]), + $this->modernHeaders('tools/call', 'admin_only'), + ); + $this->assertSame('Tool not found: "admin_only".', $this->json()['error']['message'] ?? null); + + $this->mcp($this->initialize(3)); + $session = [ + 'Mcp-Session-Id' => $this->_response->getHeaderLine('Mcp-Session-Id'), + 'MCP-Protocol-Version' => self::HANDSHAKE, + ]; + $this->mcp(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], $session); + $this->mcp($this->handshakeCall(4, 'admin_only'), $session); + $this->assertSame('Tool not found: "admin_only".', $this->json()['error']['message'] ?? null); + + $this->mcp($this->modern(5, 'tools/list'), $this->modernHeaders('tools/list') + [ + 'Authorization' => 'Bearer ' . TestTokens::ADMIN, + ]); + $this->assertContains('admin_only', $this->toolNames()); + } + + public function testToolFailureDoesNotLeakDetails(): void + { + $this->mcp( + $this->modern(1, 'tools/call', ['name' => 'explode', 'arguments' => new stdClass()]), + $this->modernHeaders('tools/call', 'explode'), + ); + + $body = (string)$this->_response->getBody(); + $this->assertStringNotContainsString('secret details', $body); + $this->assertStringNotContainsString('#0 ', $body); + } + + public function testServerBuildFailureIsAJsonRpcInternalError(): void + { + Configure::write('Mcp.authorization.authorizer', 'NotAnAuthorizer'); + + $this->mcp($this->initialize(1)); + + $this->assertResponseCode(500); + $this->assertSame( + ['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32603, 'message' => 'Internal error']], + $this->json(), + ); + $this->assertStringNotContainsString('NotAnAuthorizer', (string)$this->_response->getBody()); + } + + public function testHostAuthorizationCheckIsSkipped(): void + { + $this->mcp($this->initialize(1)); + + $this->assertTrue(AuthorizationSpy::$skipped); + } + + /** + * @param array $message + * @param array $headers Overrides; an empty Authorization removes it. + */ + private function mcp(array $message, array $headers = []): void + { + $headers += [ + 'Host' => 'localhost', + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . TestTokens::EDITOR, + ]; + $this->configRequest(['headers' => array_filter($headers, fn(string $value): bool => $value !== '')]); + $this->post('/mcp', (string)json_encode($message)); + } + + /** + * @return array + */ + private function initialize(int $id): array + { + return ['jsonrpc' => '2.0', 'id' => $id, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => self::HANDSHAKE, + 'capabilities' => new stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1.0'], + ]]; + } + + /** + * @param array $arguments + * @return array + */ + private function handshakeCall(int $id, string $tool, array $arguments = []): array + { + return ['jsonrpc' => '2.0', 'id' => $id, 'method' => 'tools/call', 'params' => [ + 'name' => $tool, + 'arguments' => $arguments === [] ? new stdClass() : $arguments, + ]]; + } + + /** + * @param array $params + * @return array + */ + private function modern(int $id, string $method, array $params = []): array + { + $params['_meta'] = [ + 'io.modelcontextprotocol/protocolVersion' => self::MODERN, + 'io.modelcontextprotocol/clientCapabilities' => new stdClass(), + ]; + + return ['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params]; + } + + /** + * @return array + */ + private function modernHeaders(string $method, ?string $name = null): array + { + $headers = ['MCP-Protocol-Version' => self::MODERN, 'Mcp-Method' => $method]; + if ($name !== null) { + $headers['Mcp-Name'] = $name; + } + + return $headers; + } + + /** + * @return array + */ + private function json(): array + { + $decoded = json_decode((string)$this->_response->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @return list + */ + private function toolNames(): array + { + return array_column($this->json()['result']['tools'] ?? [], 'name'); + } +} diff --git a/tests/TestCase/McpPluginTest.php b/tests/TestCase/McpPluginTest.php index 7e6c67b..5cb8b25 100644 --- a/tests/TestCase/McpPluginTest.php +++ b/tests/TestCase/McpPluginTest.php @@ -7,7 +7,10 @@ use Cake\Cache\Engine\ArrayEngine; use Cake\Cache\Engine\FileEngine; use Cake\Core\Configure; +use Cake\Routing\RouteBuilder; +use Cake\Routing\RouteCollection; use Cake\TestSuite\TestCase; +use CakeDC\Mcp\McpPlugin; use TestApp\Application; class McpPluginTest extends TestCase @@ -61,4 +64,25 @@ public function testBootstrapKeepsCacheConfigsTheHostDefined(): void $this->assertInstanceOf(ArrayEngine::class, Cache::pool('host_sessions')); } + + public function testRoutesConnectTheEndpointAtTheConfiguredPath(): void + { + Configure::write('Mcp.route', '/ai/mcp'); + $collection = new RouteCollection(); + + (new McpPlugin())->routes(new RouteBuilder($collection, '/')); + + $this->assertCount(1, $collection->routes()); + $this->assertSame('/ai/mcp', $collection->routes()[0]->template); + } + + public function testRoutesAreSkippedWhenDisabled(): void + { + Configure::write('Mcp.route', false); + $collection = new RouteCollection(); + + (new McpPlugin())->routes(new RouteBuilder($collection, '/')); + + $this->assertCount(0, $collection->routes()); + } } From 978dea088ac763be58e253bef7e96345d633f16a Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:03:07 +0200 Subject: [PATCH 09/49] Assert the failing tool was reached in the leak test --- tests/TestCase/Controller/McpControllerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/TestCase/Controller/McpControllerTest.php b/tests/TestCase/Controller/McpControllerTest.php index 9ece208..6731859 100644 --- a/tests/TestCase/Controller/McpControllerTest.php +++ b/tests/TestCase/Controller/McpControllerTest.php @@ -175,6 +175,13 @@ public function testToolFailureDoesNotLeakDetails(): void $this->modernHeaders('tools/call', 'explode'), ); + // The SDK answers an unhandled tool exception with HTTP 400 and this JSON-RPC error, which proves + // the call was not rejected earlier (that would be 401, 403 or 404, or "Tool not found"). + $this->assertResponseCode(400); + $this->assertSame( + ['code' => -32603, 'message' => 'Error while executing tool'], + $this->json()['error'] ?? null, + ); $body = (string)$this->_response->getBody(); $this->assertStringNotContainsString('secret details', $body); $this->assertStringNotContainsString('#0 ', $body); From 4f5d580e3b146cfdc4683d8f35bef4f9f3329a07 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:04:52 +0200 Subject: [PATCH 10/49] Add the mcp server stdio command --- src/Command/McpServerCommand.php | 72 +++++++++++ src/McpPlugin.php | 16 +++ .../TestCase/Command/McpServerCommandTest.php | 117 ++++++++++++++++++ .../src/Command/RecordingMcpServerCommand.php | 34 +++++ 4 files changed, 239 insertions(+) create mode 100644 src/Command/McpServerCommand.php create mode 100644 tests/TestCase/Command/McpServerCommandTest.php create mode 100644 tests/test_app/src/Command/RecordingMcpServerCommand.php diff --git a/src/Command/McpServerCommand.php b/src/Command/McpServerCommand.php new file mode 100644 index 0000000..a72ea9a --- /dev/null +++ b/src/Command/McpServerCommand.php @@ -0,0 +1,72 @@ +out()`. + */ +class McpServerCommand extends Command +{ + /** + * @param \CakeDC\Mcp\Server\ServerFactory $serverFactory Builds the server. + * @param \CakeDC\Mcp\Identity\CurrentIdentity $identity Shared with the tools. + */ + public function __construct( + private readonly ServerFactory $serverFactory, + private readonly CurrentIdentity $identity, + ) { + parent::__construct(); + } + + /** + * @inheritDoc + */ + public static function defaultName(): string + { + return 'mcp server'; + } + + /** + * @inheritDoc + */ + public static function getDescription(): string + { + return 'Serve MCP over stdio, for MCP clients running on this machine.'; + } + + /** + * @inheritDoc + */ + public function execute(Arguments $args, ConsoleIo $io): int + { + $resolver = McpConfig::get('stdio.identity'); + $this->identity->set(is_callable($resolver) ? $resolver() : null); + + $this->serverFactory->create((bool)McpConfig::get('stdio.trusted'))->run($this->createTransport()); + + return static::CODE_SUCCESS; + } + + /** + * @return \Mcp\Server\Transport\TransportInterface + */ + protected function createTransport(): TransportInterface + { + return new StdioTransport(logger: new McpLogger()); + } +} diff --git a/src/McpPlugin.php b/src/McpPlugin.php index 2e8d401..a52bde7 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -5,10 +5,12 @@ use Cake\Cache\Cache; use Cake\Cache\Engine\FileEngine; +use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; use Cake\Routing\RouteBuilder; +use CakeDC\Mcp\Command\McpServerCommand; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\Server\ServerFactory; @@ -57,6 +59,17 @@ public function routes(RouteBuilder $routes): void ->setMethods(['GET', 'POST', 'DELETE', 'OPTIONS']); } + /** + * Registers `bin/cake mcp server`, the stdio transport. + * + * @param \Cake\Console\CommandCollection $commands The collection. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + return $commands->add('mcp server', McpServerCommand::class); + } + /** * @param \Cake\Core\ContainerInterface $container The host container. * @return void @@ -67,5 +80,8 @@ public function services(ContainerInterface $container): void $container->addShared(ServerFactory::class) ->addArgument($container) ->addArgument(CurrentIdentity::class); + $container->add(McpServerCommand::class) + ->addArgument(ServerFactory::class) + ->addArgument(CurrentIdentity::class); } } diff --git a/tests/TestCase/Command/McpServerCommandTest.php b/tests/TestCase/Command/McpServerCommandTest.php new file mode 100644 index 0000000..f3e873d --- /dev/null +++ b/tests/TestCase/Command/McpServerCommandTest.php @@ -0,0 +1,117 @@ + ArrayEngine::class]); + } + + protected function tearDown(): void + { + Cache::drop('mcp_sessions'); + parent::tearDown(); + } + + public function testIsRegisteredAsMcpServer(): void + { + $commands = (new McpPlugin())->console(new CommandCollection()); + + $this->assertTrue($commands->has('mcp server')); + $this->assertSame(McpServerCommand::class, $commands->get('mcp server')); + } + + public function testTheContainerBuildsTheCommand(): void + { + $container = new Container(); + (new McpPlugin())->services($container); + + $this->assertInstanceOf(McpServerCommand::class, $container->get(McpServerCommand::class)); + } + + public function testServesToolsOverTheTransport(): void + { + $command = $this->command(); + + $this->assertSame(McpServerCommand::CODE_SUCCESS, $this->runCommand($command)); + $this->assertContains('echo', array_column($command->transport?->responseFor(2)['result']['tools'] ?? [], 'name')); + } + + public function testIdentityComesFromTheStdioResolver(): void + { + Configure::write('Mcp.stdio.identity', fn(): array => ['id' => 42]); + $command = $this->command([(string)json_encode(['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => [ + 'name' => 'whoami', + 'arguments' => new stdClass(), + ]])]); + + $this->runCommand($command); + + $text = (string)($command->transport?->responseFor(3)['result']['content'][0]['text'] ?? ''); + $this->assertSame(42, json_decode($text, true)['identifier'] ?? null); + } + + public function testTrustedStdioSkipsRbacAndUntrustedDoesNot(): void + { + Configure::write('Mcp.authorization', [ + 'authorizer' => RbacToolAuthorizer::class, + 'rbac' => ['log' => false, 'permissions' => []], + ]); + + $trusted = $this->command(); + $this->runCommand($trusted); + $this->assertContains('admin_only', array_column($trusted->transport?->responseFor(2)['result']['tools'] ?? [], 'name')); + + Configure::write('Mcp.stdio.trusted', false); + $untrusted = $this->command(); + $this->runCommand($untrusted); + $this->assertSame([], $untrusted->transport?->responseFor(2)['result']['tools'] ?? null); + } + + /** + * @param list $extra More JSON-RPC lines after initialize/initialized/tools list (id 2). + */ + private function command(array $extra = []): RecordingMcpServerCommand + { + $container = new Container(); + $identity = new CurrentIdentity(); + $container->addShared(CurrentIdentity::class, $identity); + + return new RecordingMcpServerCommand(new ServerFactory($container, $identity), $identity, array_merge([ + (string)json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1.0'], + ]]), + (string)json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']), + (string)json_encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list']), + ], $extra)); + } + + private function runCommand(McpServerCommand $command): ?int + { + return $command->run([], new ConsoleIo(new StubConsoleOutput(), new StubConsoleOutput(), new StubConsoleInput([]))); + } +} diff --git a/tests/test_app/src/Command/RecordingMcpServerCommand.php b/tests/test_app/src/Command/RecordingMcpServerCommand.php new file mode 100644 index 0000000..c7d348a --- /dev/null +++ b/tests/test_app/src/Command/RecordingMcpServerCommand.php @@ -0,0 +1,34 @@ + $messages JSON-RPC lines to feed. + */ + public function __construct(ServerFactory $factory, CurrentIdentity $identity, private readonly array $messages) + { + parent::__construct($factory, $identity); + } + + /** + * @inheritDoc + */ + protected function createTransport(): TransportInterface + { + return $this->transport = new RecordingTransport($this->messages); + } +} From 52740fef545cdf3029f79f3f55bc65a714d10a92 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:08:16 +0200 Subject: [PATCH 11/49] Add CI workflow, README, docs and changelog for 2.0.0-beta1 --- .github/workflows/ci.yml | 46 +++++++++++++ CHANGELOG.md | 14 ++++ README.md | 102 ++++++++++++++++++++++++++-- docs/authentication.md | 97 +++++++++++++++++++++++++++ docs/authorization.md | 130 ++++++++++++++++++++++++++++++++++++ docs/clients.md | 43 ++++++++++++ docs/configuration.md | 76 +++++++++++++++++++++ docs/installation.md | 78 ++++++++++++++++++++++ docs/stdio.md | 57 ++++++++++++++++ docs/tools.md | 140 +++++++++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 72 ++++++++++++++++++++ 11 files changed, 848 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 docs/authentication.md create mode 100644 docs/authorization.md create mode 100644 docs/clients.md create mode 100644 docs/configuration.md create mode 100644 docs/installation.md create mode 100644 docs/stdio.md create mode 100644 docs/tools.md create mode 100644 docs/troubleshooting.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee6b71f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: ['2.next-cake5', 'master'] + pull_request: + +permissions: + contents: read + +jobs: + testsuite: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php-version: ['8.2', '8.3', '8.4', '8.5'] + dependencies: ['highest'] + include: + - php-version: '8.2' + dependencies: 'lowest' + name: PHP ${{ matrix.php-version }} (${{ matrix.dependencies }}) + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl + coverage: none + - uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} + - run: vendor/bin/phpunit + + cs-stan: + runs-on: ubuntu-24.04 + name: Coding standard and static analysis + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - uses: ramsey/composer-install@v3 + - run: vendor/bin/phpcs + - run: vendor/bin/phpstan analyse --no-progress diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0eb0d97 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 2.0.0-beta1 (unreleased) + +Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. + +- Remote MCP over Streamable HTTP at a Cake route (`/mcp` by default), serving both protocol eras (handshake with sessions, and the stateless 2026-07-28 revision). +- `bin/cake mcp server` for stdio. +- Tool discovery through `#[McpTool]`, `#[McpResource]` and `#[McpPrompt]` attributes in `src/Mcp/`, with constructor injection from Cake's container (autowired). +- Sessions and the discovery cache stored in Cake cache configs. +- Authentication delegated to the host: the identity set by `cakephp/authentication` becomes the SDK access token and the injectable `CurrentIdentity`. +- Per-tool authorization with `ToolAuthorizerInterface`; `RbacToolAuthorizer` for `cakedc/auth` permission rules. +- DNS-rebinding protection defaults to the `App.fullBaseUrl` host. +- Removed `bin/mcp-server` and `CakeDC\Mcp\Server\CakeServer`. diff --git a/README.md b/README.md index 702f6c0..19f2c69 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,101 @@ -# MCP Server for CakePHP +# CakePHP MCP Server -## Installation - * composer require cakedc/cakephp-mcp +Expose tools, resources and prompts from a CakePHP 5 application to Claude and +any other [Model Context Protocol](https://modelcontextprotocol.io) client. +Built on the official [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sdk). -## Usage - * Select one or many methods in your CakePHP project. Add the attribute to expose the method to the MCP server. +- Remote MCP over Streamable HTTP, mounted as a regular Cake route (`/mcp`). +- `bin/cake mcp server` for local stdio clients. +- Tools are plain PHP classes with `#[McpTool]` attributes, with constructor injection. +- Authentication stays in your app (`cakephp/authentication`); the plugin reads the identity. +- Per-tool authorization, including `config/permissions.php` rules through `cakedc/auth`. +## Requirements + +- PHP 8.2+ +- CakePHP 5 + +## Install + +```bash +composer require cakedc/cakephp-mcp +``` + +Load the plugin in `src/Application.php`: + +```php +$this->addPlugin('CakeDC/Mcp'); +``` + +Let MCP requests through CSRF protection (in `Application::middleware()`): + +```php +$csrf = new CsrfProtectionMiddleware(); +$csrf->skipCheckCallback(fn (ServerRequest $request): bool => str_starts_with($request->getPath(), '/mcp')); +``` + +## Your first tool + +Create `src/Mcp/ArticleTools.php`: + +```php +namespace App\Mcp; + +use Cake\ORM\Locator\LocatorAwareTrait; +use Mcp\Capability\Attribute\McpTool; + +class ArticleTools +{ + use LocatorAwareTrait; + + /** + * Lists the titles of the latest articles. + * + * @return list + */ + #[McpTool(name: 'latest_articles')] + public function latest(int $limit = 5): array + { + return $this->fetchTable('Articles')->find() + ->select(['title']) + ->orderByDesc('created') + ->limit($limit) + ->all() + ->extract('title') + ->toList(); + } +} ``` -example here + +## Authenticate callers + +By default `/mcp` requires an identity. Add an authenticator that reads a Bearer +token, for example with `cakephp/authentication`: + +```php +$service->loadAuthenticator('Authentication.Token', [ + 'header' => 'Authorization', + 'tokenPrefix' => 'Bearer', + 'identifier' => ['Authentication.Token' => ['tokenField' => 'api_token']], +]); ``` -## Test your MCP server works properly +## Connect Claude + +- Claude Code: `claude mcp add --transport http my-app https://example.com/mcp --header "Authorization: Bearer "` +- claude.ai: Settings, Connectors, Add custom connector, with the URL and (where available) a request header. + +## Documentation + +- [Installation](docs/installation.md) +- [Configuration](docs/configuration.md) +- [Writing tools](docs/tools.md) +- [Authentication](docs/authentication.md) +- [Authorization](docs/authorization.md) +- [stdio](docs/stdio.md) +- [Connecting clients](docs/clients.md) +- [Troubleshooting](docs/troubleshooting.md) + +## License + +MIT. Copyright (c) Cake Development Corporation. diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..6c78ad0 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,97 @@ +# Authentication + +The plugin does not authenticate anyone itself. Your application does, with the +tools it already uses (`cakephp/authentication`, `cakedc/users`, and so on), +and the plugin reads the result. + +## The identity contract + +`IdentityMiddleware` looks for the identity in the request attribute named by +`Mcp.identity.attribute` (default `identity`, which is what +`cakephp/authentication` sets). When it finds one it: + +- stores it as the `CurrentIdentity` that tools can inject; +- gives the SDK an access token whose `sub` claim is the identity's id. + +The id is taken from `getIdentifier()` when the identity has that method +(`Authentication\Identity`), otherwise from the `id` key of an array, +`ArrayObject` or entity. + +`Mcp.identity.required` (default `true`) decides what happens without an +identity. With `false`, unauthenticated calls go through and tools see no +identity. + +## Bearer tokens + +Add a token authenticator to your authentication service. With a token column +on your users table: + +```php +public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface +{ + $service = new AuthenticationService(); + $service->loadAuthenticator('Authentication.Token', [ + 'header' => 'Authorization', + 'tokenPrefix' => 'Bearer', + 'identifier' => ['Authentication.Token' => ['tokenField' => 'api_token']], + ]); + + return $service; +} +``` + +For tokens that are not a column on one table, use a callback identifier: + +```php +$service->loadAuthenticator('Authentication.Token', [ + 'header' => 'Authorization', + 'tokenPrefix' => 'Bearer', + 'identifier' => [ + 'Authentication.Callback' => [ + 'callback' => fn (array $data): ?ArrayObject => $this->tokens->identityFor($data['token'] ?? null), + ], + ], +]); +``` + +The authenticator is not tied to the MCP path, so the rest of your app keeps +working as before. + +## Scopes + +`Mcp.identity.scopes` can map an identity to the scope strings the SDK access +token carries: + +```php +Configure::write('Mcp.identity.scopes', fn (mixed $identity): array => ['articles:read']); +``` + +Tools read them with `RequestContext::getAccessToken()?->getScopes()`; see +[Writing tools](tools.md). + +## Missing identity + +When an identity is required and the request has none, the response is: + +``` +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer +Content-Type: application/json + +{"error":"unauthorized","error_description":"Authentication required."} +``` + +CORS preflight requests (OPTIONS) carry no credentials and are never +challenged. + +## cakedc/auth and cakedc/users + +Applications built on `cakedc/auth` or `cakedc/users` need nothing special. +They already put the identity in the `identity` attribute, so the plugin works +unchanged. To restrict tools by role, see [Authorization](authorization.md). + +## OAuth + +OAuth 2.1 (what claude.ai connectors expect for remote servers with +sign-in) is planned for 2.1. The identity middleware sits in the same slot the +SDK's OAuth middleware will take, so your tools will not need to change. diff --git a/docs/authorization.md b/docs/authorization.md new file mode 100644 index 0000000..9f8ac70 --- /dev/null +++ b/docs/authorization.md @@ -0,0 +1,130 @@ +# Authorization + +Authentication says who is calling. Authorization says which tools that caller +may use. The plugin checks it on every `tools/list` and `tools/call`. + +## The interface + +```php +namespace CakeDC\Mcp\Authorization; + +interface ToolAuthorizerInterface +{ + public function canCall(mixed $identity, string $tool): bool; +} +``` + +`$identity` is the host identity as your authentication produced it (or `null` +when identity is not required and the caller is anonymous). `$tool` is the MCP +tool name. + +The default is `AllowAllToolAuthorizer`: every authenticated caller may use +every tool. + +## A custom authorizer + +```php +namespace App\Mcp; + +use CakeDC\Mcp\Authorization\ToolAuthorizerInterface; + +class StaffOnlyAuthorizer implements ToolAuthorizerInterface +{ + public function canCall(mixed $identity, string $tool): bool + { + if (str_starts_with($tool, 'admin_')) { + return ($identity['role'] ?? null) === 'admin'; + } + + return $identity !== null; + } +} +``` + +Configure it by class name (built through the container, so it can have +injected dependencies) or by instance: + +```php +Configure::write('Mcp.authorization.authorizer', \App\Mcp\StaffOnlyAuthorizer::class); +// or +Configure::write('Mcp.authorization.authorizer', new StaffOnlyAuthorizer()); +``` + +## What a denied caller sees + +- A denied tool is hidden from `tools/list`. +- Calling it anyway gets the same answer as calling a tool that does not + exist: JSON-RPC error `-32602` with the message `Tool not found: "".` + +So callers cannot discover which protected tools exist. + +## RbacToolAuthorizer (cakedc/auth) + +If your app uses `cakedc/auth` permissions, install it and select +`RbacToolAuthorizer`: + +```bash +composer require cakedc/auth +``` + +```php +use CakeDC\Mcp\Authorization\RbacToolAuthorizer; + +Configure::write('Mcp.authorization', [ + 'authorizer' => RbacToolAuthorizer::class, + 'rbac' => [ + 'permissions' => [ + // editors may use two tools + [ + 'role' => 'editor', + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'Tools', + 'action' => ['find_order', 'whoami'], + ], + // admins may use all of them + [ + 'role' => 'admin', + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'Tools', + 'action' => '*', + ], + // any role, but only for the user whose id is 1 + [ + 'role' => '*', + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'Tools', + 'action' => 'own_profile', + 'allowed' => fn ($user, $role, $request): bool => ($user['id'] ?? null) === 1, + ], + ], + ], +]); +``` + +Tool `X` is checked as plugin `CakeDC/Mcp`, controller `Tools`, action `X`. +Rules are the same format as `config/permissions.php`, and `Mcp.authorization.rbac` +accepts the same options as the cakedc/auth `Rbac` class (`permissions`, +`permissions_provider_class`, `role_field`, `default_role`, `log`, and so on). +You can also pass a prepared `Rbac` instance to `new RbacToolAuthorizer($rbac)` +and configure the authorizer by instance. + +Identities that are not arrays or `ArrayAccess` objects are denied. + +### Warning: host rules without a plugin key + +cakedc/auth rules only constrain the keys they list. A host rule that has no +`plugin` key, for example: + +```php +['role' => 'admin', 'controller' => '*', 'action' => '*'] +``` + +also grants every MCP tool to admins, because the missing `plugin` matches +anything. Write MCP rules with `'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools'`, +and review the host-wide wildcard rules whenever the MCP rules share a +permissions file with the rest of your application. + +## stdio + +Per-tool authorization is skipped for stdio by default (`Mcp.stdio.trusted`). +See [stdio](stdio.md). diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 0000000..f977802 --- /dev/null +++ b/docs/clients.md @@ -0,0 +1,43 @@ +# Connecting clients + +## Claude Code over HTTP + +```bash +claude mcp add --transport http my-app https://example.com/mcp \ + --header "Authorization: Bearer " +``` + +Check that it connected with `claude mcp list`, or `/mcp` inside Claude Code. +The server must be reachable from your machine, and its host must be allowed +(`http.allowedHosts`, by default the host of `App.fullBaseUrl`). + +For a server running on your own machine you can use the local URL, for +example `https://my-app.ddev.site/mcp`. + +## Claude Code over stdio + +```bash +claude mcp add my-app -- bin/cake mcp server +``` + +See [stdio](stdio.md). + +## claude.ai + +In claude.ai go to Settings, Connectors, Add custom connector, and enter the +URL of your endpoint. Where your account allows custom request headers you can +send the Bearer token there. Connectors that sign the user in through OAuth +need the OAuth support planned for 2.1. + +## MCP Inspector + +The Inspector is a small web app for trying a server by hand: + +```bash +npx @modelcontextprotocol/inspector +``` + +Choose the Streamable HTTP transport, enter the URL and add an +`Authorization: Bearer ` header. You can then list tools, call them and +see the raw JSON-RPC messages, which helps when a client says only that the +connection failed. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..204d620 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,76 @@ +# Configuration + +All settings live under the `Mcp` key in Configure. You only write what you +want to change; everything else keeps its default. These are the defaults +(`CakeDC\Mcp\McpConfig::DEFAULTS`): + +```php +'Mcp' => [ + 'route' => '/mcp', + 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null], + 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], + 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], + 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], + 'identity' => ['required' => true, 'attribute' => 'identity', 'scopes' => null], + 'authorization' => ['authorizer' => AllowAllToolAuthorizer::class, 'rbac' => []], + 'stdio' => ['identity' => null, 'trusted' => true], +], +``` + +## Keys + +- `route`: path of the HTTP endpoint. `false` (or an empty string) connects no route. +- `server.name`, `server.version`: reported to clients in the `initialize` result. +- `server.instructions`: optional text telling the client's model how to use your server. `null` sends none. +- `discovery.scanDirs`: directories (relative to `ROOT`) scanned for attributes. Missing directories are skipped. +- `discovery.excludeDirs`: directories to skip while scanning. +- `discovery.cache`: name of the cache config for the discovery result. Set it to `null` or an empty string to disable the cache. +- `session.cache`: name of the cache config that stores handshake-era sessions. +- `session.ttl`: session lifetime in seconds. +- `http.allowedHosts`: hosts accepted in the `Host` header (DNS-rebinding protection). `null` means the host of `App.fullBaseUrl` plus `localhost`, `127.0.0.1` and `[::1]`. A list replaces that. +- `http.allowedOrigins`: origins accepted for browser clients, also used for CORS. Empty by default. +- `identity.required`: answer 401 when the request has no identity. +- `identity.attribute`: request attribute that holds the identity. +- `identity.scopes`: optional `fn (mixed $identity): array` returning scope strings for the SDK access token. +- `authorization.authorizer`: class name or instance of a `ToolAuthorizerInterface`. +- `authorization.rbac`: options for `RbacToolAuthorizer` (the `cakedc/auth` Rbac config). +- `stdio.identity`: closure returning the identity for `bin/cake mcp server`. +- `stdio.trusted`: skip per-tool authorization for stdio. + +## Lists replace defaults + +Associative arrays merge with the defaults, but a list replaces the default +list. So this keeps `excludeDirs` and `cache` as they are and only swaps the +scan directories: + +```php +Configure::write('Mcp.discovery.scanDirs', ['src/Mcp', 'plugins/Shop/src/Mcp']); +``` + +The default `src/Mcp` is not scanned any more unless you list it again. + +## Caches + +If the cache configs named in `discovery.cache` and `session.cache` do not +exist, the plugin creates file caches for them under `CACHE/mcp/` (one hour +default duration). Define your own configs with the same names (for example on +Redis) and the plugin leaves them alone. + +The discovery cache is always off in debug mode. The session cache must be +shared by every PHP worker; see [Troubleshooting](troubleshooting.md). + +## Logging + +The plugin logs through Cake's `Log` with the `mcp` scope. Route those messages +to their own file with a scoped log config: + +```php +'Log' => [ + 'mcp' => [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => 'mcp', + 'scopes' => ['mcp'], + ], +], +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..121707e --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,78 @@ +# Installation + +## Composer + +```bash +composer require cakedc/cakephp-mcp +``` + +The plugin needs PHP 8.2+ and CakePHP 5. It builds on the official `mcp/sdk`. +The `php-http/discovery` Composer plugin is not needed: the plugin passes +Cake's own PSR-17 factories to the SDK, so nothing has to be discovered. + +## Load the plugin + +In `src/Application.php`: + +```php +public function bootstrap(): void +{ + parent::bootstrap(); + $this->addPlugin('CakeDC/Mcp'); +} +``` + +## Skip CSRF for the endpoint + +MCP clients are not browsers and do not send CSRF tokens. In +`Application::middleware()` let requests to the MCP path through: + +```php +use Cake\Http\Middleware\CsrfProtectionMiddleware; +use Cake\Http\ServerRequest; + +$csrf = new CsrfProtectionMiddleware(); +$csrf->skipCheckCallback(fn (ServerRequest $request): bool => str_starts_with($request->getPath(), '/mcp')); + +$middlewareQueue->add($csrf); +``` + +If you change `Mcp.route`, change the path here too. + +## The route + +The plugin connects `/mcp` (methods GET, POST, DELETE and OPTIONS) to its own +controller. To use another path: + +```php +// config/app_local.php or config/bootstrap.php +Configure::write('Mcp.route', '/api/mcp'); +``` + +To not connect any route (for example when you only use stdio), set it to +`false`: + +```php +Configure::write('Mcp.route', false); +``` + +## Where tools live + +By default the plugin scans `src/Mcp/` in your application for classes with +`#[McpTool]`, `#[McpResource]` and `#[McpPrompt]` attributes. Change the +directories with `Mcp.discovery.scanDirs` (paths relative to `ROOT`). See +[Writing tools](tools.md). + +If the directory does not exist yet, the endpoint still works and returns an +empty tool list. + +## After deploying new tools + +Outside debug mode the discovery result is cached in the `_cake_mcp_` cache +config. After you deploy new or changed tools, clear it: + +```bash +bin/cake cache clear _cake_mcp_ +``` + +In debug mode the discovery cache is off, so new tools show up right away. diff --git a/docs/stdio.md b/docs/stdio.md new file mode 100644 index 0000000..025d0d2 --- /dev/null +++ b/docs/stdio.md @@ -0,0 +1,57 @@ +# stdio + +For MCP clients that run on the same machine as your project, the plugin +provides a command that speaks MCP over standard input and output: + +```bash +bin/cake mcp server +``` + +## Claude Code + +```bash +claude mcp add my-app -- bin/cake mcp server +``` + +Run it from the project directory, or give the full path to `bin/cake`. With +DDEV or Docker, wrap the command, for example +`claude mcp add my-app -- ddev exec bin/cake mcp server`. + +## Identity + +There is no HTTP request, so there is no authentication. By default tools see +no identity. To run tools as a particular user, give `Mcp.stdio.identity` a +closure that returns the identity: + +```php +Configure::write('Mcp.stdio.identity', function (): ArrayObject { + return new ArrayObject(['id' => 1, 'role' => 'admin']); +}); +``` + +Return whatever shape your tools and authorizer expect (see +[Authentication](authentication.md)). The closure runs once when the command +starts. + +## Trusted mode + +`Mcp.stdio.trusted` defaults to `true`. A trusted stdio session skips +per-tool authorization: the developer who starts the command already has +access to the code and the database. Set it to `false` if you want the +configured authorizer applied to the identity from `stdio.identity`. + +Only authorization is skipped. Everything else, including tool discovery and +dependency injection, works as over HTTP. + +## Keep STDOUT clean + +The protocol uses STDOUT, so anything else written there corrupts the +conversation and the client disconnects. + +- Tools must never `echo`, `print` or `var_dump`. +- No Log engine may write to `php://stdout`. Cake's `ConsoleLog` writes to + stderr by default, which is fine. If you configured a console log with + `'stream' => 'php://stdout'`, change it or scope it away from the `mcp` + scope for this command. +- Make sure PHP notices and deprecations are not printed to STDOUT + (`display_errors` off for the CLI, or sent to stderr). diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..531519b --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,140 @@ +# Writing tools + +A tool is a method of a plain PHP class, marked with an attribute from the +official SDK. The plugin scans the directories in `Mcp.discovery.scanDirs` +(`src/Mcp` by default) and registers what it finds. + +## Attributes + +```php +use Mcp\Capability\Attribute\McpPrompt; +use Mcp\Capability\Attribute\McpResource; +use Mcp\Capability\Attribute\McpTool; +``` + +- `#[McpTool]` exposes a method clients can call. +- `#[McpResource]` exposes read-only content addressed by a URI. +- `#[McpPrompt]` exposes a reusable prompt template. + +The description comes from the method's docblock. The input schema is built +from the parameter types, and `@param` lines become the parameter descriptions. + +## Example returning structured data + +```php +namespace App\Mcp; + +use Cake\ORM\Locator\LocatorAwareTrait; +use Mcp\Capability\Attribute\McpTool; + +class OrderTools +{ + use LocatorAwareTrait; + + /** + * Finds an order by its number. + * + * @param string $number The order number, for example "A-1042". + * @return array{number: string, status: string, total: float} + */ + #[McpTool(name: 'find_order')] + public function find(string $number): array + { + $order = $this->fetchTable('Orders')->find()->where(['number' => $number])->firstOrFail(); + + return [ + 'number' => $order->number, + 'status' => $order->status, + 'total' => (float)$order->total, + ]; + } +} +``` + +## Constructor injection + +Tool classes are built through Cake's container, so constructor dependencies +are injected. Services you registered in `Application::services()` come first; +anything else is autowired when its own dependencies can be resolved. + +```php +class ReportTools +{ + public function __construct(private readonly ReportService $reports) + { + } + + #[McpTool(name: 'monthly_report')] + public function monthly(int $year, int $month): array + { + return $this->reports->forMonth($year, $month); + } +} +``` + +For ORM tables use `LocatorAwareTrait` and `fetchTable()`, as in the examples +above, instead of injecting a table. + +## Knowing who is calling + +Inject `CakeDC\Mcp\Identity\CurrentIdentity`: + +```php +use CakeDC\Mcp\Identity\CurrentIdentity; + +class WhoAmITools +{ + public function __construct(private readonly CurrentIdentity $identity) + { + } + + #[McpTool(name: 'whoami')] + public function whoami(): array + { + return ['id' => $this->identity->getIdentifier()]; + } +} +``` + +`get()` returns the host identity exactly as your authentication produced it, +`has()` tells whether there is one, and `getIdentifier()` returns its id. + +The SDK's own access token is also available. Add a `RequestContext` parameter +to the tool method and call `$context->getAccessToken()`: + +```php +use Mcp\Server\RequestContext; + +#[McpTool(name: 'token_scopes')] +public function scopes(RequestContext $context): array +{ + return $context->getAccessToken()?->getScopes() ?? []; +} +``` + +## Errors + +If a tool throws an unexpected exception, the client receives a JSON-RPC +`-32603` error with the message `Error while executing tool`. The original +message never reaches the client, so internals do not leak. With the stateless +2026-07-28 protocol revision the SDK answers that error with HTTP 400. + +To return a message to the client on purpose, throw +`Mcp\Exception\ToolCallException`: + +```php +use Mcp\Exception\ToolCallException; + +#[McpTool(name: 'cancel_order')] +public function cancel(string $number): string +{ + $order = $this->fetchTable('Orders')->find()->where(['number' => $number])->first(); + if ($order === null) { + throw new ToolCallException(sprintf('Order "%s" does not exist.', $number)); + } + + return 'Cancelled.'; +} +``` + +Unhandled exceptions are logged through Cake's `Log` with the `mcp` scope. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..c172e50 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,72 @@ +# Troubleshooting + +## The tool list is empty + +- A class in a scan directory fails to load (a syntax error, a missing + parent class or interface). The SDK silently skips discovery and returns + nothing, so run `php -l` on the files in `src/Mcp/` and load the classes from + a script or test to see the real error. +- `Mcp.discovery.scanDirs` points to the wrong place. Paths are relative to + `ROOT`, and a directory that does not exist is ignored. +- The discovery cache is stale. Outside debug mode run + `bin/cake cache clear _cake_mcp_` after every deploy of new tools. + +## 403 Invalid Host header + +The `Host` of the request is not allowed. By default the host of +`App.fullBaseUrl` and localhost are. Make sure `App.fullBaseUrl` is set +correctly in production, or list the hosts yourself: + +```php +Configure::write('Mcp.http.allowedHosts', ['example.com', 'localhost']); +``` + +## 403 from CSRF + +Cake's CSRF middleware is rejecting the POST. Add the `skipCheckCallback` shown +in [Installation](installation.md), and make sure it matches your +`Mcp.route`. + +## 401 on every request + +The authenticator is not reading the header. Check that the client sends +`Authorization: Bearer `, that `tokenPrefix` matches (`Bearer`), and +that your web server passes the `Authorization` header to PHP (Apache needs +`SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1` in some setups). Also +confirm the `AuthenticationMiddleware` runs before the request reaches the MCP +route. + +## AuthorizationRequiredException from cakephp/authorization + +This should not happen: the MCP controller marks the request as authorized +because authorization is done per tool. If you see it, check that you run the +latest version of this plugin and that no other middleware calls the +authorization service on the MCP route. + +## Server-sent events stall behind nginx + +The plugin sends `X-Accel-Buffering: no` on event streams so nginx does not +buffer them. If a stream still stalls, a proxy or CDN in front is ignoring the +header. Disable response buffering for the MCP path there. + +## The session is lost between requests + +With the 2025 handshake protocol, the `Mcp-Session-Id` returned by +`initialize` must work on the next request, which may be handled by another PHP +worker. The `mcp_sessions` cache therefore has to be shared by all workers. Do +not point it at the Array engine. The default file cache works on one server; +use Redis or Memcached when you run several. + +## A tool failed and the client only says "Error while executing tool" + +That is intentional: exception messages are not sent to clients. Check your +logs (the `mcp` scope), or throw `Mcp\Exception\ToolCallException` when you +want the client to see a message. See [Writing tools](tools.md). + +## HTTP 500 with "Internal error" + +Something failed outside the SDK, for example the server could not be built +because the configuration is invalid. The response is always the JSON body +`{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Internal error"}}`, +never an HTML page, even in debug mode. The full error is in your log under the +`mcp` scope. From 70d46cc0dc525fa061217b54ab44feb7ce1c88bf Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:10:08 +0200 Subject: [PATCH 12/49] Document the temporary mcp/sdk pin for host installs --- CHANGELOG.md | 1 + README.md | 13 +++++++++++++ docs/installation.md | 13 +++++++++++++ docs/stdio.md | 4 ++++ 4 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb0d97..59dcb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,4 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Per-tool authorization with `ToolAuthorizerInterface`; `RbacToolAuthorizer` for `cakedc/auth` permission rules. - DNS-rebinding protection defaults to the `App.fullBaseUrl` host. - Removed `bin/mcp-server` and `CakeDC\Mcp\Server\CakeServer`. +- Temporary: `mcp/sdk` is pinned to a fixed commit aliased as 0.9.0 until the SDK tags that release; host apps must add the same requirement and allow dev stability (see the install notes). diff --git a/README.md b/README.md index 19f2c69..0512865 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,19 @@ Built on the official [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sd composer require cakedc/cakephp-mcp ``` +> **Beta note (temporary).** `mcp/sdk` 0.9.0 is not released yet, and this +> plugin pins the SDK to a fixed commit. Composer only applies inline aliases and +> `minimum-stability` from the root package, so until the SDK tags 0.9.0 your +> application must declare the same requirement and allow dev stability: +> +> ```bash +> composer config minimum-stability dev +> composer config prefer-stable true +> composer require "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" cakedc/cakephp-mcp:dev-2.next-cake5 +> ``` +> +> This note will go away once the SDK tags 0.9.0. + Load the plugin in `src/Application.php`: ```php diff --git a/docs/installation.md b/docs/installation.md index 121707e..78133b6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,6 +6,19 @@ composer require cakedc/cakephp-mcp ``` +> **Beta note (temporary).** `mcp/sdk` 0.9.0 is not released yet, and this +> plugin pins the SDK to a fixed commit. Composer only applies inline aliases and +> `minimum-stability` from the root package, so until the SDK tags 0.9.0 your +> application must declare the same requirement and allow dev stability: +> +> ```bash +> composer config minimum-stability dev +> composer config prefer-stable true +> composer require "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" cakedc/cakephp-mcp:dev-2.next-cake5 +> ``` +> +> This note will go away once the SDK tags 0.9.0. + The plugin needs PHP 8.2+ and CakePHP 5. It builds on the official `mcp/sdk`. The `php-http/discovery` Composer plugin is not needed: the plugin passes Cake's own PSR-17 factories to the SDK, so nothing has to be discovered. diff --git a/docs/stdio.md b/docs/stdio.md index 025d0d2..d1a48c7 100644 --- a/docs/stdio.md +++ b/docs/stdio.md @@ -29,6 +29,10 @@ Configure::write('Mcp.stdio.identity', function (): ArrayObject { }); ``` +`Mcp.stdio.identity` must be a callable. A plain array or `ArrayObject` there is +silently ignored (the command only calls it when it is callable), so tools +would see no identity. + Return whatever shape your tools and authorizer expect (see [Authentication](authentication.md)). The closure runs once when the command starts. From 8c8d743453ab41cc4e6eeaaf6036805074954834 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:12:34 +0200 Subject: [PATCH 13/49] Keep info logs off STDOUT in the stdio server command --- docs/stdio.md | 2 ++ src/Command/McpServerCommand.php | 4 ++++ tests/TestCase/Command/McpServerCommandTest.php | 13 +++++++++++++ 3 files changed, 19 insertions(+) diff --git a/docs/stdio.md b/docs/stdio.md index d1a48c7..f5daf59 100644 --- a/docs/stdio.md +++ b/docs/stdio.md @@ -52,6 +52,8 @@ dependency injection, works as over HTTP. The protocol uses STDOUT, so anything else written there corrupts the conversation and the client disconnects. +- The command removes Cake's default console loggers that mirror info logs to + STDOUT, keeping only the stderr one for errors and warnings. - Tools must never `echo`, `print` or `var_dump`. - No Log engine may write to `php://stdout`. Cake's `ConsoleLog` writes to stderr by default, which is fine. If you configured a console log with diff --git a/src/Command/McpServerCommand.php b/src/Command/McpServerCommand.php index a72ea9a..f2eccb5 100644 --- a/src/Command/McpServerCommand.php +++ b/src/Command/McpServerCommand.php @@ -54,6 +54,10 @@ public static function getDescription(): string */ public function execute(Arguments $args, ConsoleIo $io): int { + // The console framework mirrors info logs to STDOUT, which would corrupt the protocol. + // QUIET keeps only the stderr logger for errors and warnings. + $io->setLoggers(ConsoleIo::QUIET); + $resolver = McpConfig::get('stdio.identity'); $this->identity->set(is_callable($resolver) ? $resolver() : null); diff --git a/tests/TestCase/Command/McpServerCommandTest.php b/tests/TestCase/Command/McpServerCommandTest.php index f3e873d..479f16c 100644 --- a/tests/TestCase/Command/McpServerCommandTest.php +++ b/tests/TestCase/Command/McpServerCommandTest.php @@ -11,6 +11,7 @@ use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Core\Configure; use Cake\Core\Container; +use Cake\Log\Log; use Cake\TestSuite\TestCase; use CakeDC\Mcp\Authorization\RbacToolAuthorizer; use CakeDC\Mcp\Command\McpServerCommand; @@ -32,6 +33,8 @@ protected function setUp(): void protected function tearDown(): void { Cache::drop('mcp_sessions'); + Log::drop('stdout'); + Log::drop('stderr'); parent::tearDown(); } @@ -90,6 +93,16 @@ public function testTrustedStdioSkipsRbacAndUntrustedDoesNot(): void $this->assertSame([], $untrusted->transport?->responseFor(2)['result']['tools'] ?? null); } + public function testNothingButProtocolReachesStdout(): void + { + $out = new StubConsoleOutput(); + $command = $this->command(); + + $command->run([], new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput([]))); + + $this->assertSame([], $out->messages()); + } + /** * @param list $extra More JSON-RPC lines after initialize/initialized/tools list (id 2). */ From bfbb11ad2f44c31f0969eb93ae9376b99a169d11 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:12:35 +0200 Subject: [PATCH 14/49] Add a front controller for manual testing of the test app --- tests/test_app/webroot/index.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/test_app/webroot/index.php diff --git a/tests/test_app/webroot/index.php b/tests/test_app/webroot/index.php new file mode 100644 index 0000000..e01031a --- /dev/null +++ b/tests/test_app/webroot/index.php @@ -0,0 +1,15 @@ +emit($server->run()); From 557a7d880b9cd823850802270cb819ce0479419a Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:29:32 +0200 Subject: [PATCH 15/49] Do not call a parent constructor Cake 5.0 Command lacks --- src/Command/McpServerCommand.php | 1 - tests/TestCase/Command/McpServerCommandTest.php | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Command/McpServerCommand.php b/src/Command/McpServerCommand.php index f2eccb5..5080b30 100644 --- a/src/Command/McpServerCommand.php +++ b/src/Command/McpServerCommand.php @@ -30,7 +30,6 @@ public function __construct( private readonly ServerFactory $serverFactory, private readonly CurrentIdentity $identity, ) { - parent::__construct(); } /** diff --git a/tests/TestCase/Command/McpServerCommandTest.php b/tests/TestCase/Command/McpServerCommandTest.php index 479f16c..0d31685 100644 --- a/tests/TestCase/Command/McpServerCommandTest.php +++ b/tests/TestCase/Command/McpServerCommandTest.php @@ -18,6 +18,7 @@ use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\McpPlugin; use CakeDC\Mcp\Server\ServerFactory; +use ReflectionClass; use stdClass; use TestApp\Command\RecordingMcpServerCommand; @@ -46,6 +47,14 @@ public function testIsRegisteredAsMcpServer(): void $this->assertSame(McpServerCommand::class, $commands->get('mcp server')); } + public function testConstructorDoesNotCallTheParentConstructor(): void + { + // Cake 5.0.x Command has no constructor, so calling it fatals on the lowest supported version. + $source = (string)file_get_contents((string)(new ReflectionClass(McpServerCommand::class))->getFileName()); + + $this->assertStringNotContainsString('parent::__construct', $source); + } + public function testTheContainerBuildsTheCommand(): void { $container = new Container(); From 9ab9fa95dc56d56804b2f896d5cfb40db92ec6c3 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:29:32 +0200 Subject: [PATCH 16/49] Log exception details and unused context from the SDK --- src/Log/McpLogger.php | 21 ++++++++++++++++++++- tests/TestCase/Log/McpLoggerTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/Log/McpLogger.php b/src/Log/McpLogger.php index cb334f2..9366c6e 100644 --- a/src/Log/McpLogger.php +++ b/src/Log/McpLogger.php @@ -6,6 +6,7 @@ use Cake\Log\Log; use Psr\Log\AbstractLogger; use Stringable; +use Throwable; /** * The PSR-3 logger handed to the MCP SDK: forwards to Cake's `Log` under the @@ -24,6 +25,24 @@ final class McpLogger extends AbstractLogger public function log(mixed $level, string|Stringable $message, array $context = []): void { $context['scope'] = array_merge((array)($context['scope'] ?? []), [self::SCOPE]); - Log::write(is_string($level) ? $level : 'info', $message, $context); + $message = (string)$message; + + // Cake log engines only interpolate {placeholders}, so anything else in the + // context (the SDK passes the exception and the tool name) would be lost. + $extra = []; + foreach ($context as $key => $value) { + if ($key === 'scope' || str_contains($message, '{' . $key . '}')) { + continue; + } + if ($value instanceof Throwable) { + $extra[] = "\n" . (string)$value; + } elseif (is_scalar($value) || $value instanceof Stringable) { + $extra[] = ' ' . $key . '=' . $value; + } + } + // Throwables go last so the one-line key=value pairs stay next to the message. + usort($extra, fn(string $a, string $b): int => ($a[0] === "\n") <=> ($b[0] === "\n")); + + Log::write(is_string($level) ? $level : 'info', $message . implode('', $extra), $context); } } diff --git a/tests/TestCase/Log/McpLoggerTest.php b/tests/TestCase/Log/McpLoggerTest.php index ff2c6d2..8151acc 100644 --- a/tests/TestCase/Log/McpLoggerTest.php +++ b/tests/TestCase/Log/McpLoggerTest.php @@ -7,6 +7,7 @@ use Cake\Log\Log; use Cake\TestSuite\TestCase; use CakeDC\Mcp\Log\McpLogger; +use RuntimeException; class McpLoggerTest extends TestCase { @@ -33,4 +34,27 @@ public function testWritesToCakeLogUnderTheMcpScope(): void $this->assertStringContainsString('warning', $lines[0]); $this->assertStringContainsString('Tool echo failed', $lines[0]); } + + public function testExceptionDetailsAndUnusedContextReachTheLog(): void + { + (new McpLogger())->error( + 'Unhandled error during tool execution', + ['name' => 'echo', 'exception' => new RuntimeException('database exploded')], + ); + + $engine = Log::engine('mcp_test'); + $this->assertInstanceOf(ArrayLog::class, $engine); + $line = $engine->read()[0]; + $this->assertStringContainsString('database exploded', $line); + $this->assertStringContainsString('name=echo', $line); + } + + public function testPlaceholderContextIsNotAppendedTwice(): void + { + (new McpLogger())->warning('Tool {name} failed', ['name' => 'echo']); + + $engine = Log::engine('mcp_test'); + $this->assertInstanceOf(ArrayLog::class, $engine); + $this->assertStringNotContainsString('name=echo', $engine->read()[0]); + } } From 8836b1769cfcbbde5d5c24fe0a532c173d09e7dd Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:29:32 +0200 Subject: [PATCH 17/49] Keep authentication on unless identity.required is explicitly false --- src/Controller/McpController.php | 2 +- src/McpConfig.php | 10 +++++++++- tests/TestCase/Controller/McpControllerTest.php | 9 +++++++++ tests/TestCase/McpConfigTest.php | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Controller/McpController.php b/src/Controller/McpController.php index 04f410c..7dbce15 100644 --- a/src/Controller/McpController.php +++ b/src/Controller/McpController.php @@ -57,7 +57,7 @@ public function handle(ServerFactory $factory, CurrentIdentity $identity): Respo $identity, $responseFactory, $streamFactory, - (bool)McpConfig::get('identity.required'), + McpConfig::get('identity.required') !== false, (string)McpConfig::get('identity.attribute'), is_callable($scopes) ? Closure::fromCallable($scopes) : null, ), diff --git a/src/McpConfig.php b/src/McpConfig.php index ec69564..c822abf 100644 --- a/src/McpConfig.php +++ b/src/McpConfig.php @@ -11,7 +11,8 @@ * Reads the plugin configuration (`Mcp` in Configure) merged over its defaults. * * The merge happens on every read, so the host may write `Mcp` at any point of - * its bootstrap. Lists (such as `discovery.scanDirs`) replace the default list. + * its bootstrap. Lists (such as `discovery.scanDirs`) replace the default list. An empty array + * over an associative default keeps the default, so `Mcp.identity => []` cannot disable authentication. */ final class McpConfig { @@ -89,6 +90,13 @@ public static function allowedHosts(): array private static function merge(array $base, array $override): array { foreach ($override as $key => $value) { + if ( + $value === [] && isset($base[$key]) && is_array($base[$key]) + && $base[$key] !== [] && !array_is_list($base[$key]) + ) { + // An empty array over an associative default is a misconfiguration, not an intent to clear it. + continue; + } if ( is_array($value) && isset($base[$key]) && is_array($base[$key]) && !array_is_list($value) && !array_is_list($base[$key]) diff --git a/tests/TestCase/Controller/McpControllerTest.php b/tests/TestCase/Controller/McpControllerTest.php index 6731859..25468c8 100644 --- a/tests/TestCase/Controller/McpControllerTest.php +++ b/tests/TestCase/Controller/McpControllerTest.php @@ -95,6 +95,15 @@ public function testMissingTokenIsChallenged(): void $this->assertHeader('WWW-Authenticate', 'Bearer'); } + public function testNullRequiredStillChallenges(): void + { + Configure::write('Mcp.identity.required', null); + + $this->mcp($this->initialize(1), ['Authorization' => '']); + + $this->assertResponseCode(401); + } + public function testInvalidTokenIsChallenged(): void { $this->mcp($this->initialize(1), ['Authorization' => 'Bearer nope']); diff --git a/tests/TestCase/McpConfigTest.php b/tests/TestCase/McpConfigTest.php index c4015c0..360c5f1 100644 --- a/tests/TestCase/McpConfigTest.php +++ b/tests/TestCase/McpConfigTest.php @@ -68,4 +68,19 @@ public function testExplicitAllowedHostsAreUsedAsGivenLowercased(): void $this->assertSame(['api.example.com'], McpConfig::allowedHosts()); } + + public function testEmptyArrayOverAnAssociativeDefaultKeepsTheDefault(): void + { + Configure::write('Mcp.identity', []); + + $this->assertTrue(McpConfig::get('identity.required')); + $this->assertSame('identity', McpConfig::get('identity.attribute')); + } + + public function testEmptyListOverAListDefaultStillReplaces(): void + { + Configure::write('Mcp.discovery.scanDirs', []); + + $this->assertSame([], McpConfig::get('discovery.scanDirs')); + } } From 115913dc47ceb097d40746aea037bcbab83cb5b6 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 14:30:31 +0200 Subject: [PATCH 18/49] Fix documentation inaccuracies from the final review --- CHANGELOG.md | 2 +- README.md | 2 +- docs/authentication.md | 35 +++++++++++++++++++++++++++--- docs/authorization.md | 12 ++++++++-- docs/clients.md | 2 +- docs/configuration.md | 9 +++++++- docs/installation.md | 5 +++-- docs/troubleshooting.md | 16 ++++++++++++-- tests/test_app/src/Application.php | 2 +- 9 files changed, 71 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59dcb2a..aa2e4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,6 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Sessions and the discovery cache stored in Cake cache configs. - Authentication delegated to the host: the identity set by `cakephp/authentication` becomes the SDK access token and the injectable `CurrentIdentity`. - Per-tool authorization with `ToolAuthorizerInterface`; `RbacToolAuthorizer` for `cakedc/auth` permission rules. -- DNS-rebinding protection defaults to the `App.fullBaseUrl` host. +- DNS-rebinding protection defaults to the `App.fullBaseUrl` host. That only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise); set `Mcp.http.allowedHosts`, which is required for servers with `identity.required` set to `false`. - Removed `bin/mcp-server` and `CakeDC\Mcp\Server\CakeServer`. - Temporary: `mcp/sdk` is pinned to a fixed commit aliased as 0.9.0 until the SDK tags that release; host apps must add the same requirement and allow dev stability (see the install notes). diff --git a/README.md b/README.md index 0512865..3408adf 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Let MCP requests through CSRF protection (in `Application::middleware()`): ```php $csrf = new CsrfProtectionMiddleware(); -$csrf->skipCheckCallback(fn (ServerRequest $request): bool => str_starts_with($request->getPath(), '/mcp')); +$csrf->skipCheckCallback(fn (ServerRequest $request): bool => $request->getPath() === '/mcp'); ``` ## Your first tool diff --git a/docs/authentication.md b/docs/authentication.md index 6c78ad0..47f436e 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -86,9 +86,38 @@ challenged. ## cakedc/auth and cakedc/users -Applications built on `cakedc/auth` or `cakedc/users` need nothing special. -They already put the identity in the `identity` attribute, so the plugin works -unchanged. To restrict tools by role, see [Authorization](authorization.md). +Applications built on `cakedc/auth` or `cakedc/users` already put the identity +in the `identity` attribute, so the plugin reads it unchanged. Two defaults of +`cakedc/users` get in the way of the endpoint, though, and need configuration: + +1. `cakedc/users` loads `RequestAuthorizationMiddleware` with `RbacPolicy`, which + authorizes the route before the controller runs. The plugin's + `skipAuthorization()` call never gets a chance, and an anonymous request is + redirected to the login page instead of receiving the plugin's 401. Add a + permission rule for the endpoint in your `config/permissions.php`, with + `bypassAuth` so the request reaches the plugin, which then does its own 401 + and per-tool authorization: + + ```php + [ + 'role' => '*', + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'Mcp', + 'action' => 'handle', + 'bypassAuth' => true, + ], + ``` + +2. The default `Token` authenticator of `cakedc/users` reads the `api_key` query + parameter (`'header' => null`, `'queryParam' => 'api_key'`), not the + `Authorization` header. Configure a header based Bearer authenticator, as + shown in [Bearer tokens](#bearer-tokens), for example through + `Auth.Authenticators.Token` (`'header' => 'Authorization'`, + `'queryParam' => null`, `'tokenPrefix' => 'Bearer'`). + +The key names above come from `cakedc/users` and `cakedc/auth` as installed in +a local project; check them against the versions you use. To restrict tools by +role, see [Authorization](authorization.md). ## OAuth diff --git a/docs/authorization.md b/docs/authorization.md index 9f8ac70..49fef7e 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -42,7 +42,9 @@ class StaffOnlyAuthorizer implements ToolAuthorizerInterface ``` Configure it by class name (built through the container, so it can have -injected dependencies) or by instance: +injected dependencies) or by instance. The authorizer is chosen only through +`Mcp.authorization.authorizer`; binding `ToolAuthorizerInterface` in the +container is not consulted: ```php Configure::write('Mcp.authorization.authorizer', \App\Mcp\StaffOnlyAuthorizer::class); @@ -56,7 +58,13 @@ Configure::write('Mcp.authorization.authorizer', new StaffOnlyAuthorizer()); - Calling it anyway gets the same answer as calling a tool that does not exist: JSON-RPC error `-32602` with the message `Tool not found: "".` -So callers cannot discover which protected tools exist. +So callers generally cannot discover which protected tools exist. One +exception: tools whose input schema uses `x-mcp-header` annotations can be told +apart on the stateless (2026-07-28) era, because header validation runs before +the guard. + +Resources and prompts are not covered by the tool authorizer: any identified +caller can list and read them. ## RbacToolAuthorizer (cakedc/auth) diff --git a/docs/clients.md b/docs/clients.md index f977802..061b9c7 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -9,7 +9,7 @@ claude mcp add --transport http my-app https://example.com/mcp \ Check that it connected with `claude mcp list`, or `/mcp` inside Claude Code. The server must be reachable from your machine, and its host must be allowed -(`http.allowedHosts`, by default the host of `App.fullBaseUrl`). +(`http.allowedHosts`; by default the host of `App.fullBaseUrl`, which only restricts anything when it is set explicitly, so set `http.allowedHosts` for a real deployment). For a server running on your own machine you can use the local URL, for example `https://my-app.ddev.site/mcp`. diff --git a/docs/configuration.md b/docs/configuration.md index 204d620..62c0dd8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,7 +27,7 @@ want to change; everything else keeps its default. These are the defaults - `discovery.cache`: name of the cache config for the discovery result. Set it to `null` or an empty string to disable the cache. - `session.cache`: name of the cache config that stores handshake-era sessions. - `session.ttl`: session lifetime in seconds. -- `http.allowedHosts`: hosts accepted in the `Host` header (DNS-rebinding protection). `null` means the host of `App.fullBaseUrl` plus `localhost`, `127.0.0.1` and `[::1]`. A list replaces that. +- `http.allowedHosts`: hosts accepted in the `Host` header (DNS-rebinding protection). `null` means the host of `App.fullBaseUrl` plus `localhost`, `127.0.0.1` and `[::1]`, which only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise, and then every host is accepted). A list replaces that. Set it explicitly in production; it is required for servers with `identity.required` set to `false`. - `http.allowedOrigins`: origins accepted for browser clients, also used for CORS. Empty by default. - `identity.required`: answer 401 when the request has no identity. - `identity.attribute`: request attribute that holds the identity. @@ -65,6 +65,8 @@ The plugin logs through Cake's `Log` with the `mcp` scope. Route those messages to their own file with a scoped log config: ```php +use Cake\Log\Engine\FileLog; + 'Log' => [ 'mcp' => [ 'className' => FileLog::class, @@ -74,3 +76,8 @@ to their own file with a scoped log config: ], ], ``` + +Engines without `scopes` (the default `[]`) catch every scope, so the default +`debug` and `error` engines of the skeleton also receive the `mcp` messages. To +keep them out, give those engines `'scopes' => null`: an engine with `null` +scopes only handles messages that have no scope. diff --git a/docs/installation.md b/docs/installation.md index 78133b6..48def1f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -45,7 +45,7 @@ use Cake\Http\Middleware\CsrfProtectionMiddleware; use Cake\Http\ServerRequest; $csrf = new CsrfProtectionMiddleware(); -$csrf->skipCheckCallback(fn (ServerRequest $request): bool => str_starts_with($request->getPath(), '/mcp')); +$csrf->skipCheckCallback(fn (ServerRequest $request): bool => $request->getPath() === '/mcp'); $middlewareQueue->add($csrf); ``` @@ -55,7 +55,8 @@ If you change `Mcp.route`, change the path here too. ## The route The plugin connects `/mcp` (methods GET, POST, DELETE and OPTIONS) to its own -controller. To use another path: +controller. GET is routed, but this SDK version answers it with 405 (there is +no standalone server-to-client stream). To use another path: ```php // config/app_local.php or config/bootstrap.php diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c172e50..b81a346 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,13 +14,25 @@ ## 403 Invalid Host header The `Host` of the request is not allowed. By default the host of -`App.fullBaseUrl` and localhost are. Make sure `App.fullBaseUrl` is set -correctly in production, or list the hosts yourself: +`App.fullBaseUrl` and localhost are, but only when `App.fullBaseUrl` is set +explicitly: the Cake 5 skeleton derives it from the request `Host` when it is +not set, and then every host is accepted. Set `Mcp.http.allowedHosts` yourself +(required for servers with `identity.required` set to `false`): ```php Configure::write('Mcp.http.allowedHosts', ['example.com', 'localhost']); ``` +## 403 or a redirect to the login page (cakedc/users) + +On a `cakedc/users` host, `RequestAuthorizationMiddleware` authorizes the +route before the plugin runs, so an anonymous or unlisted request gets a 403 or +a redirect to the login page instead of the plugin's 401. Add a `bypassAuth` +permission rule for `CakeDC/Mcp` `Mcp::handle`; see +[Authentication](authentication.md#cakedcauth-and-cakedcusers). If tokens are +sent but ignored, the default `Token` authenticator reads `api_key` from the +query string, not the header. + ## 403 from CSRF Cake's CSRF middleware is rejecting the POST. Add the `skipCheckCallback` shown diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index a8bc3aa..abd486c 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -42,7 +42,7 @@ public function bootstrap(): void public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue { $csrf = new CsrfProtectionMiddleware(); - $csrf->skipCheckCallback(fn(ServerRequest $request): bool => str_starts_with($request->getPath(), '/mcp')); + $csrf->skipCheckCallback(fn(ServerRequest $request): bool => $request->getPath() === '/mcp'); return $middlewareQueue ->add(new ErrorHandlerMiddleware()) From 208ef33f403aefdedff2f822ad2b0a5005be8416 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 22:29:33 +0200 Subject: [PATCH 19/49] Document tool annotations and check they reach tools/list --- docs/tools.md | 29 +++++++++++++++++++++ tests/TestCase/Server/ServerFactoryTest.php | 8 ++++++ tests/test_app/src/Mcp/EchoTools.php | 3 ++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/tools.md b/docs/tools.md index 531519b..60173cf 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -51,6 +51,35 @@ class OrderTools } ``` +## Annotations + +Annotations tell the client how a tool behaves. Clients such as Claude use them +to decide when to ask the user before running a tool, so mark the tools that +only read as read-only and the ones that delete or overwrite as destructive: + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\ToolAnnotations; + +#[McpTool(name: 'find_order', annotations: new ToolAnnotations(readOnlyHint: true))] +public function find(string $number): array + +#[McpTool(name: 'cancel_order', annotations: new ToolAnnotations(destructiveHint: true, idempotentHint: true))] +public function cancel(string $number): string +``` + +| Hint | Meaning | +| --- | --- | +| `title` | A human-readable name for the tool. | +| `readOnlyHint` | The tool does not change anything. | +| `destructiveHint` | The tool may delete or overwrite data. Only meaningful when it is not read-only. | +| `idempotentHint` | Calling it again with the same arguments changes nothing more. Only meaningful when it is not read-only. | +| `openWorldHint` | The tool reaches systems outside your application, such as a third-party API. | + +A hint you leave out means the worst case: not read-only, destructive, not +idempotent and open world. Annotations are hints for the client, not a security +boundary. Who may call a tool is decided by [authorization](authorization.md). + ## Constructor injection Tool classes are built through Cake's container, so constructor dependencies diff --git a/tests/TestCase/Server/ServerFactoryTest.php b/tests/TestCase/Server/ServerFactoryTest.php index 2ba6420..0c090b4 100644 --- a/tests/TestCase/Server/ServerFactoryTest.php +++ b/tests/TestCase/Server/ServerFactoryTest.php @@ -44,6 +44,14 @@ public function testListsDiscoveredTools(): void $this->assertSame(['admin_only', 'echo', 'explode', 'whoami'], $this->toolNames($this->runServer())); } + public function testToolAnnotationsReachTheToolList(): void + { + $tools = array_column($this->runServer()->responseFor(2)['result']['tools'] ?? [], null, 'name'); + + $this->assertSame(['readOnlyHint' => true, 'idempotentHint' => true], $tools['echo']['annotations'] ?? null); + $this->assertArrayNotHasKey('annotations', $tools['explode'] ?? []); + } + public function testCallsATool(): void { $transport = $this->runServer([$this->call(3, 'echo', ['text' => 'hello'])]); diff --git a/tests/test_app/src/Mcp/EchoTools.php b/tests/test_app/src/Mcp/EchoTools.php index ebb9e7a..7276b60 100644 --- a/tests/test_app/src/Mcp/EchoTools.php +++ b/tests/test_app/src/Mcp/EchoTools.php @@ -5,6 +5,7 @@ use CakeDC\Mcp\Identity\CurrentIdentity; use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\ToolAnnotations; use Mcp\Server\RequestContext; use RuntimeException; @@ -17,7 +18,7 @@ public function __construct(private readonly CurrentIdentity $identity) /** * Returns the given text unchanged. */ - #[McpTool(name: 'echo')] + #[McpTool(name: 'echo', annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true))] public function echoText(string $text): string { return $text; From 1a13fdcdfbec9fe5002d2c88c85f2e1209083443 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 22:30:30 +0200 Subject: [PATCH 20/49] Add ToolError::invalid() to send Cake validation errors to the model --- CHANGELOG.md | 1 + docs/tools.md | 45 +++++++++++++ src/Tool/ToolError.php | 71 +++++++++++++++++++++ tests/TestCase/Server/ServerFactoryTest.php | 17 ++++- tests/TestCase/Tool/ToolErrorTest.php | 63 ++++++++++++++++++ tests/test_app/src/Mcp/NoteTools.php | 28 ++++++++ 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/Tool/ToolError.php create mode 100644 tests/TestCase/Tool/ToolErrorTest.php create mode 100644 tests/test_app/src/Mcp/NoteTools.php diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2e4e8..0130b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Sessions and the discovery cache stored in Cake cache configs. - Authentication delegated to the host: the identity set by `cakephp/authentication` becomes the SDK access token and the injectable `CurrentIdentity`. - Per-tool authorization with `ToolAuthorizerInterface`; `RbacToolAuthorizer` for `cakedc/auth` permission rules. +- `ToolError::invalid()` turns Cake validation errors (an entity or a `Validator` result) into a tool error the model can read and act on. - DNS-rebinding protection defaults to the `App.fullBaseUrl` host. That only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise); set `Mcp.http.allowedHosts`, which is required for servers with `identity.required` set to `false`. - Removed `bin/mcp-server` and `CakeDC\Mcp\Server\CakeServer`. - Temporary: `mcp/sdk` is pinned to a fixed commit aliased as 0.9.0 until the SDK tags that release; host apps must add the same requirement and allow dev stability (see the install notes). diff --git a/docs/tools.md b/docs/tools.md index 60173cf..6ebf59d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -167,3 +167,48 @@ public function cancel(string $number): string ``` Unhandled exceptions are logged through Cake's `Log` with the `mcp` scope. + +### Validation errors + +The SDK already rejects arguments that do not match the tool's input schema. +For the rules that live in your tables and validators, throw +`CakeDC\Mcp\Tool\ToolError::invalid()`. It turns Cake's validation errors into +a message with one line per field, so the model sees what to fix and can try +again: + +```php +use Cake\ORM\Locator\LocatorAwareTrait; +use CakeDC\Mcp\Tool\ToolError; +use Mcp\Capability\Attribute\McpTool; + +class ArticleTools +{ + use LocatorAwareTrait; + + #[McpTool(name: 'create_draft')] + public function createDraft(string $title, string $body): array + { + $articles = $this->fetchTable('Articles'); + $article = $articles->newEntity(['title' => $title, 'body' => $body]); + if (!$articles->save($article)) { + throw ToolError::invalid($article, 'The draft was not saved.'); + } + + return ['id' => $article->id]; + } +} +``` + +The client receives a tool error with this text: + +``` +The draft was not saved. +- title: This field cannot be left empty +- tags.0.name: This field is required +``` + +`invalid()` takes an entity (errors of associated entities included) or the +array returned by `Validator::validate()` or `getErrors()`. With `saveOrFail()`, +catch `Cake\ORM\Exception\PersistenceFailedException` and pass +`$exception->getEntity()`. The messages are your validation messages as they +are, so write them for someone who has to fix the data. diff --git a/src/Tool/ToolError.php b/src/Tool/ToolError.php new file mode 100644 index 0000000..5ba4922 --- /dev/null +++ b/src/Tool/ToolError.php @@ -0,0 +1,71 @@ + $errors The entity or its errors. + * @param string $message The first line of the message. + * @return \Mcp\Exception\ToolCallException + */ + public static function invalid( + EntityInterface|array $errors, + string $message = 'The data is invalid.', + ): ToolCallException { + $lines = [$message]; + $fields = self::flatten($errors instanceof EntityInterface ? $errors->getErrors() : $errors); + foreach ($fields as $field => $messages) { + $text = implode('; ', $messages); + $lines[] = $field === '' ? '- ' . $text : sprintf('- %s: %s', $field, $text); + } + + return new ToolCallException(implode("\n", $lines)); + } + + /** + * Turns Cake's nested errors into messages keyed by dotted field path. A + * string is a message, and its key is the name of the rule that failed. + * + * @param array $errors Nested errors. + * @param string $path The path of $errors. + * @return array> + */ + private static function flatten(array $errors, string $path = ''): array + { + $flat = []; + foreach ($errors as $key => $value) { + if (is_string($value)) { + $flat[$path][] = $value; + + continue; + } + if (!is_array($value)) { + continue; + } + $child = $path === '' ? (string)$key : $path . '.' . $key; + foreach (self::flatten($value, $child) as $field => $messages) { + $flat[$field] = array_merge($flat[$field] ?? [], $messages); + } + } + + return $flat; + } +} diff --git a/tests/TestCase/Server/ServerFactoryTest.php b/tests/TestCase/Server/ServerFactoryTest.php index 0c090b4..111ad8d 100644 --- a/tests/TestCase/Server/ServerFactoryTest.php +++ b/tests/TestCase/Server/ServerFactoryTest.php @@ -41,7 +41,22 @@ protected function tearDown(): void public function testListsDiscoveredTools(): void { - $this->assertSame(['admin_only', 'echo', 'explode', 'whoami'], $this->toolNames($this->runServer())); + $this->assertSame( + ['admin_only', 'create_note', 'echo', 'explode', 'whoami'], + $this->toolNames($this->runServer()), + ); + } + + public function testValidationErrorsReachTheModelAsAToolError(): void + { + $transport = $this->runServer([$this->call(3, 'create_note', ['title' => 'ab'])]); + + $result = $transport->responseFor(3)['result'] ?? []; + $this->assertTrue($result['isError'] ?? false); + $this->assertSame( + "The note was not created.\n- title: Use at least 3 characters.", + $result['content'][0]['text'] ?? null, + ); } public function testToolAnnotationsReachTheToolList(): void diff --git a/tests/TestCase/Tool/ToolErrorTest.php b/tests/TestCase/Tool/ToolErrorTest.php new file mode 100644 index 0000000..f8e1580 --- /dev/null +++ b/tests/TestCase/Tool/ToolErrorTest.php @@ -0,0 +1,63 @@ + ['_empty' => 'This field cannot be left empty'], + 'slug' => ['unique' => 'Already taken', 'format' => 'Use lowercase letters'], + ]); + + $this->assertSame( + "The data is invalid.\n- title: This field cannot be left empty\n- slug: Already taken; Use lowercase letters", + $exception->getMessage(), + ); + } + + public function testNestedErrorsUseDottedPaths(): void + { + $exception = ToolError::invalid([ + 'tags' => [0 => ['name' => ['_required' => 'This field is required']]], + ]); + + $this->assertSame("The data is invalid.\n- tags.0.name: This field is required", $exception->getMessage()); + } + + public function testReadsTheErrorsOfAnEntityAndItsAssociations(): void + { + $tag = new Entity(); + $tag->setError('name', ['_required' => 'This field is required']); + $article = new Entity(['tags' => [$tag]]); + $article->setError('title', ['_empty' => 'This field cannot be left empty']); + + $this->assertSame( + "The data is invalid.\n- title: This field cannot be left empty\n- tags.0.name: This field is required", + ToolError::invalid($article)->getMessage(), + ); + } + + public function testCustomFirstLine(): void + { + $exception = ToolError::invalid(['title' => ['_empty' => 'Empty']], 'The article was not saved.'); + + $this->assertSame("The article was not saved.\n- title: Empty", $exception->getMessage()); + } + + public function testMessageWithoutAFieldIsListedAlone(): void + { + $this->assertSame("The data is invalid.\n- Something is off", ToolError::invalid(['Something is off'])->getMessage()); + } + + public function testNoErrorsLeavesOnlyTheFirstLine(): void + { + $this->assertSame('The data is invalid.', ToolError::invalid([])->getMessage()); + } +} diff --git a/tests/test_app/src/Mcp/NoteTools.php b/tests/test_app/src/Mcp/NoteTools.php new file mode 100644 index 0000000..0d77f91 --- /dev/null +++ b/tests/test_app/src/Mcp/NoteTools.php @@ -0,0 +1,28 @@ + + */ + #[McpTool(name: 'create_note')] + public function create(string $title): array + { + $validator = (new Validator())->minLength('title', 3, 'Use at least 3 characters.'); + $errors = $validator->validate(['title' => $title]); + if ($errors !== []) { + throw ToolError::invalid($errors, 'The note was not created.'); + } + + return ['title' => $title]; + } +} From 0ce6b2c669af81fecc8fc3b47ff5b113e3dd8bb1 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 22:33:54 +0200 Subject: [PATCH 21/49] Add McpTestTrait to test an application's tools in memory --- CHANGELOG.md | 1 + README.md | 2 + docs/testing.md | 99 +++++ phpstan.neon | 2 + src/Middleware/IdentityMiddleware.php | 26 +- src/TestSuite/McpTestTrait.php | 392 ++++++++++++++++++ src/TestSuite/RecordingTransport.php | 67 +++ tests/TestCase/Server/ServerFactoryTest.php | 2 +- tests/TestCase/TestSuite/McpTestTraitTest.php | 155 +++++++ .../src/Command/RecordingMcpServerCommand.php | 2 +- .../src/McpGreeting/GreetingTools.php | 23 + tests/test_app/src/RecordingTransport.php | 44 -- 12 files changed, 757 insertions(+), 58 deletions(-) create mode 100644 docs/testing.md create mode 100644 src/TestSuite/McpTestTrait.php create mode 100644 src/TestSuite/RecordingTransport.php create mode 100644 tests/TestCase/TestSuite/McpTestTraitTest.php create mode 100644 tests/test_app/src/McpGreeting/GreetingTools.php delete mode 100644 tests/test_app/src/RecordingTransport.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0130b9e..67fc13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Sessions and the discovery cache stored in Cake cache configs. - Authentication delegated to the host: the identity set by `cakephp/authentication` becomes the SDK access token and the injectable `CurrentIdentity`. - Per-tool authorization with `ToolAuthorizerInterface`; `RbacToolAuthorizer` for `cakedc/auth` permission rules. +- `McpTestTrait` calls the application's tools in memory from a Cake test case, acting as any identity, with assertions for results, tool errors, JSON-RPC errors, denials and `tools/list`. - `ToolError::invalid()` turns Cake validation errors (an entity or a `Validator` result) into a tool error the model can read and act on. - DNS-rebinding protection defaults to the `App.fullBaseUrl` host. That only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise); set `Mcp.http.allowedHosts`, which is required for servers with `identity.required` set to `false`. - Removed `bin/mcp-server` and `CakeDC\Mcp\Server\CakeServer`. diff --git a/README.md b/README.md index 3408adf..c8d74c8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Built on the official [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sd - Tools are plain PHP classes with `#[McpTool]` attributes, with constructor injection. - Authentication stays in your app (`cakephp/authentication`); the plugin reads the identity. - Per-tool authorization, including `config/permissions.php` rules through `cakedc/auth`. +- `McpTestTrait` to test your tools, and who may call them, from a regular Cake test case. ## Requirements @@ -105,6 +106,7 @@ $service->loadAuthenticator('Authentication.Token', [ - [Writing tools](docs/tools.md) - [Authentication](docs/authentication.md) - [Authorization](docs/authorization.md) +- [Testing your tools](docs/testing.md) - [stdio](docs/stdio.md) - [Connecting clients](docs/clients.md) - [Troubleshooting](docs/troubleshooting.md) diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..4d7fbf7 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,99 @@ +# Testing your tools + +`CakeDC\Mcp\TestSuite\McpTestTrait` calls your tools from a regular Cake test +case, without HTTP. Each call builds your application and its container, then +the MCP server the same way the endpoint does: tool discovery, constructor +injection and your authorizer. So a test covers the tool, its dependencies and +who may call it. + +```php +namespace App\Test\TestCase\Mcp; + +use Cake\TestSuite\TestCase; +use CakeDC\Mcp\TestSuite\McpTestTrait; + +class ArticleToolsTest extends TestCase +{ + use McpTestTrait; + + protected array $fixtures = ['app.Articles', 'app.Users']; + + public function testEditorsCreateDrafts(): void + { + $editor = $this->fetchTable('Users')->get(1); + $this->mcpActingAs($editor); + + $this->mcpCallTool('create_draft', ['title' => 'Hello', 'body' => 'First post']); + + $this->assertMcpToolSucceeded(); + $this->assertMcpResultContains('id'); + } + + public function testInvalidDraftsExplainWhatToFix(): void + { + $this->mcpActingAs($this->fetchTable('Users')->get(1)); + + $this->mcpCallTool('create_draft', ['title' => '', 'body' => 'No title']); + + $this->assertMcpToolError('- title:'); + } + + public function testReadersCannotCreateDrafts(): void + { + $this->mcpActingAs($this->fetchTable('Users')->get(2)); + + $this->assertMcpToolNotListed('create_draft'); + $this->mcpCallTool('create_draft', ['title' => 'Hello', 'body' => 'Nope']); + $this->assertMcpToolDenied(); + } +} +``` + +## The identity + +`mcpActingAs()` sets who makes the next calls, in the shape your +authentication produces: an entity, an `ArrayObject`, an array. Tools see it +through `CurrentIdentity`, and through the SDK access token built as the +endpoint builds it (identifier as subject, scopes from `Mcp.identity.scopes`). +Without `mcpActingAs()` the calls are anonymous. + +The trait does not go through your HTTP authentication, so it does not check +tokens or `Mcp.identity.required`. Cover that with an integration test of the +endpoint (`IntegrationTestTrait`, a `POST` to `/mcp` with your +`Authorization` header). Both traits can be used in the same test case. + +## Calls and results + +| Method | Does | +| --- | --- | +| `mcpCallTool($name, $arguments)` | Calls a tool and keeps its response. Returns the raw JSON-RPC response. | +| `mcpListTools()` | Returns the definitions of the tools the current identity sees. | +| `mcpResultText()` | The text of the last result, one line per text item. | +| `mcpResponse()` | The raw JSON-RPC response of the last call. | + +## Assertions + +| Assertion | Passes when the last call | +| --- | --- | +| `assertMcpToolSucceeded()` | returned a result that is not a tool error. | +| `assertMcpToolError(?$contains)` | returned a tool error, the kind the model reads (`ToolCallException`, `ToolError::invalid()`). | +| `assertMcpJsonRpcError(?$code, ?$contains)` | was answered with a JSON-RPC error: `-32602` for arguments that do not match the input schema, `-32603` for an unexpected exception. | +| `assertMcpToolDenied()` | was rejected by your authorizer. It also checks that the tool exists, because a denied tool is answered like an unknown one and a typo would pass otherwise. | +| `assertMcpResultContains($text)` | returned text containing `$text`. | +| `assertMcpStructuredContent($expected)` | returned this `structuredContent`. Key order does not matter, value types do. | + +`assertMcpToolListed($name)` and `assertMcpToolNotListed($name)` check what the +current identity sees in `tools/list`. They make their own call. + +## Replacing services + +The trait uses Cake's `ContainerStubTrait`, so `mockService()` replaces a +service for the tools: + +```php +$this->mockService(PaymentGateway::class, fn () => new FakePaymentGateway()); +$this->mcpCallTool('refund_order', ['number' => 'A-1042']); +``` + +To build the server from another application class, call +`configApplication()` as you would for an integration test. diff --git a/phpstan.neon b/phpstan.neon index 12855a9..37c6968 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,5 +2,7 @@ parameters: level: 8 paths: - src/ + # Analyses McpTestTrait, which PHPStan only checks through a class using it. + - tests/TestCase/TestSuite/McpTestTraitTest.php bootstrapFiles: - tests/bootstrap.php diff --git a/src/Middleware/IdentityMiddleware.php b/src/Middleware/IdentityMiddleware.php index 96d93ab..1ec68f3 100644 --- a/src/Middleware/IdentityMiddleware.php +++ b/src/Middleware/IdentityMiddleware.php @@ -57,30 +57,32 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } $this->current->set($identity); - $claims = []; - $identifier = CurrentIdentity::identifierOf($identity); - if ($identifier !== null) { - $claims['sub'] = (string)$identifier; - } return $handler->handle($request->withAttribute( AccessToken::class, - new AccessToken($this->scopesFor($identity), $claims), + self::accessTokenFor($identity, $this->scopes), )); } /** + * The SDK access token for a host identity: its identifier as the `sub` + * claim, and the scopes returned by `$scopes`. + * * @param mixed $identity The identity. - * @return list + * @param \Closure|null $scopes `fn(mixed $identity): array` returning scope strings. + * @return \Mcp\Server\Authorization\AccessToken */ - private function scopesFor(mixed $identity): array + public static function accessTokenFor(mixed $identity, ?Closure $scopes = null): AccessToken { - if ($this->scopes === null) { - return []; + $claims = []; + $identifier = CurrentIdentity::identifierOf($identity); + if ($identifier !== null) { + $claims['sub'] = (string)$identifier; } - $scopes = ($this->scopes)($identity); + $granted = $scopes === null ? [] : $scopes($identity); + $granted = is_array($granted) ? array_values(array_filter($granted, 'is_string')) : []; - return is_array($scopes) ? array_values(array_filter($scopes, 'is_string')) : []; + return new AccessToken($granted, $claims); } /** diff --git a/src/TestSuite/McpTestTrait.php b/src/TestSuite/McpTestTrait.php new file mode 100644 index 0000000..d4c1b67 --- /dev/null +++ b/src/TestSuite/McpTestTrait.php @@ -0,0 +1,392 @@ +|null + */ + protected ?array $_mcpResponse = null; + + /** + * The name of the tool of the last `mcpCallTool()`. + */ + protected ?string $_mcpTool = null; + + /** + * Makes the next calls as this identity, in the shape your authentication + * produces (an entity, an `ArrayObject`, an array...). Null makes them anonymous. + * + * @param mixed $identity The identity. + * @return void + */ + public function mcpActingAs(mixed $identity): void + { + $this->_mcpIdentity = $identity; + } + + /** + * Calls a tool and keeps the response for the `assertMcp*()` methods. + * + * @param string $name The tool name. + * @param array $arguments The tool arguments. + * @return array The JSON-RPC response. + */ + public function mcpCallTool(string $name, array $arguments = []): array + { + $this->_mcpTool = $name; + $this->_mcpResponse = $this->mcpRequest('tools/call', [ + 'name' => $name, + 'arguments' => $arguments === [] ? new stdClass() : $arguments, + ]); + + return $this->_mcpResponse; + } + + /** + * The definitions of the tools the current identity may use. + * + * @return list> + */ + public function mcpListTools(): array + { + return $this->mcpToolDefinitions(false); + } + + /** + * @return array The JSON-RPC response to the last `mcpCallTool()`. + */ + public function mcpResponse(): array + { + if ($this->_mcpResponse === null) { + Assert::fail('No tool was called yet. Call mcpCallTool() first.'); + } + + return $this->_mcpResponse; + } + + /** + * The text content of the last result (or tool error), one line per item. + * + * @return string + */ + public function mcpResultText(): string + { + $content = $this->mcpResult()['content'] ?? []; + $texts = []; + foreach (is_array($content) ? $content : [] as $item) { + if (is_array($item) && ($item['type'] ?? null) === 'text' && is_string($item['text'] ?? null)) { + $texts[] = $item['text']; + } + } + + return implode("\n", $texts); + } + + /** + * Asserts that the last tool call returned a result that is not a tool error. + * + * @param string $message The failure message. + * @return void + */ + public function assertMcpToolSucceeded(string $message = ''): void + { + $response = $this->mcpResponse(); + Assert::assertArrayHasKey( + 'result', + $response, + $message ?: 'The tool call failed: ' . $this->mcpJson($response), + ); + Assert::assertNotTrue( + $this->mcpResult()['isError'] ?? false, + $message ?: 'The tool returned an error: ' . $this->mcpResultText(), + ); + } + + /** + * Asserts that the last tool call returned a tool error, the kind the model + * reads (`ToolCallException`, `ToolError::invalid()`). + * + * @param string|null $contains Text the error message must contain. + * @param string $message The failure message. + * @return void + */ + public function assertMcpToolError(?string $contains = null, string $message = ''): void + { + Assert::assertTrue( + ($this->mcpResult()['isError'] ?? false) === true, + $message ?: 'Expected a tool error, got: ' . $this->mcpJson($this->mcpResponse()), + ); + if ($contains !== null) { + Assert::assertStringContainsString($contains, $this->mcpResultText(), $message); + } + } + + /** + * Asserts that the last tool call was answered with a JSON-RPC error: invalid + * arguments (-32602), an unexpected exception (-32603), an unknown or denied tool. + * + * @param int|null $code The expected error code. + * @param string|null $contains Text the error message must contain. + * @param string $message The failure message. + * @return void + */ + public function assertMcpJsonRpcError(?int $code = null, ?string $contains = null, string $message = ''): void + { + $response = $this->mcpResponse(); + $error = $response['error'] ?? null; + if (!is_array($error)) { + Assert::fail($message ?: 'Expected a JSON-RPC error, got: ' . $this->mcpJson($response)); + } + if ($code !== null) { + Assert::assertSame($code, $error['code'] ?? null, $message ?: 'Unexpected JSON-RPC error code.'); + } + if ($contains !== null) { + Assert::assertStringContainsString($contains, (string)($error['message'] ?? ''), $message); + } + } + + /** + * Asserts that the authorizer rejected the last tool call. A denied tool is + * answered exactly like an unknown one, so this also checks that the tool + * exists; otherwise a typo in the name would pass as a denial. + * + * @param string $message The failure message. + * @return void + */ + public function assertMcpToolDenied(string $message = ''): void + { + $this->mcpResponse(); + $tool = (string)$this->_mcpTool; + $this->assertMcpJsonRpcError(-32602, sprintf('Tool not found: "%s".', $tool), $message); + Assert::assertContains( + $tool, + array_column($this->mcpToolDefinitions(true), 'name'), + $message ?: sprintf('Tool "%s" is not registered, so its rejection proves nothing. Check the name.', $tool), + ); + } + + /** + * Asserts that the text of the last result contains a string. + * + * @param string $text The expected text. + * @param string $message The failure message. + * @return void + */ + public function assertMcpResultContains(string $text, string $message = ''): void + { + Assert::assertStringContainsString($text, $this->mcpResultText(), $message); + } + + /** + * Asserts the `structuredContent` of the last result. Key order does not + * matter, value types do. + * + * @param array $expected The expected structured content. + * @param string $message The failure message. + * @return void + */ + public function assertMcpStructuredContent(array $expected, string $message = ''): void + { + Assert::assertSame( + $this->mcpSortKeys($expected), + $this->mcpSortKeys($this->mcpResult()['structuredContent'] ?? null), + $message, + ); + } + + /** + * Asserts that the current identity sees a tool in `tools/list`. + * + * @param string $name The tool name. + * @param string $message The failure message. + * @return void + */ + public function assertMcpToolListed(string $name, string $message = ''): void + { + Assert::assertContains( + $name, + array_column($this->mcpListTools(), 'name'), + $message ?: sprintf('Tool "%s" is not listed for the current identity.', $name), + ); + } + + /** + * Asserts that the current identity does not see a tool in `tools/list`. + * + * @param string $name The tool name. + * @param string $message The failure message. + * @return void + */ + public function assertMcpToolNotListed(string $name, string $message = ''): void + { + Assert::assertNotContains( + $name, + array_column($this->mcpListTools(), 'name'), + $message ?: sprintf('Tool "%s" is listed for the current identity.', $name), + ); + } + + /** + * The container the server is built from: the application's, after its + * bootstrap, with the services replaced through `mockService()`. + * + * @return \Cake\Core\ContainerInterface + */ + protected function mcpContainer(): ContainerInterface + { + $app = $this->createApp(); + $app->bootstrap(); + if ($app instanceof PluginApplicationInterface) { + $app->pluginBootstrap(); + } + if (!$app instanceof ContainerApplicationInterface) { + Assert::fail(sprintf('`%s` has no container to build the MCP server from.', $app::class)); + } + + return $app->getContainer(); + } + + /** + * Sends one request after the initialize handshake. + * + * @param string $method The JSON-RPC method. + * @param array|null $params Its params. + * @param bool $trusted Skip the authorizer. + * @return array + */ + private function mcpRequest(string $method, ?array $params, bool $trusted = false): array + { + $request = ['jsonrpc' => '2.0', 'id' => 2, 'method' => $method]; + if ($params !== null) { + $request['params'] = $params; + } + + $factory = $this->mcpContainer()->get(ServerFactory::class); + if (!$factory instanceof ServerFactory) { + Assert::fail('The container has no MCP ServerFactory. Is the CakeDC/Mcp plugin loaded?'); + } + $factory->identity()->set($this->_mcpIdentity); + + $scopes = McpConfig::get('identity.scopes'); + $transport = new RecordingTransport( + [ + (string)json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => ProtocolVersion::latestHandshake()->value, + 'capabilities' => new stdClass(), + 'clientInfo' => ['name' => 'McpTestTrait', 'version' => '1.0'], + ]]), + (string)json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']), + (string)json_encode($request), + ], + $this->_mcpIdentity === null ? null : IdentityMiddleware::accessTokenFor( + $this->_mcpIdentity, + is_callable($scopes) ? Closure::fromCallable($scopes) : null, + ), + ); + $factory->create($trusted)->run($transport); + + $response = $transport->responseFor(2); + if ($response === null) { + Assert::fail(sprintf('The server sent no response to `%s`.', $method)); + } + + return $response; + } + + /** + * @param bool $trusted Skip the authorizer, to see every registered tool. + * @return list> + */ + private function mcpToolDefinitions(bool $trusted): array + { + $tools = []; + $cursor = null; + for ($page = 0; $page < 100; $page++) { + $response = $this->mcpRequest('tools/list', $cursor === null ? null : ['cursor' => $cursor], $trusted); + $result = $response['result'] ?? null; + if (!is_array($result) || !is_array($result['tools'] ?? null)) { + Assert::fail('tools/list failed: ' . $this->mcpJson($response)); + } + foreach ($result['tools'] as $tool) { + if (is_array($tool)) { + $tools[] = $tool; + } + } + $cursor = $result['nextCursor'] ?? null; + if (!is_string($cursor)) { + break; + } + } + + return $tools; + } + + /** + * @return array The `result` of the last tool call, or an empty array. + */ + private function mcpResult(): array + { + $result = $this->mcpResponse()['result'] ?? null; + + return is_array($result) ? $result : []; + } + + /** + * @param mixed $value A decoded JSON value. + * @return mixed The value with the keys of its objects sorted, recursively. + */ + private function mcpSortKeys(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + $value = array_map($this->mcpSortKeys(...), $value); + if (!array_is_list($value)) { + ksort($value); + } + + return $value; + } + + /** + * @param array $value A decoded JSON value. + * @return string + */ + private function mcpJson(array $value): string + { + return (string)json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/src/TestSuite/RecordingTransport.php b/src/TestSuite/RecordingTransport.php new file mode 100644 index 0000000..706d22e --- /dev/null +++ b/src/TestSuite/RecordingTransport.php @@ -0,0 +1,67 @@ +> + */ + public array $sent = []; + + /** + * @param list $messages The JSON-RPC messages to feed, in order. + * @param \Mcp\Server\Authorization\AccessToken|null $accessToken Sent with every message, as the HTTP endpoint does. + */ + public function __construct(array $messages = [], private readonly ?AccessToken $accessToken = null) + { + parent::__construct($messages); + } + + /** + * @inheritDoc + */ + protected function handleMessage(string $payload, ?Uuid $sessionId, ?AccessToken $accessToken = null): void + { + parent::handleMessage($payload, $sessionId, $accessToken ?? $this->accessToken); + } + + /** + * @inheritDoc + */ + public function send(string $data, array $context): void + { + $decoded = json_decode($data, true); + if (is_array($decoded)) { + $this->sent[] = $decoded; + } + parent::send($data, $context); + } + + /** + * @param string|int $id The id of the request. + * @return array|null The response to that request. + */ + public function responseFor(string|int $id): ?array + { + foreach ($this->sent as $message) { + if (($message['id'] ?? null) === $id) { + return $message; + } + } + + return null; + } +} diff --git a/tests/TestCase/Server/ServerFactoryTest.php b/tests/TestCase/Server/ServerFactoryTest.php index 111ad8d..da3ca81 100644 --- a/tests/TestCase/Server/ServerFactoryTest.php +++ b/tests/TestCase/Server/ServerFactoryTest.php @@ -12,9 +12,9 @@ use CakeDC\Mcp\Authorization\ToolAuthorizerInterface; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\Server\ServerFactory; +use CakeDC\Mcp\TestSuite\RecordingTransport; use InvalidArgumentException; use stdClass; -use TestApp\RecordingTransport; class ServerFactoryTest extends TestCase { diff --git a/tests/TestCase/TestSuite/McpTestTraitTest.php b/tests/TestCase/TestSuite/McpTestTraitTest.php new file mode 100644 index 0000000..fe22939 --- /dev/null +++ b/tests/TestCase/TestSuite/McpTestTraitTest.php @@ -0,0 +1,155 @@ + ArrayEngine::class]); + } + + protected function tearDown(): void + { + Cache::drop('mcp_sessions'); + parent::tearDown(); + } + + public function testCallsAToolAndReadsItsText(): void + { + $this->mcpCallTool('echo', ['text' => 'hello']); + + $this->assertMcpToolSucceeded(); + $this->assertMcpResultContains('hell'); + $this->assertSame('hello', $this->mcpResultText()); + } + + public function testToolsSeeTheIdentityAndTheAccessToken(): void + { + $this->mcpActingAs(['id' => 7]); + + $this->mcpCallTool('whoami'); + + $this->assertMcpStructuredContent(['subject' => '7', 'identifier' => 7]); + } + + public function testAnonymousByDefault(): void + { + $this->mcpCallTool('whoami'); + + $this->assertMcpStructuredContent(['identifier' => null, 'subject' => null]); + } + + public function testToolErrorsAreReadable(): void + { + $this->mcpCallTool('create_note', ['title' => 'ab']); + + $this->assertMcpToolError('- title: Use at least 3 characters.'); + $this->expectException(AssertionFailedError::class); + $this->assertMcpToolSucceeded(); + } + + public function testUnexpectedExceptionsAreJsonRpcErrors(): void + { + $this->mcpCallTool('explode'); + + $this->assertMcpJsonRpcError(-32603, 'Error while executing tool'); + $this->assertStringNotContainsString('secret', (string)json_encode($this->mcpResponse())); + } + + public function testInvalidArgumentsAreJsonRpcErrors(): void + { + $this->mcpCallTool('echo', []); + + $this->assertMcpJsonRpcError(-32602, 'Invalid parameters'); + } + + public function testDeniedToolsAreHiddenAndRejected(): void + { + Configure::write('Mcp.authorization.authorizer', $this->onlyForAdmins('admin_only')); + $this->mcpActingAs(['id' => 1, 'role' => 'user']); + + $this->assertMcpToolNotListed('admin_only'); + $this->assertMcpToolListed('echo'); + $this->mcpCallTool('admin_only'); + $this->assertMcpToolDenied(); + + $this->mcpActingAs(['id' => 2, 'role' => 'admin']); + $this->assertMcpToolListed('admin_only'); + $this->mcpCallTool('admin_only'); + $this->assertMcpToolSucceeded(); + } + + public function testDeniedFailsForAToolThatDoesNotExist(): void + { + $this->mcpCallTool('no_such_tool'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('is not registered'); + $this->assertMcpToolDenied(); + } + + public function testDeniedFailsWhenTheCallSucceeded(): void + { + $this->mcpCallTool('echo', ['text' => 'hi']); + + $this->expectException(AssertionFailedError::class); + $this->assertMcpToolDenied(); + } + + public function testListToolsReturnsTheDefinitions(): void + { + $tools = array_column($this->mcpListTools(), null, 'name'); + + $this->assertSame(['readOnlyHint' => true, 'idempotentHint' => true], $tools['echo']['annotations'] ?? null); + } + + public function testMockedServicesReachTheTools(): void + { + Configure::write('Mcp.discovery.scanDirs', ['src/Mcp', 'src/McpGreeting']); + $this->mockService(Greeter::class, fn(): Greeter => new Greeter(new Clock(), 'Hola')); + + $this->mcpCallTool('greet', ['name' => 'Ana']); + + $this->assertSame('Hola, Ana', $this->mcpResultText()); + } + + public function testAssertionsNeedACall(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('mcpCallTool()'); + $this->assertMcpToolSucceeded(); + } + + private function onlyForAdmins(string $tool): ToolAuthorizerInterface + { + return new class ($tool) implements ToolAuthorizerInterface { + public function __construct(private readonly string $tool) + { + } + + public function canCall(mixed $identity, string $tool): bool + { + return $tool !== $this->tool || (is_array($identity) && ($identity['role'] ?? null) === 'admin'); + } + }; + } +} diff --git a/tests/test_app/src/Command/RecordingMcpServerCommand.php b/tests/test_app/src/Command/RecordingMcpServerCommand.php index c7d348a..8f0a288 100644 --- a/tests/test_app/src/Command/RecordingMcpServerCommand.php +++ b/tests/test_app/src/Command/RecordingMcpServerCommand.php @@ -6,8 +6,8 @@ use CakeDC\Mcp\Command\McpServerCommand; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\Server\ServerFactory; +use CakeDC\Mcp\TestSuite\RecordingTransport; use Mcp\Server\Transport\TransportInterface; -use TestApp\RecordingTransport; /** * Runs the real command against queued messages instead of STDIN/STDOUT. diff --git a/tests/test_app/src/McpGreeting/GreetingTools.php b/tests/test_app/src/McpGreeting/GreetingTools.php new file mode 100644 index 0000000..ff0d069 --- /dev/null +++ b/tests/test_app/src/McpGreeting/GreetingTools.php @@ -0,0 +1,23 @@ +greeter->greeting . ', ' . $name; + } +} diff --git a/tests/test_app/src/RecordingTransport.php b/tests/test_app/src/RecordingTransport.php deleted file mode 100644 index 7147ae6..0000000 --- a/tests/test_app/src/RecordingTransport.php +++ /dev/null @@ -1,44 +0,0 @@ -> - */ - public array $sent = []; - - /** - * @inheritDoc - */ - public function send(string $data, array $context): void - { - $decoded = json_decode($data, true); - if (is_array($decoded)) { - $this->sent[] = $decoded; - } - parent::send($data, $context); - } - - /** - * @param string|int $id JSON-RPC id. - * @return array|null - */ - public function responseFor(string|int $id): ?array - { - foreach ($this->sent as $message) { - if (($message['id'] ?? null) === $id) { - return $message; - } - } - - return null; - } -} From e9d974142d4874c54a727eb683431259526569eb Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 22:34:45 +0200 Subject: [PATCH 22/49] Add Mcp.server.configure to customize the SDK builder --- CHANGELOG.md | 1 + docs/configuration.md | 22 ++++++++- src/McpConfig.php | 2 +- src/Server/ServerFactory.php | 32 +++++++++++-- tests/TestCase/Server/ServerFactoryTest.php | 50 +++++++++++++++++++++ 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67fc13e..4130f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Sessions and the discovery cache stored in Cake cache configs. - Authentication delegated to the host: the identity set by `cakephp/authentication` becomes the SDK access token and the injectable `CurrentIdentity`. - Per-tool authorization with `ToolAuthorizerInterface`; `RbacToolAuthorizer` for `cakedc/auth` permission rules. +- `Mcp.server.configure` hands the SDK builder to the host for anything the plugin does not configure itself; the plugin's own settings and the authorizer still apply. - `McpTestTrait` calls the application's tools in memory from a Cake test case, acting as any identity, with assertions for results, tool errors, JSON-RPC errors, denials and `tools/list`. - `ToolError::invalid()` turns Cake validation errors (an entity or a `Validator` result) into a tool error the model can read and act on. - DNS-rebinding protection defaults to the `App.fullBaseUrl` host. That only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise); set `Mcp.http.allowedHosts`, which is required for servers with `identity.required` set to `false`. diff --git a/docs/configuration.md b/docs/configuration.md index 62c0dd8..4f27f31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,7 +7,7 @@ want to change; everything else keeps its default. These are the defaults ```php 'Mcp' => [ 'route' => '/mcp', - 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null], + 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null, 'configure' => null], 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], @@ -22,6 +22,7 @@ want to change; everything else keeps its default. These are the defaults - `route`: path of the HTTP endpoint. `false` (or an empty string) connects no route. - `server.name`, `server.version`: reported to clients in the `initialize` result. - `server.instructions`: optional text telling the client's model how to use your server. `null` sends none. +- `server.configure`: optional callable that receives the SDK `Mcp\Server\Builder` before the server is built. See [Using the SDK builder](#using-the-sdk-builder). - `discovery.scanDirs`: directories (relative to `ROOT`) scanned for attributes. Missing directories are skipped. - `discovery.excludeDirs`: directories to skip while scanning. - `discovery.cache`: name of the cache config for the discovery result. Set it to `null` or an empty string to disable the cache. @@ -49,6 +50,25 @@ Configure::write('Mcp.discovery.scanDirs', ['src/Mcp', 'plugins/Shop/src/Mcp']); The default `src/Mcp` is not scanned any more unless you list it again. +## Using the SDK builder + +`server.configure` gives you the SDK builder, for what the plugin does not +configure itself: tools registered in code instead of with attributes, resource +templates, a cache policy, request state for multi round-trip tools: + +```php +use Mcp\Server\Builder; + +Configure::write('Mcp.server.configure', function (Builder $builder): void { + $builder->addTool(fn (): string => date('c'), name: 'server_time', description: 'Current server time.'); +}); +``` + +The plugin applies its own settings after the callable, so these always win: +server name and version, container, logger, event dispatcher, session store, +and discovery when `discovery.scanDirs` has a directory. The authorizer applies +to every tool, including the ones added here. + ## Caches If the cache configs named in `discovery.cache` and `session.cache` do not diff --git a/src/McpConfig.php b/src/McpConfig.php index c822abf..a11fc10 100644 --- a/src/McpConfig.php +++ b/src/McpConfig.php @@ -21,7 +21,7 @@ final class McpConfig */ public const DEFAULTS = [ 'route' => '/mcp', - 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null], + 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null, 'configure' => null], 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], diff --git a/src/Server/ServerFactory.php b/src/Server/ServerFactory.php index 1abdb16..23e754d 100644 --- a/src/Server/ServerFactory.php +++ b/src/Server/ServerFactory.php @@ -17,6 +17,7 @@ use InvalidArgumentException; use Mcp\Event\ResponseEvent; use Mcp\Server; +use Mcp\Server\Builder; use Mcp\Server\Session\Psr16SessionStore; use Psr\Container\ContainerInterface; @@ -55,7 +56,11 @@ public function create(bool $trusted = false): Server $events = new EventDispatcher(); $events->listen(ResponseEvent::class, new ToolListFilter($gate)); - $builder = Server::builder() + // The guard goes first: the SDK asks request handlers in the order they were added. + $builder = Server::builder()->addRequestHandler(new ToolCallGuard($gate)); + $this->configure($builder); + // Set after the host hook, so the hook cannot drop the tool list filter or replace these. + $builder ->setServerInfo((string)McpConfig::get('server.name'), (string)McpConfig::get('server.version')) ->setContainer(new ContainerBridge($this->container)) ->setLogger(new McpLogger()) @@ -64,8 +69,7 @@ public function create(bool $trusted = false): Server Cache::pool((string)McpConfig::get('session.cache')), 'mcp-', (int)McpConfig::get('session.ttl'), - )) - ->addRequestHandler(new ToolCallGuard($gate)); + )); $instructions = McpConfig::get('server.instructions'); if (is_string($instructions) && $instructions !== '') { @@ -86,6 +90,28 @@ public function create(bool $trusted = false): Server return $builder->build(); } + /** + * Runs the `Mcp.server.configure` hook, which may use anything the SDK + * builder offers (tools added in code, request state, cache policy...). + * + * @param \Mcp\Server\Builder $builder The SDK builder. + * @return void + */ + private function configure(Builder $builder): void + { + $configure = McpConfig::get('server.configure'); + if ($configure === null) { + return; + } + if (!is_callable($configure)) { + throw new InvalidArgumentException(sprintf( + '`Mcp.server.configure` must be a callable that receives the SDK `%s`.', + Builder::class, + )); + } + $configure($builder); + } + /** * Keeps only the scan directories that exist, so a host that has not * created its tools directory yet gets an empty tool list, not an error. diff --git a/tests/TestCase/Server/ServerFactoryTest.php b/tests/TestCase/Server/ServerFactoryTest.php index da3ca81..1dca240 100644 --- a/tests/TestCase/Server/ServerFactoryTest.php +++ b/tests/TestCase/Server/ServerFactoryTest.php @@ -10,10 +10,12 @@ use Cake\TestSuite\TestCase; use CakeDC\Mcp\Authorization\RbacToolAuthorizer; use CakeDC\Mcp\Authorization\ToolAuthorizerInterface; +use CakeDC\Mcp\Event\EventDispatcher; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\Server\ServerFactory; use CakeDC\Mcp\TestSuite\RecordingTransport; use InvalidArgumentException; +use Mcp\Server\Builder; use stdClass; class ServerFactoryTest extends TestCase @@ -140,6 +142,54 @@ public function testInstructionsAreAnnouncedOnInitialize(): void $this->assertSame('Use echo to test.', $result['instructions'] ?? null); } + public function testConfigureHookReceivesTheBuilder(): void + { + Configure::write('Mcp.server.configure', function (Builder $builder): void { + $builder->addTool(fn(): string => 'pong', name: 'ping', description: 'Answers pong.'); + }); + + $transport = $this->runServer([$this->call(3, 'ping')]); + + $this->assertContains('ping', $this->toolNames($transport)); + $this->assertSame('pong', $transport->responseFor(3)['result']['content'][0]['text'] ?? null); + } + + public function testConfigureHookCannotReplaceThePluginSettings(): void + { + Configure::write('Mcp.authorization.authorizer', $this->onlyEcho()); + Configure::write('Mcp.server.configure', function (Builder $builder): void { + $builder->setServerInfo('Replaced', '9.9.9')->setEventDispatcher(new EventDispatcher()); + }); + + $transport = $this->runServer([$this->call(3, 'admin_only')]); + + $this->assertSame('CakePHP MCP Server', $transport->responseFor(1)['result']['serverInfo']['name'] ?? null); + $this->assertSame(['echo'], $this->toolNames($transport)); + $this->assertSame('Tool not found: "admin_only".', $transport->responseFor(3)['error']['message'] ?? null); + } + + public function testAuthorizerAppliesToToolsAddedByTheHook(): void + { + Configure::write('Mcp.authorization.authorizer', $this->onlyEcho()); + Configure::write('Mcp.server.configure', function (Builder $builder): void { + $builder->addTool(fn(): string => 'pong', name: 'ping', description: 'Answers pong.'); + }); + + $transport = $this->runServer([$this->call(3, 'ping')]); + + $this->assertSame(['echo'], $this->toolNames($transport)); + $this->assertSame('Tool not found: "ping".', $transport->responseFor(3)['error']['message'] ?? null); + } + + public function testRejectsAConfigureHookThatIsNotCallable(): void + { + Configure::write('Mcp.server.configure', 'not_a_function'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Mcp.server.configure'); + $this->factory->create(); + } + public function testRejectsAnInvalidAuthorizer(): void { Configure::write('Mcp.authorization.authorizer', 'NotAnAuthorizer'); From bce6eb6340c3d95ad9515943df607a89e804431f Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 22:39:46 +0200 Subject: [PATCH 23/49] Run the official MCP conformance suite against the plugin endpoint --- .gitattributes | 8 + .github/workflows/ci.yml | 15 ++ .gitignore | 2 + CHANGELOG.md | 1 + README.md | 1 + docs/conformance.md | 40 +++ phpcs.xml | 4 + tests/Conformance/ConformanceFixture.php | 313 ++++++++++++++++++++++ tests/Conformance/Dockerfile | 6 + tests/Conformance/Sdk/Elements.php | 247 +++++++++++++++++ tests/Conformance/Sdk/LICENSE | 216 +++++++++++++++ tests/Conformance/Sdk/MrtrElements.php | 216 +++++++++++++++ tests/Conformance/baseline-2025-11-25.yml | 3 + tests/Conformance/baseline-2026-07-28.yml | 3 + tests/Conformance/docker-compose.yml | 19 ++ tests/Conformance/nginx.conf | 15 ++ tests/Conformance/run.sh | 55 ++++ tests/Conformance/webroot/index.php | 47 ++++ tests/bootstrap.php | 13 +- 19 files changed, 1221 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/conformance.md create mode 100644 tests/Conformance/ConformanceFixture.php create mode 100644 tests/Conformance/Dockerfile create mode 100644 tests/Conformance/Sdk/Elements.php create mode 100644 tests/Conformance/Sdk/LICENSE create mode 100644 tests/Conformance/Sdk/MrtrElements.php create mode 100644 tests/Conformance/baseline-2025-11-25.yml create mode 100644 tests/Conformance/baseline-2026-07-28.yml create mode 100644 tests/Conformance/docker-compose.yml create mode 100644 tests/Conformance/nginx.conf create mode 100755 tests/Conformance/run.sh create mode 100644 tests/Conformance/webroot/index.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5cfc2ea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Kept out of the archives Composer installs. +/.github export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpcs.xml export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee6b71f..c6247b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,18 @@ jobs: - uses: ramsey/composer-install@v3 - run: vendor/bin/phpcs - run: vendor/bin/phpstan analyse --no-progress + + conformance: + runs-on: ubuntu-24.04 + name: MCP conformance + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + - uses: ramsey/composer-install@v3 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: tests/Conformance/run.sh diff --git a/.gitignore b/.gitignore index b0abe95..4970756 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /.phpunit.cache/ /tests/test_app/tmp/ .phpunit.result.cache +/tests/Conformance/tmp/ +/tests/Conformance/results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4130f48..0672c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - `Mcp.server.configure` hands the SDK builder to the host for anything the plugin does not configure itself; the plugin's own settings and the authorizer still apply. - `McpTestTrait` calls the application's tools in memory from a Cake test case, acting as any identity, with assertions for results, tool errors, JSON-RPC errors, denials and `tools/list`. - `ToolError::invalid()` turns Cake validation errors (an entity or a `Validator` result) into a tool error the model can read and act on. +- Checked against the official MCP conformance suite for both protocol revisions, behind nginx and php-fpm (`tests/Conformance/run.sh`, also in CI). - DNS-rebinding protection defaults to the `App.fullBaseUrl` host. That only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise); set `Mcp.http.allowedHosts`, which is required for servers with `identity.required` set to `false`. - Removed `bin/mcp-server` and `CakeDC\Mcp\Server\CakeServer`. - Temporary: `mcp/sdk` is pinned to a fixed commit aliased as 0.9.0 until the SDK tags that release; host apps must add the same requirement and allow dev stability (see the install notes). diff --git a/README.md b/README.md index c8d74c8..64d5473 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ $service->loadAuthenticator('Authentication.Token', [ - [stdio](docs/stdio.md) - [Connecting clients](docs/clients.md) - [Troubleshooting](docs/troubleshooting.md) +- [Protocol conformance](docs/conformance.md) ## License diff --git a/docs/conformance.md b/docs/conformance.md new file mode 100644 index 0000000..968a4aa --- /dev/null +++ b/docs/conformance.md @@ -0,0 +1,40 @@ +# Protocol conformance + +The plugin is checked against the official +[MCP conformance suite](https://github.com/modelcontextprotocol/conformance), +the same one the SDK runs. The SDK already passes it on its own; running it +again through the plugin checks what the plugin adds on top: the Cake route and +controller, the request and response conversion, sessions in Cake cache, +the identity middleware and the DNS-rebinding protection. + +The endpoint runs the way a real deployment does, with nginx in front of +php-fpm, so server-sent events also go through nginx. + +## Running it + +You need Docker, Node.js 22 and the Composer dependencies installed: + +```bash +composer install +tests/Conformance/run.sh # both protocol revisions +tests/Conformance/run.sh 2026-07-28 # one revision +``` + +`CONFORMANCE_PORT` changes the local port (8000 by default) and +`CONFORMANCE_PACKAGE` the suite version. The suite version is pinned, so a new +release cannot break the build on its own; raise it on purpose. + +The CI workflow runs the suite on every push and pull request. + +## How it is set up + +- `webroot/index.php` serves the test application with the plugin's `/mcp` + endpoint, open to anonymous callers, without discovery. +- `ConformanceFixture` registers, through `Mcp.server.configure`, the same + tools, resources and prompts as the SDK's own conformance server. They live in + `Sdk/`, copied from the SDK with their license. +- `baseline-.yml` lists the scenarios expected to fail at each + revision, with the reason. Both are empty: every scenario passes. + +Results are written to `tests/Conformance/results/` and the plugin log to +`tests/Conformance/tmp/logs/mcp.log`. diff --git a/phpcs.xml b/phpcs.xml index 7cd6e78..ea48954 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -6,4 +6,8 @@ tests/ tests/test_app/tmp/* + + tests/Conformance/Sdk/* + tests/Conformance/tmp/* + tests/Conformance/results/* diff --git a/tests/Conformance/ConformanceFixture.php b/tests/Conformance/ConformanceFixture.php new file mode 100644 index 0000000..3b36714 --- /dev/null +++ b/tests/Conformance/ConformanceFixture.php @@ -0,0 +1,313 @@ +setRegistry($registry) + ->addTool( + static fn() => 'This is a simple text response for testing.', + name: 'test_simple_text', + description: 'Tests simple text content response', + ) + ->addTool( + static fn() => new ImageContent(Elements::TEST_IMAGE_BASE64, 'image/png'), + name: 'test_image_content', + description: 'Tests image content response', + ) + ->addTool( + static fn() => new AudioContent(Elements::TEST_AUDIO_BASE64, 'audio/wav'), + name: 'test_audio_content', + description: 'Tests audio content response', + ) + ->addTool( + static fn() => EmbeddedResource::fromText( + 'test://embedded-resource', + 'This is an embedded resource content.', + ), + name: 'test_embedded_resource', + description: 'Tests embedded resource content response', + ) + ->addTool( + [Elements::class, 'toolMultipleTypes'], + name: 'test_multiple_content_types', + description: 'Tests response with multiple content types', + ) + ->addTool( + static fn() => CallToolResult::error([ + new TextContent('This tool intentionally returns an error for testing'), + ]), + name: 'test_error_handling', + description: 'Tests error response handling', + ) + ->addTool( + static fn() => 'ok', + name: 'json_schema_2020_12_tool', + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: Elements::jsonSchema2020_12Fixture(), + ) + ->addTool( + static function (): never { + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities(roots: false, sampling: true), + 'test_missing_capability requires the sampling capability.', + ); + }, + name: 'test_missing_capability', + description: 'Always reports a missing client capability, for testing -32021 handling', + ) + ->addTool( + static fn(): InputRequiredResult => new InputRequiredResult( + [ + 'conformance_probe' => new ElicitRequest( + 'Please provide a value for the conformance probe.', + new ElicitationSchema(['value' => new StringSchemaDefinition('Value')], ['value']), + ), + ], + requestState: base64_encode( + json_encode(['tool' => 'test_streaming_elicitation'], JSON_THROW_ON_ERROR), + ), + ), + name: 'test_streaming_elicitation', + description: 'Returns an InputRequiredResult asking for elicitation input', + ) + ->addTool( + static function () use ($logger): string { + $logger->info('test_logging_tool executed'); + + return 'Logged.'; + }, + name: 'test_logging_tool', + description: 'Emits a server-side log message while returning normally', + ) + ->addTool( + static fn(string $region = '', int $retries = 0): string => sprintf( + 'region=%s retries=%d', + $region, + $retries, + ), + name: 'test_custom_headers', + description: 'Tests custom header mirroring via x-mcp-header', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'retries' => ['type' => 'integer', 'x-mcp-header' => 'Retries'], + ], + 'required' => ['region'], + ], + ) + ->addTool( + [Elements::class, 'toolWithProgress'], + name: 'test_tool_with_progress', + description: 'Tests tool that reports progress notifications', + ) + ->addTool( + [Elements::class, 'toolWithLogging'], + name: 'test_tool_with_logging', + description: 'Tests tool that emits log messages', + ) + ->addTool( + [Elements::class, 'toolWithSampling'], + name: 'test_sampling', + description: 'Tests server-initiated sampling', + ) + ->addTool( + [Elements::class, 'toolWithElicitation'], + name: 'test_elicitation', + description: 'Tests server-initiated elicitation', + ) + ->addTool( + [Elements::class, 'toolWithElicitationDefaults'], + name: 'test_elicitation_sep1034_defaults', + description: 'Tests elicitation with default values', + ) + ->addTool( + [Elements::class, 'toolWithElicitationEnums'], + name: 'test_elicitation_sep1330_enums', + description: 'Tests elicitation with enum schemas', + ) + ->addTool( + static function () use ($registry): string { + $registry->registerTool( + new Tool( + name: 'test_ephemeral_tool_' . bin2hex(random_bytes(4)), + title: null, + inputSchema: ['type' => 'object', 'properties' => new stdClass(), 'required' => null], + description: 'Registered to trigger a list change', + annotations: null, + ), + static fn(): string => 'ephemeral', + ); + + return 'Tool list mutated.'; + }, + name: 'test_trigger_tool_change', + description: 'Registers a tool so the tool list changes', + ) + ->addTool( + static function () use ($registry): string { + $registry->registerPrompt( + new Prompt( + 'test_ephemeral_prompt_' . bin2hex(random_bytes(4)), + null, + 'Registered to trigger a list change', + ), + static fn(): array => [['role' => 'user', 'content' => 'ephemeral']], + ); + + return 'Prompt list mutated.'; + }, + name: 'test_trigger_prompt_change', + description: 'Registers a prompt so the prompt list changes', + ) + ->addTool( + [MrtrElements::class, 'elicitation'], + name: 'test_input_required_result_elicitation', + description: 'MRTR: asks for a name via elicitation', + ) + ->addTool( + [MrtrElements::class, 'sampling'], + name: 'test_input_required_result_sampling', + description: 'MRTR: asks for a sampling completion', + ) + ->addTool( + [MrtrElements::class, 'listRoots'], + name: 'test_input_required_result_list_roots', + description: 'MRTR: asks for the client roots', + ) + ->addTool( + [MrtrElements::class, 'elicitation'], + name: 'test_input_required_result_request_state', + description: 'MRTR: exercises requestState round-tripping', + ) + ->addTool( + [MrtrElements::class, 'multipleInputs'], + name: 'test_input_required_result_multiple_inputs', + description: 'MRTR: asks for two inputs at once', + ) + ->addTool( + [MrtrElements::class, 'multiRound'], + name: 'test_input_required_result_multi_round', + description: 'MRTR: asks across two sequential rounds', + ) + ->addTool( + [MrtrElements::class, 'capabilities'], + name: 'test_input_required_result_capabilities', + description: 'MRTR: asks only for capabilities the client declared', + ) + ->addTool( + [MrtrElements::class, 'tamperedState'], + name: 'test_input_required_result_tampered_state', + description: 'MRTR: completes only when the echoed state verifies', + ) + ->addResource( + static fn() => 'This is the content of the static text resource.', + 'test://static-text', + 'static-text', + 'A static text resource for testing', + ) + ->addResource( + static fn() => fopen('data://image/png;base64,' . Elements::TEST_IMAGE_BASE64, 'r'), + 'test://static-binary', + 'static-binary', + 'A static binary resource (image) for testing', + ) + ->addResourceTemplate( + [Elements::class, 'resourceTemplate'], + 'test://template/{id}/data', + 'template', + 'A resource template with parameter substitution', + 'application/json', + ) + ->addResource( + static fn() => 'Watched resource content', + 'test://watched-resource', + 'watched-resource', + 'A resource that can be watched', + ) + ->addPrompt( + static fn() => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], + name: 'test_simple_prompt', + description: 'A simple prompt without arguments', + ) + ->addPrompt( + [Elements::class, 'promptWithArguments'], + name: 'test_prompt_with_arguments', + description: 'A prompt with required arguments', + ) + ->addPrompt( + [Elements::class, 'promptWithEmbeddedResource'], + name: 'test_prompt_with_embedded_resource', + description: 'A prompt that includes an embedded resource', + ) + ->addPrompt( + [Elements::class, 'promptWithImage'], + name: 'test_prompt_with_image', + description: 'A prompt that includes image content', + ) + ->addPrompt( + [MrtrElements::class, 'prompt'], + name: 'test_input_required_result_prompt', + description: 'MRTR: a prompt that asks for input first', + ) + // Fixed, so a retry served by another worker still verifies. + ->setRequestState(str_repeat('conformance-fixture-key-', 2)) + ->setNotificationBus($bus) + // Short, so a listen stream does not hold a php-fpm worker for the whole run. + ->setSubscriptionLifetime(5.0) + ->setCachePolicy( + CachePolicy::default(60_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('prompts/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/list', 3_600_000, CacheScope::Public) + ->withMethod('resources/templates/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ); + } +} diff --git a/tests/Conformance/Dockerfile b/tests/Conformance/Dockerfile new file mode 100644 index 0000000..d957366 --- /dev/null +++ b/tests/Conformance/Dockerfile @@ -0,0 +1,6 @@ +# php-fpm with intl, which CakePHP needs. +FROM mirror.gcr.io/library/php:8.4-fpm-alpine +RUN apk add --no-cache icu-libs \ + && apk add --no-cache --virtual .build-deps icu-dev $PHPIZE_DEPS \ + && docker-php-ext-install intl \ + && apk del .build-deps diff --git a/tests/Conformance/Sdk/Elements.php b/tests/Conformance/Sdk/Elements.php new file mode 100644 index 0000000..ed26d1a --- /dev/null +++ b/tests/Conformance/Sdk/Elements.php @@ -0,0 +1,247 @@ + + */ + public static function jsonSchema2020_12Fixture(): array + { + return [ + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', + '$defs' => [ + 'address' => [ + '$anchor' => 'addressDef', + 'type' => 'object', + 'properties' => [ + 'street' => ['type' => 'string'], + 'city' => ['type' => 'string'], + ], + ], + ], + 'properties' => [ + 'name' => ['type' => 'string'], + 'address' => ['$ref' => '#/$defs/address'], + 'contactMethod' => ['type' => 'string', 'enum' => ['phone', 'email']], + 'phone' => ['type' => 'string'], + 'email' => ['type' => 'string'], + ], + 'allOf' => [['anyOf' => [['required' => ['phone']], ['required' => ['email']]]]], + 'if' => [ + 'properties' => ['contactMethod' => ['const' => 'phone']], + 'required' => ['contactMethod'], + ], + 'then' => ['required' => ['phone']], + 'else' => ['required' => ['email']], + 'additionalProperties' => false, + ]; + } + + public function toolMultipleTypes(): CallToolResult + { + return new CallToolResult([ + new TextContent('Multiple content types test:'), + new ImageContent(self::TEST_IMAGE_BASE64, 'image/png'), + EmbeddedResource::fromText( + 'test://mixed-content-resource', + '{ "test" = "data", "value" = 123 }', + 'application/json', + ), + ]); + } + + public function toolWithLogging(RequestContext $context): string + { + $logger = $context->getClientLogger(); + + $logger->info('Tool execution started'); + $logger->info('Tool processing data'); + $logger->info('Tool execution completed'); + + return 'Tool with logging executed successfully'; + } + + public function toolWithProgress(RequestContext $context): ?string + { + $client = $context->getClientGateway(); + + $client->progress(0, 100, 'Completed step 0 of 100'); + $client->progress(50, 100, 'Completed step 50 of 100'); + $client->progress(100, 100, 'Completed step 100 of 100'); + + $meta = $context->getSession()->get(Protocol::SESSION_ACTIVE_REQUEST_META, []); + + return $meta['progressToken'] ?? null; + } + + /** + * @param string $prompt The prompt to send to the LLM + */ + public function toolWithSampling(RequestContext $context, string $prompt): string + { + $result = $context->getClientGateway()->sample($prompt, 100); + + return \sprintf( + 'LLM response: %s', + $result->content instanceof TextContent ? trim((string) $result->content->text) : '' + ); + } + + /** + * @param string $message The message to display to the user + */ + public function toolWithElicitation(RequestContext $context, string $message): string + { + $schema = new ElicitationSchema( + properties: [ + 'username' => new StringSchemaDefinition('Username'), + 'email' => new StringSchemaDefinition('Email'), + ], + ); + + $context->getClientGateway()->elicit($message, $schema); + + return 'ok'; + } + + public function toolWithElicitationDefaults(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'name' => new StringSchemaDefinition('Name', default: 'John Doe'), + 'age' => new NumberSchemaDefinition('Age', integerOnly: true, default: 30), + 'score' => new NumberSchemaDefinition('Score', default: 95.5), + 'status' => new EnumSchemaDefinition('Status', enum: ['active', 'inactive', 'pending'], default: 'active'), + 'verified' => new BooleanSchemaDefinition('Verified', default: true), + ], + ); + + $context->getClientGateway()->elicit('Provide profile information', $schema); + + return 'ok'; + } + + public function toolWithElicitationEnums(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'untitledSingle' => new EnumSchemaDefinition('Untitled Single', enum: ['option1', 'option2', 'option3']), + 'titledSingle' => new TitledEnumSchemaDefinition('Titled Single', oneOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + 'legacyEnum' => new EnumSchemaDefinition('Legacy Enum', enum: ['opt1', 'opt2', 'opt3'], enumNames: ['Option 1', 'Option 2', 'Option 3']), + 'untitledMulti' => new MultiSelectEnumSchemaDefinition('Untitled Multi', enum: ['option1', 'option2', 'option3']), + 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Titled Multi', anyOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + ], + ); + + $context->getClientGateway()->elicit('Select options', $schema); + + return 'ok'; + } + + public function resourceTemplate(string $id): TextResourceContents + { + return new TextResourceContents( + uri: \sprintf('test://template/%s/data', $id), + mimeType: 'application/json', + text: json_encode([ + 'id' => $id, + 'templateTest' => true, + 'data' => \sprintf('Data for ID: %s', $id), + ], \JSON_THROW_ON_ERROR), + ); + } + + /** + * @param string $arg1 First test argument + * @param string $arg2 Second test argument + * + * @return PromptMessage[] + */ + public function promptWithArguments(string $arg1, string $arg2): array + { + return [ + new PromptMessage(Role::User, new TextContent(\sprintf('Prompt with arguments: arg1="%s", arg2="%s"', $arg1, $arg2))), + ]; + } + + /** + * @param string $resourceUri URI of the resource to embed + * + * @return PromptMessage[] + */ + public function promptWithEmbeddedResource(string $resourceUri): array + { + return [ + new PromptMessage(Role::User, EmbeddedResource::fromText($resourceUri, 'Embedded resource content for testing.')), + new PromptMessage(Role::User, new TextContent('Please process the embedded resource above.')), + ]; + } + + /** + * @return PromptMessage[] + */ + public function promptWithImage(): array + { + return [ + new PromptMessage(Role::User, new ImageContent(self::TEST_IMAGE_BASE64, 'image/png')), + new PromptMessage(Role::User, new TextContent('Please analyze the image above.')), + ]; + } +} diff --git a/tests/Conformance/Sdk/LICENSE b/tests/Conformance/Sdk/LICENSE new file mode 100644 index 0000000..4a93985 --- /dev/null +++ b/tests/Conformance/Sdk/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/tests/Conformance/Sdk/MrtrElements.php b/tests/Conformance/Sdk/MrtrElements.php new file mode 100644 index 0000000..cb92f7e --- /dev/null +++ b/tests/Conformance/Sdk/MrtrElements.php @@ -0,0 +1,216 @@ +getInputContext(); + + if (null === $input || !$input->has(self::ELICIT_KEY)) { + return new InputRequiredResult([ + self::ELICIT_KEY => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent(\sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY))))]); + } + + public static function sampling(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('capital')) { + return new InputRequiredResult([ + 'capital' => new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('What is the capital of France?'))], + maxTokens: 100, + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('Sampling complete.')]); + } + + public static function listRoots(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('roots')) { + return new InputRequiredResult(['roots' => new ListRootsRequest()], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('Roots received.')]); + } + + /** Asks for everything in one result, so the client retries once. */ + public static function multipleInputs(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('user_name')) { + return new InputRequiredResult([ + 'user_name' => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + 'greeting' => new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Generate a greeting'))], + maxTokens: 50, + ), + 'client_roots' => new ListRootsRequest(), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return new CallToolResult([new TextContent('All inputs received.')]); + } + + /** + * Reads the round out of the state, not the answers: a client retrying + * round two sends only round two's answer, so counting answers would read + * that as a fresh start and loop forever. + */ + public static function multiRound(RequestContext $context): CallToolResult|InputRequiredResult + { + $round = $context->getInputContext()?->requestState()['round'] ?? 0; + + if ($round < 1) { + return new InputRequiredResult([ + 'step_one' => new ElicitRequest( + 'Step one: what is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['round' => 1])); + } + + if ($round < 2) { + return new InputRequiredResult([ + 'step_two' => new ElicitRequest( + 'Step two: what is your favourite colour?', + new ElicitationSchema(['color' => new StringSchemaDefinition('Colour')], ['color']), + ), + ], requestState: $context->mintRequestState(['round' => 2])); + } + + return new CallToolResult([new TextContent('All steps complete.')]); + } + + /** Reaches the second round only when the echoed state verified. */ + public static function tamperedState(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has('confirm')) { + return new InputRequiredResult([ + 'confirm' => new ElicitRequest( + 'Confirm?', + new ElicitationSchema(['ok' => new StringSchemaDefinition('Ok')], ['ok']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-confirmation'])); + } + + return new CallToolResult([new TextContent('State verified.')]); + } + + /** + * Assembles the ask from the declared capabilities: a server MUST NOT + * request input the client never said it could supply. + */ + public static function capabilities(RequestContext $context): CallToolResult|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null !== $input && [] !== $input->all()) { + return new CallToolResult([new TextContent('Input received.')]); + } + + $capabilities = $context->getClientCapabilities(); + $requests = []; + + if (true === $capabilities?->elicitation) { + $requests['user_name'] = new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ); + } + + if (true === $capabilities?->sampling) { + $requests['greeting'] = new CreateSamplingMessageRequest( + [new SamplingMessage(Role::User, new TextContent('Generate a greeting'))], + maxTokens: 50, + ); + } + + // Nothing the client can service; asking would never come back. + if ([] === $requests) { + return new CallToolResult([new TextContent('No supported input capabilities.')]); + } + + return new InputRequiredResult($requests, requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + /** + * @return array>|InputRequiredResult + */ + public static function prompt(RequestContext $context): array|InputRequiredResult + { + $input = $context->getInputContext(); + + if (null === $input || !$input->has(self::ELICIT_KEY)) { + return new InputRequiredResult([ + self::ELICIT_KEY => new ElicitRequest( + 'What is your name?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + ), + ], requestState: $context->mintRequestState(['stage' => 'awaiting-input'])); + } + + return [['role' => 'user', 'content' => \sprintf('Hello, %s!', self::nameFrom($input->elicitResult(self::ELICIT_KEY)))]]; + } + + /** A declined or cancelled answer has no content, so the greeting falls back. */ + private static function nameFrom(?ElicitResult $result): string + { + $name = $result?->content['name'] ?? null; + + return \is_string($name) ? $name : 'friend'; + } +} diff --git a/tests/Conformance/baseline-2025-11-25.yml b/tests/Conformance/baseline-2025-11-25.yml new file mode 100644 index 0000000..e7aa6dd --- /dev/null +++ b/tests/Conformance/baseline-2025-11-25.yml @@ -0,0 +1,3 @@ +# Server scenarios expected to fail at this revision, each with the reason. +# Empty: every scenario must pass. +server: [] diff --git a/tests/Conformance/baseline-2026-07-28.yml b/tests/Conformance/baseline-2026-07-28.yml new file mode 100644 index 0000000..e7aa6dd --- /dev/null +++ b/tests/Conformance/baseline-2026-07-28.yml @@ -0,0 +1,3 @@ +# Server scenarios expected to fail at this revision, each with the reason. +# Empty: every scenario must pass. +server: [] diff --git a/tests/Conformance/docker-compose.yml b/tests/Conformance/docker-compose.yml new file mode 100644 index 0000000..abfd7d3 --- /dev/null +++ b/tests/Conformance/docker-compose.yml @@ -0,0 +1,19 @@ +# nginx in front of php-fpm, like a production deployment, so the suite also +# covers SSE responses going through nginx. +services: + nginx: + image: mirror.gcr.io/library/nginx:1.26-alpine + ports: + - "${CONFORMANCE_PORT:-8000}:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ../..:/app:ro + depends_on: + - php-fpm + + php-fpm: + build: . + volumes: + - ../..:/app:ro + - ./tmp:/app/tests/Conformance/tmp + working_dir: /app diff --git a/tests/Conformance/nginx.conf b/tests/Conformance/nginx.conf new file mode 100644 index 0000000..387c5a5 --- /dev/null +++ b/tests/Conformance/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name localhost; + root /app/tests/Conformance/webroot; + + location / { + try_files $uri /index.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_pass php-fpm:9000; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } +} diff --git a/tests/Conformance/run.sh b/tests/Conformance/run.sh new file mode 100755 index 0000000..8572ad4 --- /dev/null +++ b/tests/Conformance/run.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Runs the official MCP conformance suite against the plugin's /mcp endpoint, +# served by nginx and php-fpm in Docker. Needs Docker, Node 22 and the +# Composer dependencies installed. +# +# tests/Conformance/run.sh both protocol revisions +# tests/Conformance/run.sh 2026-07-28 one revision + +set -uo pipefail + +cd "$(dirname "$0")" + +PACKAGE="${CONFORMANCE_PACKAGE:-@modelcontextprotocol/conformance@0.2.0-alpha.11}" +export CONFORMANCE_PORT="${CONFORMANCE_PORT:-8000}" +URL="http://localhost:${CONFORMANCE_PORT}/mcp" +if [ $# -gt 0 ]; then + SPECS=("$@") +else + SPECS=(2025-11-25 2026-07-28) +fi + +rm -rf results tmp +mkdir -p tmp/cache tmp/logs +chmod -R 777 tmp + +docker compose up -d --build --quiet-pull || exit 1 +trap 'docker compose down >/dev/null 2>&1' EXIT + +for _ in $(seq 1 60); do + if curl -s -o /dev/null "$URL"; then + break + fi + sleep 1 +done + +status=0 +for spec in "${SPECS[@]}"; do + echo "== Conformance: server, ${spec} ==" + npx --yes "$PACKAGE" server \ + --url "$URL" \ + --suite all \ + --spec-version "$spec" \ + --expected-failures "baseline-${spec}.yml" \ + --output-dir "results/${spec}" || status=1 +done + +if [ $status -ne 0 ]; then + echo "== Plugin log ==" + cat tmp/logs/*.log 2>/dev/null || echo "(empty)" + echo "== Containers ==" + docker compose logs --tail 100 +fi + +exit $status diff --git a/tests/Conformance/webroot/index.php b/tests/Conformance/webroot/index.php new file mode 100644 index 0000000..96dbbb7 --- /dev/null +++ b/tests/Conformance/webroot/index.php @@ -0,0 +1,47 @@ + FileEngine::class, 'path' => CACHE . 'sessions' . DS]); +Cache::setConfig('mcp_conformance_bus', ['className' => FileEngine::class, 'path' => CACHE . 'bus' . DS]); +Log::setConfig('mcp', ['className' => FileLog::class, 'path' => LOGS, 'file' => 'mcp', 'scopes' => ['mcp']]); + +Configure::write('Mcp', [ + 'server' => [ + 'name' => 'mcp-conformance-test-server', + 'version' => '1.0.0', + 'configure' => [ConformanceFixture::class, 'configure'], + ], + 'discovery' => ['scanDirs' => [], 'cache' => null], + 'session' => ['cache' => 'mcp_conformance'], + 'identity' => ['required' => false], + 'http' => ['allowedHosts' => ['localhost', '127.0.0.1']], +]); + +$server = new Server(new Application(CONFIG)); +$server->emit($server->run()); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b210919..955a2f0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -16,9 +16,16 @@ define('APP', ROOT . 'src' . DS); define('CONFIG', ROOT . 'config' . DS); define('WWW_ROOT', ROOT . 'webroot' . DS); -define('TMP', ROOT . 'tmp' . DS); -define('LOGS', TMP . 'logs' . DS); -define('CACHE', TMP . 'cache' . DS); +// The conformance fixture defines its own writable directories first. +if (!defined('TMP')) { + define('TMP', ROOT . 'tmp' . DS); +} +if (!defined('LOGS')) { + define('LOGS', TMP . 'logs' . DS); +} +if (!defined('CACHE')) { + define('CACHE', TMP . 'cache' . DS); +} define('CAKE_CORE_INCLUDE_PATH', $pluginRoot . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp'); define('CORE_PATH', CAKE_CORE_INCLUDE_PATH . DS); define('CAKE', CORE_PATH . 'src' . DS); From 5f7214d920608e8512012ca427d6a3da6a6ad7b0 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 22:40:43 +0200 Subject: [PATCH 24/49] Add bin/cake mcp inspector to open the MCP Inspector on the server --- CHANGELOG.md | 1 + README.md | 2 +- docs/clients.md | 30 ++- src/Command/McpInspectorCommand.php | 253 ++++++++++++++++++ src/McpPlugin.php | 7 +- .../Command/McpInspectorCommandTest.php | 161 +++++++++++ tests/test_app/bin/cake.php | 13 + 7 files changed, 458 insertions(+), 9 deletions(-) create mode 100644 src/Command/McpInspectorCommand.php create mode 100644 tests/TestCase/Command/McpInspectorCommandTest.php create mode 100644 tests/test_app/bin/cake.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0672c91..127aa3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Remote MCP over Streamable HTTP at a Cake route (`/mcp` by default), serving both protocol eras (handshake with sessions, and the stateless 2026-07-28 revision). - `bin/cake mcp server` for stdio. +- `bin/cake mcp inspector` opens the MCP Inspector already configured for this server, over stdio or HTTP. - Tool discovery through `#[McpTool]`, `#[McpResource]` and `#[McpPrompt]` attributes in `src/Mcp/`, with constructor injection from Cake's container (autowired). - Sessions and the discovery cache stored in Cake cache configs. - Authentication delegated to the host: the identity set by `cakephp/authentication` becomes the SDK access token and the injectable `CurrentIdentity`. diff --git a/README.md b/README.md index 64d5473..658a047 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ any other [Model Context Protocol](https://modelcontextprotocol.io) client. Built on the official [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sdk). - Remote MCP over Streamable HTTP, mounted as a regular Cake route (`/mcp`). -- `bin/cake mcp server` for local stdio clients. +- `bin/cake mcp server` for local stdio clients, and `bin/cake mcp inspector` to try the server by hand. - Tools are plain PHP classes with `#[McpTool]` attributes, with constructor injection. - Authentication stays in your app (`cakephp/authentication`); the plugin reads the identity. - Per-tool authorization, including `config/permissions.php` rules through `cakedc/auth`. diff --git a/docs/clients.md b/docs/clients.md index 061b9c7..b70a9fb 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -31,13 +31,31 @@ need the OAuth support planned for 2.1. ## MCP Inspector -The Inspector is a small web app for trying a server by hand: +The Inspector is a small web app for trying a server by hand: list the tools, +call them and see the raw JSON-RPC messages, which helps when a client says +only that the connection failed. It needs Node.js 22 or later. + +`bin/cake mcp inspector` opens it already pointed at your server: ```bash -npx @modelcontextprotocol/inspector +bin/cake mcp inspector # starts `bin/cake mcp server` over stdio +bin/cake mcp inspector --http --header "Authorization: Bearer " +bin/cake mcp inspector --url https://staging.example.com/mcp --header "Authorization: Bearer " ``` -Choose the Streamable HTTP transport, enter the URL and add an -`Authorization: Bearer ` header. You can then list tools, call them and -see the raw JSON-RPC messages, which helps when a client says only that the -connection failed. +- Without options it uses stdio, so it sees what `bin/cake mcp server` serves + (with `stdio.identity` and `stdio.trusted`). +- `--http` uses the endpoint at `App.fullBaseUrl` plus `Mcp.route`. If + `App.fullBaseUrl` is not set in the console, pass the URL with `--url`. +- `--header` adds a request header, as `"Name: Value"`. Repeat it for more. +- `--dry-run` prints the Inspector configuration and command instead of + starting it. Header values are shown as `***`. + +The configuration goes to a temporary file only you can read, and it is +deleted when the Inspector stops. If the token is wrong, the endpoint answers +401 and the Inspector offers an OAuth sign-in, which this version does not +support: check the token instead. + +You can also start the Inspector yourself with +`npx @modelcontextprotocol/inspector`, choose the Streamable HTTP transport, +enter the URL and add the `Authorization: Bearer ` header. diff --git a/src/Command/McpInspectorCommand.php b/src/Command/McpInspectorCommand.php new file mode 100644 index 0000000..913384e --- /dev/null +++ b/src/Command/McpInspectorCommand.php @@ -0,0 +1,253 @@ +setDescription(static::getDescription()) + ->addOption('http', [ + 'boolean' => true, + 'help' => 'Inspect the HTTP endpoint instead of starting `bin/cake mcp server`.', + ]) + ->addOption('url', [ + 'help' => 'URL of the HTTP endpoint (implies --http). Defaults to App.fullBaseUrl plus Mcp.route.', + ]) + ->addOption('header', [ + 'multiple' => true, + 'help' => 'Header sent to the HTTP endpoint, as "Name: Value". Repeat it for more headers.', + ]) + ->addOption('dry-run', [ + 'boolean' => true, + 'help' => 'Print the Inspector configuration and command without starting it.', + ]); + } + + /** + * @inheritDoc + */ + public function execute(Arguments $args, ConsoleIo $io): int + { + $url = $args->getOption('url'); + // Read raw: getMultipleOption() is deprecated from Cake 5.2 and getArrayOption() is new in 5.2. + $headers = $args->getOptions()['header'] ?? []; + $headers = is_array($headers) ? array_values(array_filter($headers, 'is_string')) : []; + $http = $args->getOption('http') === true || is_string($url); + if ($headers !== [] && !$http) { + $io->error('Headers are only sent over HTTP. Add --http or --url.'); + + return static::CODE_ERROR; + } + + $server = $http ? $this->httpServer(is_string($url) ? $url : null, $headers, $io) : $this->stdioServer($io); + if ($server === null) { + return static::CODE_ERROR; + } + $config = ['mcpServers' => [$this->serverName() => $server]]; + + if ($args->getOption('dry-run') === true) { + $io->out((string)json_encode($this->redact($config), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $io->out(implode(' ', $this->command(''))); + + return static::CODE_SUCCESS; + } + + $file = $this->writeConfig($config); + if ($file === null) { + $io->error('Could not write the Inspector configuration file.'); + + return static::CODE_ERROR; + } + $io->out(sprintf('Opening the MCP Inspector on %s. Stop it with Ctrl+C.', $server['url'] ?? 'stdio')); + + try { + $code = $this->launch($this->command($file), $io); + } finally { + unlink($file); + } + if ($code === 127) { + $io->error('Could not run npx. The Inspector needs Node.js 22 or later.'); + } + + return $code; + } + + /** + * Runs the Inspector in the foreground, attached to this terminal. + * + * @param list $command The command and its arguments. + * @param \Cake\Console\ConsoleIo $io The console. + * @return int The exit code of the Inspector. + */ + protected function launch(array $command, ConsoleIo $io): int + { + $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, rtrim(ROOT, DS)); + if ($process === false) { + $io->error('Could not start the Inspector.'); + + return static::CODE_ERROR; + } + + return proc_close($process); + } + + /** + * @param string $configFile Path of the configuration file. + * @return list + */ + private function command(string $configFile): array + { + return ['npx', '--yes', self::PACKAGE, '--config', $configFile]; + } + + /** + * @param \Cake\Console\ConsoleIo $io The console. + * @return array|null + */ + private function stdioServer(ConsoleIo $io): ?array + { + // ROOT has no trailing separator in the app skeleton. + $cake = rtrim(ROOT, DS) . DS . 'bin' . DS . 'cake.php'; + if (!is_file($cake)) { + $io->error(sprintf('`%s` does not exist, so the Inspector cannot start the stdio server.', $cake)); + + return null; + } + + return [ + 'type' => 'stdio', + 'command' => PHP_BINARY, + 'args' => [$cake, 'mcp', 'server'], + 'cwd' => rtrim(ROOT, DS), + 'protocolEra' => 'auto', + ]; + } + + /** + * @param string|null $url The --url option. + * @param list $headers The --header options. + * @param \Cake\Console\ConsoleIo $io The console. + * @return array|null + */ + private function httpServer(?string $url, array $headers, ConsoleIo $io): ?array + { + if ($url === null) { + $route = McpConfig::get('route'); + if (!is_string($route) || $route === '') { + $io->error('The HTTP endpoint is disabled (`Mcp.route`). Pass --url to inspect another server.'); + + return null; + } + $base = Configure::read('App.fullBaseUrl'); + if (!is_string($base) || $base === '') { + $io->error('`App.fullBaseUrl` is not set, so the endpoint URL is unknown. Pass it with --url.'); + + return null; + } + $url = rtrim($base, '/') . $route; + } + + $server = ['type' => 'http', 'url' => $url]; + foreach ($headers as $header) { + $parts = explode(':', $header, 2); + if (count($parts) !== 2 || trim($parts[0]) === '') { + $io->error(sprintf('Header `%s` is not in the "Name: Value" form.', $header)); + + return null; + } + $server['headers'][trim($parts[0])] = trim($parts[1]); + } + $server['protocolEra'] = 'auto'; + + return $server; + } + + /** + * @return string The key of the server in the Inspector configuration. + */ + private function serverName(): string + { + return strtolower(Text::slug((string)McpConfig::get('server.name'))) ?: 'cakephp'; + } + + /** + * @param array $config The Inspector configuration. + * @return array The same, with header values hidden. + */ + private function redact(array $config): array + { + foreach ($config['mcpServers'] as $name => $server) { + foreach (array_keys($server['headers'] ?? []) as $header) { + $config['mcpServers'][$name]['headers'][$header] = '***'; + } + } + + return $config; + } + + /** + * Writes the configuration to a file only the current user can read, since + * headers may carry a token. + * + * @param array $config The Inspector configuration. + * @return string|null The path, or null on failure. + */ + private function writeConfig(array $config): ?string + { + $temp = tempnam(sys_get_temp_dir(), 'mcp-inspector-'); + if ($temp === false) { + return null; + } + $file = $temp . '.json'; + if (!rename($temp, $file)) { + unlink($temp); + + return null; + } + chmod($file, 0600); + file_put_contents($file, (string)json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $file; + } +} diff --git a/src/McpPlugin.php b/src/McpPlugin.php index a52bde7..0f1f042 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -10,6 +10,7 @@ use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; use Cake\Routing\RouteBuilder; +use CakeDC\Mcp\Command\McpInspectorCommand; use CakeDC\Mcp\Command\McpServerCommand; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\Server\ServerFactory; @@ -60,14 +61,16 @@ public function routes(RouteBuilder $routes): void } /** - * Registers `bin/cake mcp server`, the stdio transport. + * Registers `bin/cake mcp server`, the stdio transport, and `bin/cake mcp inspector`. * * @param \Cake\Console\CommandCollection $commands The collection. * @return \Cake\Console\CommandCollection */ public function console(CommandCollection $commands): CommandCollection { - return $commands->add('mcp server', McpServerCommand::class); + return $commands + ->add('mcp server', McpServerCommand::class) + ->add('mcp inspector', McpInspectorCommand::class); } /** diff --git a/tests/TestCase/Command/McpInspectorCommandTest.php b/tests/TestCase/Command/McpInspectorCommandTest.php new file mode 100644 index 0000000..3a77921 --- /dev/null +++ b/tests/TestCase/Command/McpInspectorCommandTest.php @@ -0,0 +1,161 @@ +, config: array}> + */ + private array $launched = []; + + public function testIsRegisteredAsMcpInspector(): void + { + $commands = (new McpPlugin())->console(new CommandCollection()); + + $this->assertSame(McpInspectorCommand::class, $commands->get('mcp inspector')); + } + + public function testStdioStartsTheServerCommand(): void + { + $this->assertSame(0, $this->runCommand([])); + + $this->assertCount(1, $this->launched); + $command = $this->launched[0]['command']; + $this->assertSame(['npx', '--yes', '@modelcontextprotocol/inspector', '--config'], array_slice($command, 0, 4)); + $this->assertSame([ + 'type' => 'stdio', + 'command' => PHP_BINARY, + 'args' => [rtrim(ROOT, DS) . DS . 'bin' . DS . 'cake.php', 'mcp', 'server'], + 'cwd' => rtrim(ROOT, DS), + 'protocolEra' => 'auto', + ], $this->launched[0]['config']['mcpServers']['cakephp-mcp-server'] ?? null); + } + + public function testHttpUsesTheFullBaseUrlAndTheRoute(): void + { + Configure::write('App.fullBaseUrl', 'https://app.example.com/'); + + $this->assertSame(0, $this->runCommand(['--http', '--header', 'Authorization: Bearer secret-token'])); + + $this->assertSame([ + 'type' => 'http', + 'url' => 'https://app.example.com/mcp', + 'headers' => ['Authorization' => 'Bearer secret-token'], + 'protocolEra' => 'auto', + ], $this->launched[0]['config']['mcpServers']['cakephp-mcp-server'] ?? null); + } + + public function testUrlOptionImpliesHttp(): void + { + $this->assertSame(0, $this->runCommand(['--url', 'http://localhost:8765/mcp'])); + + $server = $this->launched[0]['config']['mcpServers']['cakephp-mcp-server'] ?? []; + $this->assertSame('http', $server['type'] ?? null); + $this->assertSame('http://localhost:8765/mcp', $server['url'] ?? null); + $this->assertArrayNotHasKey('headers', $server); + } + + public function testTheConfigFileIsRemovedAfterwards(): void + { + $this->runCommand([]); + + $this->assertFileDoesNotExist($this->launched[0]['command'][4]); + } + + public function testDryRunPrintsWithoutLaunchingAndHidesHeaderValues(): void + { + $code = $this->runCommand(['--url', 'https://app.example.com/mcp', '--header', 'Authorization: Bearer secret-token', '--dry-run']); + + $this->assertSame(0, $code); + $this->assertSame([], $this->launched); + $output = implode("\n", $this->out->messages()); + $this->assertStringContainsString('"url": "https://app.example.com/mcp"', $output); + $this->assertStringContainsString('"Authorization": "***"', $output); + $this->assertStringNotContainsString('secret-token', $output); + $this->assertStringContainsString('npx --yes @modelcontextprotocol/inspector --config', $output); + } + + public function testHttpWithoutABaseUrlAsksForOne(): void + { + Configure::write('App.fullBaseUrl', false); + + $this->assertSame(1, $this->runCommand(['--http'])); + + $this->assertSame([], $this->launched); + $this->assertStringContainsString('--url', implode("\n", $this->err->messages())); + } + + public function testHttpWithTheRouteDisabledFails(): void + { + Configure::write('Mcp.route', false); + + $this->assertSame(1, $this->runCommand(['--http'])); + + $this->assertStringContainsString('Mcp.route', implode("\n", $this->err->messages())); + } + + public function testMalformedHeadersAreRejected(): void + { + $this->assertSame(1, $this->runCommand(['--url', 'https://app.example.com/mcp', '--header', 'no-colon'])); + + $this->assertSame([], $this->launched); + $this->assertStringContainsString('"Name: Value"', implode("\n", $this->err->messages())); + } + + public function testHeadersNeedHttp(): void + { + $this->assertSame(1, $this->runCommand(['--header', 'Authorization: Bearer x'])); + + $this->assertStringContainsString('--http', implode("\n", $this->err->messages())); + } + + public function testExitCodeOfTheInspectorIsReturned(): void + { + $this->assertSame(3, $this->runCommand([], 3)); + } + + /** + * @param list $argv Command arguments. + */ + private function runCommand(array $argv, int $exitCode = 0): ?int + { + $this->out = new StubConsoleOutput(); + $this->err = new StubConsoleOutput(); + $io = new ConsoleIo($this->out, $this->err, new StubConsoleInput([])); + + $launched = &$this->launched; + $command = new class ($launched, $exitCode) extends McpInspectorCommand { + /** + * @param list, config: array}> $launched + */ + public function __construct(private array &$launched, private readonly int $exitCode) + { + } + + protected function launch(array $command, ConsoleIo $io): int + { + $config = json_decode((string)file_get_contents($command[4]), true); + $this->launched[] = ['command' => $command, 'config' => is_array($config) ? $config : []]; + + return $this->exitCode; + } + }; + + return $command->run($argv, $io); + } +} diff --git a/tests/test_app/bin/cake.php b/tests/test_app/bin/cake.php new file mode 100644 index 0000000..e9154d1 --- /dev/null +++ b/tests/test_app/bin/cake.php @@ -0,0 +1,13 @@ +run($argv)); From e9d494861570ad30be0a85cb940baa2a90b3d05d Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 23:03:14 +0200 Subject: [PATCH 25/49] McpTestTrait: load routes, show the server log on failure, check tools exist when not listed --- docs/testing.md | 35 +++++---- src/TestSuite/McpTestTrait.php | 74 ++++++++++++++++--- tests/TestCase/TestSuite/McpTestTraitTest.php | 29 +++++++- tests/test_app/src/McpExtra/FailingTools.php | 29 ++++++++ .../GreetingTools.php | 2 +- 5 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 tests/test_app/src/McpExtra/FailingTools.php rename tests/test_app/src/{McpGreeting => McpExtra}/GreetingTools.php (93%) diff --git a/docs/testing.md b/docs/testing.md index 4d7fbf7..0d659d1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,14 +1,15 @@ # Testing your tools `CakeDC\Mcp\TestSuite\McpTestTrait` calls your tools from a regular Cake test -case, without HTTP. Each call builds your application and its container, then -the MCP server the same way the endpoint does: tool discovery, constructor -injection and your authorizer. So a test covers the tool, its dependencies and -who may call it. +case, without HTTP. Each call builds your application (bootstrap and routes) +and its container, then the MCP server the same way the endpoint does: tool +discovery, constructor injection and your authorizer. So a test covers the +tool, its dependencies and who may call it. ```php namespace App\Test\TestCase\Mcp; +use Authentication\Identity; use Cake\TestSuite\TestCase; use CakeDC\Mcp\TestSuite\McpTestTrait; @@ -20,8 +21,7 @@ class ArticleToolsTest extends TestCase public function testEditorsCreateDrafts(): void { - $editor = $this->fetchTable('Users')->get(1); - $this->mcpActingAs($editor); + $this->mcpActingAs(new Identity($this->fetchTable('Users')->get(1))); $this->mcpCallTool('create_draft', ['title' => 'Hello', 'body' => 'First post']); @@ -31,7 +31,7 @@ class ArticleToolsTest extends TestCase public function testInvalidDraftsExplainWhatToFix(): void { - $this->mcpActingAs($this->fetchTable('Users')->get(1)); + $this->mcpActingAs(new Identity($this->fetchTable('Users')->get(1))); $this->mcpCallTool('create_draft', ['title' => '', 'body' => 'No title']); @@ -40,7 +40,7 @@ class ArticleToolsTest extends TestCase public function testReadersCannotCreateDrafts(): void { - $this->mcpActingAs($this->fetchTable('Users')->get(2)); + $this->mcpActingAs(new Identity($this->fetchTable('Users')->get(2))); $this->assertMcpToolNotListed('create_draft'); $this->mcpCallTool('create_draft', ['title' => 'Hello', 'body' => 'Nope']); @@ -51,11 +51,16 @@ class ArticleToolsTest extends TestCase ## The identity -`mcpActingAs()` sets who makes the next calls, in the shape your -authentication produces: an entity, an `ArrayObject`, an array. Tools see it -through `CurrentIdentity`, and through the SDK access token built as the -endpoint builds it (identifier as subject, scopes from `Mcp.identity.scopes`). -Without `mcpActingAs()` the calls are anonymous. +`mcpActingAs()` sets who makes the next calls. Pass it in the shape your +middleware produces: `cakephp/authentication` wraps the user in +`Authentication\Identity`, and `cakephp/authorization` wraps that in +`Authorization\IdentityDecorator`. A raw entity works for tools and authorizers +that only read fields, but not for code that calls `can()` or +`getOriginalData()`. + +Tools see the identity through `CurrentIdentity`, and through the SDK access +token built as the endpoint builds it (identifier as subject, scopes from +`Mcp.identity.scopes`). Without `mcpActingAs()` the calls are anonymous. The trait does not go through your HTTP authentication, so it does not check tokens or `Mcp.identity.required`. Cover that with an integration test of the @@ -75,7 +80,7 @@ endpoint (`IntegrationTestTrait`, a `POST` to `/mcp` with your | Assertion | Passes when the last call | | --- | --- | -| `assertMcpToolSucceeded()` | returned a result that is not a tool error. | +| `assertMcpToolSucceeded()` | returned a result that is not a tool error. On failure it shows what the server logged, including the exception of a tool that threw. | | `assertMcpToolError(?$contains)` | returned a tool error, the kind the model reads (`ToolCallException`, `ToolError::invalid()`). | | `assertMcpJsonRpcError(?$code, ?$contains)` | was answered with a JSON-RPC error: `-32602` for arguments that do not match the input schema, `-32603` for an unexpected exception. | | `assertMcpToolDenied()` | was rejected by your authorizer. It also checks that the tool exists, because a denied tool is answered like an unknown one and a typo would pass otherwise. | @@ -84,6 +89,8 @@ endpoint (`IntegrationTestTrait`, a `POST` to `/mcp` with your `assertMcpToolListed($name)` and `assertMcpToolNotListed($name)` check what the current identity sees in `tools/list`. They make their own call. +`assertMcpToolNotListed()` also checks that the tool exists, for the same +reason as `assertMcpToolDenied()`. ## Replacing services diff --git a/src/TestSuite/McpTestTrait.php b/src/TestSuite/McpTestTrait.php index d4c1b67..94a051a 100644 --- a/src/TestSuite/McpTestTrait.php +++ b/src/TestSuite/McpTestTrait.php @@ -7,6 +7,11 @@ use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; use Cake\Core\TestSuite\ContainerStubTrait; +use Cake\Log\Engine\ArrayLog; +use Cake\Log\Log; +use Cake\Routing\Router; +use Cake\Routing\RoutingApplicationInterface; +use CakeDC\Mcp\Log\McpLogger; use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\Middleware\IdentityMiddleware; use CakeDC\Mcp\Server\ServerFactory; @@ -18,9 +23,9 @@ /** * Calls the MCP tools of the application in memory, for `Cake\TestSuite\TestCase`. * - * Every call builds the application and its container, then the server the - * same way the HTTP endpoint does: discovery, constructor injection and the - * configured authorizer. The identity comes from `mcpActingAs()` instead of + * Every call builds the application (bootstrap, routes) and its container, + * then the server the same way the HTTP endpoint does: discovery, constructor + * injection and the configured authorizer. The identity comes from `mcpActingAs()` instead of * the HTTP authentication, which `IntegrationTestTrait` can cover. Services * can be replaced with `mockService()`. */ @@ -45,6 +50,14 @@ trait McpTestTrait */ protected ?string $_mcpTool = null; + /** + * What the server logged in the `mcp` scope during the last `mcpCallTool()`, + * warnings and worse. Shown when an assertion fails. + * + * @var list + */ + protected array $_mcpLog = []; + /** * Makes the next calls as this identity, in the shape your authentication * produces (an entity, an `ArrayObject`, an array...). Null makes them anonymous. @@ -70,7 +83,7 @@ public function mcpCallTool(string $name, array $arguments = []): array $this->_mcpResponse = $this->mcpRequest('tools/call', [ 'name' => $name, 'arguments' => $arguments === [] ? new stdClass() : $arguments, - ]); + ], log: $this->_mcpLog); return $this->_mcpResponse; } @@ -127,11 +140,11 @@ public function assertMcpToolSucceeded(string $message = ''): void Assert::assertArrayHasKey( 'result', $response, - $message ?: 'The tool call failed: ' . $this->mcpJson($response), + $message ?: 'The tool call failed: ' . $this->mcpJson($response) . $this->mcpLogText(), ); Assert::assertNotTrue( $this->mcpResult()['isError'] ?? false, - $message ?: 'The tool returned an error: ' . $this->mcpResultText(), + $message ?: 'The tool returned an error: ' . $this->mcpResultText() . $this->mcpLogText(), ); } @@ -147,7 +160,7 @@ public function assertMcpToolError(?string $contains = null, string $message = ' { Assert::assertTrue( ($this->mcpResult()['isError'] ?? false) === true, - $message ?: 'Expected a tool error, got: ' . $this->mcpJson($this->mcpResponse()), + $message ?: 'Expected a tool error, got: ' . $this->mcpJson($this->mcpResponse()) . $this->mcpLogText(), ); if ($contains !== null) { Assert::assertStringContainsString($contains, $this->mcpResultText(), $message); @@ -168,7 +181,9 @@ public function assertMcpJsonRpcError(?int $code = null, ?string $contains = nul $response = $this->mcpResponse(); $error = $response['error'] ?? null; if (!is_array($error)) { - Assert::fail($message ?: 'Expected a JSON-RPC error, got: ' . $this->mcpJson($response)); + Assert::fail( + $message ?: 'Expected a JSON-RPC error, got: ' . $this->mcpJson($response) . $this->mcpLogText(), + ); } if ($code !== null) { Assert::assertSame($code, $error['code'] ?? null, $message ?: 'Unexpected JSON-RPC error code.'); @@ -244,7 +259,8 @@ public function assertMcpToolListed(string $name, string $message = ''): void } /** - * Asserts that the current identity does not see a tool in `tools/list`. + * Asserts that the current identity does not see a tool in `tools/list`. It + * also checks that the tool exists, so a typo cannot pass as hidden. * * @param string $name The tool name. * @param string $message The failure message. @@ -257,6 +273,11 @@ public function assertMcpToolNotListed(string $name, string $message = ''): void array_column($this->mcpListTools(), 'name'), $message ?: sprintf('Tool "%s" is listed for the current identity.', $name), ); + Assert::assertContains( + $name, + array_column($this->mcpToolDefinitions(true), 'name'), + $message ?: sprintf('Tool "%s" is not registered, so its absence proves nothing. Check the name.', $name), + ); } /** @@ -272,6 +293,15 @@ protected function mcpContainer(): ContainerInterface if ($app instanceof PluginApplicationInterface) { $app->pluginBootstrap(); } + // createApp() resets the routes. Load them as the HTTP and console runners do, + // so tools that build URLs work as in production. + if ($app instanceof RoutingApplicationInterface) { + $builder = Router::createRouteBuilder('/'); + $app->routes($builder); + if ($app instanceof PluginApplicationInterface) { + $app->pluginRoutes($builder); + } + } if (!$app instanceof ContainerApplicationInterface) { Assert::fail(sprintf('`%s` has no container to build the MCP server from.', $app::class)); } @@ -285,9 +315,10 @@ protected function mcpContainer(): ContainerInterface * @param string $method The JSON-RPC method. * @param array|null $params Its params. * @param bool $trusted Skip the authorizer. + * @param list $log Receives what the server logged, warnings and worse. * @return array */ - private function mcpRequest(string $method, ?array $params, bool $trusted = false): array + private function mcpRequest(string $method, ?array $params, bool $trusted = false, array &$log = []): array { $request = ['jsonrpc' => '2.0', 'id' => 2, 'method' => $method]; if ($params !== null) { @@ -316,7 +347,20 @@ private function mcpRequest(string $method, ?array $params, bool $trusted = fals is_callable($scopes) ? Closure::fromCallable($scopes) : null, ), ); - $factory->create($trusted)->run($transport); + // The real exception of a failing tool only reaches the log, rarely configured in tests. + $logConfig = '_mcp_test_trait'; + Log::setConfig($logConfig, [ + 'className' => ArrayLog::class, + 'scopes' => [McpLogger::SCOPE], + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ]); + try { + $factory->create($trusted)->run($transport); + } finally { + $engine = Log::engine($logConfig); + $log = $engine instanceof ArrayLog ? array_values(array_map('strval', $engine->read())) : []; + Log::drop($logConfig); + } $response = $transport->responseFor(2); if ($response === null) { @@ -381,6 +425,14 @@ private function mcpSortKeys(mixed $value): mixed return $value; } + /** + * @return string The server log of the last call, for failure messages, or an empty string. + */ + private function mcpLogText(): string + { + return $this->_mcpLog === [] ? '' : "\nServer log:\n" . implode("\n", $this->_mcpLog); + } + /** * @param array $value A decoded JSON value. * @return string diff --git a/tests/TestCase/TestSuite/McpTestTraitTest.php b/tests/TestCase/TestSuite/McpTestTraitTest.php index fe22939..4ba4236 100644 --- a/tests/TestCase/TestSuite/McpTestTraitTest.php +++ b/tests/TestCase/TestSuite/McpTestTraitTest.php @@ -124,7 +124,7 @@ public function testListToolsReturnsTheDefinitions(): void public function testMockedServicesReachTheTools(): void { - Configure::write('Mcp.discovery.scanDirs', ['src/Mcp', 'src/McpGreeting']); + Configure::write('Mcp.discovery.scanDirs', ['src/Mcp', 'src/McpExtra']); $this->mockService(Greeter::class, fn(): Greeter => new Greeter(new Clock(), 'Hola')); $this->mcpCallTool('greet', ['name' => 'Ana']); @@ -132,6 +132,33 @@ public function testMockedServicesReachTheTools(): void $this->assertSame('Hola, Ana', $this->mcpResultText()); } + public function testToolsCanUseTheApplicationRoutes(): void + { + Configure::write('Mcp.discovery.scanDirs', ['src/McpExtra']); + + $this->mcpCallTool('article_link', ['id' => 5]); + + $this->assertMcpToolSucceeded(); + $this->assertSame('/Articles/view/5', $this->mcpResultText()); + } + + public function testFailuresShowTheServerLog(): void + { + Configure::write('Mcp.discovery.scanDirs', ['src/McpExtra']); + $this->mcpCallTool('broken'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('The broken tool is broken on purpose.'); + $this->assertMcpToolSucceeded(); + } + + public function testNotListedFailsForAToolThatDoesNotExist(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('is not registered'); + $this->assertMcpToolNotListed('no_such_tool'); + } + public function testAssertionsNeedACall(): void { $this->expectException(AssertionFailedError::class); diff --git a/tests/test_app/src/McpExtra/FailingTools.php b/tests/test_app/src/McpExtra/FailingTools.php new file mode 100644 index 0000000..2700047 --- /dev/null +++ b/tests/test_app/src/McpExtra/FailingTools.php @@ -0,0 +1,29 @@ + 'Articles', 'action' => 'view', $id]); + } + + /** + * Fails with an exception only the server log shows. + */ + #[McpTool(name: 'broken')] + public function broken(): string + { + throw new LogicException('The broken tool is broken on purpose.'); + } +} diff --git a/tests/test_app/src/McpGreeting/GreetingTools.php b/tests/test_app/src/McpExtra/GreetingTools.php similarity index 93% rename from tests/test_app/src/McpGreeting/GreetingTools.php rename to tests/test_app/src/McpExtra/GreetingTools.php index ff0d069..5daf077 100644 --- a/tests/test_app/src/McpGreeting/GreetingTools.php +++ b/tests/test_app/src/McpExtra/GreetingTools.php @@ -1,7 +1,7 @@ Date: Thu, 15 Oct 2026 23:03:14 +0200 Subject: [PATCH 26/49] Keep field names of flat error arrays, add server title, description and URL --- CHANGELOG.md | 1 + docs/configuration.md | 22 +++++++++++++++++---- docs/tools.md | 5 +++-- src/McpConfig.php | 10 +++++++++- src/Server/ServerFactory.php | 19 +++++++++++++++++- src/Tool/ToolError.php | 7 +++++-- tests/TestCase/Server/ServerFactoryTest.php | 16 +++++++++++++++ tests/TestCase/Tool/ToolErrorTest.php | 8 ++++++++ 8 files changed, 78 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 127aa3a..c1e9d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Sessions and the discovery cache stored in Cake cache configs. - Authentication delegated to the host: the identity set by `cakephp/authentication` becomes the SDK access token and the injectable `CurrentIdentity`. - Per-tool authorization with `ToolAuthorizerInterface`; `RbacToolAuthorizer` for `cakedc/auth` permission rules. +- `server.title`, `server.description` and `server.websiteUrl` config keys, reported in the server info. - `Mcp.server.configure` hands the SDK builder to the host for anything the plugin does not configure itself; the plugin's own settings and the authorizer still apply. - `McpTestTrait` calls the application's tools in memory from a Cake test case, acting as any identity, with assertions for results, tool errors, JSON-RPC errors, denials and `tools/list`. - `ToolError::invalid()` turns Cake validation errors (an entity or a `Validator` result) into a tool error the model can read and act on. diff --git a/docs/configuration.md b/docs/configuration.md index 4f27f31..033cb6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,7 +7,15 @@ want to change; everything else keeps its default. These are the defaults ```php 'Mcp' => [ 'route' => '/mcp', - 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null, 'configure' => null], + 'server' => [ + 'name' => 'CakePHP MCP Server', + 'version' => '1.0.0', + 'title' => null, + 'description' => null, + 'websiteUrl' => null, + 'instructions' => null, + 'configure' => null, + ], 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], @@ -21,6 +29,7 @@ want to change; everything else keeps its default. These are the defaults - `route`: path of the HTTP endpoint. `false` (or an empty string) connects no route. - `server.name`, `server.version`: reported to clients in the `initialize` result. +- `server.title`, `server.description`, `server.websiteUrl`: optional, also reported to clients. `null` sends none. - `server.instructions`: optional text telling the client's model how to use your server. `null` sends none. - `server.configure`: optional callable that receives the SDK `Mcp\Server\Builder` before the server is built. See [Using the SDK builder](#using-the-sdk-builder). - `discovery.scanDirs`: directories (relative to `ROOT`) scanned for attributes. Missing directories are skipped. @@ -65,9 +74,14 @@ Configure::write('Mcp.server.configure', function (Builder $builder): void { ``` The plugin applies its own settings after the callable, so these always win: -server name and version, container, logger, event dispatcher, session store, -and discovery when `discovery.scanDirs` has a directory. The authorizer applies -to every tool, including the ones added here. +the server info (a `setServerInfo()` call in the callable is ignored; use the +`server.*` keys), container, logger, event dispatcher, session store, and +discovery when `discovery.scanDirs` has a directory. The authorizer applies to +every tool, including the ones added here. + +If you set a cache policy, keep `tools/list` private unless every caller sees +the same tools. With an authorizer, the list depends on who asks, and a +`CacheScope::Public` hint lets shared caches serve one user's list to others. ## Caches diff --git a/docs/tools.md b/docs/tools.md index 6ebf59d..c9c0446 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -207,8 +207,9 @@ The draft was not saved. - tags.0.name: This field is required ``` -`invalid()` takes an entity (errors of associated entities included) or the -array returned by `Validator::validate()` or `getErrors()`. With `saveOrFail()`, +`invalid()` takes an entity (errors of associated entities included), the +array returned by `Validator::validate()` or `getErrors()`, or a simple +`['field' => 'message']` array. With `saveOrFail()`, catch `Cake\ORM\Exception\PersistenceFailedException` and pass `$exception->getEntity()`. The messages are your validation messages as they are, so write them for someone who has to fix the data. diff --git a/src/McpConfig.php b/src/McpConfig.php index a11fc10..14a9e3d 100644 --- a/src/McpConfig.php +++ b/src/McpConfig.php @@ -21,7 +21,15 @@ final class McpConfig */ public const DEFAULTS = [ 'route' => '/mcp', - 'server' => ['name' => 'CakePHP MCP Server', 'version' => '1.0.0', 'instructions' => null, 'configure' => null], + 'server' => [ + 'name' => 'CakePHP MCP Server', + 'version' => '1.0.0', + 'title' => null, + 'description' => null, + 'websiteUrl' => null, + 'instructions' => null, + 'configure' => null, + ], 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], diff --git a/src/Server/ServerFactory.php b/src/Server/ServerFactory.php index 23e754d..b3fdbf0 100644 --- a/src/Server/ServerFactory.php +++ b/src/Server/ServerFactory.php @@ -61,7 +61,13 @@ public function create(bool $trusted = false): Server $this->configure($builder); // Set after the host hook, so the hook cannot drop the tool list filter or replace these. $builder - ->setServerInfo((string)McpConfig::get('server.name'), (string)McpConfig::get('server.version')) + ->setServerInfo( + (string)McpConfig::get('server.name'), + (string)McpConfig::get('server.version'), + $this->optionalString('server.description'), + websiteUrl: $this->optionalString('server.websiteUrl'), + title: $this->optionalString('server.title'), + ) ->setContainer(new ContainerBridge($this->container)) ->setLogger(new McpLogger()) ->setEventDispatcher($events) @@ -112,6 +118,17 @@ private function configure(Builder $builder): void $configure($builder); } + /** + * @param string $path Dot path inside the `Mcp` config. + * @return string|null The value, or null when it is not a non-empty string. + */ + private function optionalString(string $path): ?string + { + $value = McpConfig::get($path); + + return is_string($value) && $value !== '' ? $value : null; + } + /** * Keeps only the scan directories that exist, so a host that has not * created its tools directory yet gets an empty tool list, not an error. diff --git a/src/Tool/ToolError.php b/src/Tool/ToolError.php index 5ba4922..44f1b88 100644 --- a/src/Tool/ToolError.php +++ b/src/Tool/ToolError.php @@ -42,7 +42,9 @@ public static function invalid( /** * Turns Cake's nested errors into messages keyed by dotted field path. A - * string is a message, and its key is the name of the rule that failed. + * string is a message, and its key is the name of the rule that failed. At + * the top level, where Cake never puts a message, a string under a named key + * is a hand-written `field => message`. * * @param array $errors Nested errors. * @param string $path The path of $errors. @@ -53,7 +55,8 @@ private static function flatten(array $errors, string $path = ''): array $flat = []; foreach ($errors as $key => $value) { if (is_string($value)) { - $flat[$path][] = $value; + $field = $path === '' && is_string($key) ? $key : $path; + $flat[$field][] = $value; continue; } diff --git a/tests/TestCase/Server/ServerFactoryTest.php b/tests/TestCase/Server/ServerFactoryTest.php index 1dca240..0f81123 100644 --- a/tests/TestCase/Server/ServerFactoryTest.php +++ b/tests/TestCase/Server/ServerFactoryTest.php @@ -142,6 +142,22 @@ public function testInstructionsAreAnnouncedOnInitialize(): void $this->assertSame('Use echo to test.', $result['instructions'] ?? null); } + public function testServerInfoFromConfig(): void + { + Configure::write('Mcp.server', [ + 'title' => 'My App', + 'description' => 'Articles and orders.', + 'websiteUrl' => 'https://app.example.com', + ]); + + $info = $this->runServer()->responseFor(1)['result']['serverInfo'] ?? []; + + $this->assertSame('CakePHP MCP Server', $info['name'] ?? null); + $this->assertSame('My App', $info['title'] ?? null); + $this->assertSame('Articles and orders.', $info['description'] ?? null); + $this->assertSame('https://app.example.com', $info['websiteUrl'] ?? null); + } + public function testConfigureHookReceivesTheBuilder(): void { Configure::write('Mcp.server.configure', function (Builder $builder): void { diff --git a/tests/TestCase/Tool/ToolErrorTest.php b/tests/TestCase/Tool/ToolErrorTest.php index f8e1580..e3746af 100644 --- a/tests/TestCase/Tool/ToolErrorTest.php +++ b/tests/TestCase/Tool/ToolErrorTest.php @@ -22,6 +22,14 @@ public function testListsEachFieldWithItsMessages(): void ); } + public function testFlatFieldMessagesKeepTheFieldName(): void + { + $this->assertSame( + "The data is invalid.\n- title: Required\n- slug: Taken", + ToolError::invalid(['title' => 'Required', 'slug' => 'Taken'])->getMessage(), + ); + } + public function testNestedErrorsUseDottedPaths(): void { $exception = ToolError::invalid([ From b6b58cdbf7efcfd83e33173900040c201aa6db72 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 23:03:15 +0200 Subject: [PATCH 27/49] Inspector command: delete the config on Ctrl+C, support Windows, pass env vars, pin v2 --- docs/clients.md | 17 ++- src/Command/McpInspectorCommand.php | 142 +++++++++++++++--- .../Command/McpInspectorCommandTest.php | 55 ++++++- 3 files changed, 186 insertions(+), 28 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index b70a9fb..a67918a 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -33,7 +33,8 @@ need the OAuth support planned for 2.1. The Inspector is a small web app for trying a server by hand: list the tools, call them and see the raw JSON-RPC messages, which helps when a client says -only that the connection failed. It needs Node.js 22 or later. +only that the connection failed. It needs Node.js 22.19 or later. +`bin/cake mcp inspector` runs version 2 of the Inspector (`@modelcontextprotocol/inspector@2`). `bin/cake mcp inspector` opens it already pointed at your server: @@ -43,16 +44,22 @@ bin/cake mcp inspector --http --header "Authorization: Bearer " bin/cake mcp inspector --url https://staging.example.com/mcp --header "Authorization: Bearer " ``` -- Without options it uses stdio, so it sees what `bin/cake mcp server` serves - (with `stdio.identity` and `stdio.trusted`). +- Without options it uses stdio and starts `bin/cake mcp server` (with + `stdio.identity` and `stdio.trusted`). The Inspector starts it with only a + few environment variables (`PATH`, `HOME`, `USER` and similar), so if your + app reads its settings from the environment, pass them by name with + `--env DATABASE_URL --env DEBUG`. Apps configured through `config/.env` or + `app_local.php` need nothing. - `--http` uses the endpoint at `App.fullBaseUrl` plus `Mcp.route`. If `App.fullBaseUrl` is not set in the console, pass the URL with `--url`. - `--header` adds a request header, as `"Name: Value"`. Repeat it for more. - `--dry-run` prints the Inspector configuration and command instead of - starting it. Header values are shown as `***`. + starting it. Header and environment values are shown as `***`. The configuration goes to a temporary file only you can read, and it is -deleted when the Inspector stops. If the token is wrong, the endpoint answers +deleted when the Inspector stops, also when you stop it with Ctrl+C. Only if +the PHP process is killed outright (for example `kill -9`) does it stay behind: +remove `mcp-inspector-*.json` from the temporary directory then. If the token is wrong, the endpoint answers 401 and the Inspector offers an OAuth sign-in, which this version does not support: check the token instead. diff --git a/src/Command/McpInspectorCommand.php b/src/Command/McpInspectorCommand.php index 913384e..e9196b5 100644 --- a/src/Command/McpInspectorCommand.php +++ b/src/Command/McpInspectorCommand.php @@ -10,6 +10,7 @@ use Cake\Core\Configure; use Cake\Utility\Text; use CakeDC\Mcp\McpConfig; +use Closure; /** * `bin/cake mcp inspector`: opens the MCP Inspector (a web app, run with npx) @@ -18,11 +19,12 @@ class McpInspectorCommand extends Command { /** - * The npm package of the Inspector. + * The npm package of the Inspector. Pinned to the major version whose + * configuration format this command writes. * * @var string */ - public const PACKAGE = '@modelcontextprotocol/inspector'; + public const PACKAGE = '@modelcontextprotocol/inspector@2'; /** * @inheritDoc @@ -37,7 +39,7 @@ public static function defaultName(): string */ public static function getDescription(): string { - return 'Open the MCP Inspector on this server, over stdio or HTTP. Needs Node.js.'; + return 'Open the MCP Inspector on this server, over stdio or HTTP. Needs Node.js 22.19 or later.'; } /** @@ -58,6 +60,11 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'multiple' => true, 'help' => 'Header sent to the HTTP endpoint, as "Name: Value". Repeat it for more headers.', ]) + ->addOption('env', [ + 'multiple' => true, + 'help' => 'Environment variable passed to the stdio server, by name. The Inspector passes only ' + . 'a few (PATH, HOME...), so name the ones your app reads. Repeat it for more.', + ]) ->addOption('dry-run', [ 'boolean' => true, 'help' => 'Print the Inspector configuration and command without starting it.', @@ -70,17 +77,23 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption public function execute(Arguments $args, ConsoleIo $io): int { $url = $args->getOption('url'); - // Read raw: getMultipleOption() is deprecated from Cake 5.2 and getArrayOption() is new in 5.2. - $headers = $args->getOptions()['header'] ?? []; - $headers = is_array($headers) ? array_values(array_filter($headers, 'is_string')) : []; + $headers = $this->listOption($args, 'header'); + $env = $this->listOption($args, 'env'); $http = $args->getOption('http') === true || is_string($url); if ($headers !== [] && !$http) { $io->error('Headers are only sent over HTTP. Add --http or --url.'); return static::CODE_ERROR; } + if ($env !== [] && $http) { + $io->error('Environment variables only reach the stdio server. Drop --http and --url.'); + + return static::CODE_ERROR; + } - $server = $http ? $this->httpServer(is_string($url) ? $url : null, $headers, $io) : $this->stdioServer($io); + $server = $http + ? $this->httpServer(is_string($url) ? $url : null, $headers, $io) + : $this->stdioServer($env, $io); if ($server === null) { return static::CODE_ERROR; } @@ -107,7 +120,7 @@ public function execute(Arguments $args, ConsoleIo $io): int unlink($file); } if ($code === 127) { - $io->error('Could not run npx. The Inspector needs Node.js 22 or later.'); + $io->error('Could not run npx. The Inspector needs Node.js 22.19 or later.'); } return $code; @@ -122,14 +135,69 @@ public function execute(Arguments $args, ConsoleIo $io): int */ protected function launch(array $command, ConsoleIo $io): int { - $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, rtrim(ROOT, DS)); - if ($process === false) { - $io->error('Could not start the Inspector.'); + $process = null; + $restore = $this->outliveInterrupts($process); + try { + $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, rtrim(ROOT, DS)); + if ($process === false) { + $io->error('Could not start the Inspector. It needs Node.js 22.19 or later, with npx.'); - return static::CODE_ERROR; + return static::CODE_ERROR; + } + + return proc_close($process); + } finally { + $restore(); + } + } + + /** + * Ctrl+C reaches PHP as well as the Inspector, both being in the terminal's + * process group. Left alone, PHP would die at once and leave the + * configuration file, and any token in it, on disk. These handlers keep PHP + * waiting for the Inspector to exit so the file is deleted; SIGTERM and + * SIGHUP are passed on to the Inspector. They are handlers and not + * `SIG_IGN`, which the Inspector would inherit and then ignore Ctrl+C. + * + * @param resource|false|null $process The Inspector process, once started. + * @return \Closure Restores the previous behavior. + */ + private function outliveInterrupts(mixed &$process): Closure + { + if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + $async = pcntl_async_signals(true); + $forward = static function (int $signal) use (&$process): void { + if (is_resource($process)) { + proc_terminate($process, $signal); + } + }; + $handlers = [SIGINT => static function (): void { + }, SIGTERM => $forward, SIGHUP => $forward]; + $previous = []; + foreach ($handlers as $signal => $handler) { + $previous[$signal] = pcntl_signal_get_handler($signal); + pcntl_signal($signal, $handler); + } + + return static function () use ($previous, $async): void { + foreach ($previous as $signal => $handler) { + pcntl_signal($signal, $handler); + } + pcntl_async_signals($async); + }; + } + if (function_exists('sapi_windows_set_ctrl_handler')) { + $handler = static function (int $event): void { + }; + sapi_windows_set_ctrl_handler($handler); + + return static function () use ($handler): void { + sapi_windows_set_ctrl_handler($handler, false); + }; } - return proc_close($process); + return static function (): void { + }; } /** @@ -138,14 +206,33 @@ protected function launch(array $command, ConsoleIo $io): int */ private function command(string $configFile): array { - return ['npx', '--yes', self::PACKAGE, '--config', $configFile]; + // On Windows npm installs npx as a batch file, which proc_open() only finds by its full name. + $npx = PHP_OS_FAMILY === 'Windows' ? 'npx.cmd' : 'npx'; + + return [$npx, '--yes', self::PACKAGE, '--config', $configFile]; + } + + /** + * The values of a multiple option. Read raw: `getMultipleOption()` is + * deprecated from Cake 5.2 and `getArrayOption()` only exists from 5.2. + * + * @param \Cake\Console\Arguments $args The arguments. + * @param string $name The option. + * @return list + */ + private function listOption(Arguments $args, string $name): array + { + $values = $args->getOptions()[$name] ?? []; + + return is_array($values) ? array_values(array_filter($values, 'is_string')) : []; } /** + * @param list $env Names of the environment variables to pass on. * @param \Cake\Console\ConsoleIo $io The console. * @return array|null */ - private function stdioServer(ConsoleIo $io): ?array + private function stdioServer(array $env, ConsoleIo $io): ?array { // ROOT has no trailing separator in the app skeleton. $cake = rtrim(ROOT, DS) . DS . 'bin' . DS . 'cake.php'; @@ -155,13 +242,24 @@ private function stdioServer(ConsoleIo $io): ?array return null; } - return [ + $server = [ 'type' => 'stdio', 'command' => PHP_BINARY, 'args' => [$cake, 'mcp', 'server'], 'cwd' => rtrim(ROOT, DS), - 'protocolEra' => 'auto', ]; + foreach ($env as $name) { + $value = getenv($name); + if ($value === false) { + $io->error(sprintf('The environment variable `%s` is not set.', $name)); + + return null; + } + $server['env'][$name] = $value; + } + $server['protocolEra'] = 'auto'; + + return $server; } /** @@ -213,13 +311,15 @@ private function serverName(): string /** * @param array $config The Inspector configuration. - * @return array The same, with header values hidden. + * @return array The same, with header and environment values hidden. */ private function redact(array $config): array { foreach ($config['mcpServers'] as $name => $server) { - foreach (array_keys($server['headers'] ?? []) as $header) { - $config['mcpServers'][$name]['headers'][$header] = '***'; + foreach (['headers', 'env'] as $secrets) { + foreach (array_keys($server[$secrets] ?? []) as $key) { + $config['mcpServers'][$name][$secrets][$key] = '***'; + } } } @@ -228,7 +328,7 @@ private function redact(array $config): array /** * Writes the configuration to a file only the current user can read, since - * headers may carry a token. + * headers and environment variables may carry secrets. * * @param array $config The Inspector configuration. * @return string|null The path, or null on failure. diff --git a/tests/TestCase/Command/McpInspectorCommandTest.php b/tests/TestCase/Command/McpInspectorCommandTest.php index 3a77921..d6e6536 100644 --- a/tests/TestCase/Command/McpInspectorCommandTest.php +++ b/tests/TestCase/Command/McpInspectorCommandTest.php @@ -36,7 +36,7 @@ public function testStdioStartsTheServerCommand(): void $this->assertCount(1, $this->launched); $command = $this->launched[0]['command']; - $this->assertSame(['npx', '--yes', '@modelcontextprotocol/inspector', '--config'], array_slice($command, 0, 4)); + $this->assertSame(['npx', '--yes', '@modelcontextprotocol/inspector@2', '--config'], array_slice($command, 0, 4)); $this->assertSame([ 'type' => 'stdio', 'command' => PHP_BINARY, @@ -87,7 +87,58 @@ public function testDryRunPrintsWithoutLaunchingAndHidesHeaderValues(): void $this->assertStringContainsString('"url": "https://app.example.com/mcp"', $output); $this->assertStringContainsString('"Authorization": "***"', $output); $this->assertStringNotContainsString('secret-token', $output); - $this->assertStringContainsString('npx --yes @modelcontextprotocol/inspector --config', $output); + $this->assertStringContainsString('npx --yes @modelcontextprotocol/inspector@2 --config', $output); + } + + public function testEnvironmentVariablesArePassedToTheStdioServer(): void + { + putenv('MCP_INSPECTOR_TEST=from-the-shell'); + try { + $this->assertSame(0, $this->runCommand(['--env', 'MCP_INSPECTOR_TEST'])); + $this->assertSame(0, $this->runCommand(['--env', 'MCP_INSPECTOR_TEST', '--dry-run'])); + } finally { + putenv('MCP_INSPECTOR_TEST'); + } + + $server = $this->launched[0]['config']['mcpServers']['cakephp-mcp-server'] ?? []; + $this->assertSame(['MCP_INSPECTOR_TEST' => 'from-the-shell'], $server['env'] ?? null); + $output = implode("\n", $this->out->messages()); + $this->assertStringContainsString('"MCP_INSPECTOR_TEST": "***"', $output); + $this->assertStringNotContainsString('from-the-shell', $output); + } + + public function testMissingEnvironmentVariablesAreRejected(): void + { + $this->assertSame(1, $this->runCommand(['--env', 'MCP_INSPECTOR_NOT_SET'])); + + $this->assertSame([], $this->launched); + $this->assertStringContainsString('MCP_INSPECTOR_NOT_SET', implode("\n", $this->err->messages())); + } + + public function testEnvironmentVariablesNeedStdio(): void + { + $this->assertSame(1, $this->runCommand(['--http', '--env', 'PATH'])); + + $this->assertStringContainsString('stdio', implode("\n", $this->err->messages())); + } + + public function testLaunchRunsTheCommandAndRestoresSignalHandlers(): void + { + $before = function_exists('pcntl_signal_get_handler') ? pcntl_signal_get_handler(SIGINT) : null; + $command = new class extends McpInspectorCommand { + /** + * @param list $command + */ + public function launchNow(array $command): int + { + return $this->launch($command, new ConsoleIo(new StubConsoleOutput(), new StubConsoleOutput())); + } + }; + + $this->assertSame(3, $command->launchNow([PHP_BINARY, '-r', 'exit(3);'])); + if (function_exists('pcntl_signal_get_handler')) { + $this->assertSame($before, pcntl_signal_get_handler(SIGINT)); + } } public function testHttpWithoutABaseUrlAsksForOne(): void From d46e3eba04950a80dc4dd13549e305c158619e9f Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 23:03:53 +0200 Subject: [PATCH 28/49] Conformance script: wait for php-fpm, clean files it created, match the host PHP --- docs/conformance.md | 7 ++++++- tests/Conformance/Dockerfile | 6 ++++-- tests/Conformance/docker-compose.yml | 5 ++++- tests/Conformance/run.sh | 19 +++++++++++++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/conformance.md b/docs/conformance.md index 968a4aa..9f74de1 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -24,7 +24,12 @@ tests/Conformance/run.sh 2026-07-28 # one revision `CONFORMANCE_PACKAGE` the suite version. The suite version is pinned, so a new release cannot break the build on its own; raise it on purpose. -The CI workflow runs the suite on every push and pull request. +The CI workflow runs the suite on pushes to the main branches and on every +pull request. + +The container uses the PHP version of the host that runs the script (8.4 in +CI), because it runs the `vendor/` directory installed there. Set +`CONFORMANCE_PHP` to use another one. ## How it is set up diff --git a/tests/Conformance/Dockerfile b/tests/Conformance/Dockerfile index d957366..a5cf3c9 100644 --- a/tests/Conformance/Dockerfile +++ b/tests/Conformance/Dockerfile @@ -1,5 +1,7 @@ -# php-fpm with intl, which CakePHP needs. -FROM mirror.gcr.io/library/php:8.4-fpm-alpine +# php-fpm with intl, which CakePHP needs. PHP_VERSION follows the host that +# installed vendor/ (see run.sh), so Composer's platform check passes. +ARG PHP_VERSION=8.4 +FROM mirror.gcr.io/library/php:${PHP_VERSION}-fpm-alpine RUN apk add --no-cache icu-libs \ && apk add --no-cache --virtual .build-deps icu-dev $PHPIZE_DEPS \ && docker-php-ext-install intl \ diff --git a/tests/Conformance/docker-compose.yml b/tests/Conformance/docker-compose.yml index abfd7d3..ecef6ed 100644 --- a/tests/Conformance/docker-compose.yml +++ b/tests/Conformance/docker-compose.yml @@ -12,7 +12,10 @@ services: - php-fpm php-fpm: - build: . + build: + context: . + args: + PHP_VERSION: "${CONFORMANCE_PHP:-8.4}" volumes: - ../..:/app:ro - ./tmp:/app/tests/Conformance/tmp diff --git a/tests/Conformance/run.sh b/tests/Conformance/run.sh index 8572ad4..70f1cce 100755 --- a/tests/Conformance/run.sh +++ b/tests/Conformance/run.sh @@ -13,6 +13,8 @@ cd "$(dirname "$0")" PACKAGE="${CONFORMANCE_PACKAGE:-@modelcontextprotocol/conformance@0.2.0-alpha.11}" export CONFORMANCE_PORT="${CONFORMANCE_PORT:-8000}" +# The container runs the vendor/ installed by this PHP, so it uses the same version. +export CONFORMANCE_PHP="${CONFORMANCE_PHP:-$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')}" URL="http://localhost:${CONFORMANCE_PORT}/mcp" if [ $# -gt 0 ]; then SPECS=("$@") @@ -20,19 +22,32 @@ else SPECS=(2025-11-25 2026-07-28) fi -rm -rf results tmp +if ! rm -rf results tmp 2>/dev/null; then + # On Linux php-fpm created some of these files as its own user; remove them from inside. + docker compose run --rm --no-deps --entrypoint sh php-fpm -c 'rm -rf /app/tests/Conformance/tmp/*' || exit 1 + rm -rf results tmp || exit 1 +fi mkdir -p tmp/cache tmp/logs chmod -R 777 tmp docker compose up -d --build --quiet-pull || exit 1 trap 'docker compose down >/dev/null 2>&1' EXIT +# Ready once php-fpm answers: no connection (000) or nginx's 502 mean not yet. +ready=0 for _ in $(seq 1 60); do - if curl -s -o /dev/null "$URL"; then + code=$(curl -s -o /dev/null -w '%{http_code}' "$URL") + if [ "$code" != "000" ] && [ "$code" != "502" ]; then + ready=1 break fi sleep 1 done +if [ $ready -ne 1 ]; then + echo "The endpoint at ${URL} did not come up." + docker compose logs --tail 100 + exit 1 +fi status=0 for spec in "${SPECS[@]}"; do From 476d30a9ff23d90a3b620e5dae6ba6fb49050686 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Thu, 15 Oct 2026 23:06:58 +0200 Subject: [PATCH 29/49] Inspector command: poll the process so SIGTERM is passed on, warn without pcntl --- docs/clients.md | 8 +++-- src/Command/McpInspectorCommand.php | 49 ++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index a67918a..c264db3 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -57,9 +57,11 @@ bin/cake mcp inspector --url https://staging.example.com/mcp --header "Authoriza starting it. Header and environment values are shown as `***`. The configuration goes to a temporary file only you can read, and it is -deleted when the Inspector stops, also when you stop it with Ctrl+C. Only if -the PHP process is killed outright (for example `kill -9`) does it stay behind: -remove `mcp-inspector-*.json` from the temporary directory then. If the token is wrong, the endpoint answers +deleted when the Inspector stops, also when you stop it with Ctrl+C. On Linux +and macOS that needs PHP's `pcntl` extension, usual in the CLI; without it the +command warns and prints the path to delete. If the PHP process is killed +outright (for example `kill -9`), remove `mcp-inspector-*.json` from the +temporary directory yourself. If the token is wrong, the endpoint answers 401 and the Inspector offers an OAuth sign-in, which this version does not support: check the token instead. diff --git a/src/Command/McpInspectorCommand.php b/src/Command/McpInspectorCommand.php index e9196b5..398c0e3 100644 --- a/src/Command/McpInspectorCommand.php +++ b/src/Command/McpInspectorCommand.php @@ -113,6 +113,12 @@ public function execute(Arguments $args, ConsoleIo $io): int return static::CODE_ERROR; } $io->out(sprintf('Opening the MCP Inspector on %s. Stop it with Ctrl+C.', $server['url'] ?? 'stdio')); + if (!self::interruptsHandled() && (isset($server['headers']) || isset($server['env']))) { + $io->warning(sprintf( + 'Without the pcntl extension, stopping with Ctrl+C leaves `%s` behind. Delete it afterwards.', + $file, + )); + } try { $code = $this->launch($this->command($file), $io); @@ -145,12 +151,34 @@ protected function launch(array $command, ConsoleIo $io): int return static::CODE_ERROR; } - return proc_close($process); + // Polled rather than waited for in proc_close(): PHP only runs signal + // handlers between calls, so this lets SIGTERM and SIGHUP be passed on. + do { + $status = proc_get_status($process); + if ($status['running']) { + usleep(100000); + } + } while ($status['running']); + proc_close($process); + + return $status['signaled'] ? 128 + $status['termsig'] : $status['exitcode']; } finally { $restore(); } } + /** + * Whether `launch()` can keep PHP alive through Ctrl+C to delete the + * configuration file: it needs the pcntl extension, or PHP on Windows. + * + * @return bool + */ + private static function interruptsHandled(): bool + { + return (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) + || function_exists('sapi_windows_set_ctrl_handler'); + } + /** * Ctrl+C reaches PHP as well as the Inspector, both being in the terminal's * process group. Left alone, PHP would die at once and leave the @@ -164,6 +192,10 @@ protected function launch(array $command, ConsoleIo $io): int */ private function outliveInterrupts(mixed &$process): Closure { + if (!self::interruptsHandled()) { + return static function (): void { + }; + } if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { $async = pcntl_async_signals(true); $forward = static function (int $signal) use (&$process): void { @@ -186,17 +218,12 @@ private function outliveInterrupts(mixed &$process): Closure pcntl_async_signals($async); }; } - if (function_exists('sapi_windows_set_ctrl_handler')) { - $handler = static function (int $event): void { - }; - sapi_windows_set_ctrl_handler($handler); - - return static function () use ($handler): void { - sapi_windows_set_ctrl_handler($handler, false); - }; - } + $handler = static function (int $event): void { + }; + sapi_windows_set_ctrl_handler($handler); - return static function (): void { + return static function () use ($handler): void { + sapi_windows_set_ctrl_handler($handler, false); }; } From 981124d0e3d58543bcafc5b50cdd090a9de770c4 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 15:31:04 +0200 Subject: [PATCH 30/49] Run the test suite with the oauth plugin and a SQLite database --- .github/workflows/ci.yml | 61 ++++++++- composer.json | 24 +++- phpunit.xml.dist | 2 +- .../OauthAuthorizationCodesFixture.php | 14 ++ tests/Fixture/OauthClientsFixture.php | 14 ++ tests/Fixture/OauthConsentsFixture.php | 14 ++ tests/Fixture/OauthTokensFixture.php | 14 ++ tests/Fixture/UsersFixture.php | 15 +++ tests/Support/Html.php | 46 +++++++ tests/Support/McpHttpTrait.php | 123 ++++++++++++++++++ tests/Support/OAuthFixtures.php | 18 +++ tests/TestCase/OAuth/OAuthTestAppTest.php | 50 +++++++ tests/bootstrap_suite.php | 37 ++++++ .../Migrations/20260101000000_CreateUsers.php | 27 ++++ tests/test_app/src/Application.php | 20 ++- tests/test_app/src/Model/Table/UsersTable.php | 22 ++++ tests/test_app/src/OAuthTestConfig.php | 58 +++++++++ 17 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 tests/Fixture/OauthAuthorizationCodesFixture.php create mode 100644 tests/Fixture/OauthClientsFixture.php create mode 100644 tests/Fixture/OauthConsentsFixture.php create mode 100644 tests/Fixture/OauthTokensFixture.php create mode 100644 tests/Fixture/UsersFixture.php create mode 100644 tests/Support/Html.php create mode 100644 tests/Support/McpHttpTrait.php create mode 100644 tests/Support/OAuthFixtures.php create mode 100644 tests/TestCase/OAuth/OAuthTestAppTest.php create mode 100644 tests/bootstrap_suite.php create mode 100644 tests/test_app/config/Migrations/20260101000000_CreateUsers.php create mode 100644 tests/test_app/src/Model/Table/UsersTable.php create mode 100644 tests/test_app/src/OAuthTestConfig.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6247b1..2731ad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,14 @@ on: permissions: contents: read +# The OAuth tests resolve cakedc/oauth-server and cakedc/cakephp-oauth-server from path repositories +# (../oauth-server and ../cakephp-oauth-server), so every job checks them out next to this one. While +# those repositories are private, the CAKEDC_PRIVATE_REPOS_TOKEN secret must hold a token that can +# read them; once they are public the workflow token is enough. +env: + OAUTH_SERVER_REF: '1.x' + OAUTH_PLUGIN_REF: '1.next-cake5' + jobs: testsuite: runs-on: ubuntu-24.04 @@ -22,40 +30,91 @@ jobs: name: PHP ${{ matrix.php-version }} (${{ matrix.dependencies }}) steps: - uses: actions/checkout@v4 + with: + path: cakephp-mcp + - uses: actions/checkout@v4 + with: + repository: CakeDC/oauth-server + ref: ${{ env.OAUTH_SERVER_REF }} + path: oauth-server + token: ${{ secrets.CAKEDC_PRIVATE_REPOS_TOKEN || github.token }} + - uses: actions/checkout@v4 + with: + repository: CakeDC/cakephp-oauth-server + ref: ${{ env.OAUTH_PLUGIN_REF }} + path: cakephp-oauth-server + token: ${{ secrets.CAKEDC_PRIVATE_REPOS_TOKEN || github.token }} - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: mbstring, intl + extensions: mbstring, intl, pdo_sqlite, openssl coverage: none - uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }} + working-directory: cakephp-mcp - run: vendor/bin/phpunit + working-directory: cakephp-mcp cs-stan: runs-on: ubuntu-24.04 name: Coding standard and static analysis steps: - uses: actions/checkout@v4 + with: + path: cakephp-mcp + - uses: actions/checkout@v4 + with: + repository: CakeDC/oauth-server + ref: ${{ env.OAUTH_SERVER_REF }} + path: oauth-server + token: ${{ secrets.CAKEDC_PRIVATE_REPOS_TOKEN || github.token }} + - uses: actions/checkout@v4 + with: + repository: CakeDC/cakephp-oauth-server + ref: ${{ env.OAUTH_PLUGIN_REF }} + path: cakephp-oauth-server + token: ${{ secrets.CAKEDC_PRIVATE_REPOS_TOKEN || github.token }} - uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: none - uses: ramsey/composer-install@v3 + with: + working-directory: cakephp-mcp - run: vendor/bin/phpcs + working-directory: cakephp-mcp - run: vendor/bin/phpstan analyse --no-progress + working-directory: cakephp-mcp conformance: runs-on: ubuntu-24.04 name: MCP conformance steps: - uses: actions/checkout@v4 + with: + path: cakephp-mcp + - uses: actions/checkout@v4 + with: + repository: CakeDC/oauth-server + ref: ${{ env.OAUTH_SERVER_REF }} + path: oauth-server + token: ${{ secrets.CAKEDC_PRIVATE_REPOS_TOKEN || github.token }} + - uses: actions/checkout@v4 + with: + repository: CakeDC/cakephp-oauth-server + ref: ${{ env.OAUTH_PLUGIN_REF }} + path: cakephp-oauth-server + token: ${{ secrets.CAKEDC_PRIVATE_REPOS_TOKEN || github.token }} - uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: none - uses: ramsey/composer-install@v3 + with: + working-directory: cakephp-mcp - uses: actions/setup-node@v4 with: node-version: '22' - run: tests/Conformance/run.sh + working-directory: cakephp-mcp diff --git a/composer.json b/composer.json index 3ba6f16..0c7f807 100644 --- a/composer.json +++ b/composer.json @@ -18,15 +18,35 @@ }, "require-dev": { "cakedc/auth": "^10.1 || ^11.0", + "cakedc/cakephp-oauth-server": "^1.0", + "cakedc/oauth-server": "^1.0", "cakephp/authentication": "^3.3", "cakephp/cakephp-codesniffer": "^5.1", + "cakephp/migrations": "^4.0 || ^5.0", + "firebase/php-jwt": "^6.10 || ^7.0", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.5.5 || ^11.1.3 || ^12.1 || ^13.0" + "phpunit/phpunit": "^10.5.5 || ^11.1.3 || ^12.1 || ^13.0", + "psr/cache": "^2.0 || ^3.0" }, "suggest": { "cakephp/authentication": "Identifies the caller of the MCP endpoint (for example with a Bearer token authenticator)", - "cakedc/auth": "Enables RbacToolAuthorizer, per-tool permissions in the config/permissions.php format" + "cakedc/auth": "Enables RbacToolAuthorizer, per-tool permissions in the config/permissions.php format", + "cakedc/cakephp-oauth-server": "OAuth 2.1 sign-in for remote MCP clients with the application's own login (Mcp.auth.mode oauth)", + "firebase/php-jwt": "Validates access tokens of an external authorization server (Mcp.auth.mode external)", + "psr/cache": "Required with firebase/php-jwt for Mcp.auth.mode external" }, + "repositories": [ + { + "type": "path", + "url": "../oauth-server", + "options": {"symlink": true, "versions": {"cakedc/oauth-server": "1.x-dev"}} + }, + { + "type": "path", + "url": "../cakephp-oauth-server", + "options": {"symlink": true, "versions": {"cakedc/cakephp-oauth-server": "1.x-dev"}} + } + ], "autoload": { "psr-4": { "CakeDC\\Mcp\\": "src/" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eb79a0c..31270e4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ diff --git a/tests/Fixture/OauthAuthorizationCodesFixture.php b/tests/Fixture/OauthAuthorizationCodesFixture.php new file mode 100644 index 0000000..fc40446 --- /dev/null +++ b/tests/Fixture/OauthAuthorizationCodesFixture.php @@ -0,0 +1,14 @@ + 1, 'name' => 'Ada', 'email' => 'ada@example.com', 'role' => 'admin', 'active' => true], + ['id' => 2, 'name' => 'Grace', 'email' => 'grace@example.com', 'role' => 'editor', 'active' => true], + ['id' => 3, 'name' => 'Mallory', 'email' => 'mallory@example.com', 'role' => 'editor', 'active' => false], + ]; +} diff --git a/tests/Support/Html.php b/tests/Support/Html.php new file mode 100644 index 0000000..ab8a3e1 --- /dev/null +++ b/tests/Support/Html.php @@ -0,0 +1,46 @@ + + */ + public static function hiddenFields(string $html, ?string $marker = null): array + { + $document = new DOMDocument(); + libxml_use_internal_errors(true); + $document->loadHTML($html); + libxml_clear_errors(); + $xpath = new DOMXPath($document); + $fields = []; + foreach ($xpath->query('//form') ?: [] as $form) { + if (!$form instanceof DOMElement) { + continue; + } + $inputs = []; + foreach ($xpath->query('.//input[@type="hidden"]', $form) ?: [] as $input) { + if ($input instanceof DOMElement) { + $inputs[$input->getAttribute('name')] = $input->getAttribute('value'); + } + } + if ($marker === null) { + $fields += $inputs; + } elseif (isset($inputs[$marker])) { + return $inputs; + } + } + + return $fields; + } +} diff --git a/tests/Support/McpHttpTrait.php b/tests/Support/McpHttpTrait.php new file mode 100644 index 0000000..a308b1a --- /dev/null +++ b/tests/Support/McpHttpTrait.php @@ -0,0 +1,123 @@ + $_request + * @property \Psr\Http\Message\ResponseInterface|null $_response + */ +trait McpHttpTrait +{ + private const MCP_REVISION = '2026-07-28'; + + /** + * Posts $message to /mcp. No Authorization header unless $headers has one. + * + * @param array $message The JSON-RPC message. + * @param array $headers Extra or replacing headers. + * @return void + */ + protected function postMcp(array $message, array $headers = []): void + { + $this->_request['headers'] = $headers + [ + 'Host' => 'localhost', + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ]; + $this->post('/mcp', (string)json_encode($message)); + } + + /** + * @param int $id The JSON-RPC id. + * @param string $method The method. + * @param array $params Its params. + * @return array + */ + protected function mcpMessage(int $id, string $method, array $params = []): array + { + $params['_meta'] = [ + 'io.modelcontextprotocol/protocolVersion' => self::MCP_REVISION, + 'io.modelcontextprotocol/clientCapabilities' => new stdClass(), + ]; + + return ['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params]; + } + + /** + * @param string $method The JSON-RPC method. + * @param string|null $name The tool name, for tools/call. + * @param string|null $token A bearer token. + * @return array + */ + protected function mcpHeaders(string $method, ?string $name = null, ?string $token = null): array + { + $headers = ['MCP-Protocol-Version' => self::MCP_REVISION, 'Mcp-Method' => $method]; + if ($name !== null) { + $headers['Mcp-Name'] = $name; + } + if ($token !== null) { + $headers['Authorization'] = 'Bearer ' . $token; + } + + return $headers; + } + + /** + * @param string $name The tool. + * @param array $arguments Its arguments. + * @param string|null $token A bearer token. + * @return void + */ + protected function callTool(string $name, array $arguments = [], ?string $token = null): void + { + $this->postMcp( + $this->mcpMessage(1, 'tools/call', [ + 'name' => $name, + 'arguments' => $arguments === [] ? new stdClass() : $arguments, + ]), + $this->mcpHeaders('tools/call', $name, $token), + ); + } + + /** + * @param string|null $token A bearer token. + * @return void + */ + protected function listTools(?string $token = null): void + { + $this->postMcp($this->mcpMessage(1, 'tools/list'), $this->mcpHeaders('tools/list', null, $token)); + } + + /** + * @return array + */ + protected function responseJson(): array + { + $decoded = json_decode((string)$this->_response?->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * The quoted parameters of the response's WWW-Authenticate challenge. + * + * @return array + */ + protected function challenge(): array + { + $header = (string)$this->_response?->getHeaderLine('WWW-Authenticate'); + preg_match_all('/(\w+)="((?:[^"\\\\]|\\\\.)*)"/', $header, $matches, PREG_SET_ORDER); + $params = []; + foreach ($matches as $match) { + $params[$match[1]] = stripslashes($match[2]); + } + + return $params; + } +} diff --git a/tests/Support/OAuthFixtures.php b/tests/Support/OAuthFixtures.php new file mode 100644 index 0000000..f691fd4 --- /dev/null +++ b/tests/Support/OAuthFixtures.php @@ -0,0 +1,18 @@ +get('/.well-known/oauth-authorization-server'); + + $this->assertResponseOk(); + $metadata = json_decode((string)$this->_response->getBody(), true); + $this->assertSame('http://localhost', $metadata['issuer'] ?? null); + } + + public function testTokensCanBeIssuedForActiveTestUsers(): void + { + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $this->assertStringStartsWith('test_', $token); + } +} diff --git a/tests/bootstrap_suite.php b/tests/bootstrap_suite.php new file mode 100644 index 0000000..1408b66 --- /dev/null +++ b/tests/bootstrap_suite.php @@ -0,0 +1,37 @@ + 'sqlite:///' . $sqliteFile, 'timezone' => 'UTC']); +ConnectionManager::alias('test', 'default'); + +Configure::write('OAuthServer', OAuthTestConfig::SERVER); + +Plugin::getCollection()->add(new OAuthServerPlugin()); +(new Migrator())->runMany([['plugin' => 'CakeDC/OAuthServer'], []]); diff --git a/tests/test_app/config/Migrations/20260101000000_CreateUsers.php b/tests/test_app/config/Migrations/20260101000000_CreateUsers.php new file mode 100644 index 0000000..e121eed --- /dev/null +++ b/tests/test_app/config/Migrations/20260101000000_CreateUsers.php @@ -0,0 +1,27 @@ +table('users') + ->addColumn('name', 'string', ['limit' => 100]) + ->addColumn('email', 'string', ['limit' => 100]) + ->addColumn('role', 'string', ['limit' => 20]) + ->addColumn('active', 'boolean', ['default' => true]) + ->create(); + } + + public function down(): void + { + $this->table('users')->drop()->save(); + } +} diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index abd486c..e09cdc9 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -8,6 +8,8 @@ use Authentication\AuthenticationServiceInterface; use Authentication\AuthenticationServiceProviderInterface; use Authentication\Middleware\AuthenticationMiddleware; +use Cake\Core\Configure; +use Cake\Core\Plugin; use Cake\Error\Middleware\ErrorHandlerMiddleware; use Cake\Http\BaseApplication; use Cake\Http\Middleware\BodyParserMiddleware; @@ -17,12 +19,15 @@ use Cake\Routing\Middleware\RoutingMiddleware; use Cake\Routing\RouteBuilder; use CakeDC\Mcp\McpPlugin; +use CakeDC\OAuthServer\OAuthServer; +use CakeDC\OAuthServer\OAuthServerPlugin; use Psr\Http\Message\ServerRequestInterface; use TestApp\Middleware\AuthorizationSpyMiddleware; /** * Minimal host app: the middleware queue a real Cake 5 app would have, plus a - * Bearer token authenticator and catch-all fallback routes (to prove the MCP + * Bearer token authenticator, a session login, optionally the authorization + * server of the oauth plugin, and catch-all fallback routes (to prove the MCP * route still wins). */ class Application extends BaseApplication implements AuthenticationServiceProviderInterface @@ -33,6 +38,10 @@ class Application extends BaseApplication implements AuthenticationServiceProvid public function bootstrap(): void { $this->addPlugin(McpPlugin::class); + if (Configure::check('OAuthServer.resources')) { + // The PHPUnit suite: the authorization server of the same application (Mcp.auth.mode oauth). + $this->addPlugin(OAuthServerPlugin::class); + } } /** @@ -42,7 +51,8 @@ public function bootstrap(): void public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue { $csrf = new CsrfProtectionMiddleware(); - $csrf->skipCheckCallback(fn(ServerRequest $request): bool => $request->getPath() === '/mcp'); + $csrf->skipCheckCallback(fn(ServerRequest $request): bool => $request->getPath() === '/mcp' + || (Plugin::isLoaded('CakeDC/OAuthServer') && OAuthServer::csrfSkip($request))); return $middlewareQueue ->add(new ErrorHandlerMiddleware()) @@ -71,6 +81,10 @@ public function routes(RouteBuilder $routes): void public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface { $service = new AuthenticationService(); + if (Configure::read('TestApp.oauthTokenAuthenticator') === true) { + // A host that also protects an API with the oauth plugin's Token authenticator (mcp check test). + $service->loadAuthenticator('CakeDC/OAuthServer.Token', ['resource' => OAuthTestConfig::API]); + } $service->loadAuthenticator('Authentication.Token', [ 'header' => 'Authorization', 'tokenPrefix' => 'Bearer', @@ -80,6 +94,8 @@ public function getAuthenticationService(ServerRequestInterface $request): Authe ], ], ]); + // The browser login the oauth plugin's consent page needs. + $service->loadAuthenticator('Authentication.Session', ['sessionKey' => 'Auth']); return $service; } diff --git a/tests/test_app/src/Model/Table/UsersTable.php b/tests/test_app/src/Model/Table/UsersTable.php new file mode 100644 index 0000000..4cc5d35 --- /dev/null +++ b/tests/test_app/src/Model/Table/UsersTable.php @@ -0,0 +1,22 @@ +where([$this->aliasField('active') => true]); + } +} diff --git a/tests/test_app/src/OAuthTestConfig.php b/tests/test_app/src/OAuthTestConfig.php new file mode 100644 index 0000000..8143a4b --- /dev/null +++ b/tests/test_app/src/OAuthTestConfig.php @@ -0,0 +1,58 @@ + + */ + public const SERVER = [ + 'resources' => [ + self::MCP => [ + 'name' => 'Test MCP', + 'scopes' => [ + 'notes:read' => 'Read your notes', + 'notes:write' => 'Write notes', + 'admin' => 'Administer the application', + ], + 'implies' => ['notes:write' => ['notes:read']], + 'defaultScopes' => ['notes:read'], + ], + self::API => [ + 'name' => 'Test API', + 'scopes' => ['api' => 'Use the API'], + 'defaultScopes' => ['api'], + ], + ], + 'tokenPrefix' => 'test', + 'userProvider' => ['table' => 'Users', 'finder' => 'active'], + 'cimd' => ['enabled' => false], + // Rate limiting would count across the tests of one process. + 'rateLimit' => ['cache' => null], + ]; + + /** + * `Mcp.auth` of the oauth mode tests: every tool call needs `notes:read`, `create_note` also + * `notes:write`, `admin_only` also `admin`. + * + * @var array + */ + public const MCP_AUTH = [ + 'mode' => 'oauth', + 'scopes' => [ + 'methods' => ['tools/call' => ['notes:read']], + 'tools' => ['create_note' => ['notes:write'], 'admin_only' => ['admin']], + ], + ]; +} From aa060eb296638fdc6133c9850d4973316d2ab925 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 15:34:06 +0200 Subject: [PATCH 31/49] Add the Mcp.auth settings --- src/McpConfig.php | 18 ++ src/OAuth/AuthSettings.php | 268 ++++++++++++++++++++++ src/OAuth/ExternalSettings.php | 118 ++++++++++ tests/TestCase/OAuth/AuthSettingsTest.php | 239 +++++++++++++++++++ 4 files changed, 643 insertions(+) create mode 100644 src/OAuth/AuthSettings.php create mode 100644 src/OAuth/ExternalSettings.php create mode 100644 tests/TestCase/OAuth/AuthSettingsTest.php diff --git a/src/McpConfig.php b/src/McpConfig.php index 14a9e3d..703ab73 100644 --- a/src/McpConfig.php +++ b/src/McpConfig.php @@ -35,6 +35,24 @@ final class McpConfig 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], 'identity' => ['required' => true, 'attribute' => 'identity', 'scopes' => null], 'authorization' => ['authorizer' => AllowAllToolAuthorizer::class, 'rbac' => []], + 'auth' => [ + 'mode' => 'host', + 'resource' => null, + 'resourceName' => null, + 'resourceDocumentation' => null, + 'scopes' => ['default' => [], 'methods' => [], 'tools' => [], 'implies' => []], + 'identity' => null, + 'external' => [ + 'issuer' => null, + 'audience' => null, + 'algorithms' => ['RS256'], + 'scopeClaim' => 'scope', + 'tokenType' => null, + 'leeway' => 0, + 'cache' => 'mcp_oauth', + 'httpClient' => null, + ], + ], 'stdio' => ['identity' => null, 'trusted' => true], ]; diff --git a/src/OAuth/AuthSettings.php b/src/OAuth/AuthSettings.php new file mode 100644 index 0000000..64c679b --- /dev/null +++ b/src/OAuth/AuthSettings.php @@ -0,0 +1,268 @@ + $defaultScopes Scopes every MCP request needs. + * @param array> $methodScopes Scopes per JSON-RPC method. + * @param array> $toolScopes Scopes per tool. + * @param array> $implies Scope hierarchy: a key also grants its scopes. + * @param \Closure|null $identity `fn(AccessToken $token, mixed $identity): mixed`. + * @param \CakeDC\Mcp\OAuth\ExternalSettings|null $external The external mode settings. + */ + private function __construct( + public readonly string $mode, + public readonly string $resource, + public readonly ?string $resourceName, + public readonly ?string $resourceDocumentation, + public readonly array $defaultScopes, + public readonly array $methodScopes, + public readonly array $toolScopes, + public readonly array $implies, + public readonly ?Closure $identity, + public readonly ?ExternalSettings $external, + ) { + } + + /** + * Reads and validates `Mcp.auth`. + * + * @return self + * @throws \InvalidArgumentException For an invalid setting, named in the message. + */ + public static function fromConfig(): self + { + $auth = McpConfig::get('auth'); + $auth = is_array($auth) ? $auth : []; + $defaults = McpConfig::DEFAULTS['auth']; + self::assertKeys($auth, $defaults, 'Mcp.auth'); + + $mode = $auth['mode'] ?? self::HOST; + if (!in_array($mode, [self::HOST, self::OAUTH, self::EXTERNAL], true)) { + throw new InvalidArgumentException(sprintf( + '"Mcp.auth.mode" must be "host", "oauth" or "external", got %s.', + is_scalar($mode) ? '"' . $mode . '"' : get_debug_type($mode), + )); + } + $scopes = is_array($auth['scopes'] ?? null) ? $auth['scopes'] : []; + self::assertKeys($scopes, $defaults['scopes'], 'Mcp.auth.scopes'); + $external = is_array($auth['external'] ?? null) ? $auth['external'] : []; + self::assertKeys($external, $defaults['external'], 'Mcp.auth.external'); + + $identity = $auth['identity'] ?? null; + if ($identity !== null && !is_callable($identity)) { + throw new InvalidArgumentException('"Mcp.auth.identity" must be a callable or null.'); + } + $resource = $mode === self::HOST ? '' : self::resource($auth['resource'] ?? null); + + return new self( + $mode, + $resource, + self::optionalString($auth['resourceName'] ?? null, 'Mcp.auth.resourceName'), + self::optionalString($auth['resourceDocumentation'] ?? null, 'Mcp.auth.resourceDocumentation'), + self::scopeList($scopes['default'] ?? [], 'Mcp.auth.scopes.default'), + self::scopeMap($scopes['methods'] ?? [], 'Mcp.auth.scopes.methods'), + self::scopeMap($scopes['tools'] ?? [], 'Mcp.auth.scopes.tools'), + self::scopeMap($scopes['implies'] ?? [], 'Mcp.auth.scopes.implies'), + $identity === null ? null : Closure::fromCallable($identity), + $mode === self::EXTERNAL ? ExternalSettings::fromArray($external, $resource) : null, + ); + } + + /** + * The SDK scope policy, or null when no request needs a scope. + * + * @return \Mcp\Server\Transport\Http\OAuth\ScopePolicy|null + */ + public function scopePolicy(): ?ScopePolicy + { + if ($this->defaultScopes === [] && $this->methodScopes === [] && $this->toolScopes === []) { + return null; + } + + return new ScopePolicy($this->defaultScopes, $this->methodScopes, $this->toolScopes, $this->implies); + } + + /** + * Every scope the settings name, once. + * + * @return list + */ + public function configuredScopes(): array + { + $scopes = $this->defaultScopes; + foreach ([$this->methodScopes, $this->toolScopes, $this->implies] as $map) { + foreach ($map as $values) { + array_push($scopes, ...$values); + } + } + foreach (array_keys($this->implies) as $scope) { + $scopes[] = (string)$scope; + } + + return array_values(array_unique($scopes)); + } + + /** + * $url with a lowercase scheme and host, without the default port or a trailing slash. Only https, + * or plain http for a loopback host; no fragment and no credentials. + * + * @param string $url The URL. + * @param string $key The setting it comes from, for the error message. + * @return string + * @throws \InvalidArgumentException + */ + public static function canonicalUrl(string $url, string $key): string + { + $parts = parse_url($url); + if ($parts === false || !isset($parts['scheme'], $parts['host'])) { + throw new InvalidArgumentException(sprintf('%s must be an absolute URL, got "%s".', $key, $url)); + } + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host']); + $loopback = in_array(trim($host, '[]'), ['localhost', '127.0.0.1', '::1'], true); + if ($scheme !== 'https' && !($scheme === 'http' && $loopback)) { + throw new InvalidArgumentException(sprintf( + '%s must use https (plain http only for localhost), got "%s".', + $key, + $url, + )); + } + if (isset($parts['fragment']) || isset($parts['user']) || isset($parts['pass'])) { + throw new InvalidArgumentException(sprintf( + '%s must not contain a fragment or credentials, got "%s".', + $key, + $url, + )); + } + $port = $parts['port'] ?? null; + if (($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80)) { + $port = null; + } + + return $scheme . '://' . $host . ($port === null ? '' : ':' . $port) + . rtrim($parts['path'] ?? '', '/') + . (isset($parts['query']) ? '?' . $parts['query'] : ''); + } + + /** + * The canonical resource URL: `Mcp.auth.resource`, or the base URL, base path and `Mcp.route`. + * + * @param mixed $configured `Mcp.auth.resource`. + * @return string + */ + private static function resource(mixed $configured): string + { + if ($configured !== null) { + if (!is_string($configured)) { + throw new InvalidArgumentException('"Mcp.auth.resource" must be a URL or null.'); + } + + return self::canonicalUrl($configured, '"Mcp.auth.resource"'); + } + $base = Configure::read('App.base'); + $route = McpConfig::get('route'); + + return self::canonicalUrl( + rtrim((string)Configure::read('App.fullBaseUrl'), '/') + . (is_string($base) ? rtrim($base, '/') : '') + . (is_string($route) ? $route : ''), + '"Mcp.auth.resource" (derived from App.fullBaseUrl, App.base and Mcp.route)', + ); + } + + /** + * @param array $values The values. + * @param array $defaults Their defaults, whose keys are the known ones. + * @param string $path The Configure path, for the error message. + * @return void + */ + private static function assertKeys(array $values, array $defaults, string $path): void + { + foreach (array_keys($values) as $key) { + if (!array_key_exists($key, $defaults)) { + throw new InvalidArgumentException(sprintf('Unknown key "%s.%s".', $path, $key)); + } + } + } + + /** + * @param mixed $value The value. + * @param string $key The Configure path. + * @return string|null + */ + private static function optionalString(mixed $value, string $key): ?string + { + if ($value === null || (is_string($value) && $value !== '')) { + return $value; + } + + throw new InvalidArgumentException(sprintf('"%s" must be a non-empty string or null.', $key)); + } + + /** + * @param mixed $value The value. + * @param string $key The Configure path. + * @return list + */ + private static function scopeList(mixed $value, string $key): array + { + if (!is_array($value) || !array_is_list($value)) { + throw new InvalidArgumentException(sprintf('"%s" must be a list of scope names.', $key)); + } + foreach ($value as $scope) { + if (!is_string($scope) || preg_match('/^[\x21\x23-\x5B\x5D-\x7E]+$/', $scope) !== 1) { + throw new InvalidArgumentException(sprintf( + '"%s" must be a list of scope names, %s is not one.', + $key, + is_string($scope) ? '"' . $scope . '"' : get_debug_type($scope), + )); + } + } + + return array_values(array_unique($value)); + } + + /** + * @param mixed $value The value. + * @param string $key The Configure path. + * @return array> + */ + private static function scopeMap(mixed $value, string $key): array + { + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('"%s" must map names to lists of scopes.', $key)); + } + $map = []; + foreach ($value as $name => $scopes) { + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException(sprintf('"%s" must map names to lists of scopes.', $key)); + } + $map[$name] = self::scopeList($scopes, $key . '.' . $name); + } + + return $map; + } +} diff --git a/src/OAuth/ExternalSettings.php b/src/OAuth/ExternalSettings.php new file mode 100644 index 0000000..19bee74 --- /dev/null +++ b/src/OAuth/ExternalSettings.php @@ -0,0 +1,118 @@ + $audiences Accepted `aud` values. + * @param list $algorithms Accepted signature algorithms. + * @param string $scopeClaim The claim holding the scopes. + * @param string|null $tokenType Required `typ` header, if any. + * @param int $leeway Clock skew tolerated, in seconds. + * @param string $cache The Cake cache config for discovery metadata and keys. + * @param \Psr\Http\Client\ClientInterface|null $httpClient The HTTP client; Cake's when null. + */ + private function __construct( + public readonly string $issuer, + public readonly array $audiences, + public readonly array $algorithms, + public readonly string $scopeClaim, + public readonly ?string $tokenType, + public readonly int $leeway, + public readonly string $cache, + public readonly ?ClientInterface $httpClient, + ) { + } + + /** + * @param array $settings `Mcp.auth.external`, merged over its defaults. + * @param string $resource The canonical resource URL, the default audience. + * @return self + * @throws \InvalidArgumentException For an invalid setting, named in the message. + */ + public static function fromArray(array $settings, string $resource): self + { + $issuer = $settings['issuer'] ?? null; + if (!is_string($issuer) || $issuer === '') { + throw new InvalidArgumentException( + '"Mcp.auth.external.issuer" is required in external mode: the issuer URL of your authorization server.', + ); + } + // Validated like a resource URL, but kept verbatim: it must equal the `iss` claim byte for byte. + AuthSettings::canonicalUrl($issuer, '"Mcp.auth.external.issuer"'); + + $audience = $settings['audience'] ?? null; + $audiences = match (true) { + $audience === null => [$resource], + is_string($audience) && $audience !== '' => [$audience], + is_array($audience) && $audience !== [] && array_is_list($audience) + && array_filter($audience, fn(mixed $value): bool => !is_string($value) || $value === '') === [] + => array_values(array_unique($audience)), + default => throw new InvalidArgumentException( + '"Mcp.auth.external.audience" must be a string, a non-empty list of strings or null.', + ), + }; + + $algorithms = $settings['algorithms'] ?? ['RS256']; + if ( + !is_array($algorithms) || $algorithms === [] || !array_is_list($algorithms) + || array_diff($algorithms, self::ALGORITHMS) !== [] + ) { + throw new InvalidArgumentException(sprintf( + '"Mcp.auth.external.algorithms" must be a non-empty list of: %s.', + implode(', ', self::ALGORITHMS), + )); + } + + $scopeClaim = $settings['scopeClaim'] ?? 'scope'; + if (!is_string($scopeClaim) || $scopeClaim === '') { + throw new InvalidArgumentException('"Mcp.auth.external.scopeClaim" must be a non-empty string.'); + } + $tokenType = $settings['tokenType'] ?? null; + if ($tokenType !== null && (!is_string($tokenType) || $tokenType === '')) { + throw new InvalidArgumentException('"Mcp.auth.external.tokenType" must be a non-empty string or null.'); + } + $leeway = $settings['leeway'] ?? 0; + if (!is_int($leeway) || $leeway < 0 || $leeway > 300) { + throw new InvalidArgumentException('"Mcp.auth.external.leeway" must be an integer from 0 to 300.'); + } + $cache = $settings['cache'] ?? 'mcp_oauth'; + if (!is_string($cache) || $cache === '') { + throw new InvalidArgumentException('"Mcp.auth.external.cache" must name a Cake cache config.'); + } + $client = $settings['httpClient'] ?? null; + if ($client !== null && !$client instanceof ClientInterface) { + throw new InvalidArgumentException( + '"Mcp.auth.external.httpClient" must be a PSR-18 client (Psr\Http\Client\ClientInterface) or null.', + ); + } + + return new self( + $issuer, + $audiences, + array_values(array_unique($algorithms)), + $scopeClaim, + $tokenType, + $leeway, + $cache, + $client, + ); + } +} diff --git a/tests/TestCase/OAuth/AuthSettingsTest.php b/tests/TestCase/OAuth/AuthSettingsTest.php new file mode 100644 index 0000000..166e23a --- /dev/null +++ b/tests/TestCase/OAuth/AuthSettingsTest.php @@ -0,0 +1,239 @@ +assertSame(AuthSettings::HOST, $settings->mode); + $this->assertSame('', $settings->resource); + $this->assertNull($settings->scopePolicy()); + $this->assertNull($settings->external); + } + + public function testHostModeDoesNotNeedAnHttpsBaseUrl(): void + { + Configure::write('App.fullBaseUrl', 'http://intranet.example'); + + $this->assertSame(AuthSettings::HOST, AuthSettings::fromConfig()->mode); + } + + public function testUnknownModeIsRejected(): void + { + Configure::write('Mcp.auth.mode', 'session'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"Mcp.auth.mode"'); + AuthSettings::fromConfig(); + } + + public function testUnknownKeysAreRejected(): void + { + foreach ( + [ + ['Mcp.auth.scope', ['default' => ['x']], 'Mcp.auth.scope'], + ['Mcp.auth.scopes.tool', ['a' => ['b']], 'Mcp.auth.scopes.tool'], + ['Mcp.auth.external.issuerUrl', 'https://x.example', 'Mcp.auth.external.issuerUrl'], + ] as [$path, $value, $named] + ) { + Configure::write('Mcp', ['auth' => ['mode' => 'oauth']]); + Configure::write($path, $value); + try { + AuthSettings::fromConfig(); + $this->fail('No exception for ' . $path); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"' . $named . '"', $e->getMessage()); + } + } + } + + public function testResourceDerivesFromTheBaseUrlAndTheRoute(): void + { + Configure::write('App.fullBaseUrl', 'HTTPS://Example.COM:443'); + Configure::write('Mcp.route', '/mcp'); + Configure::write('Mcp.auth.mode', 'oauth'); + + $this->assertSame('https://example.com/mcp', AuthSettings::fromConfig()->resource); + } + + public function testResourceKeepsTheBasePathOfASubdirectoryInstall(): void + { + Configure::write('App.fullBaseUrl', 'https://example.com'); + Configure::write('App.base', '/app'); + Configure::write('Mcp.auth.mode', 'oauth'); + + $this->assertSame('https://example.com/app/mcp', AuthSettings::fromConfig()->resource); + } + + public function testExplicitResourceIsCanonicalised(): void + { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'resource' => 'https://MCP.example.com:8443/mcp/']); + + $this->assertSame('https://mcp.example.com:8443/mcp', AuthSettings::fromConfig()->resource); + } + + public function testPlainHttpIsOnlyAcceptedForLoopbackHosts(): void + { + Configure::write('Mcp.auth.mode', 'oauth'); + Configure::write('App.fullBaseUrl', 'http://localhost:8765'); + $this->assertSame('http://localhost:8765/mcp', AuthSettings::fromConfig()->resource); + + Configure::write('App.fullBaseUrl', 'http://example.com'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('App.fullBaseUrl'); + AuthSettings::fromConfig(); + } + + public function testResourceWithAFragmentOrCredentialsIsRejected(): void + { + foreach (['https://example.com/mcp#x', 'https://user:pass@example.com/mcp'] as $resource) { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'resource' => $resource]); + try { + AuthSettings::fromConfig(); + $this->fail('Accepted ' . $resource); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"Mcp.auth.resource"', $e->getMessage()); + } + } + } + + public function testScopesMustBeListsOfScopeNames(): void + { + foreach ( + [ + ['default' => 'notes:read'], + ['default' => ['notes read']], + ['methods' => ['tools/call' => 'notes:read']], + ['tools' => [['notes:read']]], + ['implies' => ['notes:write' => [42]]], + ] as $scopes + ) { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'scopes' => $scopes]); + try { + AuthSettings::fromConfig(); + $this->fail('Accepted ' . json_encode($scopes)); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"Mcp.auth.scopes.', $e->getMessage()); + } + } + } + + public function testScopePolicyCombinesDefaultMethodAndToolScopes(): void + { + Configure::write('Mcp.auth', [ + 'mode' => 'oauth', + 'scopes' => [ + 'default' => ['mcp'], + 'methods' => ['tools/call' => ['notes:read']], + 'tools' => ['create_note' => ['notes:write']], + 'implies' => ['notes:write' => ['notes:read']], + ], + ]); + $settings = AuthSettings::fromConfig(); + $policy = $settings->scopePolicy(); + + $this->assertNotNull($policy); + $this->assertEqualsCanonicalizing( + ['mcp', 'notes:read', 'notes:write'], + $policy->requiredFor([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'create_note'], + ]), + ); + $this->assertEqualsCanonicalizing(['mcp', 'notes:read', 'notes:write'], $settings->configuredScopes()); + } + + public function testIdentityMustBeCallable(): void + { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'identity' => 'not a function']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"Mcp.auth.identity"'); + AuthSettings::fromConfig(); + } + + public function testIdentityCallableBecomesAClosure(): void + { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'identity' => 'strtoupper']); + + $this->assertNotNull(AuthSettings::fromConfig()->identity); + } + + public function testExternalModeNeedsAnHttpsIssuer(): void + { + Configure::write('Mcp.auth', ['mode' => 'external']); + try { + AuthSettings::fromConfig(); + $this->fail('No issuer accepted'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"Mcp.auth.external.issuer"', $e->getMessage()); + } + + Configure::write('Mcp.auth.external.issuer', 'http://issuer.example'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"Mcp.auth.external.issuer"'); + AuthSettings::fromConfig(); + } + + public function testExternalSettingsDefaults(): void + { + Configure::write('App.fullBaseUrl', 'https://example.com'); + Configure::write('Mcp.auth', ['mode' => 'external', 'external' => ['issuer' => 'https://tenant.auth0.com/']]); + $external = AuthSettings::fromConfig()->external; + + $this->assertNotNull($external); + // The issuer is compared verbatim with the `iss` claim, so it is kept as configured. + $this->assertSame('https://tenant.auth0.com/', $external->issuer); + $this->assertSame(['https://example.com/mcp'], $external->audiences); + $this->assertSame(['RS256'], $external->algorithms); + $this->assertSame('scope', $external->scopeClaim); + $this->assertSame('mcp_oauth', $external->cache); + $this->assertNull($external->httpClient); + } + + public function testExternalAlgorithmsAreAsymmetricOnly(): void + { + foreach (['none', 'HS256'] as $algorithm) { + Configure::write('Mcp.auth', ['mode' => 'external', 'external' => [ + 'issuer' => 'https://issuer.example', + 'algorithms' => [$algorithm], + ]]); + try { + AuthSettings::fromConfig(); + $this->fail('Accepted ' . $algorithm); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"Mcp.auth.external.algorithms"', $e->getMessage()); + } + } + } + + public function testExternalHttpClientMustBePsr18(): void + { + Configure::write('Mcp.auth', ['mode' => 'external', 'external' => [ + 'issuer' => 'https://issuer.example', + 'httpClient' => new stdClass(), + ]]); + try { + AuthSettings::fromConfig(); + $this->fail('Accepted a non PSR-18 client'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"Mcp.auth.external.httpClient"', $e->getMessage()); + } + + Configure::write('Mcp.auth.external.httpClient', new Client()); + $this->assertInstanceOf(Client::class, AuthSettings::fromConfig()->external?->httpClient); + } +} From 57fb75d98fe23a22c37840a6527873ffe6f12f69 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 15:36:44 +0200 Subject: [PATCH 32/49] Reject non-array and null Mcp.auth values --- src/OAuth/AuthSettings.php | 26 +++++++--- tests/TestCase/OAuth/AuthSettingsTest.php | 63 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/OAuth/AuthSettings.php b/src/OAuth/AuthSettings.php index 64c679b..3129996 100644 --- a/src/OAuth/AuthSettings.php +++ b/src/OAuth/AuthSettings.php @@ -55,20 +55,30 @@ private function __construct( public static function fromConfig(): self { $auth = McpConfig::get('auth'); - $auth = is_array($auth) ? $auth : []; + // A non-array (null included) is a typo, never a request for the defaults: fail closed. + if (!is_array($auth)) { + throw new InvalidArgumentException('"Mcp.auth" must be an array.'); + } $defaults = McpConfig::DEFAULTS['auth']; self::assertKeys($auth, $defaults, 'Mcp.auth'); - $mode = $auth['mode'] ?? self::HOST; + // The merged defaults always provide every key, so null here is an explicit value and fails the check. + $mode = $auth['mode']; if (!in_array($mode, [self::HOST, self::OAUTH, self::EXTERNAL], true)) { throw new InvalidArgumentException(sprintf( '"Mcp.auth.mode" must be "host", "oauth" or "external", got %s.', is_scalar($mode) ? '"' . $mode . '"' : get_debug_type($mode), )); } - $scopes = is_array($auth['scopes'] ?? null) ? $auth['scopes'] : []; + $scopes = $auth['scopes']; + if (!is_array($scopes)) { + throw new InvalidArgumentException('"Mcp.auth.scopes" must be an array.'); + } self::assertKeys($scopes, $defaults['scopes'], 'Mcp.auth.scopes'); - $external = is_array($auth['external'] ?? null) ? $auth['external'] : []; + $external = $auth['external']; + if (!is_array($external)) { + throw new InvalidArgumentException('"Mcp.auth.external" must be an array.'); + } self::assertKeys($external, $defaults['external'], 'Mcp.auth.external'); $identity = $auth['identity'] ?? null; @@ -82,10 +92,10 @@ public static function fromConfig(): self $resource, self::optionalString($auth['resourceName'] ?? null, 'Mcp.auth.resourceName'), self::optionalString($auth['resourceDocumentation'] ?? null, 'Mcp.auth.resourceDocumentation'), - self::scopeList($scopes['default'] ?? [], 'Mcp.auth.scopes.default'), - self::scopeMap($scopes['methods'] ?? [], 'Mcp.auth.scopes.methods'), - self::scopeMap($scopes['tools'] ?? [], 'Mcp.auth.scopes.tools'), - self::scopeMap($scopes['implies'] ?? [], 'Mcp.auth.scopes.implies'), + self::scopeList($scopes['default'], 'Mcp.auth.scopes.default'), + self::scopeMap($scopes['methods'], 'Mcp.auth.scopes.methods'), + self::scopeMap($scopes['tools'], 'Mcp.auth.scopes.tools'), + self::scopeMap($scopes['implies'], 'Mcp.auth.scopes.implies'), $identity === null ? null : Closure::fromCallable($identity), $mode === self::EXTERNAL ? ExternalSettings::fromArray($external, $resource) : null, ); diff --git a/tests/TestCase/OAuth/AuthSettingsTest.php b/tests/TestCase/OAuth/AuthSettingsTest.php index 166e23a..4a09351 100644 --- a/tests/TestCase/OAuth/AuthSettingsTest.php +++ b/tests/TestCase/OAuth/AuthSettingsTest.php @@ -236,4 +236,67 @@ public function testExternalHttpClientMustBePsr18(): void Configure::write('Mcp.auth.external.httpClient', new Client()); $this->assertInstanceOf(Client::class, AuthSettings::fromConfig()->external?->httpClient); } + + public function testEmptyAuthArrayKeepsHostMode(): void + { + Configure::write('Mcp.auth', []); + + $this->assertSame(AuthSettings::HOST, AuthSettings::fromConfig()->mode); + } + + public function testNonArrayAuthFailsClosed(): void + { + foreach (['oauth', null] as $auth) { + Configure::write('Mcp.auth', $auth); + try { + AuthSettings::fromConfig(); + $this->fail('Accepted ' . var_export($auth, true)); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"Mcp.auth"', $e->getMessage()); + } + } + } + + public function testNonArrayScopesOrExternalFailClosed(): void + { + $cases = [ + ['scopes' => 'notes:read', 'named' => '"Mcp.auth.scopes"'], + ['scopes' => null, 'named' => '"Mcp.auth.scopes"'], + ['external' => 'x', 'named' => '"Mcp.auth.external"'], + ['external' => null, 'named' => '"Mcp.auth.external"'], + ]; + foreach ($cases as $case) { + $named = $case['named']; + unset($case['named']); + Configure::write('Mcp.auth', ['mode' => 'oauth'] + $case); + try { + AuthSettings::fromConfig(); + $this->fail('Accepted ' . json_encode($case)); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString($named, $e->getMessage()); + } + } + } + + public function testNullScopeMapFailsClosed(): void + { + foreach (['default', 'methods', 'tools', 'implies'] as $key) { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'scopes' => [$key => null]]); + try { + AuthSettings::fromConfig(); + $this->fail('Accepted a null ' . $key); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"Mcp.auth.scopes.' . $key . '"', $e->getMessage()); + } + } + } + + public function testNullModeIsRejected(): void + { + Configure::write('Mcp.auth', ['mode' => null]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"Mcp.auth.mode"'); + AuthSettings::fromConfig(); + } } From 86391d0a8ddb23dce449ac4cbe8c5105d3c5752f Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 15:38:27 +0200 Subject: [PATCH 33/49] Validate MCP access tokens with the oauth plugin of the same app --- src/OAuth/OAuthServerTokenValidator.php | 68 +++++++ src/OAuth/ResourceServer.php | 179 ++++++++++++++++++ .../OAuth/OAuthServerTokenValidatorTest.php | 125 ++++++++++++ tests/TestCase/OAuth/ResourceServerTest.php | 90 +++++++++ 4 files changed, 462 insertions(+) create mode 100644 src/OAuth/OAuthServerTokenValidator.php create mode 100644 src/OAuth/ResourceServer.php create mode 100644 tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php create mode 100644 tests/TestCase/OAuth/ResourceServerTest.php diff --git a/src/OAuth/OAuthServerTokenValidator.php b/src/OAuth/OAuthServerTokenValidator.php new file mode 100644 index 0000000..8530d70 --- /dev/null +++ b/src/OAuth/OAuthServerTokenValidator.php @@ -0,0 +1,68 @@ +validator->validate($accessToken, $this->resource); + if ($result instanceof ValidationFailure) { + return AuthorizationResult::unauthorized($result->error, $result->description); + } + // The claims of the core's MCP SDK bridge. + $token = new AccessToken($result->scopes, array_filter([ + 'sub' => $result->owner->subject, + 'name' => $result->owner->displayName, + 'client_id' => $result->client?->clientId, + 'aud' => $result->resource->identifier, + 'exp' => $result->token->expiresAt?->getTimestamp(), + 'iat' => $result->token->createdAt->getTimestamp(), + 'subject_type' => $result->token->subjectType->value, + ], fn(mixed $value): bool => $value !== null)); + + $identity = $result->owner->identity; + if ($this->identity !== null) { + $identity = ($this->identity)($token, $identity); + } + if ($identity === null) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'The owner of this access token is not known to this application.', + ); + } + $this->current->set($identity); + + return AuthorizationResult::allow($token); + } +} diff --git a/src/OAuth/ResourceServer.php b/src/OAuth/ResourceServer.php new file mode 100644 index 0000000..8fce4af --- /dev/null +++ b/src/OAuth/ResourceServer.php @@ -0,0 +1,179 @@ +resource(), + [$this->issuer()], + $this->scopesSupported(), + $this->settings->resourceName, + $this->settings->resourceDocumentation, + ); + } + + /** + * The SDK middleware: Bearer token, scope policy and the challenges MCP clients follow. + * + * @param \CakeDC\Mcp\Identity\CurrentIdentity $identity Receives the token owner. + * @param \Psr\Http\Message\ResponseFactoryInterface $responseFactory For its responses. + * @param \Psr\Http\Message\StreamFactoryInterface $streamFactory For its bodies. + * @return \Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware + */ + public function middleware( + CurrentIdentity $identity, + ResponseFactoryInterface $responseFactory, + StreamFactoryInterface $streamFactory, + ): AuthorizationMiddleware { + return new AuthorizationMiddleware( + $this->validator($identity), + $this->metadata(), + $this->settings->scopePolicy(), + $responseFactory, + $streamFactory, + ); + } + + /** + * The token validator of the configured mode; it also records the identity in $identity. + * + * @param \CakeDC\Mcp\Identity\CurrentIdentity $identity Receives the token owner. + * @return \Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface + */ + public function validator(CurrentIdentity $identity): AuthorizationTokenValidatorInterface + { + return match ($this->settings->mode) { + AuthSettings::OAUTH => new OAuthServerTokenValidator( + $this->authorizationServer()->tokenValidator(), + $this->resource(), + $identity, + $this->settings->identity, + ), + default => throw new LogicException( + sprintf('The "%s" mode has no token validator.', $this->settings->mode), + ), + }; + } + + /** + * @return string + */ + private function resource(): string + { + return $this->settings->mode === AuthSettings::OAUTH + ? $this->declared()->identifier + : $this->settings->resource; + } + + /** + * @return string + */ + private function issuer(): string + { + if ($this->settings->mode === AuthSettings::OAUTH) { + return $this->authorizationServer()->config()->issuer; + } + + throw new LogicException(sprintf('The "%s" mode has no authorization server.', $this->settings->mode)); + } + + /** + * @return list + */ + private function scopesSupported(): array + { + return $this->settings->mode === AuthSettings::OAUTH + ? $this->declared()->scopeNames() + : $this->settings->configuredScopes(); + } + + /** + * The authorization server of this application, once its resource and scopes are checked. + * + * @return \CakeDC\OAuth\Server\AuthorizationServer + */ + private function authorizationServer(): AuthorizationServer + { + if ($this->authorizationServer !== null) { + return $this->authorizationServer; + } + if (!class_exists(OAuthServer::class)) { + throw new RuntimeException( + '"Mcp.auth.mode" oauth needs cakedc/cakephp-oauth-server: run ' + . '`composer require cakedc/cakephp-oauth-server` and load the CakeDC/OAuthServer plugin.', + ); + } + $server = OAuthServer::server(); + $resources = $server->config()->resources; + $declared = $resources->get($this->settings->resource); + if ($declared === null) { + throw new RuntimeException(sprintf( + 'The MCP resource "%s" (Mcp.auth.resource) is not declared in OAuthServer.resources (declared: %s).', + $this->settings->resource, + implode(', ', array_map( + fn(ProtectedResource $resource): string => $resource->identifier, + $resources->all(), + )), + )); + } + foreach ($this->settings->configuredScopes() as $scope) { + if (!$declared->hasScope($scope)) { + throw new RuntimeException(sprintf( + 'The scope "%s" in Mcp.auth.scopes is not declared by the OAuthServer resource "%s".', + $scope, + $declared->identifier, + )); + } + } + $this->declared = $declared; + + return $this->authorizationServer = $server; + } + + /** + * @return \CakeDC\OAuth\Server\Resource\ProtectedResource + */ + private function declared(): ProtectedResource + { + $this->authorizationServer(); + assert($this->declared !== null); + + return $this->declared; + } +} diff --git a/tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php b/tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php new file mode 100644 index 0000000..649f3e5 --- /dev/null +++ b/tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php @@ -0,0 +1,125 @@ +current = new CurrentIdentity(); + } + + protected function tearDown(): void + { + OAuthServer::reset(); + parent::tearDown(); + } + + private function validator(?callable $identity = null): OAuthServerTokenValidator + { + return new OAuthServerTokenValidator( + OAuthServer::server()->tokenValidator(), + OAuthTestConfig::MCP, + $this->current, + $identity === null ? null : $identity(...), + ); + } + + public function testValidTokenIsAllowedWithItsScopesAndOwner(): void + { + $token = $this->oauthTokenFor(1, ['notes:write'], OAuthTestConfig::MCP); + + $result = $this->validator()->validate($token); + + $this->assertTrue($result->isAllowed()); + $access = $result->getAccessToken(); + $this->assertNotNull($access); + // The core expands what notes:write implies. + $this->assertEqualsCanonicalizing(['notes:write', 'notes:read'], $access->getScopes()); + $this->assertSame('1', $access->getSubject()); + $this->assertSame('user', $access->getClaim('subject_type')); + $this->assertSame(OAuthTestConfig::MCP, $access->getClaim('aud')); + $this->assertSame('Ada', $access->getClaim('name')); + $identity = $this->current->get(); + $this->assertInstanceOf(IdentityInterface::class, $identity); + $this->assertSame(1, $this->current->getIdentifier()); + } + + public function testTokenForAnotherResourceIsRejected(): void + { + $token = $this->oauthTokenFor(1, ['api'], OAuthTestConfig::API); + + $result = $this->validator()->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertStringContainsString('another resource', (string)$result->getErrorDescription()); + $this->assertNull($this->current->get()); + } + + public function testUnknownTokenIsRejected(): void + { + $result = $this->validator()->validate('test_at_' . str_repeat('A', 43)); + + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + } + + public function testDeactivatedOwnerIsRejected(): void + { + $token = $this->oauthTokenFor(2, ['notes:read'], OAuthTestConfig::MCP); + $this->fetchTable('Users')->updateAll(['active' => false], ['id' => 2]); + + $result = $this->validator()->validate($token); + + $this->assertSame(401, $result->getStatusCode()); + $this->assertNull($this->current->get()); + } + + public function testIdentityResolverReceivesTheTokenAndTheOwnerIdentity(): void + { + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + $seen = []; + + $result = $this->validator(function (AccessToken $access, mixed $identity) use (&$seen): array { + $seen = [$access->getSubject(), $identity instanceof IdentityInterface]; + + return ['id' => 'mapped']; + })->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertSame(['1', true], $seen); + $this->assertSame(['id' => 'mapped'], $this->current->get()); + } + + public function testIdentityResolverReturningNullRejectsTheToken(): void + { + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $result = $this->validator(fn(): mixed => null)->validate($token); + + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertNull($this->current->get()); + } +} diff --git a/tests/TestCase/OAuth/ResourceServerTest.php b/tests/TestCase/OAuth/ResourceServerTest.php new file mode 100644 index 0000000..7d1c6dd --- /dev/null +++ b/tests/TestCase/OAuth/ResourceServerTest.php @@ -0,0 +1,90 @@ +server()->metadata(); + + $this->assertSame(OAuthTestConfig::MCP, $metadata->getResource()); + $this->assertSame('http://localhost/.well-known/oauth-protected-resource/mcp', $metadata->getMetadataUrl()); + $document = $metadata->jsonSerialize(); + $this->assertSame(['http://localhost'], $document['authorization_servers']); + $this->assertSame(['notes:read', 'notes:write', 'admin'], $document['scopes_supported']); + $this->assertSame('Notes', $document['resource_name']); + } + + public function testUndeclaredResourceNamesBothSettings(): void + { + Configure::write('Mcp.auth.resource', 'http://localhost/other'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Mcp\.auth\.resource.*OAuthServer\.resources/'); + $this->server()->metadata(); + } + + public function testUndeclaredScopeIsReported(): void + { + Configure::write('Mcp.auth.scopes.tools', ['create_note' => ['notes:wirte']]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"notes:wirte"'); + $this->server()->validator(new CurrentIdentity()); + } + + public function testOAuthModeValidatesWithTheAuthorizationServer(): void + { + $this->assertInstanceOf(OAuthServerTokenValidator::class, $this->server()->validator(new CurrentIdentity())); + $this->assertInstanceOf( + AuthorizationMiddleware::class, + $this->server()->middleware(new CurrentIdentity(), new ResponseFactory(), new StreamFactory()), + ); + } + + public function testHostModeHasNoValidator(): void + { + Configure::write('Mcp.auth', ['mode' => 'host']); + + $this->expectException(LogicException::class); + $this->server()->validator(new CurrentIdentity()); + } +} From 0231683a1914983ac5d31bd7aef351abd92f1786 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 15:45:45 +0200 Subject: [PATCH 34/49] Protect the MCP endpoint with OAuth tokens and serve its resource metadata --- src/Controller/McpController.php | 53 ++-- .../ProtectedResourceController.php | 70 ++++++ .../SkipsHostAuthorizationTrait.php | 22 ++ src/McpConfig.php | 27 ++ src/McpPlugin.php | 10 +- tests/TestCase/Controller/McpOAuthTest.php | 236 ++++++++++++++++++ tests/TestCase/McpConfigTest.php | 18 ++ tests/TestCase/McpPluginTest.php | 3 +- tests/test_app/src/Application.php | 3 +- 9 files changed, 421 insertions(+), 21 deletions(-) create mode 100644 src/Controller/ProtectedResourceController.php create mode 100644 src/Controller/SkipsHostAuthorizationTrait.php create mode 100644 tests/TestCase/Controller/McpOAuthTest.php diff --git a/src/Controller/McpController.php b/src/Controller/McpController.php index 7dbce15..1f44175 100644 --- a/src/Controller/McpController.php +++ b/src/Controller/McpController.php @@ -12,20 +12,27 @@ use CakeDC\Mcp\Log\McpLogger; use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\Middleware\IdentityMiddleware; +use CakeDC\Mcp\OAuth\AuthSettings; +use CakeDC\Mcp\OAuth\ResourceServer; use CakeDC\Mcp\Server\ServerFactory; use Closure; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\StreamableHttpTransport; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Server\MiddlewareInterface; use Throwable; /** * The MCP HTTP endpoint (`Mcp.route`, `/mcp` by default). Hands the request to - * the SDK's Streamable HTTP transport and returns its response. + * the SDK's Streamable HTTP transport and returns its response. In host mode the identity comes from the + * host's authentication; in `oauth` and `external` modes from the access token (the host's identity + * attribute is ignored). */ class McpController extends Controller { + use SkipsHostAuthorizationTrait; + /** * @param \CakeDC\Mcp\Server\ServerFactory $factory Builds the server. * @param \CakeDC\Mcp\Identity\CurrentIdentity $identity Shared with the tools. @@ -34,12 +41,13 @@ class McpController extends Controller public function handle(ServerFactory $factory, CurrentIdentity $identity): Response { $this->skipHostAuthorization(); + // A long-running worker reuses the container: no request starts with the previous one's identity. + $identity->set(null); $responseFactory = new ResponseFactory(); $streamFactory = new StreamFactory(); try { $origins = array_values(array_filter((array)McpConfig::get('http.allowedOrigins'), 'is_string')); - $scopes = McpConfig::get('identity.scopes'); $transport = new StreamableHttpTransport( $this->request, $responseFactory, @@ -53,14 +61,7 @@ public function handle(ServerFactory $factory, CurrentIdentity $identity): Respo $responseFactory, $streamFactory, ), - new IdentityMiddleware( - $identity, - $responseFactory, - $streamFactory, - McpConfig::get('identity.required') !== false, - (string)McpConfig::get('identity.attribute'), - is_callable($scopes) ? Closure::fromCallable($scopes) : null, - ), + $this->authentication($identity, $responseFactory, $streamFactory), ], ); $response = $factory->create()->run($transport); @@ -74,17 +75,33 @@ public function handle(ServerFactory $factory, CurrentIdentity $identity): Respo } /** - * cakephp/authorization (when the host uses it) would otherwise demand an - * authorization check for this action. Per-tool authorization is ours. + * The middleware that identifies the caller: the host's identity in host mode, an OAuth access + * token otherwise. Invalid settings throw, so the endpoint fails closed. * - * @return void + * @param \CakeDC\Mcp\Identity\CurrentIdentity $identity Shared with the tools. + * @param \Cake\Http\ResponseFactory $responseFactory For its responses. + * @param \Cake\Http\StreamFactory $streamFactory For their bodies. + * @return \Psr\Http\Server\MiddlewareInterface */ - private function skipHostAuthorization(): void - { - $authorization = $this->request->getAttribute('authorization'); - if (is_object($authorization) && method_exists($authorization, 'skipAuthorization')) { - $authorization->skipAuthorization(); + private function authentication( + CurrentIdentity $identity, + ResponseFactory $responseFactory, + StreamFactory $streamFactory, + ): MiddlewareInterface { + $settings = AuthSettings::fromConfig(); + if ($settings->mode !== AuthSettings::HOST) { + return (new ResourceServer($settings))->middleware($identity, $responseFactory, $streamFactory); } + $scopes = McpConfig::get('identity.scopes'); + + return new IdentityMiddleware( + $identity, + $responseFactory, + $streamFactory, + McpConfig::get('identity.required') !== false, + (string)McpConfig::get('identity.attribute'), + is_callable($scopes) ? Closure::fromCallable($scopes) : null, + ); } /** diff --git a/src/Controller/ProtectedResourceController.php b/src/Controller/ProtectedResourceController.php new file mode 100644 index 0000000..6d87046 --- /dev/null +++ b/src/Controller/ProtectedResourceController.php @@ -0,0 +1,70 @@ + '*', + 'Access-Control-Allow-Methods' => 'GET, OPTIONS', + 'Access-Control-Allow-Headers' => 'Accept, MCP-Protocol-Version', + 'Access-Control-Max-Age' => '86400', + ]; + + /** + * @return \Cake\Http\Response + */ + public function metadata(): Response + { + $this->skipHostAuthorization(); + $response = $this->response; + foreach (self::CORS as $name => $value) { + $response = $response->withHeader($name, $value); + } + if ($this->request->is('options')) { + return $response->withStatus(204); + } + + try { + $settings = AuthSettings::fromConfig(); + if ($settings->mode === AuthSettings::HOST) { + return $response->withStatus(404) + ->withType('application/json') + ->withStringBody((string)json_encode(['error' => 'not_found'])); + } + $body = json_encode( + (new ResourceServer($settings))->metadata(), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, + ); + } catch (Throwable $exception) { + Log::error( + sprintf("MCP protected resource metadata failed.\n%s", $exception), + ['scope' => [McpLogger::SCOPE]], + ); + + return $response->withStatus(500) + ->withType('application/json') + ->withStringBody((string)json_encode(['error' => 'server_error'])); + } + + return $response->withType('application/json') + ->withHeader('Cache-Control', 'public, max-age=300') + ->withStringBody($body); + } +} diff --git a/src/Controller/SkipsHostAuthorizationTrait.php b/src/Controller/SkipsHostAuthorizationTrait.php new file mode 100644 index 0000000..3900f91 --- /dev/null +++ b/src/Controller/SkipsHostAuthorizationTrait.php @@ -0,0 +1,22 @@ +request->getAttribute('authorization'); + if (is_object($authorization) && method_exists($authorization, 'skipAuthorization')) { + $authorization->skipAuthorization(); + } + } +} diff --git a/src/McpConfig.php b/src/McpConfig.php index 703ab73..67362b7 100644 --- a/src/McpConfig.php +++ b/src/McpConfig.php @@ -4,8 +4,10 @@ namespace CakeDC\Mcp; use Cake\Core\Configure; +use Cake\Http\ServerRequest; use Cake\Utility\Hash; use CakeDC\Mcp\Authorization\AllowAllToolAuthorizer; +use Psr\Http\Message\ServerRequestInterface; /** * Reads the plugin configuration (`Mcp` in Configure) merged over its defaults. @@ -16,6 +18,11 @@ */ final class McpConfig { + /** + * Path prefix of the protected resource metadata (RFC 9728), followed by the MCP route. + */ + public const METADATA_PREFIX = '/.well-known/oauth-protected-resource'; + /** * @var array */ @@ -136,4 +143,24 @@ private static function merge(array $base, array $override): array return $base; } + + /** + * Whether $request is for the MCP endpoint or its protected resource metadata. Use it to skip + * Cake's CSRF check there, and to keep other bearer-token authenticators off these paths. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @return bool + */ + public static function isEndpoint(ServerRequestInterface $request): bool + { + $route = self::get('route'); + if (!is_string($route) || trim($route, '/') === '') { + return false; + } + $route = '/' . trim($route, '/'); + $path = $request instanceof ServerRequest ? $request->getPath() : $request->getUri()->getPath(); + $path = '/' . trim($path, '/'); + + return $path === $route || $path === self::METADATA_PREFIX . $route; + } } diff --git a/src/McpPlugin.php b/src/McpPlugin.php index 0f1f042..8eb4807 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -44,7 +44,8 @@ public function bootstrap(PluginApplicationInterface $app): void } /** - * Connects the MCP endpoint at `Mcp.route` (nothing when it is false). + * Connects the MCP endpoint at `Mcp.route` (nothing when it is false), and its protected resource + * metadata under `/.well-known/oauth-protected-resource`. * * @param \Cake\Routing\RouteBuilder $routes The route builder. * @return void @@ -58,6 +59,13 @@ public function routes(RouteBuilder $routes): void $routes->connect($path, ['plugin' => 'CakeDC/Mcp', 'controller' => 'Mcp', 'action' => 'handle']) ->setMethods(['GET', 'POST', 'DELETE', 'OPTIONS']); + + // Always connected: routes are cached, and a mode switch must not need a cache clear. + // In host mode the controller answers 404. + $routes->connect( + McpConfig::METADATA_PREFIX . '/' . ltrim($path, '/'), + ['plugin' => 'CakeDC/Mcp', 'controller' => 'ProtectedResource', 'action' => 'metadata'], + )->setMethods(['GET', 'OPTIONS']); } /** diff --git a/tests/TestCase/Controller/McpOAuthTest.php b/tests/TestCase/Controller/McpOAuthTest.php new file mode 100644 index 0000000..efbfd8b --- /dev/null +++ b/tests/TestCase/Controller/McpOAuthTest.php @@ -0,0 +1,236 @@ +listTools(); + + $this->assertResponseCode(401); + $challenge = $this->challenge(); + $this->assertSame(self::METADATA_URL, $challenge['resource_metadata'] ?? null); + $this->assertSame('notes:read notes:write admin', $challenge['scope'] ?? null); + $this->assertArrayNotHasKey('error', $challenge); + } + + public function testValidTokenListsAndCallsTools(): void + { + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $this->listTools($token); + $this->assertResponseOk(); + $this->assertContains('whoami', array_column($this->responseJson()['result']['tools'] ?? [], 'name')); + + $this->callTool('whoami', [], $token); + $this->assertResponseOk(); + $whoami = json_decode((string)($this->responseJson()['result']['content'][0]['text'] ?? ''), true); + $this->assertSame(['identifier' => 1, 'subject' => '1'], $whoami); + } + + public function testToolNeedingAnotherScopeAnswersWithTheScopesToStepUpTo(): void + { + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $this->callTool('create_note', ['title' => 'Groceries'], $token); + + $this->assertResponseCode(403); + $challenge = $this->challenge(); + $this->assertSame('insufficient_scope', $challenge['error'] ?? null); + $this->assertEqualsCanonicalizing(['notes:read', 'notes:write'], explode(' ', $challenge['scope'] ?? '')); + } + + public function testImpliedScopesSatisfyThePolicy(): void + { + $token = $this->oauthTokenFor(1, ['notes:write'], OAuthTestConfig::MCP); + + $this->callTool('create_note', ['title' => 'Groceries'], $token); + + $this->assertResponseOk(); + $this->assertArrayHasKey('result', $this->responseJson()); + } + + public function testTokenForAnotherResourceIsRejected(): void + { + $token = $this->oauthTokenFor(1, ['api'], OAuthTestConfig::API); + + $this->callTool('whoami', [], $token); + + $this->assertResponseCode(401); + $this->assertSame('invalid_token', $this->challenge()['error'] ?? null); + } + + public function testSessionCookieAloneIsChallenged(): void + { + $this->session(['Auth' => ['id' => 1]]); + + $this->callTool('whoami'); + + $this->assertResponseCode(401); + } + + public function testHostTokensAreNotAcceptedInOAuthMode(): void + { + $this->callTool('whoami', [], TestTokens::EDITOR); + + $this->assertResponseCode(401); + $this->assertSame('invalid_token', $this->challenge()['error'] ?? null); + } + + public function testDeactivatedOwnerLosesAccessOnTheNextCall(): void + { + $token = $this->oauthTokenFor(2, ['notes:read'], OAuthTestConfig::MCP); + $this->callTool('whoami', [], $token); + $this->assertResponseOk(); + + $this->fetchTable('Users')->updateAll(['active' => false], ['id' => 2]); + $this->callTool('whoami', [], $token); + + $this->assertResponseCode(401); + } + + public function testCorsPreflightIsNotChallenged(): void + { + $this->_request['headers'] = ['Host' => 'localhost']; + $this->options('/mcp'); + + $this->assertResponseCode(204); + } + + public function testMisconfigurationFailsClosedAndNamesTheSetting(): void + { + Log::setConfig('mcp_oauth_test', ['className' => ArrayLog::class, 'scopes' => ['mcp'], 'levels' => ['error']]); + try { + Configure::write('Mcp.auth.scope', ['default' => ['notes:read']]); + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $this->callTool('whoami', [], $token); + + $this->assertResponseCode(500); + $this->assertArrayNotHasKey('result', $this->responseJson()); + $engine = Log::engine('mcp_oauth_test'); + $this->assertInstanceOf(ArrayLog::class, $engine); + $this->assertStringContainsString('Unknown key "Mcp.auth.scope"', implode("\n", $engine->read())); + } finally { + Log::drop('mcp_oauth_test'); + } + } + + public function testUndeclaredScopeFailsClosed(): void + { + Configure::write('Mcp.auth.scopes.tools', ['create_note' => ['notes:wirte']]); + $token = $this->oauthTokenFor(1, ['notes:write'], OAuthTestConfig::MCP); + + $this->callTool('create_note', ['title' => 'Groceries'], $token); + + $this->assertResponseCode(500); + $this->assertArrayNotHasKey('result', $this->responseJson()); + } + + public function testEveryRequestStartsWithoutAnIdentity(): void + { + $identity = new CurrentIdentity(); + $identity->set(new ArrayObject(['id' => 99])); + $request = new ServerRequest([ + 'url' => '/mcp', + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'HTTP_HOST' => 'localhost', + 'HTTP_ACCEPT' => 'application/json, text/event-stream', + 'CONTENT_TYPE' => 'application/json', + ], + 'input' => (string)json_encode($this->mcpMessage(1, 'tools/list')), + ]); + + $response = (new McpController($request))->handle(new ServerFactory(new Container(), $identity), $identity); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($identity->get()); + } + + public function testMetadataDocument(): void + { + $this->get('/.well-known/oauth-protected-resource/mcp'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + $this->assertHeader('Access-Control-Allow-Origin', '*'); + $this->assertHeaderContains('Cache-Control', 'public'); + $document = json_decode((string)$this->_response->getBody(), true); + $this->assertSame(OAuthTestConfig::MCP, $document['resource'] ?? null); + $this->assertSame(['http://localhost'], $document['authorization_servers'] ?? null); + $this->assertSame(['notes:read', 'notes:write', 'admin'], $document['scopes_supported'] ?? null); + } + + public function testMetadataPreflight(): void + { + $this->options('/.well-known/oauth-protected-resource/mcp'); + + $this->assertResponseCode(204); + $this->assertHeader('Access-Control-Allow-Origin', '*'); + $this->assertHeaderContains('Access-Control-Allow-Methods', 'GET'); + } + + public function testMetadataIsNotFoundInHostMode(): void + { + Configure::write('Mcp.auth', ['mode' => 'host']); + + $this->get('/.well-known/oauth-protected-resource/mcp'); + + $this->assertResponseCode(404); + } + + public function testMetadataMisconfigurationAnswersAServerError(): void + { + Configure::write('Mcp.auth.resource', 'http://localhost/other'); + + $this->get('/.well-known/oauth-protected-resource/mcp'); + + $this->assertResponseCode(500); + $this->assertSame(['error' => 'server_error'], json_decode((string)$this->_response->getBody(), true)); + } +} diff --git a/tests/TestCase/McpConfigTest.php b/tests/TestCase/McpConfigTest.php index 360c5f1..6f9b63d 100644 --- a/tests/TestCase/McpConfigTest.php +++ b/tests/TestCase/McpConfigTest.php @@ -4,6 +4,7 @@ namespace CakeDC\Mcp\Test\TestCase; use Cake\Core\Configure; +use Cake\Http\ServerRequest; use Cake\TestSuite\TestCase; use CakeDC\Mcp\Authorization\AllowAllToolAuthorizer; use CakeDC\Mcp\McpConfig; @@ -83,4 +84,21 @@ public function testEmptyListOverAListDefaultStillReplaces(): void $this->assertSame([], McpConfig::get('discovery.scanDirs')); } + + public function testIsEndpointMatchesTheRouteAndItsMetadata(): void + { + foreach (['/mcp', '/mcp/', '/.well-known/oauth-protected-resource/mcp'] as $path) { + $this->assertTrue(McpConfig::isEndpoint(new ServerRequest(['url' => $path])), $path); + } + foreach (['/mcpx', '/api/mcp', '/', '/.well-known/oauth-protected-resource'] as $path) { + $this->assertFalse(McpConfig::isEndpoint(new ServerRequest(['url' => $path])), $path); + } + } + + public function testIsEndpointIsFalseWhenTheRouteIsDisabled(): void + { + Configure::write('Mcp.route', false); + + $this->assertFalse(McpConfig::isEndpoint(new ServerRequest(['url' => '/mcp']))); + } } diff --git a/tests/TestCase/McpPluginTest.php b/tests/TestCase/McpPluginTest.php index 5cb8b25..58dc454 100644 --- a/tests/TestCase/McpPluginTest.php +++ b/tests/TestCase/McpPluginTest.php @@ -72,8 +72,9 @@ public function testRoutesConnectTheEndpointAtTheConfiguredPath(): void (new McpPlugin())->routes(new RouteBuilder($collection, '/')); - $this->assertCount(1, $collection->routes()); + $this->assertCount(2, $collection->routes()); $this->assertSame('/ai/mcp', $collection->routes()[0]->template); + $this->assertSame('/.well-known/oauth-protected-resource/ai/mcp', $collection->routes()[1]->template); } public function testRoutesAreSkippedWhenDisabled(): void diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index e09cdc9..cb3ef0a 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -18,6 +18,7 @@ use Cake\Http\ServerRequest; use Cake\Routing\Middleware\RoutingMiddleware; use Cake\Routing\RouteBuilder; +use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\McpPlugin; use CakeDC\OAuthServer\OAuthServer; use CakeDC\OAuthServer\OAuthServerPlugin; @@ -51,7 +52,7 @@ public function bootstrap(): void public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue { $csrf = new CsrfProtectionMiddleware(); - $csrf->skipCheckCallback(fn(ServerRequest $request): bool => $request->getPath() === '/mcp' + $csrf->skipCheckCallback(fn(ServerRequest $request): bool => McpConfig::isEndpoint($request) || (Plugin::isLoaded('CakeDC/OAuthServer') && OAuthServer::csrfSkip($request))); return $middlewareQueue From f90810e2ad2554ab88c8c0a8e4337f73693817ca Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 15:54:58 +0200 Subject: [PATCH 35/49] Accept JWT access tokens from an external authorization server --- src/McpPlugin.php | 11 +- src/OAuth/CacheKeyException.php | 14 ++ src/OAuth/CakeCacheItem.php | 112 +++++++++++ src/OAuth/CakeCachePool.php | 185 ++++++++++++++++++ src/OAuth/ExternalTokenValidator.php | 55 ++++++ src/OAuth/ResourceServer.php | 45 +++++ tests/Support/FakeHttpClient.php | 51 +++++ tests/Support/TestJwt.php | 94 +++++++++ tests/TestCase/Controller/McpExternalTest.php | 66 +++++++ tests/TestCase/McpPluginTest.php | 22 +++ tests/TestCase/OAuth/CakeCachePoolTest.php | 99 ++++++++++ .../OAuth/ExternalTokenValidatorTest.php | 119 +++++++++++ 12 files changed, 871 insertions(+), 2 deletions(-) create mode 100644 src/OAuth/CacheKeyException.php create mode 100644 src/OAuth/CakeCacheItem.php create mode 100644 src/OAuth/CakeCachePool.php create mode 100644 src/OAuth/ExternalTokenValidator.php create mode 100644 tests/Support/FakeHttpClient.php create mode 100644 tests/Support/TestJwt.php create mode 100644 tests/TestCase/Controller/McpExternalTest.php create mode 100644 tests/TestCase/OAuth/CakeCachePoolTest.php create mode 100644 tests/TestCase/OAuth/ExternalTokenValidatorTest.php diff --git a/src/McpPlugin.php b/src/McpPlugin.php index 8eb4807..de35477 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -13,6 +13,7 @@ use CakeDC\Mcp\Command\McpInspectorCommand; use CakeDC\Mcp\Command\McpServerCommand; use CakeDC\Mcp\Identity\CurrentIdentity; +use CakeDC\Mcp\OAuth\AuthSettings; use CakeDC\Mcp\Server\ServerFactory; /** @@ -21,7 +22,8 @@ class McpPlugin extends BasePlugin { /** - * Defines the discovery and session cache configs when the host did not. + * Defines the discovery and session cache configs, and in external mode the authorization server's, + * when the host did not. * * @param \Cake\Core\PluginApplicationInterface $app The host application. * @return void @@ -30,7 +32,12 @@ public function bootstrap(PluginApplicationInterface $app): void { parent::bootstrap($app); - foreach ([McpConfig::get('discovery.cache'), McpConfig::get('session.cache')] as $name) { + $caches = [McpConfig::get('discovery.cache'), McpConfig::get('session.cache')]; + if (McpConfig::get('auth.mode') === AuthSettings::EXTERNAL) { + // Discovery metadata and signing keys of the external authorization server. + $caches[] = McpConfig::get('auth.external.cache'); + } + foreach ($caches as $name) { if (!is_string($name) || $name === '' || Cache::getConfig($name) !== null) { continue; } diff --git a/src/OAuth/CacheKeyException.php b/src/OAuth/CacheKeyException.php new file mode 100644 index 0000000..6085fa5 --- /dev/null +++ b/src/OAuth/CacheKeyException.php @@ -0,0 +1,14 @@ +expiresAt = $expiresAt; + } + + /** + * @inheritDoc + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @inheritDoc + */ + public function get(): mixed + { + return $this->value; + } + + /** + * @inheritDoc + */ + public function isHit(): bool + { + return $this->hit; + } + + /** + * @inheritDoc + */ + public function set(mixed $value): static + { + $this->value = $value; + + return $this; + } + + /** + * @inheritDoc + */ + public function expiresAt(?DateTimeInterface $expiration): static + { + $this->expiresAt = $expiration?->getTimestamp(); + + return $this; + } + + /** + * @inheritDoc + */ + public function expiresAfter(DateInterval|int|null $time): static + { + $this->expiresAt = match (true) { + $time === null => null, + is_int($time) => time() + $time, + default => (new DateTimeImmutable())->add($time)->getTimestamp(), + }; + + return $this; + } + + /** + * Seconds left, null for the cache config's duration. + * + * @return int|null + */ + public function ttl(): ?int + { + return $this->expiresAt === null ? null : $this->expiresAt - time(); + } + + /** + * Unix time it expires at, null for the cache config's duration. + * + * @return int|null + */ + public function expiry(): ?int + { + return $this->expiresAt; + } +} diff --git a/src/OAuth/CakeCachePool.php b/src/OAuth/CakeCachePool.php new file mode 100644 index 0000000..74d5bc1 --- /dev/null +++ b/src/OAuth/CakeCachePool.php @@ -0,0 +1,185 @@ + + */ + private array $deferred = []; + + /** + * @param string $config A Cake cache config name. + * @throws \RuntimeException When the config does not exist. + */ + public function __construct(private readonly string $config) + { + if (Cache::getConfig($config) === null) { + throw new RuntimeException(sprintf( + 'The cache config "%s" (Mcp.auth.external.cache) does not exist.', + $config, + )); + } + } + + /** + * Commits deferred items, as PSR-6 allows. + */ + public function __destruct() + { + $this->commit(); + } + + /** + * @inheritDoc + */ + public function getItem(string $key): CacheItemInterface + { + self::assertKey($key); + if (isset($this->deferred[$key])) { + $deferred = $this->deferred[$key]; + + return new CakeCacheItem($key, $deferred->get(), true, $deferred->expiry()); + } + // Stored wrapped, so false and null values are hits too. + $stored = $this->cache()->get($key); + if (is_array($stored) && array_key_exists('value', $stored)) { + return new CakeCacheItem($key, $stored['value'], true); + } + + return new CakeCacheItem($key, null, false); + } + + /** + * @param array $keys The keys. + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->getItem($key); + } + + return $items; + } + + /** + * @inheritDoc + */ + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + /** + * @inheritDoc + */ + public function clear(): bool + { + $this->deferred = []; + + return $this->cache()->clear(); + } + + /** + * @inheritDoc + */ + public function deleteItem(string $key): bool + { + self::assertKey($key); + unset($this->deferred[$key]); + $this->cache()->delete($key); + + return true; + } + + /** + * @inheritDoc + */ + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem($key); + } + + return true; + } + + /** + * @inheritDoc + */ + public function save(CacheItemInterface $item): bool + { + if (!$item instanceof CakeCacheItem) { + return false; + } + $ttl = $item->ttl(); + if ($ttl !== null && $ttl <= 0) { + $this->cache()->delete($item->getKey()); + + return true; + } + + return $this->cache()->set($item->getKey(), ['value' => $item->get()], $ttl); + } + + /** + * @inheritDoc + */ + public function saveDeferred(CacheItemInterface $item): bool + { + if (!$item instanceof CakeCacheItem) { + return false; + } + $this->deferred[$item->getKey()] = $item; + + return true; + } + + /** + * @inheritDoc + */ + public function commit(): bool + { + $saved = true; + foreach ($this->deferred as $item) { + $saved = $this->save($item) && $saved; + } + $this->deferred = []; + + return $saved; + } + + /** + * @return \Psr\SimpleCache\CacheInterface + */ + private function cache(): CacheInterface + { + return Cache::pool($this->config); + } + + /** + * @param string $key The key. + * @return void + * @throws \CakeDC\Mcp\OAuth\CacheKeyException For an empty key or a reserved character. + */ + private static function assertKey(string $key): void + { + if ($key === '' || strpbrk($key, '{}()/\\@:') !== false) { + throw new CacheKeyException(sprintf('"%s" is not a valid cache key.', $key)); + } + } +} diff --git a/src/OAuth/ExternalTokenValidator.php b/src/OAuth/ExternalTokenValidator.php new file mode 100644 index 0000000..3a3d9d1 --- /dev/null +++ b/src/OAuth/ExternalTokenValidator.php @@ -0,0 +1,55 @@ +validator->validate($accessToken); + $token = $result->getAccessToken(); + if (!$result->isAllowed() || $token === null) { + return $result; + } + $subject = $token->getSubject(); + $identity = $subject === null ? null : new ArrayObject(['id' => $subject] + $token->getClaims()); + if ($this->identity !== null) { + $identity = ($this->identity)($token, $identity); + } + if ($identity === null) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'The owner of this access token is not known to this application.', + ); + } + $this->current->set($identity); + + return $result; + } +} diff --git a/src/OAuth/ResourceServer.php b/src/OAuth/ResourceServer.php index 8fce4af..a69cf30 100644 --- a/src/OAuth/ResourceServer.php +++ b/src/OAuth/ResourceServer.php @@ -3,14 +3,19 @@ namespace CakeDC\Mcp\OAuth; +use Cake\Http\Client; +use Cake\Http\RequestFactory; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\OAuth\Server\AuthorizationServer; use CakeDC\OAuth\Server\Resource\ProtectedResource; use CakeDC\OAuthServer\OAuthServer; +use Firebase\JWT\JWT; use LogicException; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface; +use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; +use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use RuntimeException; @@ -85,6 +90,11 @@ public function validator(CurrentIdentity $identity): AuthorizationTokenValidato $identity, $this->settings->identity, ), + AuthSettings::EXTERNAL => new ExternalTokenValidator( + $this->jwtValidator(), + $identity, + $this->settings->identity, + ), default => throw new LogicException( sprintf('The "%s" mode has no token validator.', $this->settings->mode), ), @@ -110,6 +120,10 @@ private function issuer(): string return $this->authorizationServer()->config()->issuer; } + if ($this->settings->external !== null) { + return $this->settings->external->issuer; + } + throw new LogicException(sprintf('The "%s" mode has no authorization server.', $this->settings->mode)); } @@ -123,6 +137,37 @@ private function scopesSupported(): array : $this->settings->configuredScopes(); } + /** + * The SDK's JWT validator for the external issuer. Discovery and keys are fetched on first use. + * + * @return \Mcp\Server\Transport\Http\OAuth\JwtTokenValidator + */ + private function jwtValidator(): JwtTokenValidator + { + $external = $this->settings->external; + if ($external === null) { + throw new LogicException('External mode without external settings.'); + } + if (!class_exists(JWT::class) || !interface_exists(CacheItemPoolInterface::class)) { + throw new RuntimeException( + '"Mcp.auth.mode" external needs firebase/php-jwt and psr/cache: run ' + . '`composer require firebase/php-jwt psr/cache`.', + ); + } + + return JwtTokenValidator::fromIssuer( + $external->issuer, + $external->audiences, + new CakeCachePool($external->cache), + $external->httpClient ?? new Client(['timeout' => 5]), + new RequestFactory(), + $external->algorithms, + $external->scopeClaim, + $external->tokenType, + $external->leeway, + ); + } + /** * The authorization server of this application, once its resource and scopes are checked. * diff --git a/tests/Support/FakeHttpClient.php b/tests/Support/FakeHttpClient.php new file mode 100644 index 0000000..7138b46 --- /dev/null +++ b/tests/Support/FakeHttpClient.php @@ -0,0 +1,51 @@ + + */ + private array $responses = []; + + /** + * The URLs requested, in order. + * + * @var list + */ + public array $requested = []; + + /** + * @param string $url The URL. + * @param array $body The JSON document. + * @return void + */ + public function serveJson(string $url, array $body): void + { + $this->responses[$url] = [200, (string)json_encode($body)]; + } + + /** + * @inheritDoc + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $url = (string)$request->getUri(); + $this->requested[] = $url; + [$status, $body] = $this->responses[$url] ?? [404, '{}']; + + $line = sprintf('HTTP/1.1 %d %s', $status, $status === 200 ? 'OK' : 'Not Found'); + + return new Response([$line, 'Content-Type: application/json'], $body); + } +} diff --git a/tests/Support/TestJwt.php b/tests/Support/TestJwt.php new file mode 100644 index 0000000..a4db12a --- /dev/null +++ b/tests/Support/TestJwt.php @@ -0,0 +1,94 @@ + $claims Claims. + * @return string + */ + public static function token(array $claims = []): string + { + $pem = ''; + openssl_pkey_export(self::key(), $pem); + + return JWT::encode($claims + [ + 'iss' => self::ISSUER, + 'aud' => OAuthTestConfig::MCP, + 'sub' => 'user-1', + 'iat' => time(), + 'exp' => time() + 300, + 'scope' => 'notes:read', + ], $pem, 'RS256', self::KID); + } + + /** + * A client serving the issuer's RFC 8414 metadata and its JWKS. + * + * @return \CakeDC\Mcp\Test\Support\FakeHttpClient + */ + public static function client(): FakeHttpClient + { + $details = openssl_pkey_get_details(self::key()); + if ($details === false) { + throw new RuntimeException('Cannot read the test key.'); + } + $client = new FakeHttpClient(); + $client->serveJson(self::ISSUER . '/.well-known/oauth-authorization-server', [ + 'issuer' => self::ISSUER, + 'jwks_uri' => self::ISSUER . '/jwks', + ]); + $client->serveJson(self::ISSUER . '/jwks', ['keys' => [[ + 'kty' => 'RSA', + 'kid' => self::KID, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => self::base64Url($details['rsa']['n']), + 'e' => self::base64Url($details['rsa']['e']), + ]]]); + + return $client; + } + + /** + * @return \OpenSSLAsymmetricKey + */ + private static function key(): OpenSSLAsymmetricKey + { + if (self::$key === null) { + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + if ($key === false) { + throw new RuntimeException('Cannot create the test key.'); + } + self::$key = $key; + } + + return self::$key; + } + + /** + * @param string $bytes Bytes. + * @return string + */ + private static function base64Url(string $bytes): string + { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } +} diff --git a/tests/TestCase/Controller/McpExternalTest.php b/tests/TestCase/Controller/McpExternalTest.php new file mode 100644 index 0000000..8eb3bef --- /dev/null +++ b/tests/TestCase/Controller/McpExternalTest.php @@ -0,0 +1,66 @@ + ArrayEngine::class]); + Configure::write('Mcp.auth', [ + 'mode' => 'external', + 'scopes' => ['methods' => ['tools/call' => ['notes:read']], 'tools' => ['create_note' => ['notes:write']]], + 'external' => ['issuer' => TestJwt::ISSUER, 'cache' => 'mcp_oauth_test', 'httpClient' => TestJwt::client()], + ]); + } + + protected function tearDown(): void + { + Cache::drop('mcp_oauth_test'); + parent::tearDown(); + } + + public function testJwtFromTheExternalIssuerCallsTools(): void + { + $this->callTool('whoami', [], TestJwt::token()); + + $this->assertResponseOk(); + $whoami = json_decode((string)($this->responseJson()['result']['content'][0]['text'] ?? ''), true); + $this->assertSame(['identifier' => 'user-1', 'subject' => 'user-1'], $whoami); + } + + public function testMissingScopeIs403(): void + { + $this->callTool('create_note', ['title' => 'Groceries'], TestJwt::token()); + + $this->assertResponseCode(403); + $this->assertSame('insufficient_scope', $this->challenge()['error'] ?? null); + } + + public function testChallengePointsAtTheMetadataNamingTheIssuer(): void + { + $this->listTools(); + $this->assertResponseCode(401); + $this->assertSame( + 'http://localhost/.well-known/oauth-protected-resource/mcp', + $this->challenge()['resource_metadata'] ?? null, + ); + + $this->get('/.well-known/oauth-protected-resource/mcp'); + $document = json_decode((string)$this->_response->getBody(), true); + $this->assertSame([TestJwt::ISSUER], $document['authorization_servers'] ?? null); + } +} diff --git a/tests/TestCase/McpPluginTest.php b/tests/TestCase/McpPluginTest.php index 58dc454..133cf86 100644 --- a/tests/TestCase/McpPluginTest.php +++ b/tests/TestCase/McpPluginTest.php @@ -65,6 +65,28 @@ public function testBootstrapKeepsCacheConfigsTheHostDefined(): void $this->assertInstanceOf(ArrayEngine::class, Cache::pool('host_sessions')); } + public function testBootstrapDefinesTheExternalCacheOnlyInExternalMode(): void + { + try { + $app = new Application(CONFIG); + $app->bootstrap(); + $app->pluginBootstrap(); + $this->assertNull(Cache::getConfig('mcp_oauth_plugin_test')); + + Configure::write('Mcp.auth', [ + 'mode' => 'external', + 'external' => ['issuer' => 'https://issuer.example', 'cache' => 'mcp_oauth_plugin_test'], + ]); + $app = new Application(CONFIG); + $app->bootstrap(); + $app->pluginBootstrap(); + + $this->assertInstanceOf(FileEngine::class, Cache::pool('mcp_oauth_plugin_test')); + } finally { + Cache::drop('mcp_oauth_plugin_test'); + } + } + public function testRoutesConnectTheEndpointAtTheConfiguredPath(): void { Configure::write('Mcp.route', '/ai/mcp'); diff --git a/tests/TestCase/OAuth/CakeCachePoolTest.php b/tests/TestCase/OAuth/CakeCachePoolTest.php new file mode 100644 index 0000000..38c2f64 --- /dev/null +++ b/tests/TestCase/OAuth/CakeCachePoolTest.php @@ -0,0 +1,99 @@ + ArrayEngine::class]); + $this->pool = new CakeCachePool('mcp_pool_test'); + } + + protected function tearDown(): void + { + Cache::drop('mcp_pool_test'); + parent::tearDown(); + } + + public function testMissIsNotAHit(): void + { + $item = $this->pool->getItem('jwks'); + + $this->assertFalse($item->isHit()); + $this->assertNull($item->get()); + $this->assertFalse($this->pool->hasItem('jwks')); + } + + public function testSavedItemIsAHit(): void + { + $this->assertTrue($this->pool->save($this->pool->getItem('jwks')->set(['keys' => []])->expiresAfter(60))); + + $item = $this->pool->getItem('jwks'); + $this->assertTrue($item->isHit()); + $this->assertSame(['keys' => []], $item->get()); + } + + public function testFalseIsCachedAsAValue(): void + { + // The SDK caches a failed discovery as false. + $this->pool->save($this->pool->getItem('failed')->set(false)); + + $item = $this->pool->getItem('failed'); + $this->assertTrue($item->isHit()); + $this->assertFalse($item->get()); + } + + public function testExpiredItemIsNotStored(): void + { + $this->pool->save($this->pool->getItem('old')->set('x')->expiresAfter(0)); + + $this->assertFalse($this->pool->getItem('old')->isHit()); + } + + public function testDeferredItemsAreVisibleBeforeAndStoredOnCommit(): void + { + $this->pool->saveDeferred($this->pool->getItem('later')->set('x')); + + $this->assertTrue($this->pool->getItem('later')->isHit()); + $this->assertNull(Cache::read('later', 'mcp_pool_test')); + $this->assertTrue($this->pool->commit()); + $this->assertTrue((new CakeCachePool('mcp_pool_test'))->getItem('later')->isHit()); + } + + public function testDeleteAndClear(): void + { + $this->pool->save($this->pool->getItem('a')->set(1)); + $this->pool->save($this->pool->getItem('b')->set(2)); + + $this->assertTrue($this->pool->deleteItem('a')); + $this->assertTrue($this->pool->deleteItem('never-stored')); + $this->assertFalse($this->pool->hasItem('a')); + $this->assertTrue($this->pool->clear()); + $this->assertFalse($this->pool->hasItem('b')); + } + + public function testReservedCharactersAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->pool->getItem('bad:key'); + } + + public function testUnknownCacheConfigNamesTheSetting(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Mcp.auth.external.cache'); + new CakeCachePool('no_such_cache'); + } +} diff --git a/tests/TestCase/OAuth/ExternalTokenValidatorTest.php b/tests/TestCase/OAuth/ExternalTokenValidatorTest.php new file mode 100644 index 0000000..a84ec21 --- /dev/null +++ b/tests/TestCase/OAuth/ExternalTokenValidatorTest.php @@ -0,0 +1,119 @@ + ArrayEngine::class]); + $this->client = TestJwt::client(); + Configure::write('Mcp.auth', [ + 'mode' => 'external', + 'scopes' => ['methods' => ['tools/call' => ['notes:read']], 'tools' => ['create_note' => ['notes:write']]], + 'external' => ['issuer' => TestJwt::ISSUER, 'cache' => 'mcp_oauth_test', 'httpClient' => $this->client], + ]); + $this->current = new CurrentIdentity(); + } + + protected function tearDown(): void + { + Cache::drop('mcp_oauth_test'); + parent::tearDown(); + } + + private function server(): ResourceServer + { + return new ResourceServer(AuthSettings::fromConfig()); + } + + public function testValidTokenIsAllowedWithAClaimsIdentity(): void + { + $result = $this->server()->validator($this->current)->validate(TestJwt::token()); + + $this->assertTrue($result->isAllowed()); + $this->assertSame(['notes:read'], $result->getAccessToken()?->getScopes()); + $identity = $this->current->get(); + $this->assertInstanceOf(ArrayObject::class, $identity); + $this->assertSame('user-1', $this->current->getIdentifier()); + $this->assertSame(TestJwt::ISSUER, $identity['iss']); + } + + public function testKeysAreFetchedOnceAndCached(): void + { + $this->server()->validator($this->current)->validate(TestJwt::token()); + $this->server()->validator(new CurrentIdentity())->validate(TestJwt::token()); + + $this->assertSame(1, count(array_keys($this->client->requested, TestJwt::ISSUER . '/jwks', true))); + } + + public function testRejectedTokens(): void + { + foreach ( + [ + 'audience' => ['aud' => 'https://other.example/mcp'], + 'issuer' => ['iss' => 'https://evil.example'], + 'expired' => ['exp' => time() - 60, 'iat' => time() - 600], + ] as $case => $claims + ) { + $result = $this->server()->validator($this->current)->validate(TestJwt::token($claims)); + + $this->assertSame(401, $result->getStatusCode(), $case); + $this->assertNull($this->current->get(), $case); + } + } + + public function testTokenWithoutSubjectHasNoDefaultIdentity(): void + { + $result = $this->server()->validator($this->current)->validate(TestJwt::token(['sub' => null])); + + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + } + + public function testIdentityResolverMapsTheToken(): void + { + Configure::write( + 'Mcp.auth.identity', + fn(AccessToken $token, mixed $identity): ?array => $token->getSubject() === 'user-1' ? ['id' => 7] : null, + ); + + $this->assertTrue($this->server()->validator($this->current)->validate(TestJwt::token())->isAllowed()); + $this->assertSame(['id' => 7], $this->current->get()); + + $result = $this->server()->validator(new CurrentIdentity())->validate(TestJwt::token(['sub' => 'user-2'])); + $this->assertSame(401, $result->getStatusCode()); + } + + public function testMetadataNamesTheExternalIssuerAndTheConfiguredScopes(): void + { + $document = $this->server()->metadata()->jsonSerialize(); + + $this->assertSame([TestJwt::ISSUER], $document['authorization_servers']); + $this->assertEqualsCanonicalizing(['notes:read', 'notes:write'], $document['scopes_supported']); + } + + public function testValidatorIsAnExternalTokenValidator(): void + { + $this->assertInstanceOf(ExternalTokenValidator::class, $this->server()->validator($this->current)); + } +} From ed4efd0caccb0e89fd41c10a028eed25ce78035f Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:03:48 +0200 Subject: [PATCH 36/49] Keep token claims out of the top level of the external identity --- src/OAuth/ExternalTokenValidator.php | 6 +- tests/Support/TestJwt.php | 74 +++++++++++++++++++ tests/TestCase/OAuth/CakeCachePoolTest.php | 14 ++++ .../OAuth/ExternalTokenValidatorTest.php | 63 +++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/OAuth/ExternalTokenValidator.php b/src/OAuth/ExternalTokenValidator.php index 3a3d9d1..dc33b5e 100644 --- a/src/OAuth/ExternalTokenValidator.php +++ b/src/OAuth/ExternalTokenValidator.php @@ -11,7 +11,9 @@ /** * external mode: the SDK's JWT validator, plus the identity tools and the tool authorizer see. Without - * `Mcp.auth.identity` the identity is an ArrayObject of the token claims with `id` set to `sub`. + * `Mcp.auth.identity` the identity is an ArrayObject with `id` set to `sub` and the token claims under + * `claims`. Claims are never merged at the top level: a `role` claim from the identity provider must not + * reach role based authorization. */ final class ExternalTokenValidator implements AuthorizationTokenValidatorInterface { @@ -38,7 +40,7 @@ public function validate(string $accessToken): AuthorizationResult return $result; } $subject = $token->getSubject(); - $identity = $subject === null ? null : new ArrayObject(['id' => $subject] + $token->getClaims()); + $identity = $subject === null ? null : new ArrayObject(['id' => $subject, 'claims' => $token->getClaims()]); if ($this->identity !== null) { $identity = ($this->identity)($token, $identity); } diff --git a/tests/Support/TestJwt.php b/tests/Support/TestJwt.php index a4db12a..6f14d85 100644 --- a/tests/Support/TestJwt.php +++ b/tests/Support/TestJwt.php @@ -39,6 +39,80 @@ public static function token(array $claims = []): string ], $pem, 'RS256', self::KID); } + /** + * A token signed by another RSA key under the same kid. + * + * @param array $claims Claims. + * @return string + */ + public static function forged(array $claims = []): string + { + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + if ($key === false) { + throw new RuntimeException('Cannot create the forging key.'); + } + $pem = ''; + openssl_pkey_export($key, $pem); + + return JWT::encode(self::claims($claims), $pem, 'RS256', self::KID); + } + + /** + * A token signed with $secret (HS256) under the test kid. + * + * @param string $secret The HMAC secret. + * @param array $claims Claims. + * @return string + */ + public static function hmac(string $secret, array $claims = []): string + { + return JWT::encode(self::claims($claims), $secret, 'HS256', self::KID); + } + + /** + * An unsigned token with alg none. + * + * @param array $claims Claims. + * @return string + */ + public static function unsigned(array $claims = []): string + { + $header = ['typ' => 'JWT', 'alg' => 'none', 'kid' => self::KID]; + + return self::base64Url((string)json_encode($header)) . '.' + . self::base64Url((string)json_encode(self::claims($claims))) . '.'; + } + + /** + * A token signed by the test key under a kid the JWKS does not list. + * + * @param array $claims Claims. + * @return string + */ + public static function unknownKid(array $claims = []): string + { + $pem = ''; + openssl_pkey_export(self::key(), $pem); + + return JWT::encode(self::claims($claims), $pem, 'RS256', 'other-key'); + } + + /** + * @param array $claims Overrides. + * @return array + */ + private static function claims(array $claims): array + { + return $claims + [ + 'iss' => self::ISSUER, + 'aud' => OAuthTestConfig::MCP, + 'sub' => 'user-1', + 'iat' => time(), + 'exp' => time() + 300, + 'scope' => 'notes:read', + ]; + } + /** * A client serving the issuer's RFC 8414 metadata and its JWKS. * diff --git a/tests/TestCase/OAuth/CakeCachePoolTest.php b/tests/TestCase/OAuth/CakeCachePoolTest.php index 38c2f64..1890131 100644 --- a/tests/TestCase/OAuth/CakeCachePoolTest.php +++ b/tests/TestCase/OAuth/CakeCachePoolTest.php @@ -7,6 +7,7 @@ use Cake\Cache\Engine\ArrayEngine; use Cake\TestSuite\TestCase; use CakeDC\Mcp\OAuth\CakeCachePool; +use DateTimeImmutable; use Psr\Cache\InvalidArgumentException; use RuntimeException; @@ -72,6 +73,19 @@ public function testDeferredItemsAreVisibleBeforeAndStoredOnCommit(): void $this->assertTrue((new CakeCachePool('mcp_pool_test'))->getItem('later')->isHit()); } + public function testTtlReachesCake(): void + { + $this->pool->save($this->pool->getItem('after')->set('x')->expiresAfter(1)); + $this->pool->save($this->pool->getItem('at')->set('y')->expiresAt(new DateTimeImmutable('+1 second'))); + $this->assertTrue($this->pool->getItem('after')->isHit()); + $this->assertTrue($this->pool->getItem('at')->isHit()); + + sleep(2); + + $this->assertFalse($this->pool->getItem('after')->isHit()); + $this->assertFalse($this->pool->getItem('at')->isHit()); + } + public function testDeleteAndClear(): void { $this->pool->save($this->pool->getItem('a')->set(1)); diff --git a/tests/TestCase/OAuth/ExternalTokenValidatorTest.php b/tests/TestCase/OAuth/ExternalTokenValidatorTest.php index a84ec21..edad12f 100644 --- a/tests/TestCase/OAuth/ExternalTokenValidatorTest.php +++ b/tests/TestCase/OAuth/ExternalTokenValidatorTest.php @@ -8,6 +8,7 @@ use Cake\Cache\Engine\ArrayEngine; use Cake\Core\Configure; use Cake\TestSuite\TestCase; +use CakeDC\Mcp\Authorization\RbacToolAuthorizer; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\OAuth\AuthSettings; use CakeDC\Mcp\OAuth\ExternalTokenValidator; @@ -15,6 +16,7 @@ use CakeDC\Mcp\Test\Support\FakeHttpClient; use CakeDC\Mcp\Test\Support\TestJwt; use Mcp\Server\Authorization\AccessToken; +use PHPUnit\Framework\Attributes\DataProvider; class ExternalTokenValidatorTest extends TestCase { @@ -55,7 +57,7 @@ public function testValidTokenIsAllowedWithAClaimsIdentity(): void $identity = $this->current->get(); $this->assertInstanceOf(ArrayObject::class, $identity); $this->assertSame('user-1', $this->current->getIdentifier()); - $this->assertSame(TestJwt::ISSUER, $identity['iss']); + $this->assertSame(TestJwt::ISSUER, $identity['claims']['iss']); } public function testKeysAreFetchedOnceAndCached(): void @@ -116,4 +118,63 @@ public function testValidatorIsAnExternalTokenValidator(): void { $this->assertInstanceOf(ExternalTokenValidator::class, $this->server()->validator($this->current)); } + + public function testTokenClaimsNeverBecomeTopLevelIdentityFields(): void + { + $this->assertTrue($this->server()->validator($this->current)->validate( + TestJwt::token(['role' => 'admin']), + )->isAllowed()); + $identity = $this->current->get(); + $this->assertInstanceOf(ArrayObject::class, $identity); + $this->assertFalse(isset($identity['role'])); + $this->assertSame('admin', $identity['claims']['role']); + + $authorizer = new RbacToolAuthorizer(['log' => false, 'permissions' => [ + ['role' => 'admin', 'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools', 'action' => '*'], + ]]); + $this->assertFalse($authorizer->canCall($identity, 'create_note')); + $this->assertTrue($authorizer->canCall(['id' => 1, 'role' => 'admin'], 'create_note')); + } + + /** + * @return array + */ + public static function forgedTokens(): array + { + return [ + 'other key same kid' => ['forged'], + 'hs256' => ['hmac'], + 'alg none' => ['unsigned'], + 'unknown kid' => ['unknownKid'], + ]; + } + + #[DataProvider('forgedTokens')] + public function testForgedOrDisallowedTokensAreRejected(string $builder): void + { + $token = $builder === 'hmac' ? TestJwt::hmac('a-shared-secret-of-sufficient-length-32') : TestJwt::$builder(); + + $result = $this->server()->validator($this->current)->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertNull($this->current->get()); + } + + public function testFailedDiscoveryIs401AndBacksOff(): void + { + $client = new FakeHttpClient(); + Configure::write('Mcp.auth.external.httpClient', $client); + + $first = $this->server()->validator($this->current)->validate(TestJwt::token()); + $this->assertSame(401, $first->getStatusCode()); + $this->assertSame('invalid_token', $first->getError()); + $requests = count($client->requested); + $this->assertGreaterThan(0, $requests); + + $second = $this->server()->validator(new CurrentIdentity())->validate(TestJwt::token()); + $this->assertSame(401, $second->getStatusCode()); + $this->assertCount($requests, $client->requested); + } } From f0cb972307ee20845f06948462716d82508ce8b9 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:05:52 +0200 Subject: [PATCH 37/49] Let McpTestTrait act with scopes and check insufficient_scope --- src/TestSuite/McpTestTrait.php | 95 ++++++++++++++++++- .../TestSuite/McpTestTraitScopesTest.php | 87 +++++++++++++++++ 2 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 tests/TestCase/TestSuite/McpTestTraitScopesTest.php diff --git a/src/TestSuite/McpTestTrait.php b/src/TestSuite/McpTestTrait.php index 94a051a..6dae067 100644 --- a/src/TestSuite/McpTestTrait.php +++ b/src/TestSuite/McpTestTrait.php @@ -14,9 +14,11 @@ use CakeDC\Mcp\Log\McpLogger; use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\Middleware\IdentityMiddleware; +use CakeDC\Mcp\OAuth\AuthSettings; use CakeDC\Mcp\Server\ServerFactory; use Closure; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Server\Authorization\AccessToken; use PHPUnit\Framework\Assert; use stdClass; @@ -38,6 +40,20 @@ trait McpTestTrait */ protected mixed $_mcpIdentity = null; + /** + * The scopes of the access token, null for 2.0 behaviour (the `identity.scopes` callable). + * + * @var list|null + */ + protected ?array $_mcpScopes = null; + + /** + * Claims added to the access token. + * + * @var array + */ + protected array $_mcpClaims = []; + /** * The JSON-RPC response to the last `mcpCallTool()`. * @@ -62,12 +78,20 @@ trait McpTestTrait * Makes the next calls as this identity, in the shape your authentication * produces (an entity, an `ArrayObject`, an array...). Null makes them anonymous. * + * With $scopes the access token carries them, and in the oauth and external modes the + * `Mcp.auth.scopes` policy is applied first, as the HTTP endpoint would: a call it refuses is + * checked with `assertMcpInsufficientScope()`. + * * @param mixed $identity The identity. + * @param list|null $scopes The token's scopes; null keeps the `identity.scopes` callable. + * @param array $claims Claims added to the token (they win over `sub`). * @return void */ - public function mcpActingAs(mixed $identity): void + public function mcpActingAs(mixed $identity, ?array $scopes = null, array $claims = []): void { $this->_mcpIdentity = $identity; + $this->_mcpScopes = $scopes === null ? null : array_values(array_filter($scopes, 'is_string')); + $this->_mcpClaims = $claims; } /** @@ -213,6 +237,32 @@ public function assertMcpToolDenied(string $message = ''): void ); } + /** + * Asserts the last call was refused because the access token lacks scopes, as the HTTP endpoint + * answers with 403 `insufficient_scope` (only with scopes given to `mcpActingAs()`, in the oauth and + * external modes). + * + * @param list|null $scopes The scopes the call needs, any when null. + * @param string $message Shown on failure. + * @return void + */ + public function assertMcpInsufficientScope(?array $scopes = null, string $message = ''): void + { + $response = $this->mcpResponse(); + $error = $response['error'] ?? null; + Assert::assertTrue( + is_array($error) && ($error['message'] ?? null) === 'insufficient_scope', + $message ?: 'Expected the call to be refused for missing scopes, got: ' . $this->mcpJson($response), + ); + if ($scopes !== null) { + Assert::assertEqualsCanonicalizing( + $scopes, + $error['data']['scopes'] ?? null, + $message, + ); + } + } + /** * Asserts that the text of the last result contains a string. * @@ -332,6 +382,44 @@ private function mcpRequest(string $method, ?array $params, bool $trusted = fals $factory->identity()->set($this->_mcpIdentity); $scopes = McpConfig::get('identity.scopes'); + $token = null; + if ($this->_mcpIdentity !== null) { + $token = IdentityMiddleware::accessTokenFor( + $this->_mcpIdentity, + is_callable($scopes) ? Closure::fromCallable($scopes) : null, + ); + if ($this->_mcpScopes !== null || $this->_mcpClaims !== []) { + $token = new AccessToken( + $this->_mcpScopes ?? $token->getScopes(), + $this->_mcpClaims + $token->getClaims(), + ); + } + } + if ($token !== null && $this->_mcpScopes !== null && !$trusted) { + $settings = AuthSettings::fromConfig(); + $policy = $settings->mode === AuthSettings::HOST ? null : $settings->scopePolicy(); + if ($policy !== null) { + $required = $policy->requiredFor($request); + $granted = $policy->expand($token->getScopes()); + if (array_diff($required, $granted) !== []) { + if ($method !== 'tools/call') { + Assert::fail(sprintf( + 'The access token lacks scopes for `%s`: it needs %s.', + $method, + implode(' ', $required), + )); + } + + // What the endpoint answers with HTTP 403 and `insufficient_scope`. + return ['jsonrpc' => '2.0', 'id' => 2, 'error' => [ + 'code' => 403, + 'message' => 'insufficient_scope', + 'data' => ['scopes' => $required], + ]]; + } + $token = new AccessToken($granted, $token->getClaims()); + } + } $transport = new RecordingTransport( [ (string)json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ @@ -342,10 +430,7 @@ private function mcpRequest(string $method, ?array $params, bool $trusted = fals (string)json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']), (string)json_encode($request), ], - $this->_mcpIdentity === null ? null : IdentityMiddleware::accessTokenFor( - $this->_mcpIdentity, - is_callable($scopes) ? Closure::fromCallable($scopes) : null, - ), + $token, ); // The real exception of a failing tool only reaches the log, rarely configured in tests. $logConfig = '_mcp_test_trait'; diff --git a/tests/TestCase/TestSuite/McpTestTraitScopesTest.php b/tests/TestCase/TestSuite/McpTestTraitScopesTest.php new file mode 100644 index 0000000..5a43dfe --- /dev/null +++ b/tests/TestCase/TestSuite/McpTestTraitScopesTest.php @@ -0,0 +1,87 @@ +mcpActingAs(new ArrayObject(['id' => 1]), ['notes:read', 'notes:write']); + + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + + $this->assertMcpToolSucceeded(); + } + + public function testImpliedScopesCount(): void + { + Configure::write('Mcp.auth.scopes.implies', ['notes:write' => ['notes:read']]); + $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:write']); + + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + + $this->assertMcpToolSucceeded(); + } + + public function testMissingScopesAreReportedLikeTheEndpointWould(): void + { + $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:read']); + + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + + $this->assertMcpInsufficientScope(['notes:read', 'notes:write']); + } + + public function testInsufficientScopeAssertionFailsOnSuccess(): void + { + $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:read', 'notes:write']); + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + + $this->expectException(AssertionFailedError::class); + $this->assertMcpInsufficientScope(); + } + + public function testWithoutScopesThePolicyIsNotApplied(): void + { + $this->mcpActingAs(new ArrayObject(['id' => 1])); + + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + + $this->assertMcpToolSucceeded(); + } + + public function testHostModeIgnoresThePolicy(): void + { + Configure::write('Mcp.auth', ['mode' => 'host']); + $this->mcpActingAs(new ArrayObject(['id' => 1]), []); + + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + + $this->assertMcpToolSucceeded(); + } + + public function testClaimsReachTheAccessToken(): void + { + $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:read'], ['sub' => 'from-claims']); + + $this->mcpCallTool('whoami'); + + $this->assertMcpResultContains('from-claims'); + } +} From 852031d4426bb7784be48744f6e4e84c292399fd Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:08:04 +0200 Subject: [PATCH 38/49] Add bin/cake mcp check --- src/Command/McpCheckCommand.php | 245 ++++++++++++++++++ src/McpPlugin.php | 6 +- .../TestCase/Command/McpCheckCommandTest.php | 102 ++++++++ tests/test_app/src/Application.php | 5 +- 4 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 src/Command/McpCheckCommand.php create mode 100644 tests/TestCase/Command/McpCheckCommandTest.php diff --git a/src/Command/McpCheckCommand.php b/src/Command/McpCheckCommand.php new file mode 100644 index 0000000..59d648b --- /dev/null +++ b/src/Command/McpCheckCommand.php @@ -0,0 +1,245 @@ +checks() as $name => $probe) { + try { + [$status, $message] = $probe(); + } catch (Throwable $e) { + [$status, $message] = ['fail', $e->getMessage()]; + } + $tag = ['pass' => 'success', 'warn' => 'warning', 'fail' => 'error'][$status]; + $io->out(sprintf('<%s>[%s] %s: %s', $tag, strtoupper($status), $name, $message, $tag)); + $failed = $failed || $status === 'fail'; + } + + return $failed ? static::CODE_ERROR : static::CODE_SUCCESS; + } + + /** + * The checks, in order. + * + * @return array + */ + protected function checks(): array + { + return [ + 'Endpoint' => fn(): array => $this->endpoint(), + 'Authentication' => fn(): array => $this->authentication(), + 'Resource' => fn(): array => $this->resource(), + 'Allowed hosts' => fn(): array => $this->hosts(), + 'CSRF' => fn(): array => $this->csrf(), + 'Authenticators' => fn(): array => $this->authenticators(), + ]; + } + + /** + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function endpoint(): array + { + $route = McpConfig::get('route'); + if (!is_string($route) || $route === '') { + return ['warn', 'Mcp.route is disabled: only stdio (bin/cake mcp server) is served']; + } + + return ['pass', rtrim((string)Configure::read('App.fullBaseUrl'), '/') . $route]; + } + + /** + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function authentication(): array + { + $settings = AuthSettings::fromConfig(); + + return ['pass', match ($settings->mode) { + AuthSettings::OAUTH => 'oauth mode: access tokens of cakedc/cakephp-oauth-server in this application', + AuthSettings::EXTERNAL => sprintf('external mode: JWT access tokens from %s', $settings->external?->issuer), + default => sprintf( + 'host mode: the identity your authentication puts in the "%s" request attribute', + (string)McpConfig::get('identity.attribute'), + ), + }]; + } + + /** + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function resource(): array + { + $settings = AuthSettings::fromConfig(); + if ($settings->mode === AuthSettings::HOST) { + return ['pass', 'not used in host mode']; + } + $server = new ResourceServer($settings); + $metadata = $server->metadata(); + $server->validator(new CurrentIdentity()); + $scopes = $metadata->getScopesSupported() ?? []; + + return ['pass', sprintf( + '%s, metadata at %s, scopes: %s', + $metadata->getResource(), + $metadata->getMetadataUrl(), + $scopes === [] ? 'none declared' : implode(' ', $scopes), + )]; + } + + /** + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function hosts(): array + { + $allowed = McpConfig::allowedHosts(); + $settings = AuthSettings::fromConfig(); + if ($settings->mode === AuthSettings::HOST) { + return ['pass', implode(', ', $allowed)]; + } + $host = strtolower((string)parse_url($settings->resource, PHP_URL_HOST)); + + return in_array($host, $allowed, true) + ? ['pass', implode(', ', $allowed)] + : ['fail', sprintf( + 'the resource host "%s" is not in Mcp.http.allowedHosts (%s), so its requests are rejected', + $host, + implode(', ', $allowed), + )]; + } + + /** + * Whether the application's CSRF middleware skips POST requests to the MCP route. + * + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function csrf(): array + { + $route = McpConfig::get('route'); + if (!is_string($route) || $route === '') { + return ['pass', 'no HTTP endpoint']; + } + $app = $this->application(); + if (is_string($app)) { + return ['warn', $app . '; its middleware was not inspected']; + } + foreach ($app->middleware(new MiddlewareQueue()) as $middleware) { + if (!$middleware instanceof CsrfProtectionMiddleware) { + continue; + } + $callback = (new ReflectionProperty(CsrfProtectionMiddleware::class, 'skipCheckCallback')) + ->getValue($middleware); + $request = new ServerRequest(['url' => $route, 'environment' => ['REQUEST_METHOD' => 'POST']]); + + return is_callable($callback) && $callback($request) === true + ? ['pass', 'the MCP route is skipped'] + : ['fail', 'add McpConfig::isEndpoint($request) to the CsrfProtectionMiddleware skipCheckCallback']; + } + + return ['pass', 'no CsrfProtectionMiddleware in the queue']; + } + + /** + * In oauth mode, whether an authenticator of the application answers this server's bearer tokens at + * the MCP route before the MCP plugin sees them (the oauth plugin's Token authenticator does). + * + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function authenticators(): array + { + $settings = AuthSettings::fromConfig(); + $route = McpConfig::get('route'); + if ($settings->mode !== AuthSettings::OAUTH || !is_string($route) || $route === '') { + return ['pass', 'nothing to check outside oauth mode']; + } + $app = $this->application(); + if (is_string($app)) { + return ['warn', $app . '; its authenticators were not inspected']; + } + if (!$app instanceof AuthenticationServiceProviderInterface) { + return ['pass', 'the application provides no authentication service']; + } + $prefix = OAuthServer::server()->config()->tokenPrefix; + $probe = (new ServerRequest(['url' => $route, 'environment' => ['REQUEST_METHOD' => 'POST']])) + ->withHeader('Authorization', 'Bearer ' . $prefix . '_at_' . str_repeat('A', 43)); + try { + $app->getAuthenticationService($probe)->authenticate($probe); + } catch (AuthenticationRequiredException) { + return ['fail', sprintf( + 'an authenticator of the application (the CakeDC/OAuthServer.Token one) answers access tokens ' + . 'at %s before the MCP plugin sees them; leave it out for the MCP route, see ' + . 'McpConfig::isEndpoint()', + $route, + )]; + } + + return ['pass', 'no authenticator answers access tokens at the MCP route first']; + } + + /** + * The host application, without replacing the running plugin collection; or why it cannot be built. + * + * @return \Cake\Http\BaseApplication|string + */ + private function application(): BaseApplication|string + { + $class = Configure::read('App.namespace') . '\\Application'; + if (!class_exists($class)) { + return 'could not find the application class to inspect it'; + } + $plugins = Plugin::getCollection(); + try { + $app = new $class(CONFIG); + } finally { + Plugin::setCollection($plugins); + } + + return $app instanceof BaseApplication ? $app : 'the application class is not a BaseApplication'; + } +} diff --git a/src/McpPlugin.php b/src/McpPlugin.php index de35477..7f9773f 100644 --- a/src/McpPlugin.php +++ b/src/McpPlugin.php @@ -10,6 +10,7 @@ use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; use Cake\Routing\RouteBuilder; +use CakeDC\Mcp\Command\McpCheckCommand; use CakeDC\Mcp\Command\McpInspectorCommand; use CakeDC\Mcp\Command\McpServerCommand; use CakeDC\Mcp\Identity\CurrentIdentity; @@ -76,7 +77,7 @@ public function routes(RouteBuilder $routes): void } /** - * Registers `bin/cake mcp server`, the stdio transport, and `bin/cake mcp inspector`. + * Registers `bin/cake mcp server`, the stdio transport, `bin/cake mcp inspector` and `bin/cake mcp check`. * * @param \Cake\Console\CommandCollection $commands The collection. * @return \Cake\Console\CommandCollection @@ -85,7 +86,8 @@ public function console(CommandCollection $commands): CommandCollection { return $commands ->add('mcp server', McpServerCommand::class) - ->add('mcp inspector', McpInspectorCommand::class); + ->add('mcp inspector', McpInspectorCommand::class) + ->add('mcp check', McpCheckCommand::class); } /** diff --git a/tests/TestCase/Command/McpCheckCommandTest.php b/tests/TestCase/Command/McpCheckCommandTest.php new file mode 100644 index 0000000..c780ea4 --- /dev/null +++ b/tests/TestCase/Command/McpCheckCommandTest.php @@ -0,0 +1,102 @@ +exec('mcp check'); + + $this->assertExitSuccess(); + $this->assertOutputContains('[PASS] Endpoint: http://localhost/mcp'); + $this->assertOutputContains('[PASS] Authentication: host mode'); + $this->assertOutputContains('[PASS] CSRF'); + } + + public function testOAuthModeReportsTheResourceAndItsMetadata(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + + $this->exec('mcp check'); + + $this->assertExitSuccess(); + $this->assertOutputContains('[PASS] Authentication: oauth mode'); + $this->assertOutputContains( + '[PASS] Resource: http://localhost/mcp, metadata at ' + . 'http://localhost/.well-known/oauth-protected-resource/mcp', + ); + $this->assertOutputContains('[PASS] Authenticators'); + } + + public function testUndeclaredResourceFails(): void + { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'resource' => 'http://localhost/other']); + + $this->exec('mcp check'); + + $this->assertExitError(); + $this->assertOutputContains('[FAIL] Resource'); + $this->assertOutputContains('OAuthServer.resources'); + } + + public function testInvalidSettingsFail(): void + { + Configure::write('Mcp.auth', ['mode' => 'oauth', 'scope' => []]); + + $this->exec('mcp check'); + + $this->assertExitError(); + $this->assertOutputContains('[FAIL] Authentication: Unknown key "Mcp.auth.scope"'); + } + + public function testResourceHostOutsideTheAllowedHostsFails(): void + { + Configure::write('Mcp.auth', ['mode' => 'external', 'resource' => 'https://mcp.example.com/mcp', 'external' => [ + 'issuer' => 'https://issuer.example', + ]]); + Configure::write('Mcp.http.allowedHosts', ['localhost']); + + $this->exec('mcp check'); + + $this->assertExitError(); + $this->assertOutputContains('[FAIL] Allowed hosts'); + $this->assertOutputContains('mcp.example.com'); + } + + public function testATokenAuthenticatorOnTheMcpRouteFails(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + Configure::write('TestApp.oauthTokenAuthenticator', true); + + $this->exec('mcp check'); + + $this->assertExitError(); + $this->assertOutputContains('[FAIL] Authenticators'); + $this->assertOutputContains('McpConfig::isEndpoint()'); + } +} diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index cb3ef0a..b8325a9 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -84,7 +84,10 @@ public function getAuthenticationService(ServerRequestInterface $request): Authe $service = new AuthenticationService(); if (Configure::read('TestApp.oauthTokenAuthenticator') === true) { // A host that also protects an API with the oauth plugin's Token authenticator (mcp check test). - $service->loadAuthenticator('CakeDC/OAuthServer.Token', ['resource' => OAuthTestConfig::API]); + $service->loadAuthenticator('OAuthToken', [ + 'className' => 'CakeDC/OAuthServer.Token', + 'resource' => OAuthTestConfig::API, + ]); } $service->loadAuthenticator('Authentication.Token', [ 'header' => 'Authorization', From 09117abcef54d6e5de387d1d35aa7c54912dc5eb Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:10:43 +0200 Subject: [PATCH 39/49] mcp check: report external audiences and verify the metadata route --- src/Command/McpCheckCommand.php | 61 +++++++++++++++- .../TestCase/Command/McpCheckCommandTest.php | 70 +++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/Command/McpCheckCommand.php b/src/Command/McpCheckCommand.php index 59d648b..c774fc7 100644 --- a/src/Command/McpCheckCommand.php +++ b/src/Command/McpCheckCommand.php @@ -14,6 +14,8 @@ use Cake\Http\Middleware\CsrfProtectionMiddleware; use Cake\Http\MiddlewareQueue; use Cake\Http\ServerRequest; +use Cake\Routing\Exception\MissingRouteException; +use Cake\Routing\Router; use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\OAuth\AuthSettings; @@ -75,6 +77,7 @@ protected function checks(): array 'Endpoint' => fn(): array => $this->endpoint(), 'Authentication' => fn(): array => $this->authentication(), 'Resource' => fn(): array => $this->resource(), + 'Metadata route' => fn(): array => $this->metadataRoute(), 'Allowed hosts' => fn(): array => $this->hosts(), 'CSRF' => fn(): array => $this->csrf(), 'Authenticators' => fn(): array => $this->authenticators(), @@ -124,13 +127,65 @@ private function resource(): array $metadata = $server->metadata(); $server->validator(new CurrentIdentity()); $scopes = $metadata->getScopesSupported() ?? []; - - return ['pass', sprintf( + $message = sprintf( '%s, metadata at %s, scopes: %s', $metadata->getResource(), $metadata->getMetadataUrl(), $scopes === [] ? 'none declared' : implode(' ', $scopes), - )]; + ); + $audiences = $settings->external?->audiences; + if ($audiences === null) { + return ['pass', $message]; + } + $message .= '; accepted audiences: ' . implode(', ', $audiences); + if (!in_array($settings->resource, $audiences, true)) { + return ['warn', $message + . '; the resource URL is not an accepted audience, so your identity provider must issue tokens ' + . 'with one of those "aud" values (an API identifier is fine, as long as it matches)']; + } + + return ['pass', $message]; + } + + /** + * Whether the RFC 9728 metadata route is connected and where the web server must serve it. + * + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function metadataRoute(): array + { + $settings = AuthSettings::fromConfig(); + if ($settings->mode === AuthSettings::HOST) { + return ['pass', 'not used in host mode']; + } + $route = McpConfig::get('route'); + if (!is_string($route) || $route === '') { + return ['fail', 'Mcp.route is disabled, so the metadata route is not connected']; + } + try { + $url = Router::url([ + 'plugin' => 'CakeDC/Mcp', + 'controller' => 'ProtectedResource', + 'action' => 'metadata', + 'prefix' => false, + ]); + } catch (MissingRouteException) { + return ['fail', 'the metadata route of the plugin is not connected: load the plugin routes']; + } + $advertised = (string)parse_url( + (new ResourceServer($settings))->metadata()->getMetadataUrl(), + PHP_URL_PATH, + ); + if ($url !== $advertised) { + return ['warn', sprintf( + 'the application serves the metadata at %s but the advertised URL path is %s; on a subdirectory ' + . 'install the web server must rewrite the well-known path to the application', + $url, + $advertised, + )]; + } + + return ['pass', $url]; } /** diff --git a/tests/TestCase/Command/McpCheckCommandTest.php b/tests/TestCase/Command/McpCheckCommandTest.php index c780ea4..1974b34 100644 --- a/tests/TestCase/Command/McpCheckCommandTest.php +++ b/tests/TestCase/Command/McpCheckCommandTest.php @@ -3,8 +3,12 @@ namespace CakeDC\Mcp\Test\TestCase\Command; +use Cake\Cache\Cache; +use Cake\Cache\Engine\ArrayEngine; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; +use Cake\Http\ServerRequest; +use Cake\Routing\Router; use Cake\TestSuite\TestCase; use CakeDC\Mcp\Test\Support\OAuthFixtures; use CakeDC\OAuthServer\OAuthServer; @@ -99,4 +103,70 @@ public function testATokenAuthenticatorOnTheMcpRouteFails(): void $this->assertOutputContains('[FAIL] Authenticators'); $this->assertOutputContains('McpConfig::isEndpoint()'); } + + public function testOAuthModeConnectsTheMetadataRoute(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + + $this->exec('mcp check'); + + $this->assertOutputContains('[PASS] Metadata route: /.well-known/oauth-protected-resource/mcp'); + } + + public function testHostModeDoesNotUseTheMetadataRoute(): void + { + $this->exec('mcp check'); + + $this->assertOutputContains('[PASS] Metadata route: not used in host mode'); + } + + public function testSubdirectoryInstallWarnsAboutTheWellKnownRewrite(): void + { + Configure::write('Mcp.auth', ['mode' => 'external', 'resource' => 'http://localhost/mcp', 'external' => [ + 'issuer' => 'https://issuer.example', + 'cache' => 'mcp_check_cache', + ]]); + Cache::setConfig('mcp_check_cache', ['className' => ArrayEngine::class]); + Configure::write('App.base', '/app'); + Router::reload(); + Router::setRequest(new ServerRequest(['base' => '/app'])); + + $this->exec('mcp check'); + Cache::drop('mcp_check_cache'); + + $this->assertOutputContains('[WARN] Metadata route'); + $this->assertOutputContains('rewrite'); + } + + public function testExternalModeReportsTheAudiences(): void + { + Configure::write('Mcp.auth', ['mode' => 'external', 'resource' => 'http://localhost/mcp', 'external' => [ + 'issuer' => 'https://issuer.example', + 'audience' => ['http://localhost/mcp', 'api://other'], + 'cache' => 'mcp_check_cache', + ]]); + Cache::setConfig('mcp_check_cache', ['className' => ArrayEngine::class]); + + $this->exec('mcp check'); + Cache::drop('mcp_check_cache'); + + $this->assertOutputContains('[PASS] Resource: http://localhost/mcp'); + $this->assertOutputContains('accepted audiences: http://localhost/mcp, api://other'); + } + + public function testAnAudienceThatIsNotTheResourceWarns(): void + { + Configure::write('Mcp.auth', ['mode' => 'external', 'resource' => 'http://localhost/mcp', 'external' => [ + 'issuer' => 'https://issuer.example', + 'audience' => 'https://api.example.com', + 'cache' => 'mcp_check_cache', + ]]); + Cache::setConfig('mcp_check_cache', ['className' => ArrayEngine::class]); + + $this->exec('mcp check'); + Cache::drop('mcp_check_cache'); + + $this->assertOutputContains('[WARN] Resource'); + $this->assertOutputContains('must issue tokens'); + } } From e9108bf7c965df5a9db6fdb38adae528b81f7fbe Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:12:24 +0200 Subject: [PATCH 40/49] mcp check: test the unconnected metadata route, reset test state --- src/Command/McpCheckCommand.php | 2 +- .../TestCase/Command/McpCheckCommandTest.php | 27 ++++++++++++++++--- tests/test_app/src/Application.php | 3 ++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Command/McpCheckCommand.php b/src/Command/McpCheckCommand.php index c774fc7..3ca8701 100644 --- a/src/Command/McpCheckCommand.php +++ b/src/Command/McpCheckCommand.php @@ -160,7 +160,7 @@ private function metadataRoute(): array } $route = McpConfig::get('route'); if (!is_string($route) || $route === '') { - return ['fail', 'Mcp.route is disabled, so the metadata route is not connected']; + return ['pass', 'no HTTP endpoint']; } try { $url = Router::url([ diff --git a/tests/TestCase/Command/McpCheckCommandTest.php b/tests/TestCase/Command/McpCheckCommandTest.php index 1974b34..c16ef97 100644 --- a/tests/TestCase/Command/McpCheckCommandTest.php +++ b/tests/TestCase/Command/McpCheckCommandTest.php @@ -29,6 +29,8 @@ protected function setUp(): void protected function tearDown(): void { OAuthServer::reset(); + Cache::drop('mcp_check_cache'); + Router::reload(); parent::tearDown(); } @@ -132,7 +134,6 @@ public function testSubdirectoryInstallWarnsAboutTheWellKnownRewrite(): void Router::setRequest(new ServerRequest(['base' => '/app'])); $this->exec('mcp check'); - Cache::drop('mcp_check_cache'); $this->assertOutputContains('[WARN] Metadata route'); $this->assertOutputContains('rewrite'); @@ -148,7 +149,6 @@ public function testExternalModeReportsTheAudiences(): void Cache::setConfig('mcp_check_cache', ['className' => ArrayEngine::class]); $this->exec('mcp check'); - Cache::drop('mcp_check_cache'); $this->assertOutputContains('[PASS] Resource: http://localhost/mcp'); $this->assertOutputContains('accepted audiences: http://localhost/mcp, api://other'); @@ -164,9 +164,30 @@ public function testAnAudienceThatIsNotTheResourceWarns(): void Cache::setConfig('mcp_check_cache', ['className' => ArrayEngine::class]); $this->exec('mcp check'); - Cache::drop('mcp_check_cache'); $this->assertOutputContains('[WARN] Resource'); $this->assertOutputContains('must issue tokens'); } + + public function testUnconnectedMetadataRouteFails(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + Configure::write('TestApp.mcpRoutes', false); + Router::reload(); + + $this->exec('mcp check'); + + $this->assertExitError(); + $this->assertOutputContains('[FAIL] Metadata route'); + } + + public function testDisabledRouteSkipsTheMetadataRoute(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + Configure::write('Mcp.route', false); + + $this->exec('mcp check'); + + $this->assertOutputContains('[PASS] Metadata route: no HTTP endpoint'); + } } diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index b8325a9..e0ac6a0 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -38,7 +38,8 @@ class Application extends BaseApplication implements AuthenticationServiceProvid */ public function bootstrap(): void { - $this->addPlugin(McpPlugin::class); + // `TestApp.mcpRoutes` false: a host that did not connect the plugin routes (mcp check test). + $this->addPlugin(McpPlugin::class, ['routes' => Configure::read('TestApp.mcpRoutes') !== false]); if (Configure::check('OAuthServer.resources')) { // The PHPUnit suite: the authorization server of the same application (Mcp.auth.mode oauth). $this->addPlugin(OAuthServerPlugin::class); From 0bcdfc3a06e3fd84afad61397b585fd1f861e6e6 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:15:45 +0200 Subject: [PATCH 41/49] Test the whole OAuth flow of a remote MCP client --- tests/TestCase/OAuth/ClientConnectsTest.php | 145 ++++++++++++++++++++ tests/bootstrap.php | 2 + 2 files changed, 147 insertions(+) create mode 100644 tests/TestCase/OAuth/ClientConnectsTest.php diff --git a/tests/TestCase/OAuth/ClientConnectsTest.php b/tests/TestCase/OAuth/ClientConnectsTest.php new file mode 100644 index 0000000..25fd65d --- /dev/null +++ b/tests/TestCase/OAuth/ClientConnectsTest.php @@ -0,0 +1,145 @@ + + */ + private function getJson(string $path): array + { + $this->_request['headers'] = ['Accept' => 'application/json']; + $this->get($path); + $this->assertResponseOk(); + + return (array)json_decode((string)$this->_response->getBody(), true); + } + + public function testAClientDiscoversSignsInCallsToolsAndIsRevoked(): void + { + // 1. The first call is challenged with the metadata URL. + $this->listTools(); + $this->assertResponseCode(401); + $metadataUrl = $this->challenge()['resource_metadata'] ?? ''; + $this->assertSame('http://localhost/.well-known/oauth-protected-resource/mcp', $metadataUrl); + + // 2. The metadata names the authorization server, whose own metadata names the endpoints. + $resource = $this->getJson((string)parse_url($metadataUrl, PHP_URL_PATH)); + $this->assertSame(['http://localhost'], $resource['authorization_servers']); + $server = $this->getJson('/.well-known/oauth-authorization-server'); + $this->assertSame('http://localhost', $server['issuer']); + + // 3. Dynamic registration, as Claude Code does. + $this->_request['headers'] = ['Content-Type' => 'application/json']; + $this->post((string)parse_url($server['registration_endpoint'], PHP_URL_PATH), (string)json_encode([ + 'client_name' => 'Claude Code', + 'redirect_uris' => [self::REDIRECT], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + 'token_endpoint_auth_method' => 'none', + ])); + $this->assertResponseCode(201); + $clientId = (string)((array)json_decode((string)$this->_response->getBody(), true))['client_id']; + + // 4. The person signs in with the application's own login and approves. + $query = http_build_query([ + 'response_type' => 'code', + 'client_id' => $clientId, + 'redirect_uri' => self::REDIRECT, + 'state' => 's', + 'scope' => 'notes:read', + 'code_challenge' => Pkce::challengeFor(self::VERIFIER), + 'code_challenge_method' => 'S256', + 'resource' => $resource['resource'], + ], '', '&', PHP_QUERY_RFC3986); + $this->_request['headers'] = []; + $authorize = (string)parse_url($server['authorization_endpoint'], PHP_URL_PATH); + $this->session(['Auth' => ['id' => 1]]); + $this->get($authorize . '?' . $query); + $this->assertResponseOk(); + $fields = Html::hiddenFields((string)$this->_response->getBody(), 'consent_token'); + unset($fields['_csrfToken']); + $this->enableCsrfToken(); + $this->post($authorize, $fields + ['decision' => 'approve']); + $location = $this->_response->getHeaderLine('Location'); + $this->assertStringStartsWith(self::REDIRECT . '?', $location); + parse_str((string)parse_url($location, PHP_URL_QUERY), $params); + $this->assertSame('http://localhost', $params['iss'] ?? null); + + // 5. The code is exchanged for tokens bound to the MCP resource. + $this->_request['headers'] = ['Content-Type' => 'application/x-www-form-urlencoded']; + $this->post((string)parse_url($server['token_endpoint'], PHP_URL_PATH), [ + 'grant_type' => 'authorization_code', + 'code' => $params['code'] ?? '', + 'redirect_uri' => self::REDIRECT, + 'code_verifier' => self::VERIFIER, + 'client_id' => $clientId, + 'resource' => $resource['resource'], + ]); + $this->assertResponseOk(); + $tokens = (array)json_decode((string)$this->_response->getBody(), true); + $access = (string)$tokens['access_token']; + + // 6. Tools work as the person who signed in. + $this->callTool('whoami', [], $access); + $this->assertResponseOk(); + $whoami = json_decode((string)($this->responseJson()['result']['content'][0]['text'] ?? ''), true); + $this->assertSame(1, $whoami['identifier'] ?? null); + + // 7. A tool beyond the granted scopes asks the client to step up. + $this->callTool('create_note', ['title' => 'Groceries'], $access); + $this->assertResponseCode(403); + $this->assertEqualsCanonicalizing( + ['notes:read', 'notes:write'], + explode(' ', $this->challenge()['scope'] ?? ''), + ); + + // 8. Revoked, the token stops working at once. + $this->_request['headers'] = ['Content-Type' => 'application/x-www-form-urlencoded']; + $this->post((string)parse_url($server['revocation_endpoint'], PHP_URL_PATH), [ + 'token' => $access, + 'client_id' => $clientId, + ]); + $this->assertResponseOk(); + $this->callTool('whoami', [], $access); + $this->assertResponseCode(401); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 955a2f0..92453a6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -32,6 +32,8 @@ require $pluginRoot . '/vendor/autoload.php'; require CORE_PATH . 'config' . DS . 'bootstrap.php'; +// The global h() and friends, which the oauth plugin's templates use. +require CAKE . 'functions.php'; Configure::write('debug', true); Configure::write('App', [ From f595602336c50436d26a5eaa77d44214157446f7 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:20:21 +0200 Subject: [PATCH 42/49] Document OAuth for the MCP endpoint --- CHANGELOG.md | 12 + README.md | 5 +- docs/authentication.md | 18 +- docs/authorization.md | 17 ++ docs/clients.md | 13 +- docs/configuration.md | 62 ++++ docs/installation.md | 16 ++ docs/oauth.md | 554 ++++++++++++++++++++++++++++++++++++ docs/testing.md | 23 ++ docs/troubleshooting.md | 7 + tests/TestCase/DocsTest.php | 70 +++++ 11 files changed, 789 insertions(+), 8 deletions(-) create mode 100644 docs/oauth.md create mode 100644 tests/TestCase/DocsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e9d08..0adf769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2.1.0-beta1 (unreleased) + +OAuth 2.1 for remote MCP clients. Existing applications keep working: `host` mode behaves as 2.0. + +- `Mcp.auth` with three modes: `host` (default, as 2.0), `oauth` (access tokens of `cakedc/cakephp-oauth-server` in the same application) and `external` (JWT access tokens of Auth0, Keycloak, Microsoft Entra ID, WorkOS or any OpenID Connect issuer, through `firebase/php-jwt` and `psr/cache`). The `Mcp.auth` section is validated strictly and fails closed. +- Protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` plus the MCP route, and the `401` challenge that points MCP clients to it. +- Scope policy per method and per tool (`Mcp.auth.scopes`), with `implies` and step-up through `403 insufficient_scope`. +- `McpConfig::isEndpoint()` recognises the MCP route and its metadata path, for the CSRF skip callback and to keep other bearer-token authenticators off them. +- `bin/cake mcp check` inspects the endpoint configuration (authentication, resource, metadata route, allowed hosts, CSRF, authenticators) without network calls. +- `McpTestTrait::mcpActingAs()` takes scopes and claims, and `assertMcpInsufficientScope()` asserts a refused call. +- Contributors: the PHPUnit suite now runs on SQLite with the oauth packages. It needs the sibling clones `../oauth-server` and `../cakephp-oauth-server` (path repositories); CI checks them out with the `CAKEDC_PRIVATE_REPOS_TOKEN` secret while they are private. + ## 2.0.0-beta1 (unreleased) Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. diff --git a/README.md b/README.md index 658a047..1b0b43e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Built on the official [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sd - `bin/cake mcp server` for local stdio clients, and `bin/cake mcp inspector` to try the server by hand. - Tools are plain PHP classes with `#[McpTool]` attributes, with constructor injection. - Authentication stays in your app (`cakephp/authentication`); the plugin reads the identity. +- OAuth 2.1 for remote clients: sign people in with your own login (`cakedc/cakephp-oauth-server`) or accept tokens of Auth0, Keycloak, Microsoft Entra ID or WorkOS, with scopes per tool. +- `bin/cake mcp check` finds what keeps remote clients from connecting, without network calls. - Per-tool authorization, including `config/permissions.php` rules through `cakedc/auth`. - `McpTestTrait` to test your tools, and who may call them, from a regular Cake test case. @@ -97,7 +99,7 @@ $service->loadAuthenticator('Authentication.Token', [ ## Connect Claude - Claude Code: `claude mcp add --transport http my-app https://example.com/mcp --header "Authorization: Bearer "` -- claude.ai: Settings, Connectors, Add custom connector, with the URL and (where available) a request header. +- claude.ai: Settings, Connectors, Add custom connector, with the URL and (where available) a request header. To have people sign in instead, see [OAuth](docs/oauth.md). ## Documentation @@ -105,6 +107,7 @@ $service->loadAuthenticator('Authentication.Token', [ - [Configuration](docs/configuration.md) - [Writing tools](docs/tools.md) - [Authentication](docs/authentication.md) +- [OAuth](docs/oauth.md) - [Authorization](docs/authorization.md) - [Testing your tools](docs/testing.md) - [stdio](docs/stdio.md) diff --git a/docs/authentication.md b/docs/authentication.md index 47f436e..185fd01 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -121,6 +121,18 @@ role, see [Authorization](authorization.md). ## OAuth -OAuth 2.1 (what claude.ai connectors expect for remote servers with -sign-in) is planned for 2.1. The identity middleware sits in the same slot the -SDK's OAuth middleware will take, so your tools will not need to change. +Remote clients such as claude.ai connectors sign people in with OAuth 2.1. +`Mcp.auth.mode` chooses how the endpoint authenticates: + +- `host` (default): everything above. Your authentication puts the identity in + the request. +- `oauth`: access tokens issued by `cakedc/cakephp-oauth-server` in the same + application, so people sign in with your own login. +- `external`: JWT access tokens issued by Auth0, Keycloak, Microsoft Entra ID, + WorkOS or another OpenID Connect issuer. + +In `oauth` and `external` modes the identity comes from the access token and +the identity attribute of your own authentication (`Mcp.identity.attribute`) is +ignored on the MCP route, as are `Mcp.identity.required` and +`Mcp.identity.scopes`. Tools still read the identity through `CurrentIdentity`. +The full guide is in [OAuth](oauth.md). diff --git a/docs/authorization.md b/docs/authorization.md index 49fef7e..17a60ba 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -132,6 +132,23 @@ anything. Write MCP rules with `'plugin' => 'CakeDC/Mcp', 'controller' => 'Tools and review the host-wide wildcard rules whenever the MCP rules share a permissions file with the rest of your application. +## Token scopes + +In `oauth` and `external` modes (see [OAuth](oauth.md)) a second check runs +before the tool authorizer: the scope policy in `Mcp.auth.scopes`. A call whose +token lacks the scopes of its method or tool is refused with `403 +insufficient_scope` and the list of scopes it needs, so clients can ask the +person for more. The tool authorizer still runs for calls that pass, so a call +needs both the scope and the permission. A tool the token has no scope for is +still listed by `tools/list`; the authorizer decides what is hidden. + +In `oauth` mode the identity the authorizer sees is the application identity of +the token owner, so `RbacToolAuthorizer` rules keep working. In `external` mode +the default identity is an `ArrayObject` with `id` and `claims` and no role, so +`cakedc/auth` applies its `default_role`; map the token to one of your users with +`Mcp.auth.identity` to use roles. See +[Identity in external mode](oauth.md#identity-in-external-mode). + ## stdio Per-tool authorization is skipped for stdio by default (`Mcp.stdio.trusted`). diff --git a/docs/clients.md b/docs/clients.md index c264db3..598b82f 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -11,6 +11,10 @@ Check that it connected with `claude mcp list`, or `/mcp` inside Claude Code. The server must be reachable from your machine, and its host must be allowed (`http.allowedHosts`; by default the host of `App.fullBaseUrl`, which only restricts anything when it is set explicitly, so set `http.allowedHosts` for a real deployment). +To sign in with OAuth instead of a header, configure `Mcp.auth` as described in +[OAuth](oauth.md), then add the server without `--header` and run `/mcp` inside +Claude Code. + For a server running on your own machine you can use the local URL, for example `https://my-app.ddev.site/mcp`. @@ -26,8 +30,8 @@ See [stdio](stdio.md). In claude.ai go to Settings, Connectors, Add custom connector, and enter the URL of your endpoint. Where your account allows custom request headers you can -send the Bearer token there. Connectors that sign the user in through OAuth -need the OAuth support planned for 2.1. +send the Bearer token there. To have the connector sign people in instead, use +`Mcp.auth.mode` `oauth` or `external`; see [OAuth](oauth.md#connecting-clients). ## MCP Inspector @@ -62,8 +66,9 @@ and macOS that needs PHP's `pcntl` extension, usual in the CLI; without it the command warns and prints the path to delete. If the PHP process is killed outright (for example `kill -9`), remove `mcp-inspector-*.json` from the temporary directory yourself. If the token is wrong, the endpoint answers -401 and the Inspector offers an OAuth sign-in, which this version does not -support: check the token instead. +401 and the Inspector offers an OAuth sign-in. That works when the endpoint +runs in `oauth` or `external` mode (see [OAuth](oauth.md)); in `host` mode, +check the token instead. You can also start the Inspector yourself with `npx @modelcontextprotocol/inspector`, choose the Streamable HTTP transport, diff --git a/docs/configuration.md b/docs/configuration.md index 033cb6d..87ae239 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,24 @@ want to change; everything else keeps its default. These are the defaults 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], 'identity' => ['required' => true, 'attribute' => 'identity', 'scopes' => null], 'authorization' => ['authorizer' => AllowAllToolAuthorizer::class, 'rbac' => []], + 'auth' => [ + 'mode' => 'host', + 'resource' => null, + 'resourceName' => null, + 'resourceDocumentation' => null, + 'scopes' => ['default' => [], 'methods' => [], 'tools' => [], 'implies' => []], + 'identity' => null, + 'external' => [ + 'issuer' => null, + 'audience' => null, + 'algorithms' => ['RS256'], + 'scopeClaim' => 'scope', + 'tokenType' => null, + 'leeway' => 0, + 'cache' => 'mcp_oauth', + 'httpClient' => null, + ], + ], 'stdio' => ['identity' => null, 'trusted' => true], ], ``` @@ -44,9 +62,53 @@ want to change; everything else keeps its default. These are the defaults - `identity.scopes`: optional `fn (mixed $identity): array` returning scope strings for the SDK access token. - `authorization.authorizer`: class name or instance of a `ToolAuthorizerInterface`. - `authorization.rbac`: options for `RbacToolAuthorizer` (the `cakedc/auth` Rbac config). +- `auth`: how the endpoint authenticates remote clients. See [Mcp.auth](#mcpauth). - `stdio.identity`: closure returning the identity for `bin/cake mcp server`. - `stdio.trusted`: skip per-tool authorization for stdio. +## Mcp.auth + +`Mcp.auth` chooses between your own authentication (`host`, the default) and +OAuth 2.1 access tokens (`oauth` and `external`). The whole story, with +examples, is in [OAuth](oauth.md). Unlike the rest of `Mcp`, this section is +validated strictly: an unknown key, a wrong type or a scope that the resource +does not declare makes the endpoint answer `500 Internal error` and logs the +setting that is wrong (read the `mcp` log scope, or run `bin/cake mcp check`). +Tools stay unreachable until it is fixed; a typo in a security setting never +opens anything. + +- `auth.mode`: `host` (default, the 2.0 behaviour: your authentication puts the identity in the request), `oauth` (tokens of `cakedc/cakephp-oauth-server` in the same application) or `external` (JWT access tokens of a third-party authorization server). +- `auth.resource`: the canonical URL of the MCP endpoint, which is the audience of its tokens. `null` derives it from `App.fullBaseUrl`, the base path of the application and `Mcp.route`. Set it when the application sits behind a proxy. Only `https` is accepted (plain `http` only for localhost), without fragment or credentials. Not used in `host` mode. +- `auth.resourceName`: optional name shown in the protected resource metadata. +- `auth.resourceDocumentation`: optional documentation URL shown in the protected resource metadata. +- `auth.scopes`: the scope policy, as below. Every scope named here must be declared by the resource in `oauth` mode. +- `auth.scopes.default`: list of scopes every MCP request needs. +- `auth.scopes.methods`: JSON-RPC method to list of scopes, for example `['tools/call' => ['content:read']]`. +- `auth.scopes.tools`: tool name to list of scopes, added to the ones of the method for that tool. +- `auth.scopes.implies`: scope to list of scopes it also grants, for example `['content:write' => ['content:read']]`. +- `auth.identity`: optional `fn (Mcp\Server\Authorization\AccessToken $token, mixed $identity): mixed` that replaces the identity tools and the authorizer see. Return `null` to reject the token with 401 `invalid_token`. See [OAuth](oauth.md#identity-in-external-mode). +- `auth.external`: the settings of `external` mode, as below. Ignored in the other modes. +- `auth.external.issuer`: required in `external` mode. The issuer URL, compared verbatim with the `iss` claim (a trailing slash counts). +- `auth.external.audience`: accepted `aud` value, a list of them, or `null` for the resource URL. +- `auth.external.algorithms`: accepted signature algorithms, a list. Only asymmetric ones (`RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`, `EdDSA`) are possible. Default `['RS256']`. +- `auth.external.scopeClaim`: the claim that holds the scopes. Default `scope` (a space separated string, or a list); Microsoft Entra ID uses `scp`. +- `auth.external.tokenType`: required `typ` header of the JWT, for example `at+jwt`. `null` accepts any. +- `auth.external.leeway`: clock skew tolerated, in seconds, from 0 to 300. Default 0. +- `auth.external.cache`: name of the Cake cache config for the discovery metadata and the signing keys. Created as a file cache when you define none. Default `mcp_oauth`. +- `auth.external.httpClient`: a PSR-18 client for discovery and key requests. `null` uses Cake's HTTP client with a 5 second timeout. + +A minimal `oauth` setup: + +```php +Configure::write('Mcp.auth', [ + 'mode' => 'oauth', + 'scopes' => [ + 'methods' => ['tools/call' => ['content:read']], + 'tools' => ['publish_post' => ['content:publish']], + ], +]); +``` + ## Lists replace defaults Associative arrays merge with the defaults, but a list replaces the default diff --git a/docs/installation.md b/docs/installation.md index 48def1f..a17f9d7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -52,6 +52,22 @@ $middlewareQueue->add($csrf); If you change `Mcp.route`, change the path here too. +## OAuth + +Remote clients that sign people in (claude.ai connectors, Claude Code, the MCP +Inspector) need `Mcp.auth` set to `oauth` or `external`. That also needs the +skip callback to cover the metadata path, so prefer this form, which follows +`Mcp.route`: + +```php +use CakeDC\Mcp\McpConfig; + +$csrf->skipCheckCallback(fn (ServerRequest $request): bool => McpConfig::isEndpoint($request)); +``` + +The setup, the optional packages and `bin/cake mcp check` are described in +[OAuth](oauth.md). + ## The route The plugin connects `/mcp` (methods GET, POST, DELETE and OPTIONS) to its own diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 0000000..0c4c567 --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,554 @@ +# OAuth 2.1 + +With OAuth, remote MCP clients sign people in instead of carrying a hand made +token. This page covers the whole setup: choosing a mode, configuring it, +connecting clients, testing and fixing what goes wrong. + +- [What OAuth gives you](#what-oauth-gives-you) +- [Choosing a mode](#choosing-a-mode) +- [oauth mode step by step](#oauth-mode-step-by-step) +- [external mode](#external-mode) +- [Scopes and tool authorization](#scopes-and-tool-authorization) +- [Connecting clients](#connecting-clients) +- [Subdirectory installs and proxies](#subdirectory-installs-and-proxies) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) + +## What OAuth gives you + +Clients such as claude.ai custom connectors, Claude Code and the MCP Inspector +find the sign-in on their own: + +1. They call `/mcp` without a token and get a `401` with + `WWW-Authenticate: Bearer resource_metadata="..."`. +2. That URL is the protected resource metadata (RFC 9728), served by the plugin + at `/.well-known/oauth-protected-resource/mcp`. It names the authorization + server. +3. The person signs in there, the client receives an access token and sends it + as `Authorization: Bearer `. + +Your tools do not change. They still receive the identity through +`CurrentIdentity` and the token through `RequestContext::getAccessToken()`, as +described in [Writing tools](tools.md). + +## Choosing a mode + +`Mcp.auth.mode` has three values: + +| Mode | Use it when | Who signs people in | Identity tools see | +| --- | --- | --- | --- | +| `host` (default) | Clients send a token your own authentication understands (2.0 behaviour). | Nobody: you issue the tokens. | What your `cakephp/authentication` setup put in the request. | +| `oauth` | The application has its own login and you want MCP clients to use it. | `cakedc/cakephp-oauth-server` in the same application: your login page, a consent page and a page where people see and revoke connections. | The application identity of the token owner, so RBAC rules keep working. | +| `external` | People already sign in at Auth0, Keycloak, Microsoft Entra ID, WorkOS or any other RFC 8414 or OpenID Connect issuer that issues JWT access tokens. | The external provider. | A small `ArrayObject` built from the token claims, or whatever `Mcp.auth.identity` returns. | + +In `oauth` and `external` modes the identity attribute of your own +authentication (`Mcp.identity.attribute`) is ignored on the MCP route: the +caller is whoever the access token says. A browser session cookie alone never +runs an MCP call. + +## oauth mode step by step + +### 1. Install the oauth plugin + +The two CakeDC OAuth packages are private repositories for now. Until they are +on Packagist, a root application with the default `stable` minimum stability +declares them as `vcs` repositories (Composer needs read access to both, for +example a GitHub token) and asks for the beta: + +```json +{ + "repositories": [ + {"type": "vcs", "url": "git@github.com:CakeDC/oauth-server.git"}, + {"type": "vcs", "url": "git@github.com:CakeDC/cakephp-oauth-server.git"} + ] +} +``` + +```bash +composer require "cakedc/cakephp-oauth-server:^1.0@beta" +``` + +When they are public they will be on Packagist and the `repositories` block +goes away. + +Load the plugin in `Application::bootstrap()`: + +```php +$this->addPlugin('CakeDC/OAuthServer'); +``` + +Run its migrations: + +```bash +bin/cake migrations migrate -p CakeDC/OAuthServer +``` + +If all your migrations live in `config/Migrations/`, copy the plugin's files +there instead, as the +[oauth plugin installation notes](https://github.com/CakeDC/cakephp-oauth-server/blob/1.next-cake5/docs/installation.md#migrations) +describe. Do not run both. + +### 2. Declare the MCP endpoint as a resource + +In the `OAuthServer` configuration the resource is keyed by the exact URL of +your endpoint: `App.fullBaseUrl`, the base path of the application and +`Mcp.route`. It must be the same string everywhere, because a token is issued +for one resource and refused anywhere else. + +```php +// config/app_local.php +'OAuthServer' => [ + 'resources' => [ + 'https://example.com/mcp' => [ + 'name' => 'Example MCP', + 'scopes' => [ + 'content:read' => 'Read your posts', + 'content:write' => 'Edit drafts', + 'content:publish' => 'Publish posts', + ], + 'implies' => [ + 'content:publish' => ['content:write'], + 'content:write' => ['content:read'], + ], + 'defaultScopes' => ['content:read'], + ], + ], +], +``` + +The description of each scope is what the person reads on the consent page. +The oauth plugin has the other settings (issuer, token lifetimes, client +registration) in its own documentation. + +### 3. Configure `Mcp.auth` + +```php +Configure::write('Mcp.auth', [ + 'mode' => 'oauth', + 'scopes' => [ + 'methods' => ['tools/call' => ['content:read']], + 'tools' => [ + 'create_draft' => ['content:write'], + 'publish_post' => ['content:publish'], + ], + ], +]); +``` + +Every scope named in `Mcp.auth.scopes` must be declared by the resource. If one +is not, the endpoint fails closed with a `500` and logs the name of the +scope. See [Scopes and tool authorization](#scopes-and-tool-authorization) for +how the policy works and every key in [Configuration](configuration.md#mcpauth). + +### 4. CSRF + +MCP clients and the OAuth endpoints are not browsers and cannot send a CSRF +token. Skip the check for both, and only for them: + +```php +use CakeDC\Mcp\McpConfig; +use CakeDC\OAuthServer\OAuthServer; + +$csrf = new CsrfProtectionMiddleware(); +$csrf->skipCheckCallback( + fn (ServerRequestInterface $request): bool => McpConfig::isEndpoint($request) + || OAuthServer::csrfSkip($request), +); +``` + +`McpConfig::isEndpoint()` is true for the MCP route and for its metadata path, +and follows `Mcp.route`, so you do not hard code `/mcp`. + +### 5. Keep the oauth Token authenticator off the MCP route + +The oauth plugin ships an authenticator, `CakeDC/OAuthServer.Token`, for the +other APIs of your application. On the MCP route it must not run: it answers a +token it does not accept for that path with its own `401` before this plugin +sees the request, and every MCP call fails. Load it only for other requests: + +```php +public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface +{ + $service = new AuthenticationService(); + $service->loadAuthenticator('Authentication.Session', ['sessionKey' => 'Auth']); + + if (!McpConfig::isEndpoint($request)) { + $service->loadAuthenticator('OAuthToken', [ + 'className' => 'CakeDC/OAuthServer.Token', + 'resource' => 'https://example.com/api', + ]); + } + + return $service; +} +``` + +If the application protects no other API with the oauth plugin, do not load +the authenticator at all. + +The example gives the authenticator its own alias, `OAuthToken`. Both +`Authentication.Token` and `CakeDC/OAuthServer.Token` default to the alias +`Token`, so a host that loads both must rename one of them. + +`bin/cake mcp check` reports this mistake (see below). + +### 6. Check the setup + +```bash +bin/cake oauth check +bin/cake mcp check +``` + +`bin/cake oauth check` is the oauth plugin's own check of its configuration, +tables and routes. `bin/cake mcp check` makes no network call and checks the +MCP side: + +| Check | What it looks at | +| --- | --- | +| Endpoint | The URL of the endpoint, or a warning when `Mcp.route` is disabled. | +| Authentication | The mode, or the `Mcp.auth` setting that is invalid. | +| Resource | The resource URL, the metadata URL and the scopes it advertises. In `oauth` mode it fails when the resource is not declared in `OAuthServer.resources`. In `external` mode it lists the accepted audiences and warns when the resource URL is not among them. | +| Metadata route | That the plugin routes are connected, and warns when the path the application serves differs from the advertised one (subdirectory installs need a rewrite, see below). | +| Allowed hosts | That the host of the resource is in `Mcp.http.allowedHosts`. | +| CSRF | That the CSRF middleware skips POST requests to the MCP route. | +| Authenticators | In `oauth` mode, that no authenticator of the application answers access tokens on the MCP route before the plugin. | + +Sample output: + +``` +[PASS] Endpoint: https://example.com/mcp +[PASS] Authentication: oauth mode: access tokens of cakedc/cakephp-oauth-server in this application +[PASS] Resource: https://example.com/mcp, metadata at https://example.com/.well-known/oauth-protected-resource/mcp, scopes: content:read content:write content:publish +[PASS] Metadata route: /.well-known/oauth-protected-resource/mcp +[PASS] Allowed hosts: example.com, localhost, 127.0.0.1, [::1] +[PASS] CSRF: the MCP route is skipped +[PASS] Authenticators: no authenticator answers access tokens at the MCP route first +``` + +A failing check looks like this, and the command exits with an error code: + +``` +[FAIL] Authenticators: an authenticator of the application (the CakeDC/OAuthServer.Token one) answers access tokens at /mcp before the MCP plugin sees them; leave it out for the MCP route, see McpConfig::isEndpoint() +``` + +### 7. The web server + +The well-known paths must reach Cake. Many nginx configurations deny every +path that starts with a dot (`location ~ /\. { deny all; }`), which also blocks +`/.well-known/`. Add this before that rule: + +```nginx +location ^~ /.well-known/oauth- { + try_files $uri /index.php?$args; +} +``` + +Apache with Cake's `.htaccess` needs nothing. + +## external mode + +Use it when an identity provider you do not run issues the tokens. The plugin +validates JWT access tokens: the signature against the provider's published +keys, `iss`, `aud`, the expiry and the scopes. Install the two libraries it +needs: + +```bash +composer require firebase/php-jwt psr/cache +``` + +Configure it: + +```php +Configure::write('Mcp.auth', [ + 'mode' => 'external', + 'external' => [ + 'issuer' => 'https://login.example.com/', + ], + 'scopes' => [ + 'methods' => ['tools/call' => ['content:read']], + ], +]); +``` + +The settings (all in [Configuration](configuration.md#mcpauth)): + +- `issuer`: required, compared verbatim with the `iss` claim, so a trailing + slash counts. +- `audience`: what `aud` must contain. The default is the resource URL + (`Mcp.auth.resource`, or derived from `App.fullBaseUrl` and `Mcp.route`). +- `algorithms`: asymmetric algorithms only, default `['RS256']`. +- `scopeClaim`: the claim with the scopes, default `scope`. +- `tokenType`: a required `typ` header, `null` by default. +- `leeway`: tolerated clock skew in seconds, up to 300. +- `cache`: the Cake cache config for discovery metadata and keys. +- `httpClient`: a PSR-18 client, for proxies or tests. + +The plugin fetches the issuer's discovery document and signing keys the first +time a token arrives, not at boot, and keeps them in the `mcp_oauth` cache +config (a file cache is created when you define none). To use Redis, define a +`mcp_oauth` cache config yourself. Nothing is fetched when you run +`bin/cake mcp check`. + +### Provider notes + +These are starting points. Providers change their defaults and their settings +differ per account: decode a real access token from your provider and check +its `iss`, `aud`, `typ` and scope claim against the values below before you +rely on them. + +- **Auth0.** Issuer `https://YOUR_TENANT.auth0.com/` (with the trailing slash). + Create an API in Auth0 and use its identifier as `audience`. Scopes are + usually in the `scope` claim. +- **Keycloak.** Issuer `https://HOST/realms/REALM`. Add an audience mapper to + the client scope so that `aud` carries the resource you set in `audience`. +- **Microsoft Entra ID.** Issuer `https://login.microsoftonline.com/TENANT_ID/v2.0`. + Delegated scopes are usually in the `scp` claim, so set + `'scopeClaim' => 'scp'`. The audience is typically the application ID of the + API you registered. +- **WorkOS AuthKit.** The issuer is your AuthKit domain. + +`bin/cake mcp check` prints the accepted audiences and warns when the resource +URL is not one of them. That is fine when your provider uses an API identifier +as `aud`, as long as you set `audience` to that identifier. + +### Identity in external mode + +Without `Mcp.auth.identity` the tools and the authorizer see an `ArrayObject` +with two keys: + +```php +new ArrayObject(['id' => $sub, 'claims' => $allClaimsOfTheToken]); +``` + +The claims are never merged at the top level. That is deliberate: a `role` +claim set by the identity provider could otherwise pass as the role of your +application. As a result `RbacToolAuthorizer` sees no role and `cakedc/auth` +applies its `default_role`. A host that uses RBAC in external mode maps the +token to one of its own users with `Mcp.auth.identity`: + +```php +use Mcp\Server\Authorization\AccessToken; + +Configure::write('Mcp.auth.identity', function (AccessToken $token, mixed $identity): mixed { + $email = $token->getClaims()['email'] ?? null; + if (!is_string($email)) { + return null; + } + $user = TableRegistry::getTableLocator()->get('Users')->find() + ->where(['email' => $email, 'active' => true]) + ->first(); + + return $user === null ? null : new Identity($user); +}); +``` + +The callable receives the access token and the default identity, and returns +the identity to use. Returning `null` rejects the token with `401 invalid_token`, +so people unknown to your application cannot call tools. The same setting works +in `oauth` mode, where the default is the application identity of the token +owner. + +## Scopes and tool authorization + +`Mcp.auth.scopes` is the policy: + +```php +'scopes' => [ + 'default' => ['content:read'], // every request + 'methods' => ['tools/call' => ['content:write']], // per JSON-RPC method + 'tools' => ['publish_post' => ['content:publish']], // per tool, on top of the method + 'implies' => ['content:publish' => ['content:write']], +], +``` + +- A request needs the `default` scopes, the scopes of its method and, for + `tools/call`, the scopes of that tool. +- `implies` makes a scope grant others, so a token with `content:publish` also + satisfies `content:write`. +- A token that lacks scopes gets `403` with + `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`, where + `scope` lists the scopes the call needs. Clients use it to ask the person + for more (step-up) instead of failing. +- `tools/list` still lists tools whose scopes the token lacks. The policy + decides on calls. + +The tool authorizer (`RbacToolAuthorizer` or yours, see +[Authorization](authorization.md)) still runs after the scope check, so a call +needs both the scope and the permission. In `oauth` mode the identity is the +application identity of the token owner, so the rules you already have keep +working. + +### Which scopes the clients ask for + +The metadata advertises as `scopes_supported` every scope the resource +declares in `oauth` mode (and, in `external` mode, every scope named in +`Mcp.auth.scopes`), and the `scope` of the `401` challenge lists the same. +MCP clients typically request all of them the first time they connect. If you +declare ten scopes on the resource, the person is asked to approve ten. + +To keep to least privilege, declare only the scopes the MCP endpoint needs +on its resource (the scopes of other resources are not advertised here), or +declare a small default set and rely on step-up: the client asks for more only +when a call answers `insufficient_scope`. + +## Connecting clients + +The endpoint and the authorization server must be reachable over https from +wherever the client runs. For a server on your own machine use a tunnel when +the client is a hosted service. + +### claude.ai + +Add a custom connector (Settings, Connectors) with the URL of your endpoint, +for example `https://example.com/mcp`. The client has to reach it from the +internet. How a client registers with your authorization server (a client +metadata document or dynamic registration) is handled by the oauth plugin, +which supports both; see its documentation on clients for the switches. + +### Claude Code + +```bash +claude mcp add --transport http my-app https://example.com/mcp +``` + +Then run `/mcp` inside Claude Code and choose the server to sign in. + +### MCP Inspector + +```bash +bin/cake mcp inspector --http +``` + +Use the OAuth sign-in the Inspector offers when the server answers `401`. + +### Consent on every connection + +Redirect URIs that are loopback addresses or private schemes (the kind native +tools use, such as Claude Code or the MCP Inspector) are asked for consent on +every new connection. Refresh tokens keep an existing connection alive without +asking again. A client with an https redirect URI (claude.ai) is approved +silently once the person has consented. Redirect URIs with a private scheme are +refused unless the oauth server's `allowPrivateSchemes` is enabled; see the +oauth plugin's configuration. + +## Subdirectory installs and proxies + +The resource carries the base path, for example `https://example.com/app/mcp`, +but RFC 9728 puts the metadata at the host root: +`https://example.com/.well-known/oauth-protected-resource/app/mcp`. That is +outside the application, so the web server must send it to the application. +In nginx: + +```nginx +location = /.well-known/oauth-protected-resource/app/mcp { + rewrite ^ /app/.well-known/oauth-protected-resource/mcp last; +} +``` + +`bin/cake mcp check` warns about the mismatch (`Metadata route`) because the +path the application serves and the advertised one differ, as they do on a +subdirectory install. The warning is the reminder to add the rewrite. + +Behind a proxy that terminates TLS, set `App.fullBaseUrl` to the public https +URL, or set `Mcp.auth.resource` explicitly. The resource must be the URL the +client types, not the internal one. + +### Caching the metadata + +The metadata answers `Cache-Control: public, max-age=300`. Cake's CSRF +middleware adds its cookie on GET requests even when your skip callback covers +the path (the callback is only consulted for state-changing methods), so the +response can carry a `Set-Cookie`. Keep shared caches from storing responses +with `Set-Cookie`: the defaults of nginx `proxy_cache`, Varnish and Cloudflare +already refuse them. Otherwise scope the CSRF middleware away from the +well-known path. + +## Testing + +In a Cake test case, `McpTestTrait` acts as a token with scopes without HTTP: + +```php +$this->mcpActingAs($user, ['content:read']); +$this->mcpCallTool('publish_post', ['id' => 1]); +$this->assertMcpInsufficientScope(['content:read', 'content:publish']); + +$this->mcpActingAs($user, ['content:publish']); +$this->mcpCallTool('publish_post', ['id' => 1]); +$this->assertMcpToolSucceeded(); +``` + +See [Testing your tools](testing.md#scopes). To test the HTTP endpoint with +real tokens, use the oauth plugin's `OAuthTestTrait` (it issues personal +tokens, so `OAuthServer.personalTokens` must be enabled in the test +configuration) next to `IntegrationTestTrait`: + +```php +use Cake\TestSuite\IntegrationTestTrait; +use CakeDC\OAuthServer\TestSuite\OAuthTestTrait; + +class McpEndpointTest extends TestCase +{ + use IntegrationTestTrait; + use OAuthTestTrait; + + public function testReaderCanListTools(): void + { + $token = $this->oauthTokenFor(1, ['content:read'], 'https://example.com/mcp'); + $this->configRequest([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + ]); + + $this->post('/mcp', json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'])); + + $this->assertResponseOk(); + } +} +``` + +## Troubleshooting + +**The client loops on sign-in.** Request the endpoint without a token and look +at the challenge, then fetch the metadata URL it names: + +```bash +curl -i -X POST https://example.com/mcp +curl -i https://example.com/.well-known/oauth-protected-resource/mcp +``` + +The first answers `401` with a `resource_metadata` URL. The second answers `200` +JSON that lists your authorization server. If either is wrong, run +`bin/cake mcp check`. + +**"issued for another resource" or `401 invalid_token`.** The resource URL +differs from the one the client used when it asked for the token. Compare +`App.fullBaseUrl`, the base path, the scheme (`http` or `https`) and a trailing +slash in the metadata, in `OAuthServer.resources` and in the URL the client +typed. + +**The well-known path answers 403 or 404 from the web server.** The web server +is blocking dot paths or not sending them to `index.php`. See +[The web server](#7-the-web-server). + +**403 `insufficient_scope`.** The token lacks scopes the call needs. The +`scope` of the `WWW-Authenticate` header lists them. A well behaved client asks +the person for more; otherwise reconnect and approve the scopes. + +**Every token is rejected with "not valid at this address".** The oauth +plugin's `Token` authenticator is running on the MCP route and answers before +this plugin. Load it only when `!McpConfig::isEndpoint($request)`; see +[step 5](#5-keep-the-oauth-token-authenticator-off-the-mcp-route). +`bin/cake mcp check` fails the `Authenticators` check in this case. + +**500 on `/mcp` after enabling OAuth.** The configuration is invalid (an unknown +`Mcp.auth` key, a scope the resource does not declare, a resource that is not +in `OAuthServer.resources`). The response is always the generic +`Internal error`; the reason is in the `mcp` log scope. Run +`bin/cake mcp check` to see it. + +**403 "Invalid Host header".** DNS rebinding protection. The host of the +resource must be in `Mcp.http.allowedHosts`; by default only the host of +`App.fullBaseUrl` and localhost are. See [Troubleshooting](troubleshooting.md). diff --git a/docs/testing.md b/docs/testing.md index 0d659d1..0788118 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -62,6 +62,28 @@ Tools see the identity through `CurrentIdentity`, and through the SDK access token built as the endpoint builds it (identifier as subject, scopes from `Mcp.identity.scopes`). Without `mcpActingAs()` the calls are anonymous. +### Scopes + +`mcpActingAs($identity, $scopes = null, $claims = [])` also takes the scopes of +the access token and extra claims for it (they win over `sub`). With `$scopes` +given, and `Mcp.auth.mode` set to `oauth` or `external`, the `Mcp.auth.scopes` +policy is applied first, as the HTTP endpoint would, and `Mcp.identity.scopes` +is not used. A call the policy refuses is checked with +`assertMcpInsufficientScope()`: + +```php +Configure::write('Mcp.auth.scopes.tools.publish_post', ['content:publish']); + +$this->mcpActingAs($user, ['content:read']); +$this->mcpCallTool('publish_post', ['id' => 1]); +$this->assertMcpInsufficientScope(['content:publish']); +``` + +Pass the scopes the call needs to `assertMcpInsufficientScope()`, or nothing to +accept any. It is only meaningful with scopes given to `mcpActingAs()`; without +them, the policy is not applied. For the HTTP endpoint with real tokens see +[OAuth](oauth.md#testing). + The trait does not go through your HTTP authentication, so it does not check tokens or `Mcp.identity.required`. Cover that with an integration test of the endpoint (`IntegrationTestTrait`, a `POST` to `/mcp` with your @@ -84,6 +106,7 @@ endpoint (`IntegrationTestTrait`, a `POST` to `/mcp` with your | `assertMcpToolError(?$contains)` | returned a tool error, the kind the model reads (`ToolCallException`, `ToolError::invalid()`). | | `assertMcpJsonRpcError(?$code, ?$contains)` | was answered with a JSON-RPC error: `-32602` for arguments that do not match the input schema, `-32603` for an unexpected exception. | | `assertMcpToolDenied()` | was rejected by your authorizer. It also checks that the tool exists, because a denied tool is answered like an unknown one and a typo would pass otherwise. | +| `assertMcpInsufficientScope(?$scopes)` | was refused because the token lacks scopes, the way the endpoint answers `403 insufficient_scope`. With `$scopes` it also checks the scopes the call needs (any order). | | `assertMcpResultContains($text)` | returned text containing `$text`. | | `assertMcpStructuredContent($expected)` | returned this `structuredContent`. Key order does not matter, value types do. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b81a346..f53f697 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,12 @@ # Troubleshooting +## OAuth sign-in problems + +Clients that loop on sign-in, `401 invalid_token`, `403 insufficient_scope`, a +well-known path blocked by the web server and a `500` after enabling OAuth are +covered in [OAuth](oauth.md#troubleshooting). `bin/cake mcp check` finds most of +them without a network call. + ## The tool list is empty - A class in a scan directory fails to load (a syntax error, a missing diff --git a/tests/TestCase/DocsTest.php b/tests/TestCase/DocsTest.php new file mode 100644 index 0000000..0bd04a0 --- /dev/null +++ b/tests/TestCase/DocsTest.php @@ -0,0 +1,70 @@ + + */ + private function markdownFiles(): array + { + $files = [self::ROOT . 'README.md', self::ROOT . 'CHANGELOG.md']; + foreach (glob(self::ROOT . 'docs/*.md') ?: [] as $file) { + $files[] = $file; + } + + return $files; + } + + public function testEveryAuthSettingIsDocumented(): void + { + $configuration = (string)file_get_contents(self::ROOT . 'docs/configuration.md'); + $keys = []; + foreach (McpConfig::DEFAULTS['auth'] as $key => $value) { + $keys[] = 'auth.' . $key; + if (is_array($value) && !array_is_list($value)) { + foreach (array_keys($value) as $child) { + $keys[] = 'auth.' . $key . '.' . $child; + } + } + } + foreach ($keys as $key) { + $this->assertStringContainsString('`' . $key . '`', $configuration, $key . ' is not documented'); + } + } + + public function testRelativeLinksResolve(): void + { + foreach ($this->markdownFiles() as $file) { + preg_match_all('/\]\((?!https?:|mailto:|#)([^)#\s]+)/', (string)file_get_contents($file), $matches); + foreach ($matches[1] as $link) { + $this->assertFileExists(dirname($file) . '/' . $link, basename($file) . ' links to ' . $link); + } + } + } + + public function testNoEmOrEnDashes(): void + { + $files = $this->markdownFiles(); + $sources = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(self::ROOT . 'src')); + foreach ($sources as $source) { + if ($source->isFile() && $source->getExtension() === 'php') { + $files[] = $source->getPathname(); + } + } + foreach ($files as $file) { + $text = (string)file_get_contents($file); + $this->assertStringNotContainsString("\u{2014}", $text, $file . ' has an em-dash'); + $this->assertStringNotContainsString("\u{2013}", $text, $file . ' has an en-dash'); + } + } +} From 7fdb9c524170acfee79248b5651ac9dba8cc3896 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:23:04 +0200 Subject: [PATCH 43/49] Fix the external identity example in the OAuth docs --- docs/oauth.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/oauth.md b/docs/oauth.md index 0c4c567..e77eb2e 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -327,6 +327,8 @@ applies its `default_role`. A host that uses RBAC in external mode maps the token to one of its own users with `Mcp.auth.identity`: ```php +use Authentication\Identity; +use Cake\Datasource\FactoryLocator; use Mcp\Server\Authorization\AccessToken; Configure::write('Mcp.auth.identity', function (AccessToken $token, mixed $identity): mixed { @@ -334,7 +336,7 @@ Configure::write('Mcp.auth.identity', function (AccessToken $token, mixed $ident if (!is_string($email)) { return null; } - $user = TableRegistry::getTableLocator()->get('Users')->find() + $user = FactoryLocator::get('Table')->get('Users')->find() ->where(['email' => $email, 'active' => true]) ->first(); @@ -343,8 +345,13 @@ Configure::write('Mcp.auth.identity', function (AccessToken $token, mixed $ident ``` The callable receives the access token and the default identity, and returns -the identity to use. Returning `null` rejects the token with `401 invalid_token`, -so people unknown to your application cannot call tools. The same setting works +the identity to use: whatever your authentication normally produces (here an +`Authentication\Identity` wrapping the user entity). Returning `null` rejects the token with `401 invalid_token`, +so people unknown to your application cannot call tools. + +A JWT without a `sub` claim has no default identity, so in `external` mode it +is rejected with `401 invalid_token` unless `Mcp.auth.identity` maps it (for +example from an `email` claim, as above). The same setting works in `oauth` mode, where the default is the application identity of the token owner. @@ -479,8 +486,8 @@ $this->assertMcpToolSucceeded(); See [Testing your tools](testing.md#scopes). To test the HTTP endpoint with real tokens, use the oauth plugin's `OAuthTestTrait` (it issues personal -tokens, so `OAuthServer.personalTokens` must be enabled in the test -configuration) next to `IntegrationTestTrait`: +tokens, which are enabled by default in `OAuthServer.personalTokens`, so +this works unless you disabled them) next to `IntegrationTestTrait`: ```php use Cake\TestSuite\IntegrationTestTrait; From eb233aacd0b32c20406c9970b53beb2d437d0a88 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 16:24:18 +0200 Subject: [PATCH 44/49] Tidy the external identity example in the OAuth docs --- docs/oauth.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/oauth.md b/docs/oauth.md index e77eb2e..8a94c51 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -328,6 +328,7 @@ token to one of its own users with `Mcp.auth.identity`: ```php use Authentication\Identity; +use Cake\Core\Configure; use Cake\Datasource\FactoryLocator; use Mcp\Server\Authorization\AccessToken; @@ -346,14 +347,16 @@ Configure::write('Mcp.auth.identity', function (AccessToken $token, mixed $ident The callable receives the access token and the default identity, and returns the identity to use: whatever your authentication normally produces (here an -`Authentication\Identity` wrapping the user entity). Returning `null` rejects the token with `401 invalid_token`, -so people unknown to your application cannot call tools. +`Authentication\Identity` wrapping the user entity). Returning `null` rejects +the token with `401 invalid_token`, so people unknown to your application +cannot call tools. A JWT without a `sub` claim has no default identity, so in `external` mode it is rejected with `401 invalid_token` unless `Mcp.auth.identity` maps it (for -example from an `email` claim, as above). The same setting works -in `oauth` mode, where the default is the application identity of the token -owner. +example from an `email` claim, as above). + +`Mcp.auth.identity` also works in `oauth` mode, where the default identity is +the application identity of the token owner. ## Scopes and tool authorization From fa94299e19d97df650b35793a5285d3529b1a024 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 21:45:19 +0200 Subject: [PATCH 45/49] Fix the OAuth review findings in code and tests Reject null, false and scalar identities from Mcp.auth.identity, expand oauth test scopes with the declared resource's implies, add McpConfig::rbacPermissions(), make mcp check read the cakedc/users authenticators and guard the oauth plugin class. --- src/Command/McpCheckCommand.php | 115 +++++++++++++++- src/McpConfig.php | 21 +++ src/OAuth/ExternalTokenValidator.php | 6 +- src/OAuth/OAuthServerTokenValidator.php | 6 +- src/OAuth/ResourceServer.php | 25 +++- src/TestSuite/McpTestTrait.php | 14 +- .../TestCase/Command/McpCheckCommandTest.php | 129 +++++++++++++++++- tests/TestCase/Controller/McpOAuthTest.php | 40 ++++++ .../Controller/OAuthDocsExampleTest.php | 66 +++++++++ tests/TestCase/McpConfigTest.php | 43 ++++++ .../OAuth/ExternalTokenValidatorTest.php | 29 ++++ .../OAuth/OAuthServerTokenValidatorTest.php | 30 ++++ tests/TestCase/OAuth/ResourceServerTest.php | 17 +++ .../TestSuite/McpTestTraitScopesTest.php | 41 +++++- tests/test_app/src/Application.php | 24 +++- .../Authenticator/ApiTokenAuthenticator.php | 39 ++++++ tests/test_app/src/UsersHost/Application.php | 71 ++++++++++ 17 files changed, 693 insertions(+), 23 deletions(-) create mode 100644 tests/TestCase/Controller/OAuthDocsExampleTest.php create mode 100644 tests/test_app/src/Authenticator/ApiTokenAuthenticator.php create mode 100644 tests/test_app/src/UsersHost/Application.php diff --git a/src/Command/McpCheckCommand.php b/src/Command/McpCheckCommand.php index 3ca8701..ffa5761 100644 --- a/src/Command/McpCheckCommand.php +++ b/src/Command/McpCheckCommand.php @@ -5,9 +5,12 @@ use Authentication\AuthenticationServiceProviderInterface; use Authentication\Authenticator\AuthenticationRequiredException; +use Authentication\Authenticator\ResultInterface; +use Authentication\Identifier\IdentifierCollection; use Cake\Command\Command; use Cake\Console\Arguments; use Cake\Console\ConsoleIo; +use Cake\Core\App; use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Http\BaseApplication; @@ -20,6 +23,7 @@ use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\OAuth\AuthSettings; use CakeDC\Mcp\OAuth\ResourceServer; +use CakeDC\OAuthServer\Authenticator\TokenAuthenticator; use CakeDC\OAuthServer\OAuthServer; use ReflectionProperty; use Throwable; @@ -253,16 +257,24 @@ private function authenticators(): array if ($settings->mode !== AuthSettings::OAUTH || !is_string($route) || $route === '') { return ['pass', 'nothing to check outside oauth mode']; } + if (!$this->oauthServerInstalled()) { + return ['fail', ResourceServer::OAUTH_INSTALL_HINT]; + } $app = $this->application(); if (is_string($app)) { return ['warn', $app . '; its authenticators were not inspected']; } - if (!$app instanceof AuthenticationServiceProviderInterface) { - return ['pass', 'the application provides no authentication service']; - } $prefix = OAuthServer::server()->config()->tokenPrefix; $probe = (new ServerRequest(['url' => $route, 'environment' => ['REQUEST_METHOD' => 'POST']])) ->withHeader('Authorization', 'Bearer ' . $prefix . '_at_' . str_repeat('A', 43)); + if (!$app instanceof AuthenticationServiceProviderInterface) { + $configured = Configure::read('Auth.Authenticators'); + if (Plugin::isLoaded('CakeDC/Users') && is_array($configured)) { + return $this->usersAuthenticators($configured, $settings, $probe); + } + + return ['pass', 'the application provides no authentication service']; + } try { $app->getAuthenticationService($probe)->authenticate($probe); } catch (AuthenticationRequiredException) { @@ -277,6 +289,103 @@ private function authenticators(): array return ['pass', 'no authenticator answers access tokens at the MCP route first']; } + /** + * cakedc/users builds the authentication service from `Auth.Authenticators` and runs it on every request, + * the MCP route included, and the oauth plugin's integration puts its Token authenticator there. That + * authenticator answers every token of the authorization server: below its paths it validates the token for + * its resource, anywhere else it refuses it with 401. MCP tokens therefore get through only when it + * validates the MCP resource at the MCP route, or when its class leaves the MCP endpoint alone. + * + * @param array $configured `Auth.Authenticators`. + * @param \CakeDC\Mcp\OAuth\AuthSettings $settings The `Mcp.auth` settings. + * @param \Cake\Http\ServerRequest $probe A request with a token of this server at the MCP route. + * @return array{0: 'pass'|'warn'|'fail', 1: string} + */ + private function usersAuthenticators(array $configured, AuthSettings $settings, ServerRequest $probe): array + { + $metadata = (new ResourceServer($settings))->metadata(); + $resource = $metadata->getResource(); + $path = (string)parse_url($resource, PHP_URL_PATH); + $warning = null; + foreach ($configured as $name => $options) { + $class = is_array($options) ? ($options['className'] ?? $name) : $name; + $class = is_string($class) ? App::className($class, 'Authenticator', 'Authenticator') : null; + if ($class === null || !is_a($class, TokenAuthenticator::class, true) || !is_array($options)) { + continue; + } + unset($options['className']); + $authenticator = new $class(new IdentifierCollection(), $options); + $setting = 'Auth.Authenticators.' . $name; + try { + ['resource' => $accepted, 'paths' => $paths] = $authenticator->resolve(); + } catch (Throwable $e) { + return ['fail', sprintf( + 'the Token authenticator of cakedc/users (%s) refuses every access token it sees: %s', + $setting, + $e->getMessage(), + )]; + } + $result = $authenticator->authenticate($probe); + if ($result->getStatus() === ResultInterface::FAILURE_CREDENTIALS_MISSING) { + // Its class leaves the MCP endpoint alone. + continue; + } + if ($accepted !== $resource || !self::within($path, $paths)) { + return ['fail', sprintf( + 'the Token authenticator of cakedc/users (%s) runs on every request and refuses MCP access ' + . 'tokens at %s: it accepts tokens for %s below %s only. See "cakedc/users" in the OAuth ' + . 'documentation of the plugin', + $setting, + $probe->getPath(), + $accepted, + implode(', ', $paths), + )]; + } + if ($authenticator->getConfig('resourceMetadataUrl') !== $metadata->getMetadataUrl()) { + $warning = sprintf( + 'the Token authenticator of cakedc/users (%s) validates MCP access tokens at %s before the ' + . 'MCP plugin, for the same resource, but the 401 it answers for a refused token does not name ' + . 'the metadata; set its resourceMetadataUrl to %s', + $setting, + $probe->getPath(), + $metadata->getMetadataUrl(), + ); + } + } + + return $warning === null + ? ['pass', 'no authenticator of cakedc/users refuses MCP access tokens at the MCP route'] + : ['warn', $warning]; + } + + /** + * Whether $path is one of $prefixes or below one, on segment boundaries. + * + * @param string $path A normalized path. + * @param list $prefixes Normalized path prefixes. + * @return bool + */ + private static function within(string $path, array $prefixes): bool + { + foreach ($prefixes as $prefix) { + if ($prefix === '/' || $path === $prefix || str_starts_with($path, rtrim($prefix, '/') . '/')) { + return true; + } + } + + return false; + } + + /** + * Whether cakedc/cakephp-oauth-server is installed. + * + * @return bool + */ + protected function oauthServerInstalled(): bool + { + return class_exists(OAuthServer::class); + } + /** * The host application, without replacing the running plugin collection; or why it cannot be built. * diff --git a/src/McpConfig.php b/src/McpConfig.php index 67362b7..834ed7d 100644 --- a/src/McpConfig.php +++ b/src/McpConfig.php @@ -144,6 +144,27 @@ private static function merge(array $base, array $override): array return $base; } + /** + * The RBAC rule (cakedc/auth, `config/permissions.php`) that lets every request reach the MCP endpoint and + * its protected resource metadata. cakedc/users authorizes the route before the controller runs, so without + * it an anonymous request is redirected to the login page instead of receiving the plugin's 401 and its + * metadata. The plugin decides access itself (authentication, scopes, then the tool authorizer). + * + * @return list> + */ + public static function rbacPermissions(): array + { + return [ + [ + 'role' => '*', + 'plugin' => 'CakeDC/Mcp', + 'controller' => ['Mcp', 'ProtectedResource'], + 'action' => ['handle', 'metadata'], + 'bypassAuth' => true, + ], + ]; + } + /** * Whether $request is for the MCP endpoint or its protected resource metadata. Use it to skip * Cake's CSRF check there, and to keep other bearer-token authenticators off these paths. diff --git a/src/OAuth/ExternalTokenValidator.php b/src/OAuth/ExternalTokenValidator.php index dc33b5e..087a71a 100644 --- a/src/OAuth/ExternalTokenValidator.php +++ b/src/OAuth/ExternalTokenValidator.php @@ -20,7 +20,8 @@ final class ExternalTokenValidator implements AuthorizationTokenValidatorInterfa /** * @param \Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface $validator Validates the JWT. * @param \CakeDC\Mcp\Identity\CurrentIdentity $current Receives the identity. - * @param \Closure|null $identity `fn(AccessToken $token, mixed $identity): mixed` (`Mcp.auth.identity`). + * @param \Closure|null $identity `fn(AccessToken $token, mixed $identity): mixed` (`Mcp.auth.identity`); + * null, false or any other scalar rejects the token with 401 `invalid_token`. */ public function __construct( private readonly AuthorizationTokenValidatorInterface $validator, @@ -44,7 +45,8 @@ public function validate(string $accessToken): AuthorizationResult if ($this->identity !== null) { $identity = ($this->identity)($token, $identity); } - if ($identity === null) { + // An identity is an array or an object: null, false or any other scalar never passes for one. + if (!is_array($identity) && !is_object($identity)) { return AuthorizationResult::unauthorized( 'invalid_token', 'The owner of this access token is not known to this application.', diff --git a/src/OAuth/OAuthServerTokenValidator.php b/src/OAuth/OAuthServerTokenValidator.php index 8530d70..d4ae660 100644 --- a/src/OAuth/OAuthServerTokenValidator.php +++ b/src/OAuth/OAuthServerTokenValidator.php @@ -21,7 +21,8 @@ final class OAuthServerTokenValidator implements AuthorizationTokenValidatorInte * @param \CakeDC\OAuth\Server\Token\TokenValidator $validator The authorization server's validator. * @param string $resource The MCP endpoint's resource identifier. * @param \CakeDC\Mcp\Identity\CurrentIdentity $current Receives the identity. - * @param \Closure|null $identity `fn(AccessToken $token, mixed $identity): mixed` (`Mcp.auth.identity`). + * @param \Closure|null $identity `fn(AccessToken $token, mixed $identity): mixed` (`Mcp.auth.identity`); + * null, false or any other scalar rejects the token with 401 `invalid_token`. */ public function __construct( private readonly TokenValidator $validator, @@ -55,7 +56,8 @@ public function validate(string $accessToken): AuthorizationResult if ($this->identity !== null) { $identity = ($this->identity)($token, $identity); } - if ($identity === null) { + // An identity is an array or an object: null, false or any other scalar never passes for one. + if (!is_array($identity) && !is_object($identity)) { return AuthorizationResult::unauthorized( 'invalid_token', 'The owner of this access token is not known to this application.', diff --git a/src/OAuth/ResourceServer.php b/src/OAuth/ResourceServer.php index a69cf30..099cde5 100644 --- a/src/OAuth/ResourceServer.php +++ b/src/OAuth/ResourceServer.php @@ -26,6 +26,12 @@ */ final class ResourceServer { + /** + * What oauth mode needs when cakedc/cakephp-oauth-server is missing. + */ + public const OAUTH_INSTALL_HINT = '"Mcp.auth.mode" oauth needs cakedc/cakephp-oauth-server: install it as ' + . '"Install the oauth plugin" in docs/oauth.md describes and load the CakeDC/OAuthServer plugin.'; + private ?AuthorizationServer $authorizationServer = null; private ?ProtectedResource $declared = null; @@ -101,6 +107,20 @@ public function validator(CurrentIdentity $identity): AuthorizationTokenValidato }; } + /** + * The scopes a token carrying $scopes holds when it reaches the scope policy: in oauth mode $scopes plus + * what they imply through the `implies` of the declared resource, the expansion the authorization server + * applies when it validates the token; unchanged in external mode. `Mcp.auth.scopes.implies` is applied + * afterwards by the policy, in both modes. + * + * @param list $scopes The scopes of the token. + * @return list + */ + public function grantedScopes(array $scopes): array + { + return $this->settings->mode === AuthSettings::OAUTH ? $this->declared()->expand($scopes) : $scopes; + } + /** * @return string */ @@ -179,10 +199,7 @@ private function authorizationServer(): AuthorizationServer return $this->authorizationServer; } if (!class_exists(OAuthServer::class)) { - throw new RuntimeException( - '"Mcp.auth.mode" oauth needs cakedc/cakephp-oauth-server: run ' - . '`composer require cakedc/cakephp-oauth-server` and load the CakeDC/OAuthServer plugin.', - ); + throw new RuntimeException(self::OAUTH_INSTALL_HINT); } $server = OAuthServer::server(); $resources = $server->config()->resources; diff --git a/src/TestSuite/McpTestTrait.php b/src/TestSuite/McpTestTrait.php index 6dae067..79711a2 100644 --- a/src/TestSuite/McpTestTrait.php +++ b/src/TestSuite/McpTestTrait.php @@ -15,6 +15,7 @@ use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\Middleware\IdentityMiddleware; use CakeDC\Mcp\OAuth\AuthSettings; +use CakeDC\Mcp\OAuth\ResourceServer; use CakeDC\Mcp\Server\ServerFactory; use Closure; use Mcp\Schema\Enum\ProtocolVersion; @@ -79,8 +80,11 @@ trait McpTestTrait * produces (an entity, an `ArrayObject`, an array...). Null makes them anonymous. * * With $scopes the access token carries them, and in the oauth and external modes the - * `Mcp.auth.scopes` policy is applied first, as the HTTP endpoint would: a call it refuses is - * checked with `assertMcpInsufficientScope()`. + * `Mcp.auth.scopes` policy is applied first, as the HTTP endpoint would. In oauth mode the scopes + * are first expanded with the `implies` of the resource declared in `OAuthServer.resources`, as the + * authorization server does for the endpoint. A `tools/call` the policy refuses is checked with + * `assertMcpInsufficientScope()`; any other refused request (the `tools/list` behind `mcpListTools()` + * and the `assertMcpTool*Listed()` assertions) fails the test instead of returning the 403 answer. * * @param mixed $identity The identity. * @param list|null $scopes The token's scopes; null keeps the `identity.scopes` callable. @@ -240,7 +244,7 @@ public function assertMcpToolDenied(string $message = ''): void /** * Asserts the last call was refused because the access token lacks scopes, as the HTTP endpoint * answers with 403 `insufficient_scope` (only with scopes given to `mcpActingAs()`, in the oauth and - * external modes). + * external modes). Only tool calls get this answer: a refused `tools/list` fails the test right away. * * @param list|null $scopes The scopes the call needs, any when null. * @param string $message Shown on failure. @@ -400,7 +404,9 @@ private function mcpRequest(string $method, ?array $params, bool $trusted = fals $policy = $settings->mode === AuthSettings::HOST ? null : $settings->scopePolicy(); if ($policy !== null) { $required = $policy->requiredFor($request); - $granted = $policy->expand($token->getScopes()); + // In oauth mode the authorization server expands the token's scopes with the `implies` of + // the declared resource before the policy sees them; the policy then adds its own `implies`. + $granted = $policy->expand((new ResourceServer($settings))->grantedScopes($token->getScopes())); if (array_diff($required, $granted) !== []) { if ($method !== 'tools/call') { Assert::fail(sprintf( diff --git a/tests/TestCase/Command/McpCheckCommandTest.php b/tests/TestCase/Command/McpCheckCommandTest.php index c16ef97..1cfb0f6 100644 --- a/tests/TestCase/Command/McpCheckCommandTest.php +++ b/tests/TestCase/Command/McpCheckCommandTest.php @@ -5,13 +5,19 @@ use Cake\Cache\Cache; use Cake\Cache\Engine\ArrayEngine; +use Cake\Console\ConsoleIo; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Console\TestSuite\StubConsoleInput; +use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Core\Configure; +use Cake\Core\Plugin; use Cake\Http\ServerRequest; use Cake\Routing\Router; use Cake\TestSuite\TestCase; +use CakeDC\Mcp\Command\McpCheckCommand; use CakeDC\Mcp\Test\Support\OAuthFixtures; use CakeDC\OAuthServer\OAuthServer; +use TestApp\Authenticator\ApiTokenAuthenticator; use TestApp\OAuthTestConfig; class McpCheckCommandTest extends TestCase @@ -31,9 +37,20 @@ protected function tearDown(): void OAuthServer::reset(); Cache::drop('mcp_check_cache'); Router::reload(); + // The stand-in for cakedc/users must not reach the next test. + Plugin::getCollection()->remove('CakeDC/Users'); parent::tearDown(); } + /** + * The cakedc/users host of the test app, in oauth mode. + */ + private function usersHost(): void + { + Configure::write('App.namespace', 'TestApp\\UsersHost'); + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + } + public function testHostMode(): void { $this->exec('mcp check'); @@ -97,7 +114,7 @@ public function testResourceHostOutsideTheAllowedHostsFails(): void public function testATokenAuthenticatorOnTheMcpRouteFails(): void { Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); - Configure::write('TestApp.oauthTokenAuthenticator', true); + Configure::write('TestApp.oauthTokenAuthenticator', 'everywhere'); $this->exec('mcp check'); @@ -190,4 +207,114 @@ public function testDisabledRouteSkipsTheMetadataRoute(): void $this->assertOutputContains('[PASS] Metadata route: no HTTP endpoint'); } + + public function testTheOAuthPluginCheckPassesWithTheDocumentedSetup(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + + $this->exec('oauth check'); + + $this->assertExitSuccess(); + $this->assertOutputNotContains('[FAIL]'); + $this->assertOutputContains('[PASS] Authenticators: Token comes before Session'); + } + + public function testTheOAuthPluginCheckFailsWhenTheSessionComesFirst(): void + { + // The order docs/oauth.md showed before: Session, then the Token authenticator. + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + Configure::write('TestApp.sessionFirst', true); + + $this->exec('oauth check'); + + $this->assertExitError(); + $this->assertOutputContains('[FAIL] Authenticators: the Session authenticator comes before the Token'); + } + + public function testAnAuthenticatorThatLeavesTheMcpEndpointAlonePasses(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + Configure::write('TestApp.oauthTokenAuthenticator', 'skipsMcp'); + + $this->exec('mcp check'); + + $this->assertExitSuccess(); + $this->assertOutputContains('[PASS] Authenticators'); + } + + public function testMissingOAuthPluginFailsWithTheInstallHint(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + $out = new StubConsoleOutput(); + $command = new class extends McpCheckCommand { + protected function oauthServerInstalled(): bool + { + return false; + } + }; + + $code = $command->run([], new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput([]))); + + $this->assertSame(McpCheckCommand::CODE_ERROR, $code); + $this->assertStringContainsString( + '[FAIL] Authenticators: "Mcp.auth.mode" oauth needs cakedc/cakephp-oauth-server', + $out->output(), + ); + } + + public function testCakeDcUsersTokenAuthenticatorForTheMcpResourceWarnsWithoutTheMetadataUrl(): void + { + // The oauth plugin's integration registers its Token authenticator for the default resource, the MCP one. + $this->usersHost(); + + $this->exec('mcp check'); + + $this->assertExitSuccess(); + $this->assertOutputContains('[WARN] Authenticators: the Token authenticator of cakedc/users'); + $this->assertOutputContains('Auth.Authenticators.OAuthServerToken'); + $this->assertOutputContains('resourceMetadataUrl'); + $this->assertOutputContains('http://localhost/.well-known/oauth-protected-resource/mcp'); + } + + public function testCakeDcUsersTokenAuthenticatorWithTheMetadataUrlPasses(): void + { + $this->usersHost(); + Configure::write('Auth.Authenticators.OAuthServerToken', [ + 'className' => 'CakeDC/OAuthServer.OAuthServerToken', + 'skipTwoFactorVerify' => true, + 'resourceMetadataUrl' => 'http://localhost/.well-known/oauth-protected-resource/mcp', + ]); + + $this->exec('mcp check'); + + $this->assertExitSuccess(); + $this->assertOutputContains('[PASS] Authenticators'); + } + + public function testCakeDcUsersTokenAuthenticatorForAnotherResourceFails(): void + { + $this->usersHost(); + Configure::write('OAuthServer.users', ['resource' => OAuthTestConfig::API]); + + $this->exec('mcp check'); + + $this->assertExitError(); + $this->assertOutputContains('[FAIL] Authenticators: the Token authenticator of cakedc/users'); + $this->assertOutputContains('refuses MCP access tokens at /mcp'); + } + + public function testCakeDcUsersTokenAuthenticatorThatLeavesTheMcpEndpointAlonePasses(): void + { + $this->usersHost(); + Configure::write('OAuthServer.users', ['resource' => OAuthTestConfig::API]); + Configure::write('Auth.Authenticators.OAuthServerToken', [ + 'className' => ApiTokenAuthenticator::class, + 'resource' => OAuthTestConfig::API, + ]); + + $this->exec('mcp check'); + + $this->assertExitSuccess(); + $this->assertOutputContains('[PASS] Authenticators'); + } } diff --git a/tests/TestCase/Controller/McpOAuthTest.php b/tests/TestCase/Controller/McpOAuthTest.php index efbfd8b..1ab54ec 100644 --- a/tests/TestCase/Controller/McpOAuthTest.php +++ b/tests/TestCase/Controller/McpOAuthTest.php @@ -121,6 +121,46 @@ public function testHostTokensAreNotAcceptedInOAuthMode(): void $this->assertSame('invalid_token', $this->challenge()['error'] ?? null); } + public function testIdentityResolverReturningFalseIsChallenged(): void + { + Configure::write('Mcp.auth.identity', fn(): bool => false); + $token = $this->oauthTokenFor(1, ['notes:read', 'notes:write'], OAuthTestConfig::MCP); + + $this->callTool('whoami', [], $token); + $this->assertResponseCode(401); + $this->assertSame('invalid_token', $this->challenge()['error'] ?? null); + + $this->callTool('create_note', ['title' => 'Groceries'], $token); + $this->assertResponseCode(401); + } + + public function testATokenAuthenticatorForAnotherResourceOnTheMcpRouteRefusesEveryCall(): void + { + // What docs/oauth.md (step 5, cakedc/users) warns about: the oauth plugin's Token authenticator for + // the API, run on every request, refuses MCP tokens outside its paths before the plugin sees them. + Configure::write('TestApp.oauthTokenAuthenticator', 'everywhere'); + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $this->callTool('whoami', [], $token); + + $this->assertResponseCode(401); + $this->assertStringContainsString('not valid at this address', $this->challenge()['error_description'] ?? ''); + } + + public function testATokenAuthenticatorThatLeavesTheMcpEndpointAloneLetsMcpCallsThrough(): void + { + // The authenticator class docs/oauth.md (cakedc/users) shows, run on every request. + Configure::write('TestApp.oauthTokenAuthenticator', 'skipsMcp'); + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $this->callTool('whoami', [], $token); + $this->assertResponseOk(); + + $this->callTool('whoami', [], 'test_at_' . str_repeat('A', 43)); + $this->assertResponseCode(401); + $this->assertSame(self::METADATA_URL, $this->challenge()['resource_metadata'] ?? null); + } + public function testDeactivatedOwnerLosesAccessOnTheNextCall(): void { $token = $this->oauthTokenFor(2, ['notes:read'], OAuthTestConfig::MCP); diff --git a/tests/TestCase/Controller/OAuthDocsExampleTest.php b/tests/TestCase/Controller/OAuthDocsExampleTest.php new file mode 100644 index 0000000..8a3995d --- /dev/null +++ b/tests/TestCase/Controller/OAuthDocsExampleTest.php @@ -0,0 +1,66 @@ +oauthTokenFor(1, ['notes:read'], 'http://localhost/mcp'); + $this->configRequest([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/list', + ], + ]); + + $this->post('/mcp', (string)json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities' => new stdClass(), + ]], + ])); + + $this->assertResponseOk(); + $tools = json_decode((string)$this->_response?->getBody(), true)['result']['tools'] ?? []; + $this->assertContains('create_note', array_column($tools, 'name')); + } +} diff --git a/tests/TestCase/McpConfigTest.php b/tests/TestCase/McpConfigTest.php index 6f9b63d..db5225a 100644 --- a/tests/TestCase/McpConfigTest.php +++ b/tests/TestCase/McpConfigTest.php @@ -5,9 +5,12 @@ use Cake\Core\Configure; use Cake\Http\ServerRequest; +use Cake\Routing\Router; use Cake\TestSuite\TestCase; +use CakeDC\Auth\Rbac\Rbac; use CakeDC\Mcp\Authorization\AllowAllToolAuthorizer; use CakeDC\Mcp\McpConfig; +use CakeDC\Mcp\McpPlugin; class McpConfigTest extends TestCase { @@ -101,4 +104,44 @@ public function testIsEndpointIsFalseWhenTheRouteIsDisabled(): void $this->assertFalse(McpConfig::isEndpoint(new ServerRequest(['url' => '/mcp']))); } + + public function testRbacPermissionsBypassAuthForTheEndpointAndItsMetadata(): void + { + $this->assertSame([ + [ + 'role' => '*', + 'plugin' => 'CakeDC/Mcp', + 'controller' => ['Mcp', 'ProtectedResource'], + 'action' => ['handle', 'metadata'], + 'bypassAuth' => true, + ], + ], McpConfig::rbacPermissions()); + } + + public function testRbacPermissionsLetAnonymousRequestsReachThePluginOnly(): void + { + Router::reload(); + (new McpPlugin())->routes(Router::createRouteBuilder('/')); + $rbac = new Rbac(['permissions' => McpConfig::rbacPermissions(), 'log' => false]); + $routed = fn(string $url, string $method): ServerRequest => (new ServerRequest([ + 'url' => $url, + 'environment' => ['REQUEST_METHOD' => $method], + ]))->withAttribute('params', Router::parseRequest(new ServerRequest([ + 'url' => $url, + 'environment' => ['REQUEST_METHOD' => $method], + ]))); + + try { + $this->assertTrue($rbac->checkPermissions([], $routed('/mcp', 'POST'))); + $this->assertTrue($rbac->checkPermissions([], $routed('/.well-known/oauth-protected-resource/mcp', 'GET'))); + $other = (new ServerRequest(['url' => '/users/delete']))->withAttribute('params', [ + 'plugin' => null, + 'controller' => 'Users', + 'action' => 'delete', + ]); + $this->assertFalse($rbac->checkPermissions([], $other)); + } finally { + Router::reload(); + } + } } diff --git a/tests/TestCase/OAuth/ExternalTokenValidatorTest.php b/tests/TestCase/OAuth/ExternalTokenValidatorTest.php index edad12f..90b45d2 100644 --- a/tests/TestCase/OAuth/ExternalTokenValidatorTest.php +++ b/tests/TestCase/OAuth/ExternalTokenValidatorTest.php @@ -177,4 +177,33 @@ public function testFailedDiscoveryIs401AndBacksOff(): void $this->assertSame(401, $second->getStatusCode()); $this->assertCount($requests, $client->requested); } + + /** + * @return array + */ + public static function notAnIdentity(): array + { + return [ + 'false' => [false], + 'true' => [true], + 'zero' => [0], + 'integer' => [7], + 'empty string' => [''], + 'string' => ['user-1'], + 'float' => [1.5], + ]; + } + + #[DataProvider('notAnIdentity')] + public function testIdentityResolverReturningAScalarRejectsTheToken(mixed $returned): void + { + Configure::write('Mcp.auth.identity', fn(): mixed => $returned); + + $result = $this->server()->validator($this->current)->validate(TestJwt::token()); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertFalse($this->current->has()); + } } diff --git a/tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php b/tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php index 649f3e5..e6360e5 100644 --- a/tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php +++ b/tests/TestCase/OAuth/OAuthServerTokenValidatorTest.php @@ -11,6 +11,7 @@ use CakeDC\OAuthServer\OAuthServer; use CakeDC\OAuthServer\TestSuite\OAuthTestTrait; use Mcp\Server\Authorization\AccessToken; +use PHPUnit\Framework\Attributes\DataProvider; use TestApp\OAuthTestConfig; class OAuthServerTokenValidatorTest extends TestCase @@ -122,4 +123,33 @@ public function testIdentityResolverReturningNullRejectsTheToken(): void $this->assertSame('invalid_token', $result->getError()); $this->assertNull($this->current->get()); } + + /** + * @return array + */ + public static function notAnIdentity(): array + { + return [ + 'false' => [false], + 'true' => [true], + 'zero' => [0], + 'integer' => [7], + 'empty string' => [''], + 'string' => ['user-1'], + 'float' => [1.5], + ]; + } + + #[DataProvider('notAnIdentity')] + public function testIdentityResolverReturningAScalarRejectsTheToken(mixed $returned): void + { + $token = $this->oauthTokenFor(1, ['notes:read'], OAuthTestConfig::MCP); + + $result = $this->validator(fn(): mixed => $returned)->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertFalse($this->current->has()); + } } diff --git a/tests/TestCase/OAuth/ResourceServerTest.php b/tests/TestCase/OAuth/ResourceServerTest.php index 7d1c6dd..d63d308 100644 --- a/tests/TestCase/OAuth/ResourceServerTest.php +++ b/tests/TestCase/OAuth/ResourceServerTest.php @@ -87,4 +87,21 @@ public function testHostModeHasNoValidator(): void $this->expectException(LogicException::class); $this->server()->validator(new CurrentIdentity()); } + + public function testOAuthModeGrantsWhatTheDeclaredResourceImplies(): void + { + $this->assertEqualsCanonicalizing(['notes:write', 'notes:read'], $this->server()->grantedScopes(['notes:write'])); + $this->assertSame(['notes:read'], $this->server()->grantedScopes(['notes:read'])); + } + + public function testExternalModeGrantsTheScopesUnchanged(): void + { + Configure::write('Mcp.auth', [ + 'mode' => 'external', + 'external' => ['issuer' => 'https://issuer.example'], + 'scopes' => ['implies' => ['notes:write' => ['notes:read']]], + ]); + + $this->assertSame(['notes:write'], $this->server()->grantedScopes(['notes:write'])); + } } diff --git a/tests/TestCase/TestSuite/McpTestTraitScopesTest.php b/tests/TestCase/TestSuite/McpTestTraitScopesTest.php index 5a43dfe..b20e4d0 100644 --- a/tests/TestCase/TestSuite/McpTestTraitScopesTest.php +++ b/tests/TestCase/TestSuite/McpTestTraitScopesTest.php @@ -7,6 +7,7 @@ use Cake\Core\Configure; use Cake\TestSuite\TestCase; use CakeDC\Mcp\TestSuite\McpTestTrait; +use CakeDC\OAuthServer\OAuthServer; use PHPUnit\Framework\AssertionFailedError; use TestApp\OAuthTestConfig; @@ -17,9 +18,16 @@ class McpTestTraitScopesTest extends TestCase protected function setUp(): void { parent::setUp(); + OAuthServer::reset(); Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); } + protected function tearDown(): void + { + OAuthServer::reset(); + parent::tearDown(); + } + public function testEnoughScopesCallTheTool(): void { $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:read', 'notes:write']); @@ -29,9 +37,11 @@ public function testEnoughScopesCallTheTool(): void $this->assertMcpToolSucceeded(); } - public function testImpliedScopesCount(): void + public function testTheResourceImpliesCountInOAuthMode(): void { - Configure::write('Mcp.auth.scopes.implies', ['notes:write' => ['notes:read']]); + // Mcp.auth.scopes.implies is empty: notes:write implies notes:read in the OAuthServer resource, as the + // authorization server expands it for the HTTP endpoint. + $this->assertSame([], Configure::read('Mcp.auth.scopes.implies') ?? []); $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:write']); $this->mcpCallTool('create_note', ['title' => 'Groceries']); @@ -39,6 +49,33 @@ public function testImpliedScopesCount(): void $this->assertMcpToolSucceeded(); } + public function testExternalModeUsesTheConfiguredImplies(): void + { + Configure::write('Mcp.auth', [ + 'mode' => 'external', + 'external' => ['issuer' => 'https://issuer.example'], + 'scopes' => OAuthTestConfig::MCP_AUTH['scopes'], + ]); + $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:write']); + + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + $this->assertMcpInsufficientScope(['notes:read', 'notes:write']); + + Configure::write('Mcp.auth.scopes.implies', ['notes:write' => ['notes:read']]); + $this->mcpCallTool('create_note', ['title' => 'Groceries']); + $this->assertMcpToolSucceeded(); + } + + public function testARefusedRequestOtherThanAToolCallFailsTheTest(): void + { + Configure::write('Mcp.auth.scopes.methods', ['tools/list' => ['admin']]); + $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:read']); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('The access token lacks scopes for `tools/list`'); + $this->mcpListTools(); + } + public function testMissingScopesAreReportedLikeTheEndpointWould(): void { $this->mcpActingAs(new ArrayObject(['id' => 1]), ['notes:read']); diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index e0ac6a0..b5db85d 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -23,6 +23,7 @@ use CakeDC\OAuthServer\OAuthServer; use CakeDC\OAuthServer\OAuthServerPlugin; use Psr\Http\Message\ServerRequestInterface; +use TestApp\Authenticator\ApiTokenAuthenticator; use TestApp\Middleware\AuthorizationSpyMiddleware; /** @@ -83,10 +84,21 @@ public function routes(RouteBuilder $routes): void public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface { $service = new AuthenticationService(); - if (Configure::read('TestApp.oauthTokenAuthenticator') === true) { - // A host that also protects an API with the oauth plugin's Token authenticator (mcp check test). + if (Configure::read('TestApp.sessionFirst') === true) { + // The wrong order, which `oauth check` fails (oauth check test). + $service->loadAuthenticator('Authentication.Session', ['sessionKey' => 'Auth']); + } + // docs/oauth.md, step 5: the oauth plugin's Token authenticator protects the application's other API, + // first in line and never on the MCP endpoint. `TestApp.oauthTokenAuthenticator`: `everywhere`, a host + // that left the guard out (mcp check test); `skipsMcp`, the authenticator of docs/oauth.md (cakedc/users) + // that leaves the MCP endpoint alone by itself, loaded for every request. + $variant = Configure::read('TestApp.oauthTokenAuthenticator'); + if ( + Plugin::isLoaded('CakeDC/OAuthServer') + && ($variant === 'everywhere' || $variant === 'skipsMcp' || !McpConfig::isEndpoint($request)) + ) { $service->loadAuthenticator('OAuthToken', [ - 'className' => 'CakeDC/OAuthServer.Token', + 'className' => $variant === 'skipsMcp' ? ApiTokenAuthenticator::class : 'CakeDC/OAuthServer.Token', 'resource' => OAuthTestConfig::API, ]); } @@ -99,8 +111,10 @@ public function getAuthenticationService(ServerRequestInterface $request): Authe ], ], ]); - // The browser login the oauth plugin's consent page needs. - $service->loadAuthenticator('Authentication.Session', ['sessionKey' => 'Auth']); + if (Configure::read('TestApp.sessionFirst') !== true) { + // The browser login the oauth plugin's consent page needs, after the Token authenticators. + $service->loadAuthenticator('Authentication.Session', ['sessionKey' => 'Auth']); + } return $service; } diff --git a/tests/test_app/src/Authenticator/ApiTokenAuthenticator.php b/tests/test_app/src/Authenticator/ApiTokenAuthenticator.php new file mode 100644 index 0000000..9b6aad2 --- /dev/null +++ b/tests/test_app/src/Authenticator/ApiTokenAuthenticator.php @@ -0,0 +1,39 @@ +addPlugin(new BasePlugin([ + 'name' => 'CakeDC/Users', + 'bootstrap' => false, + 'routes' => false, + 'middleware' => false, + 'console' => false, + 'services' => false, + 'events' => false, + ])); + $this->addPlugin(McpPlugin::class); + $this->addPlugin(OAuthServerPlugin::class); + } + + /** + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The queue. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + $csrf = new CsrfProtectionMiddleware(); + $csrf->skipCheckCallback(fn(ServerRequest $request): bool => McpConfig::isEndpoint($request) + || OAuthServer::csrfSkip($request)); + + return $middlewareQueue + ->add(new RoutingMiddleware($this)) + ->add(new BodyParserMiddleware()) + ->add($csrf); + } + + /** + * @param \Cake\Routing\RouteBuilder $routes The builder. + * @return void + */ + public function routes(RouteBuilder $routes): void + { + $routes->scope('/', function (RouteBuilder $builder): void { + $builder->fallbacks(); + }); + } +} From 306e0152fc2051a9b67e6d72c613314c3d284d02 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Oct 2026 21:45:19 +0200 Subject: [PATCH 46/49] Update the OAuth docs, changelog and CI for the review findings --- .github/workflows/ci.yml | 10 +- CHANGELOG.md | 8 +- docs/authentication.md | 27 ++++- docs/configuration.md | 4 +- docs/installation.md | 10 +- docs/oauth.md | 228 +++++++++++++++++++++++++++++++++------ docs/testing.md | 10 +- docs/troubleshooting.md | 5 +- 8 files changed, 251 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2731ad9..097e2c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,12 @@ permissions: contents: read # The OAuth tests resolve cakedc/oauth-server and cakedc/cakephp-oauth-server from path repositories -# (../oauth-server and ../cakephp-oauth-server), so every job checks them out next to this one. While -# those repositories are private, the CAKEDC_PRIVATE_REPOS_TOKEN secret must hold a token that can -# read them; once they are public the workflow token is enough. +# (../oauth-server and ../cakephp-oauth-server), so every job checks them out next to this one, at a +# released tag so CI does not drift with their branches. Both repositories are public, so the workflow +# token is enough; a CAKEDC_PRIVATE_REPOS_TOKEN secret is used instead when the organisation defines one. env: - OAUTH_SERVER_REF: '1.x' - OAUTH_PLUGIN_REF: '1.next-cake5' + OAUTH_SERVER_REF: '1.0.0-beta1' + OAUTH_PLUGIN_REF: '1.0.0-beta1' jobs: testsuite: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0adf769..9589c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ OAuth 2.1 for remote MCP clients. Existing applications keep working: `host` mod - Protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` plus the MCP route, and the `401` challenge that points MCP clients to it. - Scope policy per method and per tool (`Mcp.auth.scopes`), with `implies` and step-up through `403 insufficient_scope`. - `McpConfig::isEndpoint()` recognises the MCP route and its metadata path, for the CSRF skip callback and to keep other bearer-token authenticators off them. -- `bin/cake mcp check` inspects the endpoint configuration (authentication, resource, metadata route, allowed hosts, CSRF, authenticators) without network calls. -- `McpTestTrait::mcpActingAs()` takes scopes and claims, and `assertMcpInsufficientScope()` asserts a refused call. -- Contributors: the PHPUnit suite now runs on SQLite with the oauth packages. It needs the sibling clones `../oauth-server` and `../cakephp-oauth-server` (path repositories); CI checks them out with the `CAKEDC_PRIVATE_REPOS_TOKEN` secret while they are private. +- `bin/cake mcp check` inspects the endpoint configuration (authentication, resource, metadata route, allowed hosts, CSRF, authenticators, including the `Auth.Authenticators` of `cakedc/users`) without network calls. +- `McpTestTrait::mcpActingAs()` takes scopes and claims, and `assertMcpInsufficientScope()` asserts a refused call. In `oauth` mode the scopes are expanded with the `implies` of the declared resource, as the authorization server does. +- `McpConfig::rbacPermissions()` returns the `bypassAuth` rule `cakedc/users` hosts need for the endpoint and its metadata. +- oauth mode needs `cakedc/cakephp-oauth-server` and `cakedc/oauth-server` 1.0 (beta, public, not on Packagist yet: see the install notes), CakePHP 5.1.10+ and `cakephp/authentication` 3.3.6+. +- Contributors: the PHPUnit suite now runs on SQLite with the oauth packages. It needs the sibling clones `../oauth-server` and `../cakephp-oauth-server` (path repositories); both are public repositories, and CI checks them out at their `1.0.0-beta1` tags. ## 2.0.0-beta1 (unreleased) diff --git a/docs/authentication.md b/docs/authentication.md index 185fd01..84f0fe8 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -94,20 +94,37 @@ in the `identity` attribute, so the plugin reads it unchanged. Two defaults of authorizes the route before the controller runs. The plugin's `skipAuthorization()` call never gets a chance, and an anonymous request is redirected to the login page instead of receiving the plugin's 401. Add a - permission rule for the endpoint in your `config/permissions.php`, with - `bypassAuth` so the request reaches the plugin, which then does its own 401 - and per-tool authorization: + permission rule for the endpoint and its protected resource metadata (used + by [OAuth](oauth.md)) in your `config/permissions.php`, with `bypassAuth` so + the request reaches the plugin, which then does its own 401 and per-tool + authorization. `McpConfig::rbacPermissions()` returns that rule: + + ```php + // config/permissions.php + use CakeDC\Mcp\McpConfig; + + return [ + 'CakeDC/Auth.permissions' => array_merge(McpConfig::rbacPermissions(), [ + // ... your rules + ]), + ]; + ``` + + It is this rule, which you can also write by hand: ```php [ 'role' => '*', 'plugin' => 'CakeDC/Mcp', - 'controller' => 'Mcp', - 'action' => 'handle', + 'controller' => ['Mcp', 'ProtectedResource'], + 'action' => ['handle', 'metadata'], 'bypassAuth' => true, ], ``` + With the oauth plugin, merge `OAuthServer::rbacPermissions()` as well (see + [OAuth](oauth.md#cakedcusers)). + 2. The default `Token` authenticator of `cakedc/users` reads the `api_key` query parameter (`'header' => null`, `'queryParam' => 'api_key'`), not the `Authorization` header. Configure a header based Bearer authenticator, as diff --git a/docs/configuration.md b/docs/configuration.md index 87ae239..6cc32f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,7 +78,7 @@ Tools stay unreachable until it is fixed; a typo in a security setting never opens anything. - `auth.mode`: `host` (default, the 2.0 behaviour: your authentication puts the identity in the request), `oauth` (tokens of `cakedc/cakephp-oauth-server` in the same application) or `external` (JWT access tokens of a third-party authorization server). -- `auth.resource`: the canonical URL of the MCP endpoint, which is the audience of its tokens. `null` derives it from `App.fullBaseUrl`, the base path of the application and `Mcp.route`. Set it when the application sits behind a proxy. Only `https` is accepted (plain `http` only for localhost), without fragment or credentials. Not used in `host` mode. +- `auth.resource`: the canonical URL of the MCP endpoint, which is the audience of its tokens. `null` derives it from `App.fullBaseUrl`, `App.base` and `Mcp.route`; on a subdirectory install set `App.base` or this key, because a base path Cake detects by itself (`App.base` `false`) is not seen. Set it when the application sits behind a proxy. Only `https` is accepted (plain `http` only for localhost), without fragment or credentials. Not used in `host` mode. - `auth.resourceName`: optional name shown in the protected resource metadata. - `auth.resourceDocumentation`: optional documentation URL shown in the protected resource metadata. - `auth.scopes`: the scope policy, as below. Every scope named here must be declared by the resource in `oauth` mode. @@ -86,7 +86,7 @@ opens anything. - `auth.scopes.methods`: JSON-RPC method to list of scopes, for example `['tools/call' => ['content:read']]`. - `auth.scopes.tools`: tool name to list of scopes, added to the ones of the method for that tool. - `auth.scopes.implies`: scope to list of scopes it also grants, for example `['content:write' => ['content:read']]`. -- `auth.identity`: optional `fn (Mcp\Server\Authorization\AccessToken $token, mixed $identity): mixed` that replaces the identity tools and the authorizer see. Return `null` to reject the token with 401 `invalid_token`. See [OAuth](oauth.md#identity-in-external-mode). +- `auth.identity`: optional `fn (Mcp\Server\Authorization\AccessToken $token, mixed $identity): mixed` that replaces the identity tools and the authorizer see. Return an array or an object; `null`, `false` or any other scalar rejects the token with 401 `invalid_token`. See [OAuth](oauth.md#identity-in-external-mode). - `auth.external`: the settings of `external` mode, as below. Ignored in the other modes. - `auth.external.issuer`: required in `external` mode. The issuer URL, compared verbatim with the `iss` claim (a trailing slash counts). - `auth.external.audience`: accepted `aud` value, a list of them, or `null` for the resource URL. diff --git a/docs/installation.md b/docs/installation.md index a17f9d7..d98811d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -55,9 +55,8 @@ If you change `Mcp.route`, change the path here too. ## OAuth Remote clients that sign people in (claude.ai connectors, Claude Code, the MCP -Inspector) need `Mcp.auth` set to `oauth` or `external`. That also needs the -skip callback to cover the metadata path, so prefer this form, which follows -`Mcp.route`: +Inspector) need `Mcp.auth` set to `oauth` or `external`. Prefer this form of the +skip callback, which follows `Mcp.route`: ```php use CakeDC\Mcp\McpConfig; @@ -65,6 +64,11 @@ use CakeDC\Mcp\McpConfig; $csrf->skipCheckCallback(fn (ServerRequest $request): bool => McpConfig::isEndpoint($request)); ``` +`McpConfig::isEndpoint()` is also true for the protected resource metadata +path. That path is only routed for GET and OPTIONS, which the CSRF middleware +never checks (it does not consult the callback for them), so the callback is +not what lets the metadata through; it is the MCP route's POST that needs it. + The setup, the optional packages and `bin/cake mcp check` are described in [OAuth](oauth.md). diff --git a/docs/oauth.md b/docs/oauth.md index 8a94c51..6131ff9 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -50,26 +50,30 @@ runs an MCP call. ### 1. Install the oauth plugin -The two CakeDC OAuth packages are private repositories for now. Until they are -on Packagist, a root application with the default `stable` minimum stability -declares them as `vcs` repositories (Composer needs read access to both, for -example a GitHub token) and asks for the beta: +`oauth` mode needs CakePHP 5.1.10 or newer and `cakephp/authentication` 3.3.6 +or newer, the floors of the oauth plugin. + +The two CakeDC OAuth packages are public but not on Packagist yet. Until they +are, declare them as `vcs` repositories in your application's `composer.json`: ```json { "repositories": [ - {"type": "vcs", "url": "git@github.com:CakeDC/oauth-server.git"}, - {"type": "vcs", "url": "git@github.com:CakeDC/cakephp-oauth-server.git"} + {"type": "vcs", "url": "https://github.com/CakeDC/oauth-server"}, + {"type": "vcs", "url": "https://github.com/CakeDC/cakephp-oauth-server"} ] } ``` +and require both of them, with the beta flag on each (stability flags only +apply to the packages you require yourself, so an application with the +default `stable` minimum stability cannot require the plugin alone): + ```bash -composer require "cakedc/cakephp-oauth-server:^1.0@beta" +composer require cakedc/cakephp-oauth-server:^1.0@beta cakedc/oauth-server:^1.0@beta ``` -When they are public they will be on Packagist and the `repositories` block -goes away. +Once they are on Packagist the `repositories` block goes away. Load the plugin in `Application::bootstrap()`: @@ -91,8 +95,9 @@ describe. Do not run both. ### 2. Declare the MCP endpoint as a resource In the `OAuthServer` configuration the resource is keyed by the exact URL of -your endpoint: `App.fullBaseUrl`, the base path of the application and -`Mcp.route`. It must be the same string everywhere, because a token is issued +your endpoint: `App.fullBaseUrl`, the base path of the application +(`App.base`, see [Subdirectory installs](#subdirectory-installs-and-proxies)) +and `Mcp.route`. It must be the same string everywhere, because a token is issued for one resource and refused anywhere else. ```php @@ -164,13 +169,13 @@ and follows `Mcp.route`, so you do not hard code `/mcp`. The oauth plugin ships an authenticator, `CakeDC/OAuthServer.Token`, for the other APIs of your application. On the MCP route it must not run: it answers a token it does not accept for that path with its own `401` before this plugin -sees the request, and every MCP call fails. Load it only for other requests: +sees the request, and every MCP call fails. Load it only for other requests, +and before the session authenticator: ```php public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface { $service = new AuthenticationService(); - $service->loadAuthenticator('Authentication.Session', ['sessionKey' => 'Auth']); if (!McpConfig::isEndpoint($request)) { $service->loadAuthenticator('OAuthToken', [ @@ -178,11 +183,18 @@ public function getAuthenticationService(ServerRequestInterface $request): Authe 'resource' => 'https://example.com/api', ]); } + $service->loadAuthenticator('Authentication.Session', ['sessionKey' => 'Auth']); return $service; } ``` +The order matters: the first authenticator that succeeds wins. With the +session first, a request that carries a session cookie and a revoked or +foreign bearer token would run as the signed in person, and the token would +never be checked. `bin/cake oauth check` fails when a session, cookie or form +authenticator comes before the Token authenticator. + If the application protects no other API with the oauth plugin, do not load the authenticator at all. @@ -190,6 +202,9 @@ The example gives the authenticator its own alias, `OAuthToken`. Both `Authentication.Token` and `CakeDC/OAuthServer.Token` default to the alias `Token`, so a host that loads both must rename one of them. +On a `cakedc/users` application the oauth plugin loads its authenticator for +you, on every request; see [cakedc/users](#cakedcusers). + `bin/cake mcp check` reports this mistake (see below). ### 6. Check the setup @@ -200,8 +215,12 @@ bin/cake mcp check ``` `bin/cake oauth check` is the oauth plugin's own check of its configuration, -tables and routes. `bin/cake mcp check` makes no network call and checks the -MCP side: +tables and routes. When the MCP endpoint is the only resource, so that you did +not load the Token authenticator in step 5, it warns `no Token authenticator +(CakeDC/OAuthServer.Token) in the authentication service ...`. That warning is +expected: this plugin validates the MCP tokens itself. + +`bin/cake mcp check` makes no network call and checks the MCP side: | Check | What it looks at | | --- | --- | @@ -211,7 +230,7 @@ MCP side: | Metadata route | That the plugin routes are connected, and warns when the path the application serves differs from the advertised one (subdirectory installs need a rewrite, see below). | | Allowed hosts | That the host of the resource is in `Mcp.http.allowedHosts`. | | CSRF | That the CSRF middleware skips POST requests to the MCP route. | -| Authenticators | In `oauth` mode, that no authenticator of the application answers access tokens on the MCP route before the plugin. | +| Authenticators | In `oauth` mode, that no authenticator of the application answers access tokens on the MCP route before the plugin. On a `cakedc/users` application it reads `Auth.Authenticators` (see [cakedc/users](#cakedcusers)). Fails with an install hint when `cakedc/cakephp-oauth-server` is missing. | Sample output: @@ -245,6 +264,119 @@ location ^~ /.well-known/oauth- { Apache with Cake's `.htaccess` needs nothing. +### cakedc/users + +On an application built on `cakedc/users`, two more things matter. + +**RBAC.** `cakedc/users` authorizes the route before the controller runs, so +without a rule an anonymous MCP call and the metadata are sent to the login +page instead of the `401` and the JSON that clients need. Add the plugin's rule +to `config/permissions.php`: + +```php +// config/permissions.php +use CakeDC\Mcp\McpConfig; + +return [ + 'CakeDC/Auth.permissions' => array_merge(McpConfig::rbacPermissions(), [ + // ... your rules + ]), +]; +``` + +`McpConfig::rbacPermissions()` returns one rule: role `*`, plugin +`CakeDC/Mcp`, controllers `Mcp` and `ProtectedResource`, actions `handle` and +`metadata`, `bypassAuth` `true`. The plugin decides access itself. The oauth +plugin adds the rule for its own endpoints, see its +[cakedc/users page](https://github.com/CakeDC/cakephp-oauth-server/blob/1.next-cake5/docs/cakedc-users.md). + +**The Token authenticator.** With `cakedc/users` loaded, the oauth plugin +registers its Token authenticator itself (`Auth.Authenticators.OAuthServerToken`) +and `cakedc/users` runs it on every request, the MCP route included, so +[step 5](#5-keep-the-oauth-token-authenticator-off-the-mcp-route) cannot be +followed as written. That authenticator answers every access token of the +authorization server: below its paths it validates the token for its resource, +anywhere else it refuses it with `401` ("not valid at this address"). Its +resource decides whether MCP calls get through: + +- **The MCP endpoint is the only resource, or the default one** (the first in + `OAuthServer.resources`, unless `OAuthServer.defaultResource` names another). Leave `OAuthServer.users.resource` and + `OAuthServer.users.paths` unset, or set `users.resource` to the URL of the + endpoint. The authenticator then validates MCP tokens on `/mcp` for the MCP + resource, the same check this plugin makes, and calls work. A token it + refuses (expired, revoked) gets its own `401`, which names the metadata URL + only when its `resourceMetadataUrl` option is set. Declare the authenticator + yourself to set it, as the oauth plugin's cakedc/users page describes under + "When you override the authenticators" (the plugin then leaves it alone): + + ```php + // config/users.php + 'Auth.Authenticators' => [ + 'OAuthServerToken' => [ + 'className' => 'CakeDC/OAuthServer.OAuthServerToken', + 'skipTwoFactorVerify' => true, + 'resourceMetadataUrl' => 'https://example.com/.well-known/oauth-protected-resource/mcp', + ], + // ... your other authenticators (Session, Form, ...) follow + ], + ``` + +- **The authenticator is for another resource**, because + `OAuthServer.users.resource` names your API or the API is the default + resource. It then refuses every MCP token on `/mcp` and every MCP call fails + with `401`. `OAuthServer.users.paths` does not help: outside its paths the + authenticator refuses the token, inside them it checks it against its own + resource. Give it a class that leaves the MCP endpoint to this plugin: + + ```php + // src/Authenticator/ApiTokenAuthenticator.php + namespace App\Authenticator; + + use Authentication\Authenticator\Result; + use Authentication\Authenticator\ResultInterface; + use CakeDC\Mcp\McpConfig; + use CakeDC\OAuthServer\Authenticator\TokenAuthenticator; + use Psr\Http\Message\ServerRequestInterface; + + class ApiTokenAuthenticator extends TokenAuthenticator + { + public function authenticate(ServerRequestInterface $request): ResultInterface + { + if (McpConfig::isEndpoint($request)) { + return new Result(null, Result::FAILURE_CREDENTIALS_MISSING); + } + + return parent::authenticate($request); + } + + public function unauthorizedChallenge(ServerRequestInterface $request): void + { + if (!McpConfig::isEndpoint($request)) { + parent::unauthorizedChallenge($request); + } + } + } + ``` + + ```php + // config/users.php (the use statement goes at the top of the file) + use App\Authenticator\ApiTokenAuthenticator; + + 'Auth.Authenticators' => [ + 'OAuthServerToken' => [ + 'className' => ApiTokenAuthenticator::class, + 'skipTwoFactorVerify' => true, + 'resource' => 'https://example.com/api', + ], + // ... your other authenticators (Session, Form, ...) follow + ], + ``` + +`bin/cake mcp check` reads `Auth.Authenticators` when `CakeDC/Users` is loaded +and reports these cases on its `Authenticators` line: `FAIL` when the Token +authenticator refuses MCP tokens on the MCP route, `WARN` when it validates +them without `resourceMetadataUrl`, `PASS` otherwise. + ## external mode Use it when an identity provider you do not run issues the tokens. The plugin @@ -347,9 +479,10 @@ Configure::write('Mcp.auth.identity', function (AccessToken $token, mixed $ident The callable receives the access token and the default identity, and returns the identity to use: whatever your authentication normally produces (here an -`Authentication\Identity` wrapping the user entity). Returning `null` rejects -the token with `401 invalid_token`, so people unknown to your application -cannot call tools. +`Authentication\Identity` wrapping the user entity). An identity is an array +or an object: returning `null`, `false` or any other scalar rejects the token +with `401 invalid_token`, so people unknown to your application cannot call +tools. A JWT without a `sub` claim has no default identity, so in `external` mode it is rejected with `401 invalid_token` unless `Mcp.auth.identity` maps it (for @@ -433,13 +566,15 @@ Use the OAuth sign-in the Inspector offers when the server answers `401`. ### Consent on every connection -Redirect URIs that are loopback addresses or private schemes (the kind native -tools use, such as Claude Code or the MCP Inspector) are asked for consent on -every new connection. Refresh tokens keep an existing connection alive without -asking again. A client with an https redirect URI (claude.ai) is approved -silently once the person has consented. Redirect URIs with a private scheme are -refused unless the oauth server's `allowPrivateSchemes` is enabled; see the -oauth plugin's configuration. +A public client (one without a client secret) whose redirect URI is a +loopback address or a private scheme, the kind native tools use such as +Claude Code or the MCP Inspector, is asked for consent on every new +connection: any program on the computer could pose as it. Refresh tokens keep +an existing connection alive without asking again. A client with an https +redirect URI (claude.ai) and a confidential client, whatever its redirect URI, +are approved silently once the person has consented. Redirect URIs with a +private scheme are refused unless `OAuthServer.registration.allowPrivateSchemes` +is `true` (it is `false` by default); see the oauth plugin's configuration. ## Subdirectory installs and proxies @@ -459,6 +594,12 @@ location = /.well-known/oauth-protected-resource/app/mcp { path the application serves and the advertised one differ, as they do on a subdirectory install. The warning is the reminder to add the rewrite. +The base path is read from `App.base`. When Cake detects the base path itself +(`App.base` left at `false`, the skeleton default), the derived resource has +no base path (`https://example.com/mcp` instead of +`https://example.com/app/mcp`), so on a subdirectory install set `App.base` +(`'/app'`) or `Mcp.auth.resource` explicitly. + Behind a proxy that terminates TLS, set `App.fullBaseUrl` to the public https URL, or set `Mcp.auth.resource` explicitly. The resource must be the URL the client types, not the internal one. @@ -475,7 +616,8 @@ well-known path. ## Testing -In a Cake test case, `McpTestTrait` acts as a token with scopes without HTTP: +In a Cake test case, `McpTestTrait` acts as a token with scopes without HTTP. +With the resource and the `Mcp.auth` of the steps above: ```php $this->mcpActingAs($user, ['content:read']); @@ -487,14 +629,25 @@ $this->mcpCallTool('publish_post', ['id' => 1]); $this->assertMcpToolSucceeded(); ``` +The second call passes because the trait expands the scopes with the +`implies` of the resource, as the authorization server does: +`content:publish` also grants `content:write` and `content:read`. + See [Testing your tools](testing.md#scopes). To test the HTTP endpoint with real tokens, use the oauth plugin's `OAuthTestTrait` (it issues personal tokens, which are enabled by default in `OAuthServer.personalTokens`, so -this works unless you disabled them) next to `IntegrationTestTrait`: +this works unless you disabled them) next to `IntegrationTestTrait`. Send the +request the way an MCP client of the 2026-07-28 revision does, without a +session: the protocol version in `params._meta` and in the +`MCP-Protocol-Version` header, and the method in the `Mcp-Method` header. +Without them the endpoint expects the older handshake and answers `400` +("A valid session id is REQUIRED"). ```php use Cake\TestSuite\IntegrationTestTrait; +use Cake\TestSuite\TestCase; use CakeDC\OAuthServer\TestSuite\OAuthTestTrait; +use stdClass; class McpEndpointTest extends TestCase { @@ -509,16 +662,30 @@ class McpEndpointTest extends TestCase 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json, text/event-stream', 'Content-Type' => 'application/json', + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/list', ], ]); - $this->post('/mcp', json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'])); + $this->post('/mcp', (string)json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities' => new stdClass(), + ]], + ])); $this->assertResponseOk(); + $tools = json_decode((string)$this->_response?->getBody(), true)['result']['tools'] ?? []; + $this->assertContains('publish_post', array_column($tools, 'name')); } } ``` +A `tools/call` also sends the tool name in an `Mcp-Name` header. + ## Troubleshooting **The client loops on sign-in.** Request the endpoint without a token and look @@ -550,7 +717,8 @@ the person for more; otherwise reconnect and approve the scopes. **Every token is rejected with "not valid at this address".** The oauth plugin's `Token` authenticator is running on the MCP route and answers before this plugin. Load it only when `!McpConfig::isEndpoint($request)`; see -[step 5](#5-keep-the-oauth-token-authenticator-off-the-mcp-route). +[step 5](#5-keep-the-oauth-token-authenticator-off-the-mcp-route), or +[cakedc/users](#cakedcusers) when `cakedc/users` loads it for you. `bin/cake mcp check` fails the `Authenticators` check in this case. **500 on `/mcp` after enabling OAuth.** The configuration is invalid (an unknown diff --git a/docs/testing.md b/docs/testing.md index 0788118..dee2750 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -68,7 +68,10 @@ token built as the endpoint builds it (identifier as subject, scopes from the access token and extra claims for it (they win over `sub`). With `$scopes` given, and `Mcp.auth.mode` set to `oauth` or `external`, the `Mcp.auth.scopes` policy is applied first, as the HTTP endpoint would, and `Mcp.identity.scopes` -is not used. A call the policy refuses is checked with +is not used. In `oauth` mode the scopes are first expanded with the `implies` +of the resource in `OAuthServer.resources`, as the authorization server does +for the endpoint, so a token with `content:publish` also holds what that +scope implies there. A call the policy refuses is checked with `assertMcpInsufficientScope()`: ```php @@ -84,6 +87,11 @@ accept any. It is only meaningful with scopes given to `mcpActingAs()`; without them, the policy is not applied. For the HTTP endpoint with real tokens see [OAuth](oauth.md#testing). +Only tool calls get the `insufficient_scope` answer. When the policy refuses +any other request, such as the `tools/list` behind `mcpListTools()`, +`assertMcpToolListed()` and `assertMcpToolNotListed()`, the test fails with +`The access token lacks scopes for ...` instead of returning the `403` shape. + The trait does not go through your HTTP authentication, so it does not check tokens or `Mcp.identity.required`. Cover that with an integration test of the endpoint (`IntegrationTestTrait`, a `POST` to `/mcp` with your diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f53f697..9630910 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -34,8 +34,9 @@ Configure::write('Mcp.http.allowedHosts', ['example.com', 'localhost']); On a `cakedc/users` host, `RequestAuthorizationMiddleware` authorizes the route before the plugin runs, so an anonymous or unlisted request gets a 403 or -a redirect to the login page instead of the plugin's 401. Add a `bypassAuth` -permission rule for `CakeDC/Mcp` `Mcp::handle`; see +a redirect to the login page instead of the plugin's 401. Add the `bypassAuth` +permission rule for `CakeDC/Mcp` `Mcp::handle` and `ProtectedResource::metadata` +that `McpConfig::rbacPermissions()` returns; see [Authentication](authentication.md#cakedcauth-and-cakedcusers). If tokens are sent but ignored, the default `Token` authenticator reads `api_key` from the query string, not the header. From d880037f732c7c0ee24d9e9960cfc11841776763 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Mon, 19 Oct 2026 13:28:00 +0200 Subject: [PATCH 47/49] Require mcp/sdk ^0.9 now that 0.9.0 is released --- CHANGELOG.md | 2 +- README.md | 13 +++++-------- composer.json | 2 +- docs/installation.md | 13 +++++-------- tests/Conformance/ConformanceFixture.php | 2 +- tests/Conformance/Sdk/Elements.php | 2 +- tests/Conformance/Sdk/MrtrElements.php | 2 +- 7 files changed, 15 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9589c7f..d0d788f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,4 +32,4 @@ Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. - Checked against the official MCP conformance suite for both protocol revisions, behind nginx and php-fpm (`tests/Conformance/run.sh`, also in CI). - DNS-rebinding protection defaults to the `App.fullBaseUrl` host. That only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise); set `Mcp.http.allowedHosts`, which is required for servers with `identity.required` set to `false`. - Removed `bin/mcp-server` and `CakeDC\Mcp\Server\CakeServer`. -- Temporary: `mcp/sdk` is pinned to a fixed commit aliased as 0.9.0 until the SDK tags that release; host apps must add the same requirement and allow dev stability (see the install notes). +- Requires `mcp/sdk` ^0.9. diff --git a/README.md b/README.md index 1b0b43e..d8b3546 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,15 @@ Built on the official [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sd composer require cakedc/cakephp-mcp ``` -> **Beta note (temporary).** `mcp/sdk` 0.9.0 is not released yet, and this -> plugin pins the SDK to a fixed commit. Composer only applies inline aliases and -> `minimum-stability` from the root package, so until the SDK tags 0.9.0 your -> application must declare the same requirement and allow dev stability: +> **Before the first 2.x tag.** 2.x is not tagged yet, so require the +> development branch: > > ```bash -> composer config minimum-stability dev -> composer config prefer-stable true -> composer require "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" cakedc/cakephp-mcp:dev-2.next-cake5 +> composer require cakedc/cakephp-mcp:dev-2.next-cake5 > ``` > -> This note will go away once the SDK tags 0.9.0. +> It needs `mcp/sdk` ^0.9, a stable release, so no dev stability settings are +> needed for the SDK. Load the plugin in `src/Application.php`: diff --git a/composer.json b/composer.json index 0c7f807..8b94eea 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "require": { "php": ">=8.2", "cakephp/cakephp": "^5.0", - "mcp/sdk": "dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0", + "mcp/sdk": "^0.9", "symfony/finder": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { diff --git a/docs/installation.md b/docs/installation.md index d98811d..1fc2cbc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,18 +6,15 @@ composer require cakedc/cakephp-mcp ``` -> **Beta note (temporary).** `mcp/sdk` 0.9.0 is not released yet, and this -> plugin pins the SDK to a fixed commit. Composer only applies inline aliases and -> `minimum-stability` from the root package, so until the SDK tags 0.9.0 your -> application must declare the same requirement and allow dev stability: +> **Before the first 2.x tag.** 2.x is not tagged yet, so require the +> development branch: > > ```bash -> composer config minimum-stability dev -> composer config prefer-stable true -> composer require "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" cakedc/cakephp-mcp:dev-2.next-cake5 +> composer require cakedc/cakephp-mcp:dev-2.next-cake5 > ``` > -> This note will go away once the SDK tags 0.9.0. +> It needs `mcp/sdk` ^0.9, a stable release, so no dev stability settings are +> needed for the SDK. The plugin needs PHP 8.2+ and CakePHP 5. It builds on the official `mcp/sdk`. The `php-http/discovery` Composer plugin is not needed: the plugin passes diff --git a/tests/Conformance/ConformanceFixture.php b/tests/Conformance/ConformanceFixture.php index 3b36714..4688fba 100644 --- a/tests/Conformance/ConformanceFixture.php +++ b/tests/Conformance/ConformanceFixture.php @@ -31,7 +31,7 @@ /** * Registers, through `Mcp.server.configure`, the elements the conformance suite * calls. They are the ones of the SDK's own conformance server - * (modelcontextprotocol/php-sdk tests/Conformance/server.php at 82e19d1), so a + * (modelcontextprotocol/php-sdk tests/Conformance/server.php at v0.9.0), so a * scenario that passes there and fails here points at the plugin. */ final class ConformanceFixture diff --git a/tests/Conformance/Sdk/Elements.php b/tests/Conformance/Sdk/Elements.php index ed26d1a..89b5b2f 100644 --- a/tests/Conformance/Sdk/Elements.php +++ b/tests/Conformance/Sdk/Elements.php @@ -8,7 +8,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. * - * Copied unchanged from modelcontextprotocol/php-sdk at commit 82e19d1 + * Copied unchanged from modelcontextprotocol/php-sdk at v0.9.0 * (tests/Conformance), except for the namespace. The SDK LICENSE is in this * directory. */ diff --git a/tests/Conformance/Sdk/MrtrElements.php b/tests/Conformance/Sdk/MrtrElements.php index cb92f7e..7fd94ca 100644 --- a/tests/Conformance/Sdk/MrtrElements.php +++ b/tests/Conformance/Sdk/MrtrElements.php @@ -8,7 +8,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. * - * Copied unchanged from modelcontextprotocol/php-sdk at commit 82e19d1 + * Copied unchanged from modelcontextprotocol/php-sdk at v0.9.0 * (tests/Conformance), except for the namespace. The SDK LICENSE is in this * directory. */ From df31f5333e7ee229cec1e7d045721d641dd7b33d Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Mon, 19 Oct 2026 15:44:39 +0200 Subject: [PATCH 48/49] Bound the request body with Mcp.http.maxBodyBytes and answer 413 before authentication --- CHANGELOG.md | 2 + docs/configuration.md | 3 +- docs/troubleshooting.md | 15 ++ src/Controller/McpController.php | 17 +- src/McpConfig.php | 21 ++- src/Middleware/BodyLimitMiddleware.php | 115 +++++++++++++ src/OAuth/ResourceServer.php | 4 + .../TestCase/Controller/McpBodyLimitTest.php | 79 +++++++++ tests/TestCase/McpConfigTest.php | 26 +++ .../Middleware/BodyLimitMiddlewareTest.php | 156 ++++++++++++++++++ tests/TestCase/OAuth/ResourceServerTest.php | 14 ++ 11 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 src/Middleware/BodyLimitMiddleware.php create mode 100644 tests/TestCase/Controller/McpBodyLimitTest.php create mode 100644 tests/TestCase/Middleware/BodyLimitMiddlewareTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d788f..4772868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ OAuth 2.1 for remote MCP clients. Existing applications keep working: `host` mod - `McpConfig::rbacPermissions()` returns the `bypassAuth` rule `cakedc/users` hosts need for the endpoint and its metadata. - oauth mode needs `cakedc/cakephp-oauth-server` and `cakedc/oauth-server` 1.0 (beta, public, not on Packagist yet: see the install notes), CakePHP 5.1.10+ and `cakephp/authentication` 3.3.6+. - Contributors: the PHPUnit suite now runs on SQLite with the oauth packages. It needs the sibling clones `../oauth-server` and `../cakephp-oauth-server` (path repositories); both are public repositories, and CI checks them out at their `1.0.0-beta1` tags. +- `Mcp.http.maxBodyBytes` (default 4 MiB, the SDK's own limit) bounds the request body. The same value is passed to the SDK transport and to its `AuthorizationMiddleware`. +- A POST over that limit is answered with `413` and a JSON-RPC error (code `-32600`) whose message and `data.maxBodyBytes` name the limit and suggest a smaller payload, before authentication. Before, `oauth` and `external` modes answered it with a `400` Bearer `invalid_request` challenge. ## 2.0.0-beta1 (unreleased) diff --git a/docs/configuration.md b/docs/configuration.md index 6cc32f2..906c505 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,7 +18,7 @@ want to change; everything else keeps its default. These are the defaults ], 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], - 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], + 'http' => ['allowedHosts' => null, 'allowedOrigins' => [], 'maxBodyBytes' => 4194304], 'identity' => ['required' => true, 'attribute' => 'identity', 'scopes' => null], 'authorization' => ['authorizer' => AllowAllToolAuthorizer::class, 'rbac' => []], 'auth' => [ @@ -57,6 +57,7 @@ want to change; everything else keeps its default. These are the defaults - `session.ttl`: session lifetime in seconds. - `http.allowedHosts`: hosts accepted in the `Host` header (DNS-rebinding protection). `null` means the host of `App.fullBaseUrl` plus `localhost`, `127.0.0.1` and `[::1]`, which only protects when `App.fullBaseUrl` is set explicitly (the Cake 5 skeleton derives it from the request `Host` otherwise, and then every host is accepted). A list replaces that. Set it explicitly in production; it is required for servers with `identity.required` set to `false`. - `http.allowedOrigins`: origins accepted for browser clients, also used for CORS. Empty by default. +- `http.maxBodyBytes`: the largest request body the endpoint reads, in bytes. Default `4194304` (4 MiB, the SDK's own limit). It is passed to the SDK transport and to its `AuthorizationMiddleware`, and a larger POST is answered with `413` and a JSON-RPC error (code `-32600`, `data.maxBodyBytes`) that names the limit, before authentication. Raise it when your tools take large arguments, such as base64 uploads. A value that is not a positive integer makes the endpoint answer `500 Internal error`. - `identity.required`: answer 401 when the request has no identity. - `identity.attribute`: request attribute that holds the identity. - `identity.scopes`: optional `fn (mixed $identity): array` returning scope strings for the SDK access token. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9630910..5794995 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -30,6 +30,21 @@ not set, and then every host is accepted. Set `Mcp.http.allowedHosts` yourself Configure::write('Mcp.http.allowedHosts', ['example.com', 'localhost']); ``` +## 413 Request body too large + +The request body was larger than `Mcp.http.maxBodyBytes` (4 MiB by default). +The endpoint answers before authentication, with a JSON-RPC error whose +message and `data.maxBodyBytes` name the limit. Send less per call (a smaller +file, fewer items), or raise the limit: + +```php +Configure::write('Mcp.http.maxBodyBytes', 8 * 1024 * 1024); +``` + +Before 2.1 the `oauth` and `external` modes answered the same request with a +`400` Bearer `invalid_request` challenge, which clients read as a token +problem. + ## 403 or a redirect to the login page (cakedc/users) On a `cakedc/users` host, `RequestAuthorizationMiddleware` authorizes the diff --git a/src/Controller/McpController.php b/src/Controller/McpController.php index 1f44175..7dbbb5e 100644 --- a/src/Controller/McpController.php +++ b/src/Controller/McpController.php @@ -11,6 +11,7 @@ use CakeDC\Mcp\Identity\CurrentIdentity; use CakeDC\Mcp\Log\McpLogger; use CakeDC\Mcp\McpConfig; +use CakeDC\Mcp\Middleware\BodyLimitMiddleware; use CakeDC\Mcp\Middleware\IdentityMiddleware; use CakeDC\Mcp\OAuth\AuthSettings; use CakeDC\Mcp\OAuth\ResourceServer; @@ -47,6 +48,7 @@ public function handle(ServerFactory $factory, CurrentIdentity $identity): Respo $streamFactory = new StreamFactory(); try { + $maxBodyBytes = McpConfig::maxBodyBytes(); $origins = array_values(array_filter((array)McpConfig::get('http.allowedOrigins'), 'is_string')); $transport = new StreamableHttpTransport( $this->request, @@ -61,8 +63,12 @@ public function handle(ServerFactory $factory, CurrentIdentity $identity): Respo $responseFactory, $streamFactory, ), - $this->authentication($identity, $responseFactory, $streamFactory), + // Before authentication: an oversized body gets a 413 that names the limit, never a + // Bearer challenge. + new BodyLimitMiddleware($maxBodyBytes, $responseFactory, $streamFactory), + $this->authentication($identity, $responseFactory, $streamFactory, $maxBodyBytes), ], + $maxBodyBytes, ); $response = $factory->create()->run($transport); } catch (Throwable $exception) { @@ -81,16 +87,23 @@ public function handle(ServerFactory $factory, CurrentIdentity $identity): Respo * @param \CakeDC\Mcp\Identity\CurrentIdentity $identity Shared with the tools. * @param \Cake\Http\ResponseFactory $responseFactory For its responses. * @param \Cake\Http\StreamFactory $streamFactory For their bodies. + * @param int $maxBodyBytes The body limit, for the token middleware's scope policy. * @return \Psr\Http\Server\MiddlewareInterface */ private function authentication( CurrentIdentity $identity, ResponseFactory $responseFactory, StreamFactory $streamFactory, + int $maxBodyBytes, ): MiddlewareInterface { $settings = AuthSettings::fromConfig(); if ($settings->mode !== AuthSettings::HOST) { - return (new ResourceServer($settings))->middleware($identity, $responseFactory, $streamFactory); + return (new ResourceServer($settings))->middleware( + $identity, + $responseFactory, + $streamFactory, + $maxBodyBytes, + ); } $scopes = McpConfig::get('identity.scopes'); diff --git a/src/McpConfig.php b/src/McpConfig.php index 834ed7d..096a927 100644 --- a/src/McpConfig.php +++ b/src/McpConfig.php @@ -7,6 +7,7 @@ use Cake\Http\ServerRequest; use Cake\Utility\Hash; use CakeDC\Mcp\Authorization\AllowAllToolAuthorizer; +use InvalidArgumentException; use Psr\Http\Message\ServerRequestInterface; /** @@ -39,7 +40,7 @@ final class McpConfig ], 'discovery' => ['scanDirs' => ['src/Mcp'], 'excludeDirs' => [], 'cache' => '_cake_mcp_'], 'session' => ['cache' => 'mcp_sessions', 'ttl' => 3600], - 'http' => ['allowedHosts' => null, 'allowedOrigins' => []], + 'http' => ['allowedHosts' => null, 'allowedOrigins' => [], 'maxBodyBytes' => 4 * 1024 * 1024], 'identity' => ['required' => true, 'attribute' => 'identity', 'scopes' => null], 'authorization' => ['authorizer' => AllowAllToolAuthorizer::class, 'rbac' => []], 'auth' => [ @@ -90,6 +91,24 @@ public static function discoveryCache(): ?string return $name; } + /** + * The largest request body the endpoint reads, in bytes (`http.maxBodyBytes`, 4 MiB by default, the + * SDK's own limit). The same value goes to the SDK transport and to its AuthorizationMiddleware, and + * BodyLimitMiddleware answers a larger body with 413 before either reads it. + * + * @return int + * @throws \InvalidArgumentException When the setting is not a positive integer, so the endpoint fails closed. + */ + public static function maxBodyBytes(): int + { + $value = self::get('http.maxBodyBytes'); + if (!is_int($value) || $value < 1) { + throw new InvalidArgumentException('`Mcp.http.maxBodyBytes` must be a positive integer (bytes).'); + } + + return $value; + } + /** * Hosts the DNS-rebinding protection accepts. Defaults to the host of * `App.fullBaseUrl` plus localhost; the SDK default (localhost only) would diff --git a/src/Middleware/BodyLimitMiddleware.php b/src/Middleware/BodyLimitMiddleware.php new file mode 100644 index 0000000..e16d0f3 --- /dev/null +++ b/src/Middleware/BodyLimitMiddleware.php @@ -0,0 +1,115 @@ +getMethod() !== 'POST') { + return $handler->handle($request); + } + + $length = $request->getHeaderLine('Content-Length'); + if ($length !== '' && ctype_digit($length) && (int)$length > $this->maxBytes) { + return $this->tooLarge(); + } + + $body = $this->read($request->getBody()); + if ($body === null) { + return $this->tooLarge(); + } + + return $handler->handle($request->withBody($this->streamFactory->createStream($body))); + } + + /** + * @param \Psr\Http\Message\StreamInterface $stream The request body. + * @return string|null The body, or null when it is larger than the limit. + */ + private function read(StreamInterface $stream): ?string + { + if ($stream->isSeekable()) { + $stream->rewind(); + } + $size = $stream->getSize(); + if ($size !== null && $size > $this->maxBytes) { + return null; + } + + $contents = ''; + while (!$stream->eof()) { + // Never more than one byte past the limit is read, which is enough to know the body is too large. + $chunk = $stream->read(min(self::CHUNK, $this->maxBytes + 1 - strlen($contents))); + if ($chunk === '') { + break; + } + $contents .= $chunk; + if (strlen($contents) > $this->maxBytes) { + return null; + } + } + + return $contents; + } + + /** + * @return \Psr\Http\Message\ResponseInterface + */ + private function tooLarge(): ResponseInterface + { + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => [ + 'code' => -32600, + 'message' => sprintf( + 'Request body too large: the limit is %d bytes (Mcp.http.maxBodyBytes). Send a smaller payload, ' + . 'for example a smaller file or fewer items per call.', + $this->maxBytes, + ), + 'data' => ['maxBodyBytes' => $this->maxBytes], + ], + ], JSON_THROW_ON_ERROR); + + return $this->responseFactory->createResponse(413) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + } +} diff --git a/src/OAuth/ResourceServer.php b/src/OAuth/ResourceServer.php index 099cde5..a990d71 100644 --- a/src/OAuth/ResourceServer.php +++ b/src/OAuth/ResourceServer.php @@ -15,6 +15,7 @@ use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface; use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator; use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata; +use Mcp\Server\Transport\StreamableHttpTransport; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -65,12 +66,14 @@ public function metadata(): ProtectedResourceMetadata * @param \CakeDC\Mcp\Identity\CurrentIdentity $identity Receives the token owner. * @param \Psr\Http\Message\ResponseFactoryInterface $responseFactory For its responses. * @param \Psr\Http\Message\StreamFactoryInterface $streamFactory For its bodies. + * @param int $maxBodyBytes The largest body it reads when the scope policy inspects it (`Mcp.http.maxBodyBytes`). * @return \Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware */ public function middleware( CurrentIdentity $identity, ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory, + int $maxBodyBytes = StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES, ): AuthorizationMiddleware { return new AuthorizationMiddleware( $this->validator($identity), @@ -78,6 +81,7 @@ public function middleware( $this->settings->scopePolicy(), $responseFactory, $streamFactory, + $maxBodyBytes, ); } diff --git a/tests/TestCase/Controller/McpBodyLimitTest.php b/tests/TestCase/Controller/McpBodyLimitTest.php new file mode 100644 index 0000000..bbfbbd4 --- /dev/null +++ b/tests/TestCase/Controller/McpBodyLimitTest.php @@ -0,0 +1,79 @@ +callTool('echo', ['text' => str_repeat('a', 4096)]); + + $this->assertResponseCode(413); + $this->assertSame('', $this->_response?->getHeaderLine('WWW-Authenticate')); + $error = $this->responseJson()['error'] ?? []; + $this->assertSame(-32600, $error['code'] ?? null); + $this->assertSame(2048, $error['data']['maxBodyBytes'] ?? null); + $this->assertStringContainsString('the limit is 2048 bytes', (string)($error['message'] ?? '')); + } + + public function testInOAuthModeAnOversizedBodyIsNotABearerChallenge(): void + { + Configure::write('Mcp.auth', OAuthTestConfig::MCP_AUTH); + OAuthServer::reset(); + Configure::write('Mcp.http.maxBodyBytes', 2048); + + $this->callTool('create_note', ['title' => str_repeat('a', 4096)]); + + $this->assertResponseCode(413); + $this->assertSame('', $this->_response?->getHeaderLine('WWW-Authenticate')); + $this->assertSame(-32600, $this->responseJson()['error']['code'] ?? null); + } + + public function testABodyWithinTheLimitStillReachesTheTool(): void + { + Configure::write('Mcp.http.maxBodyBytes', 4096); + $text = str_repeat('a', 1500); + + $this->callTool('echo', ['text' => $text], TestTokens::EDITOR); + + $this->assertResponseOk(); + $this->assertSame($text, $this->responseJson()['result']['content'][0]['text'] ?? null); + } + + public function testAnInvalidLimitFailsClosed(): void + { + Configure::write('Mcp.http.maxBodyBytes', 0); + + $this->callTool('echo', ['text' => 'x'], TestTokens::EDITOR); + + $this->assertResponseCode(500); + $this->assertSame(-32603, $this->responseJson()['error']['code'] ?? null); + } +} diff --git a/tests/TestCase/McpConfigTest.php b/tests/TestCase/McpConfigTest.php index db5225a..a367132 100644 --- a/tests/TestCase/McpConfigTest.php +++ b/tests/TestCase/McpConfigTest.php @@ -11,6 +11,8 @@ use CakeDC\Mcp\Authorization\AllowAllToolAuthorizer; use CakeDC\Mcp\McpConfig; use CakeDC\Mcp\McpPlugin; +use InvalidArgumentException; +use Mcp\Server\Transport\StreamableHttpTransport; class McpConfigTest extends TestCase { @@ -144,4 +146,28 @@ public function testRbacPermissionsLetAnonymousRequestsReachThePluginOnly(): voi Router::reload(); } } + + public function testTheBodyLimitDefaultsToTheSdkLimit(): void + { + Configure::delete('Mcp'); + + $this->assertSame(StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES, McpConfig::maxBodyBytes()); + $this->assertSame(4194304, McpConfig::get('http.maxBodyBytes')); + } + + public function testTheBodyLimitMustBeAPositiveInteger(): void + { + Configure::write('Mcp.http.maxBodyBytes', 1024); + $this->assertSame(1024, McpConfig::maxBodyBytes()); + + foreach ([0, -1, '1024', 1.5, null] as $invalid) { + Configure::write('Mcp.http.maxBodyBytes', $invalid); + try { + McpConfig::maxBodyBytes(); + $this->fail('Accepted ' . var_export($invalid, true)); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('Mcp.http.maxBodyBytes', $e->getMessage()); + } + } + } } diff --git a/tests/TestCase/Middleware/BodyLimitMiddlewareTest.php b/tests/TestCase/Middleware/BodyLimitMiddlewareTest.php new file mode 100644 index 0000000..ea67091 --- /dev/null +++ b/tests/TestCase/Middleware/BodyLimitMiddlewareTest.php @@ -0,0 +1,156 @@ +middleware(16)->process($this->post(str_repeat('a', 16)), $this->handler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(str_repeat('a', 16), $this->handled); + } + + public function testAnAnnouncedOversizedBodyIsRefusedWithoutReadingIt(): void + { + $unreadable = new class ('php://memory', 'r') extends Stream { + public function read(int $length): string + { + throw new RuntimeException('The body must not be read.'); + } + + public function getSize(): ?int + { + return null; + } + }; + $request = (new ServerRequest(['environment' => ['REQUEST_METHOD' => 'POST']])) + ->withHeader('Content-Length', '17') + ->withBody($unreadable); + + $response = $this->middleware(16)->process($request, $this->handler()); + + $this->assertTooLarge($response, 16); + $this->assertNull($this->handled); + } + + public function testABodyOfUnknownSizeIsReadOnlyUpToTheLimit(): void + { + $resource = fopen('php://memory', 'r+'); + fwrite($resource, str_repeat('a', 100)); + rewind($resource); + $unsized = new class ($resource) extends Stream { + public function getSize(): ?int + { + return null; + } + }; + $request = (new ServerRequest(['environment' => ['REQUEST_METHOD' => 'POST']]))->withBody($unsized); + + $response = $this->middleware(16)->process($request, $this->handler()); + + $this->assertTooLarge($response, 16); + $this->assertNull($this->handled); + } + + public function testAnUnsizedBodyIsNeverReadPastTheLimitPlusOneByte(): void + { + $resource = fopen('php://memory', 'r+'); + fwrite($resource, str_repeat('a', 100000)); + rewind($resource); + $counting = new class ($resource) extends Stream { + public int $bytesRead = 0; + + public function getSize(): ?int + { + return null; + } + + public function read(int $length): string + { + $chunk = parent::read($length); + $this->bytesRead += strlen($chunk); + + return $chunk; + } + }; + $request = (new ServerRequest(['environment' => ['REQUEST_METHOD' => 'POST']]))->withBody($counting); + + $response = $this->middleware(16)->process($request, $this->handler()); + + $this->assertTooLarge($response, 16); + $this->assertLessThanOrEqual(17, $counting->bytesRead); + } + + public function testOtherMethodsPassUntouched(): void + { + $request = new ServerRequest(['environment' => ['REQUEST_METHOD' => 'GET']]); + + $response = $this->middleware(1)->process($request, $this->handler()); + + $this->assertSame(200, $response->getStatusCode()); + } + + private function middleware(int $max): BodyLimitMiddleware + { + return new BodyLimitMiddleware($max, new ResponseFactory(), new StreamFactory()); + } + + private function post(string $body): ServerRequest + { + return (new ServerRequest(['environment' => ['REQUEST_METHOD' => 'POST']])) + ->withBody((new StreamFactory())->createStream($body)); + } + + private function handler(): RequestHandlerInterface + { + $record = function (ServerRequestInterface $request): ResponseInterface { + $this->handled = (string)$request->getBody(); + + return new Response(['status' => 200]); + }; + + return new class ($record) implements RequestHandlerInterface { + public function __construct(private readonly Closure $record) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return ($this->record)($request); + } + }; + } + + private function assertTooLarge(ResponseInterface $response, int $limit): void + { + $this->assertSame(413, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('', $response->getHeaderLine('WWW-Authenticate')); + $body = json_decode((string)$response->getBody(), true); + $this->assertSame('2.0', $body['jsonrpc'] ?? null); + $this->assertSame(-32600, $body['error']['code'] ?? null); + $this->assertSame($limit, $body['error']['data']['maxBodyBytes'] ?? null); + $message = (string)($body['error']['message'] ?? ''); + $this->assertStringContainsString(sprintf('the limit is %d bytes', $limit), $message); + $this->assertStringContainsString('Mcp.http.maxBodyBytes', $message); + $this->assertStringContainsString('smaller payload', $message); + } +} diff --git a/tests/TestCase/OAuth/ResourceServerTest.php b/tests/TestCase/OAuth/ResourceServerTest.php index d63d308..91ca57d 100644 --- a/tests/TestCase/OAuth/ResourceServerTest.php +++ b/tests/TestCase/OAuth/ResourceServerTest.php @@ -15,6 +15,7 @@ use CakeDC\OAuthServer\OAuthServer; use LogicException; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; +use ReflectionProperty; use RuntimeException; use TestApp\OAuthTestConfig; @@ -104,4 +105,17 @@ public function testExternalModeGrantsTheScopesUnchanged(): void $this->assertSame(['notes:write'], $this->server()->grantedScopes(['notes:write'])); } + + public function testTheAuthorizationMiddlewareReadsBodiesUpToTheGivenLimit(): void + { + $middleware = $this->server()->middleware( + new CurrentIdentity(), + new ResponseFactory(), + new StreamFactory(), + 2048, + ); + + $limit = new ReflectionProperty(AuthorizationMiddleware::class, 'maxBodyBytes'); + $this->assertSame(2048, $limit->getValue($middleware)); + } } From 3adce38f7de81a0d26ad7c9fe070bf568da53f3d Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Mon, 19 Oct 2026 16:25:53 +0200 Subject: [PATCH 49/49] Run the newest alpha of the conformance suite --- CHANGELOG.md | 2 ++ docs/conformance.md | 7 +++++-- tests/Conformance/run.sh | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4772868..915573f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ OAuth 2.1 for remote MCP clients. Existing applications keep working: `host` mod - `Mcp.http.maxBodyBytes` (default 4 MiB, the SDK's own limit) bounds the request body. The same value is passed to the SDK transport and to its `AuthorizationMiddleware`. - A POST over that limit is answered with `413` and a JSON-RPC error (code `-32600`) whose message and `data.maxBodyBytes` name the limit and suggest a smaller payload, before authentication. Before, `oauth` and `external` modes answered it with a `400` Bearer `invalid_request` challenge. +- Contributors: `tests/Conformance/run.sh` (and so CI) runs the newest published alpha of the official conformance suite instead of a pinned version; `CONFORMANCE_PACKAGE` still pins one for a reproducible run. + ## 2.0.0-beta1 (unreleased) Complete rewrite for CakePHP 5 on top of the official `mcp/sdk`. diff --git a/docs/conformance.md b/docs/conformance.md index 9f74de1..225d15d 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -21,8 +21,11 @@ tests/Conformance/run.sh 2026-07-28 # one revision ``` `CONFORMANCE_PORT` changes the local port (8000 by default) and -`CONFORMANCE_PACKAGE` the suite version. The suite version is pinned, so a new -release cannot break the build on its own; raise it on purpose. +`CONFORMANCE_PACKAGE` the suite version. By default the script runs the newest +published alpha of the suite (`@modelcontextprotocol/conformance@alpha`), so the +plugin is always checked against the latest scenarios: a new release that adds a +scenario the plugin fails turns the build red on purpose. Set +`CONFORMANCE_PACKAGE` to a fixed version to reproduce an older run. The CI workflow runs the suite on pushes to the main branches and on every pull request. diff --git a/tests/Conformance/run.sh b/tests/Conformance/run.sh index 70f1cce..68d08ba 100755 --- a/tests/Conformance/run.sh +++ b/tests/Conformance/run.sh @@ -11,7 +11,9 @@ set -uo pipefail cd "$(dirname "$0")" -PACKAGE="${CONFORMANCE_PACKAGE:-@modelcontextprotocol/conformance@0.2.0-alpha.11}" +# The newest published alpha of the official suite: a new release that adds failing +# scenarios or checks turns this run red on purpose. +PACKAGE="${CONFORMANCE_PACKAGE:-@modelcontextprotocol/conformance@alpha}" export CONFORMANCE_PORT="${CONFORMANCE_PORT:-8000}" # The container runs the vendor/ installed by this PHP, so it uses the same version. export CONFORMANCE_PHP="${CONFORMANCE_PHP:-$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')}"