diff --git a/.agents/skills/php-migration/SKILL.md b/.agents/skills/php-migration/SKILL.md index f33377b97b..8773d479ef 100644 --- a/.agents/skills/php-migration/SKILL.md +++ b/.agents/skills/php-migration/SKILL.md @@ -171,7 +171,19 @@ $code = str_increment($code); `__serialize()` / `__unserialize()` を使う(PHP7 互換が不要なら移行)。 ### 6. `$http_response_header` の非推奨 -スーパーグローバル `$http_response_header` が非推奨。`http_get_last_response_headers()` を使う。**※ `http_get_last_response_headers()` は 8.4+。8.1 維持中は `$http_response_header` のまま据え置く。** +スーパーグローバル `$http_response_header` が 8.5 で非推奨。`http_get_last_response_headers()` を使う。**※ `http_get_last_response_headers()` は 8.4+** なので、8.1 互換を維持するなら**素朴な置換は不可**(8.1〜8.3 で undefined function の fatal になる)。 +- **8.1〜8.5 を一度に満たす互換パターン**(推奨): 関数があれば使い、無ければ従来のスーパーグローバルにフォールバックする。8.5 では関数経由になるので非推奨を回避でき、8.1〜8.3 では関数が無いので `if` を素通りし、その下の `isset($http_response_header)`(直前の `file_get_contents` 等が設定するスーパーグローバル)を読む——どのバージョンでも fatal にならない。 + ```php + // file_get_contents() 等の HTTP 取得直後 + if (function_exists('http_get_last_response_headers')) { // 8.4+ + $http_response_header = http_get_last_response_headers(); + } + if (isset($http_response_header)) { // 8.1〜8.3 は従来のスーパーグローバルを参照 + foreach ($http_response_header as $header) { /* Content-Type 抽出等 */ } + } + ``` +- **症状**: 8.5+テスト(`Error.errorLevel = E_ALL`)では、このスーパーグローバル参照の非推奨が顕在化し、ファイル取得系を通るテストが不安定化・失敗することがある(baserCMS 実績: `BcMcp\Mcp\BaseMcpTool` の URL 画像取得で Content-Type 判定に使用)。 +- 単純に「8.1 維持中は据え置き」でも 8.5 では警告のみで動作はするが、テストを通すなら上記の互換パターンで解消する方が確実。 ### 7. バッククォート演算子の非推奨 `` `command` ``(`shell_exec()` のエイリアス)が非推奨。`shell_exec()` を直接使う。 diff --git a/.github/instructions/basercms.instructions.md b/.github/instructions/basercms.instructions.md index 381bcdf629..88b060a171 100644 --- a/.github/instructions/basercms.instructions.md +++ b/.github/instructions/basercms.instructions.md @@ -12,6 +12,11 @@ baserCMSの開発についての指示をまとめたものです。 - プラグインが見つからない場合は `BcUtil::includePluginClass()` を利用。 - APIテストは `/baser/api/admin/baser-core/users/login.json` で認証→トークン取得→各API呼び出し。 - CI/CDはGitHub Actions(`test.yml`)で自動化。主要コマンドは `composer install`、`docker compose up`。 +- **外部プロセス(MCPサーバー等)に依存するテストの方針**: + - 必要なプロセスは**該当テスト側で起動**し、`setUp` 全体ではなく**それを要する個別テストの先頭**でガードする(他テストに起動待ちを波及させない)。 + - 起動判定は「プロセスの存在(pidファイル)」だけで済ませない。**プロキシ等が実際に接続する先(例 `127.0.0.1:{port}`)へ到達できるまで待つ**(`fsockopen` 等でポーリング、最大十数秒)。プロセス起動直後はポートの bind が間に合わず接続拒否=500 になり、CI でのみ失敗する典型。 + - **到達できない場合は `markTestSkipped` で隠さず、`assertTrue` 等で明示的に失敗させる**。スキップはサーバー起動の不具合を握りつぶし CI を緑にしてしまうため不可。「外部プロセスが動いていること」も統合テストの検証対象とみなす。 + - 実装例: `plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php` の `requireMcpServer()`。 ## コーディング規約・パターン - クラスに新メソッド追加時は「必ず最後」に追加。 diff --git a/.github/workflows/split_monorepo.yml b/.github/workflows/split_monorepo.yml index de15e95e40..06d00b996f 100644 --- a/.github/workflows/split_monorepo.yml +++ b/.github/workflows/split_monorepo.yml @@ -41,6 +41,8 @@ jobs: split_repository: 'bc-installer' - local_path: 'bc-mail' split_repository: 'bc-mail' + - local_path: 'bc-mcp' + split_repository: 'bc-mcp' - local_path: 'bc-search-index' split_repository: 'bc-search-index' - local_path: 'bc-seo' diff --git a/.gitignore b/.gitignore index d013b6934f..17c4869175 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,7 @@ yarn-error.log !/plugins/BcColumn !/plugins/bc-seo !/plugins/bc-burger-editor +!/plugins/bc-mcp /plugins/*/vendor /plugins/*/composer.lock /profile/* @@ -121,6 +122,7 @@ node_modules /webroot/bc_installer /webroot/bc_custom_content /webroot/bc_burger_editor +/webroot/bc_mcp /webroot/bc_spa_sample /webroot/debug_kit /webroot/.gitignore diff --git a/composer.json b/composer.json index 582a00232b..136c48e0db 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "ext-gd": "*", "ext-json": "*", "ext-mbstring": "*", + "ext-openssl": "*", "ext-pdo": "*", "ext-sqlite3": "*", "ext-zip": "*", @@ -20,9 +21,13 @@ "ezyang/htmlpurifier": "~4.19.0", "firebase/php-jwt": "~7.0.2", "josegonzalez/dotenv": "~4.0.0", + "league/oauth2-server": "^8.5", + "logiscape/mcp-sdk-php": "^2.0", "mobiledetect/mobiledetectlib": "~3.74.4", - "psr/http-message": "^1.0", - "robmorgan/phinx": "0.16.10" + "nyholm/psr7": "~1.8.2", + "psr/http-message": "~1.1", + "robmorgan/phinx": "0.16.10", + "symfony/psr-http-message-bridge": "~2.3.1" }, "require-dev": { "ext-xdebug": "*", @@ -47,6 +52,7 @@ "baserproject/bc-front": "5.4.x", "baserproject/bc-installer": "5.4.x", "baserproject/bc-mail": "5.4.x", + "baserproject/bc-mcp": "5.4.x", "baserproject/bc-plugin-sample": "5.4.x", "baserproject/bc-search-index": "5.4.x", "baserproject/bc-seo": "5.4.x", @@ -77,6 +83,7 @@ "BcFront\\": "plugins/bc-front/src/", "BcInstaller\\": "plugins/bc-installer/src/", "BcMail\\": "plugins/bc-mail/src/", + "BcMcp\\": "plugins/bc-mcp/src/", "BcPluginSample\\": "plugins/BcPluginSample/src/", "BcSearchIndex\\": "plugins/bc-search-index/src/", "BcSeo\\": "plugins/bc-seo/src/", @@ -99,6 +106,7 @@ "BcFavorite\\Test\\": "plugins/bc-favorite/tests/", "BcInstaller\\Test\\": "plugins/bc-installer/tests/", "BcMail\\Test\\": "plugins/bc-mail/tests/", + "BcMcp\\Test\\": "plugins/bc-mcp/tests/", "BcSearchIndex\\Test\\": "plugins/bc-search-index/tests/", "BcSeo\\Test\\": "plugins/bc-seo/tests/", "BcThemeConfig\\Test\\": "plugins/bc-theme-config/tests/", diff --git a/composer.lock b/composer.lock index 25bbf0886c..720a001d53 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "54f416cfab7abece80fcf8ffdf86b6f9", + "content-hash": "1a66ffd47d2824a95f47de2cf4217dca", "packages": [ { "name": "cakephp/authentication", @@ -437,6 +437,73 @@ ], "time": "2026-07-18T12:35:13+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "doctrine/annotations", "version": "1.14.4", @@ -977,6 +1044,143 @@ ], "time": "2025-10-12T20:58:29+00:00" }, + { + "name": "lcobucci/clock", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "039ef98c6b57b101d10bd11d8fdfda12cbd996dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/039ef98c6b57b101d10bd11d8fdfda12cbd996dc", + "reference": "039ef98c6b57b101d10bd11d8fdfda12cbd996dc", + "shasum": "" + }, + "require": { + "php": "~8.1.0 || ~8.2.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.26", + "lcobucci/coding-standard": "^9.0", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-deprecation-rules": "^1.1.1", + "phpstan/phpstan-phpunit": "^1.3.2", + "phpstan/phpstan-strict-rules": "^1.4.4", + "phpunit/phpunit": "^9.5.27" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2022-12-19T15:00:24+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.27.0", + "lcobucci/clock": "^3.0", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2.9", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^10.2.6" + }, + "suggest": { + "lcobucci/clock": ">= 3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.3.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2024-04-11T23:07:54+00:00" + }, { "name": "league/container", "version": "4.2.5", @@ -1059,6 +1263,375 @@ ], "time": "2025-05-20T12:55:37+00:00" }, + { + "name": "league/event", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/062ebb450efbe9a09bc2478e89b7c933875b0935", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "henrikbjorn/phpspec-code-coverage": "~1.0.1", + "phpspec/phpspec": "^2.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/2.3.0" + }, + "time": "2025-03-14T19:51:10+00:00" + }, + { + "name": "league/oauth2-server", + "version": "8.5.5", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/cc8778350f905667e796b3c2364a9d3bd7a73518", + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.3", + "ext-openssl": "*", + "lcobucci/clock": "^2.2 || ^3.0", + "lcobucci/jwt": "^4.3 || ^5.0", + "league/event": "^2.2", + "league/uri": "^6.7 || ^7.0", + "php": "^8.0", + "psr/http-message": "^1.0.1 || ^2.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.0.0", + "phpstan/phpstan": "^0.12.57", + "phpstan/phpstan-phpunit": "^0.12.16", + "phpunit/phpunit": "^9.6.6", + "roave/security-advisories": "dev-master" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/8.5.5" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2024-12-20T23:06:10+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "logiscape/mcp-sdk-php", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/logiscape/mcp-sdk-php.git", + "reference": "b3a8882b81a891014ef6374522dd983284496ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/logiscape/mcp-sdk-php/zipball/b3a8882b81a891014ef6374522dd983284496ca1", + "reference": "b3a8882b81a891014ef6374522dd983284496ca1", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=8.1", + "psr/log": "^2.0 ||^3.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "For better process handling in CLI environments", + "monolog/monolog": "^3.0 - For debugging and logging capabilities" + }, + "type": "library", + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Model Context Protocol SDK for PHP", + "support": { + "issues": "https://github.com/logiscape/mcp-sdk-php/issues", + "source": "https://github.com/logiscape/mcp-sdk-php/tree/v2.0.0" + }, + "time": "2026-07-28T10:00:24+00:00" + }, { "name": "m1/env", "version": "2.2.0", @@ -1116,74 +1689,202 @@ "support" ], "support": { - "issues": "https://github.com/m1/Env/issues", - "source": "https://github.com/m1/Env/tree/2.2.0" + "issues": "https://github.com/m1/Env/issues", + "source": "https://github.com/m1/Env/tree/2.2.0" + }, + "time": "2020-02-19T09:02:13+00:00" + }, + { + "name": "mobiledetect/mobiledetectlib", + "version": "3.74.4", + "source": { + "type": "git", + "url": "https://github.com/serbanghita/Mobile-Detect.git", + "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/e72098eba91e5f16278b17d42ca193ae71e4ae0a", + "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Detection\\": "src/" + }, + "classmap": [ + "src/MobileDetect.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Serban Ghita", + "email": "serbanghita@gmail.com", + "homepage": "https://mobiledetect.net", + "role": "Developer" + } + ], + "description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.", + "homepage": "https://github.com/serbanghita/Mobile-Detect", + "keywords": [ + "detect mobile devices", + "mobile", + "mobile detect", + "mobile detector", + "php mobile detect" + ], + "support": { + "issues": "https://github.com/serbanghita/Mobile-Detect/issues", + "source": "https://github.com/serbanghita/Mobile-Detect/tree/3.74.4" + }, + "funding": [ + { + "url": "https://github.com/serbanghita", + "type": "github" + } + ], + "time": "2026-04-15T08:43:14+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" }, - "time": "2020-02-19T09:02:13+00:00" + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" }, { - "name": "mobiledetect/mobiledetectlib", - "version": "3.74.4", + "name": "paragonie/random_compat", + "version": "v9.99.100", "source": { "type": "git", - "url": "https://github.com/serbanghita/Mobile-Detect.git", - "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a" + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/e72098eba91e5f16278b17d42ca193ae71e4ae0a", - "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", "shasum": "" }, "require": { - "php": ">=7.4" + "php": ">= 7" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.14", - "phpunit/phpunit": "^9.6", - "squizlabs/php_codesniffer": "^3.7" + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" }, - "type": "library", - "autoload": { - "psr-4": { - "Detection\\": "src/" - }, - "classmap": [ - "src/MobileDetect.php" - ] + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." }, + "type": "library", "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { - "name": "Serban Ghita", - "email": "serbanghita@gmail.com", - "homepage": "https://mobiledetect.net", - "role": "Developer" + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" } ], - "description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.", - "homepage": "https://github.com/serbanghita/Mobile-Detect", + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", "keywords": [ - "detect mobile devices", - "mobile", - "mobile detect", - "mobile detector", - "php mobile detect" + "csprng", + "polyfill", + "pseudorandom", + "random" ], "support": { - "issues": "https://github.com/serbanghita/Mobile-Detect/issues", - "source": "https://github.com/serbanghita/Mobile-Detect/tree/3.74.4" + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" }, - "funding": [ - { - "url": "https://github.com/serbanghita", - "type": "github" - } - ], - "time": "2026-04-15T08:43:14+00:00" + "time": "2020-10-15T08:29:30+00:00" }, { "name": "psr/cache", @@ -2114,6 +2815,87 @@ ], "time": "2026-06-27T10:13:35+00:00" }, + { + "name": "symfony/http-foundation", + "version": "v6.4.43", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ea0c801ec34e9017a8c9363e55c8ef5f1717216e", + "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v6.4.43" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-29T06:55:26+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.37.0", @@ -2449,6 +3231,175 @@ ], "time": "2026-05-27T06:59:30+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "581ca6067eb62640de5ff08ee1ba6850a0ee472e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/581ca6067eb62640de5ff08ee1ba6850a0ee472e", + "reference": "581ca6067eb62640de5ff08ee1ba6850a0ee472e", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/http-message": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/http-foundation": "^5.4 || ^6.0" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "psr/log": "^1.1 || ^2 || ^3", + "symfony/browser-kit": "^5.4 || ^6.0", + "symfony/config": "^5.4 || ^6.0", + "symfony/event-dispatcher": "^5.4 || ^6.0", + "symfony/framework-bundle": "^5.4 || ^6.0", + "symfony/http-kernel": "^5.4 || ^6.0", + "symfony/phpunit-bridge": "^6.2" + }, + "suggest": { + "nyholm/psr7": "For a super lightweight PSR-7/17 implementation" + }, + "type": "symfony-bridge", + "extra": { + "branch-alias": { + "dev-main": "2.3-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "http://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/symfony/psr-http-message-bridge/issues", + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-26T11:53:26+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.1", @@ -3886,20 +4837,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -3934,15 +4885,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", @@ -5423,16 +6374,16 @@ }, { "name": "sebastian/recursion-context", - "version": "5.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", - "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a", "shasum": "" }, "require": { @@ -5475,7 +6426,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.2" }, "funding": [ { @@ -5495,7 +6446,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T07:50:56+00:00" + "time": "2026-08-11T05:27:39+00:00" }, { "name": "sebastian/type", @@ -5672,16 +6623,16 @@ }, { "name": "seld/phar-utils", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + "reference": "990bbd0e92caa216d52eca0935f6e35e589bfaa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/990bbd0e92caa216d52eca0935f6e35e589bfaa5", + "reference": "990bbd0e92caa216d52eca0935f6e35e589bfaa5", "shasum": "" }, "require": { @@ -5714,9 +6665,9 @@ ], "support": { "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.2" }, - "time": "2022-08-31T10:31:18+00:00" + "time": "2026-08-01T12:48:55+00:00" }, { "name": "seld/signal-handler", @@ -5781,16 +6732,16 @@ }, { "name": "slevomat/coding-standard", - "version": "8.31.0", + "version": "8.31.1", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "ae5e938b49986fa48b494557445e22ee1ca795b7" + "reference": "0a40807a48873948bfa7ffce2a4e69ba40cf5e76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/ae5e938b49986fa48b494557445e22ee1ca795b7", - "reference": "ae5e938b49986fa48b494557445e22ee1ca795b7", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/0a40807a48873948bfa7ffce2a4e69ba40cf5e76", + "reference": "0a40807a48873948bfa7ffce2a4e69ba40cf5e76", "shasum": "" }, "require": { @@ -5802,11 +6753,11 @@ "require-dev": { "phing/phing": "3.0.1|3.1.2", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.2.5", - "phpstan/phpstan-deprecation-rules": "2.0.4", + "phpstan/phpstan": "2.2.7", + "phpstan/phpstan-deprecation-rules": "2.0.5", "phpstan/phpstan-phpunit": "2.0.18", "phpstan/phpstan-strict-rules": "2.0.12", - "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.55|12.5.30" + "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.56|12.5.33" }, "type": "phpcodesniffer-standard", "extra": { @@ -5830,7 +6781,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.31.0" + "source": "https://github.com/slevomat/coding-standard/tree/8.31.1" }, "funding": [ { @@ -5842,23 +6793,24 @@ "type": "tidelift" } ], - "time": "2026-07-21T16:20:20+00:00" + "time": "2026-07-31T10:42:43+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "4.0.1", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0525c73950de35ded110cffafb9892946d7771b5" + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", - "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96", "shasum": "" }, "require": { + "ext-libxml": "*", "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", @@ -5867,6 +6819,10 @@ "require-dev": { "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, + "suggest": { + "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.", + "ext-pcntl": "For parallel processing support via the --parallel CLI option." + }, "bin": [ "bin/phpcbf", "bin/phpcs" @@ -5921,7 +6877,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-10T16:43:36+00:00" + "time": "2026-08-06T02:45:27+00:00" }, { "name": "symfony/finder", @@ -6754,7 +7710,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -6762,6 +7718,7 @@ "ext-gd": "*", "ext-json": "*", "ext-mbstring": "*", + "ext-openssl": "*", "ext-pdo": "*", "ext-sqlite3": "*", "ext-zip": "*" @@ -6772,5 +7729,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.9.0" } diff --git a/docs/superpowers/plans/2026-08-12-bc-mcp-sdk-migration.md b/docs/superpowers/plans/2026-08-12-bc-mcp-sdk-migration.md new file mode 100644 index 0000000000..da1a2ea20e --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-bc-mcp-sdk-migration.md @@ -0,0 +1,2850 @@ +# bc-mcp MCP 2026-07-28(Dual-era)対応 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** bc-mcp の MCP SDK を `logiscape/mcp-sdk-php` v2 へ移植し、Modern(`2026-07-28`)と Legacy(`initialize` 方式)の両世代を同時に提供する Dual-era サーバーにする。あわせて常駐 MCP サーバープロセスを廃止し、CakePHP のリクエスト内で処理を完結させる。移植の完了後、固定ページ(Pages)ツールを新規追加する。 + +**Architecture:** `McpRequestHandler` が SDK の `HttpServerRunner` をプロセス内で実行する単一の入口になる。`McpProxyController` は認証・認可・`Origin` 検証・ロギングと、CakePHP のリクエスト/レスポンスと SDK の `HttpMessage` の相互変換に責務を絞る。プロトコルの世代判定・`server/discover`・必須ヘッダ検証・`resultType` / `ttlMs` / `cacheScope` の付与はすべて SDK が担う。 + +**Tech Stack:** PHP 8.1+ / CakePHP 5 / baserCMS 5.4 / `logiscape/mcp-sdk-php` v2.0.0 / `league/oauth2-server` 8.5.5 / PHPUnit 10.5 + +**設計書:** [2026-08-12-bc-mcp-sdk-migration-design.md](../specs/2026-08-12-bc-mcp-sdk-migration-design.md) +**前提調査:** [2026-08-12-mcp-2026-07-28-bc-mcp-impact.md](../specs/2026-08-12-mcp-2026-07-28-bc-mcp-impact.md) + +## Global Constraints + +- 作業ブランチは `dev-mcp-2026-07-28`(`dev-agentic` から分岐済み)。 +- PHP は `>=8.1` で動作すること。ルート `composer.json` の `config.platform.php` は `8.1` に固定。 +- テストはローカル Docker の **`basercms` コンテナ**で実行する。baserCMS の配置先は `/var/www/html`。 +- テスト実行コマンドはパイプを含めて**単一引用符の中に収める**(`docker exec basercms sh -c '...'` の形)。 +- コメント・コミットメッセージ・ドキュメントは**日本語**で書く。 +- `vendor/` 配下はコアハック禁止。 +- **`loginUserId` を `inputSchema` に公開してはならない。** AI クライアントに他ユーザーの ID を指定する余地を与えることになる。 +- **リクエストボディを改変してはならない。** Modern ではヘッダとボディの一致が検証される。ログインユーザーは `McpContext` 経由で渡す。 +- `nyholm/psr7` と `ext-openssl` は OAuth2 側で使用しているため削除しない。 +- `league/oauth2-server` の 9系アップデート、Client ID Metadata Documents 対応、CuMcp への反映はスコープ外。 + +## 使用する SDK の API(実地確認済み) + +| API | 用途 | +|---|---| +| `Mcp\Server\McpServer::__construct(string $name, ?LoggerInterface $logger = null, string $version = '1.0.0')` | サーバー生成。ロガーは第2引数 | +| `Mcp\Server\McpServer::tool(name:, description:, callback:, inputSchema:)` | ツール登録。`inputSchema` を明示指定できる | +| `Mcp\Server\McpServer::getServer(): Mcp\Server\Server` | コアサーバーの取得 | +| `Mcp\Server\Server::createInitializationOptions(?NotificationOptions = null): InitializationOptions` | 初期化オプション。引数は省略可 | +| `new Mcp\Server\HttpServerRunner(Server, InitializationOptions, array $httpOptions, ?LoggerInterface, ?SessionStoreInterface, ?HttpIoInterface)` | HTTP リクエストの実行器 | +| `HttpServerRunner::handleRequest(?HttpMessage $request = null): HttpMessage` | 1リクエストを処理してレスポンスを返す | +| `new Mcp\Server\Transport\Http\HttpMessage(?string $body)` + `setMethod()` / `setUri()` / `setHeader()` | リクエストの組み立て | +| `HttpMessage::getStatusCode()` / `getBody()` / `getHeaders()` | レスポンスの取り出し | +| `Mcp\Server\Transport\Http\BufferedIo` | 出力を SAPI へ書き出さずバッファに捕捉する `HttpIoInterface` 実装 | +| `Mcp\Server\Transport\Http\FileSessionStore` | Legacy 世代のセッション永続 | +| `httpOptions` の `allowed_origins` | Origin 検証(null で無効) | + +--- + +### Task 1: SDK の導入(完了済み) + +**実施日:** 2026-08-12 + +- [x] `plugins/bc-mcp/composer.json` の `php-mcp/server: ^3.3` を `logiscape/mcp-sdk-php: ^2.0` に差し替え +- [x] ルート `composer.json` を直接編集(`monorepo-builder merge` は**既存の**バージョン不一致で失敗するため。`nyholm/psr7` の `^1.8` vs `~1.8.2` 等、移植前から存在し本件とは無関係) +- [x] `composer update` により `logiscape/mcp-sdk-php v2.0.0` を導入、`php-mcp/server` と依存17パッケージを削除 +- [x] SDK の API を実地確認(上表のとおり) +- [x] SDK に listen 型サーバーが無いことを確認し、in-process 化へ方針変更(設計書 第11章) + +--- + +### Task 2: プロセス内実行の基盤 + +SDK を CakePHP のリクエスト内で実行する単一の入口を作る。本番とテストがこの経路を共有する。 + +**Files:** +- Create: `plugins/bc-mcp/src/Mcp/McpContext.php` +- Create: `plugins/bc-mcp/src/Mcp/McpRequestHandler.php` +- Create: `plugins/bc-mcp/tests/TestSuite/McpTestTrait.php` +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php` + +**Interfaces:** +- Consumes: なし +- Produces: + - `BcMcp\Mcp\McpContext::setLoginUserId(?int $userId): void` / `getLoginUserId(): ?int` / `clear(): void` + - `BcMcp\Mcp\McpRequestHandler::handle(\Mcp\Server\Transport\Http\HttpMessage $request): \Mcp\Server\Transport\Http\HttpMessage` + - `BcMcp\Mcp\McpRequestHandler::getSessionStorePath(): string` + - `BcMcp\Test\TestSuite\McpTestTrait::callMcp(array $request, array $headers = []): array` + - `BcMcp\Test\TestSuite\McpTestTrait::callMcpTool(string $name, array $arguments): array` + - `BcMcp\Test\TestSuite\McpTestTrait::modernMeta(string $protocolVersion = '2026-07-28'): array` + +- [ ] **Step 1: `McpContext` の失敗するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\McpContext; + +/** + * McpContextTest + */ +class McpContextTest extends BcTestCase +{ + + /** + * Tear down + */ + public function tearDown(): void + { + McpContext::clear(); + parent::tearDown(); + } + + /** + * test ログインユーザーIDの設定と取得 + */ + public function testSetAndGetLoginUserId() + { + $this->assertNull(McpContext::getLoginUserId()); + + McpContext::setLoginUserId(5); + $this->assertEquals(5, McpContext::getLoginUserId()); + + McpContext::clear(); + $this->assertNull(McpContext::getLoginUserId()); + } + +} +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php 2>&1 | tail -15'` + +Expected: FAIL(`BcMcp\Mcp\McpContext` が存在しない)。 + +- [ ] **Step 3: `McpContext` を実装する** + +Create: `plugins/bc-mcp/src/Mcp/McpContext.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp; + +/** + * MCP リクエストのコンテキスト + * + * MCP のツールは JSON-RPC の引数だけを受け取るため、認証済みの操作者を + * 知る手段がない。リクエストボディに引数を注入する方式は、2026-07-28 で + * ヘッダとボディの一致が検証されるようになったため採らず、同一プロセス内の + * コンテキストとして保持する。 + * + * 値は必ず認証後に設定し、リクエストの終わりに clear() する。 + */ +class McpContext +{ + + /** + * ログインユーザーID + * @var int|null + */ + private static ?int $loginUserId = null; + + /** + * ログインユーザーIDを設定する + * + * @param int|null $userId ユーザーID + * @return void + */ + public static function setLoginUserId(?int $userId): void + { + self::$loginUserId = $userId; + } + + /** + * ログインユーザーIDを取得する + * + * @return int|null + */ + public static function getLoginUserId(): ?int + { + return self::$loginUserId; + } + + /** + * コンテキストを破棄する + * + * @return void + */ + public static function clear(): void + { + self::$loginUserId = null; + } + +} +``` + +- [ ] **Step 4: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php 2>&1 | tail -15'` + +Expected: PASS。 + +- [ ] **Step 5: `McpRequestHandler` を実装する** + +Create: `plugins/bc-mcp/src/Mcp/McpRequestHandler.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp; + +use Cake\Core\Configure; +use Mcp\Server\HttpServerRunner; +use Mcp\Server\Transport\Http\BufferedIo; +use Mcp\Server\Transport\Http\FileSessionStore; +use Mcp\Server\Transport\Http\HttpMessage; + +/** + * MCP リクエストをプロセス内で処理する + * + * SDK の HTTP トランスポートは「1リクエストを処理して終わる」モデルであり、 + * 常駐プロセスを必要としない。BufferedIo により出力が SAPI へ直接書き出される + * のを防ぎ、レスポンスを CakePHP のレスポンスに載せられるようにする。 + * + * 本番(McpProxyController)とテストがこの経路を共有する。 + */ +class McpRequestHandler +{ + + /** + * MCP リクエストを処理する + * + * @param \Mcp\Server\Transport\Http\HttpMessage $request リクエスト + * @return \Mcp\Server\Transport\Http\HttpMessage レスポンス + */ + public function handle(HttpMessage $request): HttpMessage + { + $logger = new McpLogger(LOGS . 'bc_mcp_error.log'); + $sdkServer = (new McpServer())->getServer(); + $coreServer = $sdkServer->getServer(); + + $runner = new HttpServerRunner( + $coreServer, + $coreServer->createInitializationOptions(), + $this->getHttpOptions(), + $logger, + new FileSessionStore($this->getSessionStorePath()), + new BufferedIo() + ); + + return $runner->handleRequest($request); + } + + /** + * HTTP トランスポートのオプションを取得する + * + * allowed_origins は SDK 側の DNS リバインディング対策。 + * プロキシでも検証しているため二重に効かせる。 + * + * @return array + */ + public function getHttpOptions(): array + { + $options = []; + $allowedOrigins = (array)Configure::read('BcMcp.allowedOrigins', []); + if ($allowedOrigins) { + $options['allowed_origins'] = $allowedOrigins; + } + return $options; + } + + /** + * Legacy セッションの保存先を取得する + * + * Modern(2026-07-28)はセッションを使わないが、Legacy 世代の + * クライアントはセッションを必要とするためディスクへ永続する。 + * + * @return string + */ + public function getSessionStorePath(): string + { + $path = TMP . 'bc_mcp_sessions'; + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + return $path; + } + +} +``` + +- [ ] **Step 6: テスト用ヘルパを作成する** + +Create: `plugins/bc-mcp/tests/TestSuite/McpTestTrait.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestSuite; + +use BcMcp\Mcp\McpRequestHandler; +use Mcp\Server\Transport\Http\HttpMessage; + +/** + * MCP サーバーをプロセス内で実行するテスト用ヘルパ + * + * 本番と同じ McpRequestHandler を経由するため、テストが実装の実経路を検証する。 + * Modern(2026-07-28)と Legacy(initialize 方式)のどちらのリクエストも実行できる。 + */ +trait McpTestTrait +{ + + /** + * Modern リクエストの _meta を取得する + * + * @param string $protocolVersion プロトコルバージョン + * @return array + */ + protected function modernMeta(string $protocolVersion = '2026-07-28'): array + { + return [ + 'io.modelcontextprotocol/protocolVersion' => $protocolVersion, + 'io.modelcontextprotocol/clientInfo' => [ + 'name' => 'BcMcpTestClient', + 'version' => '1.0.0', + ], + 'io.modelcontextprotocol/clientCapabilities' => [], + ]; + } + + /** + * JSON-RPC リクエストをプロセス内で実行する + * + * @param array $request JSON-RPC リクエスト + * @param array $headers HTTP ヘッダ(Modern の必須ヘッダを渡す) + * @return array デコード済みのレスポンス + */ + protected function callMcp(array $request, array $headers = []): array + { + $response = $this->callMcpRaw($request, $headers); + return json_decode($response->getBody() ?? '', true) ?? []; + } + + /** + * JSON-RPC リクエストを実行して HttpMessage を得る + * + * ステータスコードやヘッダを検証したい場合に使う。 + * + * @param array $request JSON-RPC リクエスト + * @param array $headers HTTP ヘッダ + * @return \Mcp\Server\Transport\Http\HttpMessage + */ + protected function callMcpRaw(array $request, array $headers = []): HttpMessage + { + $message = new HttpMessage(json_encode($request, JSON_UNESCAPED_UNICODE)); + $message->setMethod('POST'); + $message->setUri('/bc-mcp'); + $message->setHeader('Content-Type', 'application/json'); + $message->setHeader('Accept', 'application/json, text/event-stream'); + foreach($headers as $name => $value) { + $message->setHeader($name, $value); + } + return (new McpRequestHandler())->handle($message); + } + + /** + * tools/call を実行する + * + * @param string $name ツール名 + * @param array $arguments 引数 + * @return array [デコード済みの戻り値, エラーかどうか] + */ + protected function callMcpTool(string $name, array $arguments): array + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + '_meta' => $this->modernMeta(), + ], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => $name, + ]); + + $text = $response['result']['content'][0]['text'] ?? ''; + $isError = $response['result']['isError'] ?? isset($response['error']); + return [json_decode($text, true) ?? $text, (bool)$isError]; + } + +} +``` + +- [ ] **Step 7: 構文チェック** + +Run: `docker exec basercms sh -c 'cd /var/www/html && php -l plugins/bc-mcp/src/Mcp/McpContext.php && php -l plugins/bc-mcp/src/Mcp/McpRequestHandler.php && php -l plugins/bc-mcp/tests/TestSuite/McpTestTrait.php'` + +Expected: `No syntax errors detected`。 + +この時点では `McpServer` が未移植のため `McpRequestHandler::handle()` は動かない。Task 3 で通す。 + +- [ ] **Step 8: コミット** + +```bash +git add plugins/bc-mcp/src/Mcp/McpContext.php plugins/bc-mcp/src/Mcp/McpRequestHandler.php plugins/bc-mcp/tests/TestSuite/McpTestTrait.php plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php +git commit -m "MCP リクエストをプロセス内で処理する基盤とテストヘルパを追加" +``` + +--- + +### Task 3: McpServer / BaseMcpTool / BlogPostsTool の移植 + +SDK のサーバー組み立てとツール登録の作法を確立し、プロセス内実行を疎通させる。 + +**Files:** +- Modify: `plugins/bc-mcp/src/Mcp/McpServer.php` +- Modify: `plugins/bc-mcp/src/Mcp/BaseMcpTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php` +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php` + +**Interfaces:** +- Consumes: `McpTestTrait`(Task 2)、`McpContext`(Task 2) +- Produces: + - `BcMcp\Mcp\McpServer::getServer(): \Mcp\Server\McpServer` + - `BcMcp\Mcp\BaseMcpTool::registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer`(抽象メソッド) + - `BcMcp\Mcp\BaseMcpTool::resolveLoginUserId(?int $loginUserId = null): ?int` + +- [ ] **Step 1: `tools/list` を検証する失敗するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Test\TestSuite\McpTestTrait; + +/** + * McpServerTest + */ +class McpServerTest extends BcTestCase +{ + + use McpTestTrait; + + /** + * tools/list を実行する + * + * @return array + */ + private function listTools(): array + { + return $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => $this->modernMeta()], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/list', + ]); + } + + /** + * test tools/list に全プラグインのツールが並ぶ + */ + public function testToolsListContainsAllTools() + { + $response = $this->listTools(); + + $this->assertArrayNotHasKey('error', $response, json_encode($response, JSON_UNESCAPED_UNICODE)); + $names = array_column($response['result']['tools'], 'name'); + + // BcBlog + $this->assertContains('addBlogPost', $names); + $this->assertContains('getBlogContents', $names); + $this->assertContains('addBlogCategory', $names); + $this->assertContains('addBlogTag', $names); + // BcCustomContent + $this->assertContains('addCustomTable', $names); + $this->assertContains('addCustomContent', $names); + $this->assertContains('addCustomField', $names); + $this->assertContains('addCustomEntry', $names); + $this->assertContains('addCustomLink', $names); + // BaserCore + $this->assertContains('serverInfo', $names); + } + + /** + * test tools/list の結果にキャッシュヒントが付与される + * + * 2026-07-28 では ttlMs / cacheScope が必須項目であり、SDK が付与する + */ + public function testToolsListHasCacheHints() + { + $response = $this->listTools(); + + $this->assertArrayHasKey('ttlMs', $response['result']); + $this->assertArrayHasKey('cacheScope', $response['result']); + } + + /** + * test 全 result に resultType が付与される + */ + public function testResultTypeIsComplete() + { + $response = $this->listTools(); + + $this->assertEquals('complete', $response['result']['resultType']); + } + + /** + * test loginUserId が inputSchema に公開されていない + * + * 公開すると AI クライアントが他ユーザーの ID を指定できてしまう + */ + public function testLoginUserIdIsNotExposed() + { + $response = $this->listTools(); + + foreach($response['result']['tools'] as $tool) { + $properties = $tool['inputSchema']['properties'] ?? []; + $this->assertArrayNotHasKey( + 'loginUserId', + $properties, + "ツール {$tool['name']} の inputSchema に loginUserId が公開されています" + ); + } + } + +} +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php 2>&1 | tail -20'` + +Expected: FAIL(`PhpMcp\Server\ServerBuilder` が存在しない)。 + +- [ ] **Step 3: `McpServer` を SDK ベースに書き換える** + +Modify: `plugins/bc-mcp/src/Mcp/McpServer.php` + +```php +buildServer(); + } + + /** + * サーバーのビルド + */ + private function buildServer(): void + { + $this->server = new SdkMcpServer( + 'baserCMS MCP Server', + new McpLogger(LOGS . 'bc_mcp_error.log'), + '1.0.0' + ); + + $availableServers = Configure::read('BcMcp.availableServers', []); + foreach($availableServers as $serverClass) { + foreach($serverClass::getToolClasses() as $toolClass) { + (new $toolClass())->registerTools($this->server); + } + } + + // サーバー情報ツールを追加 + $this->server->tool( + name: 'serverInfo', + description: 'サーバーのバージョンや環境情報を返します', + callback: [$this, 'serverInfo'], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ID'], + ] + ] + ); + } + + /** + * MCPサーバーの実体を取得する + * + * @return \Mcp\Server\McpServer + */ + public function getServer(): SdkMcpServer + { + return $this->server; + } + + /** + * 標準入力からサーバーを起動する + * + * @return void + */ + public function runStdio(): void + { + $this->server->runStdio(); + } + + /** + * サーバー情報を取得する + * + * @param int|null $id ID + * @return array + */ + public function serverInfo(?int $id = null): array + { + return [ + 'php_version' => PHP_VERSION, + 'basercms_version' => BcUtil::getVersion(), + 'cakephp_version' => Configure::version(), + 'server_time' => date('Y-m-d H:i:s'), + 'timezone' => date_default_timezone_get(), + 'mcp_server_version' => '1.0.0', + 'supported_clients' => ['ChatGPT', 'Claude', 'Custom MCP Clients'], + 'available_transports' => ['stdio', 'http'], + ]; + } +} +``` + +`runSse()` / `setConfig()` / `registerToolsFromServer()` / `registerResourcesFromServer()` は削除する(常駐 HTTP モードの廃止に伴い不要)。 + +- [ ] **Step 4: `BaseMcpTool` に抽象メソッドと `resolveLoginUserId()` を追加する** + +Modify: `plugins/bc-mcp/src/Mcp/BaseMcpTool.php` + +`use BcContainerTrait;` の直後に追加する。 + +```php + /** + * 自身が提供するツールをサーバーに登録する + * + * @param \Mcp\Server\McpServer $server SDK のサーバー + * @return \Mcp\Server\McpServer + */ + abstract public function registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer; + + /** + * 操作者のユーザーIDを解決する + * + * MCP のツールは JSON-RPC の引数しか受け取らないため、認証済みの操作者は + * McpContext から取得する。引数で明示された場合はそれを優先する + * (stdio 経由の利用など、コンテキストを持たない経路のため)。 + * + * @param int|null $loginUserId 引数で渡されたユーザーID + * @return int|null + */ + protected function resolveLoginUserId(?int $loginUserId = null): ?int + { + return $loginUserId ?? McpContext::getLoginUserId(); + } +``` + +- [ ] **Step 5: `BlogPostsTool` の登録処理を書き換える** + +Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php` + +1. `use PhpMcp\Server\ServerBuilder;` を削除 +2. `addToolsToBuilder(ServerBuilder $builder): ServerBuilder` → `registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer` +3. `return $builder` → `return $server` +4. `->withTool(handler: [self::class, 'x'], name: 'x', description: '…', inputSchema: […])` → `->tool(name: 'x', description: '…', callback: [$this, 'x'], inputSchema: […])` + +**`inputSchema` の中身は1文字も変えない。** + +```php + /** + * ブログ記事関連のツールをサーバーに登録する + * + * @param \Mcp\Server\McpServer $server SDK のサーバー + * @return \Mcp\Server\McpServer + */ + public function registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer + { + return $server + ->tool( + name: 'getBlogPosts', + description: 'ブログ記事の一覧を取得します', + callback: [$this, 'getBlogPosts'], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'blogContentId' => ['type' => 'number', 'description' => 'ブログコンテンツID(省略時はデフォルト)'], + 'keyword' => ['type' => 'string', 'description' => '検索キーワード'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(null: 全て, publish: 公開)(省略時は全て)'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は10件)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + // 残りの withTool(getBlogPost / addBlogPost / editBlogPost / deleteBlogPost)も同じ要領で置換する + ; + } +``` + +さらに、`getAuthorId()` を呼んでいる箇所の `$loginUserId` を `$this->resolveLoginUserId($loginUserId)` に置き換える。 + +```php + 'user_id' => $this->getAuthorId($email, $this->resolveLoginUserId($loginUserId)), +``` + +`deleteBlogPost()` や `editBlogPost()` 内で `$loginUserId` を `saveDblog()` などに渡している箇所も同様に置き換える。 + +- [ ] **Step 6: 構文チェック** + +Run: `docker exec basercms sh -c 'cd /var/www/html && php -l plugins/bc-mcp/src/Mcp/McpServer.php && php -l plugins/bc-mcp/src/Mcp/BaseMcpTool.php && php -l plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php'` + +Expected: `No syntax errors detected`。 + +- [ ] **Step 7: テストを実行する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php 2>&1 | tail -20'` + +Expected: FAIL。ただしエラーは「他のツールクラスが抽象メソッド `registerTools` を実装していない」であり、`McpServer` と `BlogPostsTool` の移植自体は正しいことを示す。Task 4 で全クラスを移植して PASS になる。 + +- [ ] **Step 8: コミット** + +```bash +git add plugins/bc-mcp/src/Mcp/McpServer.php plugins/bc-mcp/src/Mcp/BaseMcpTool.php plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php +git commit -m "McpServer と BlogPostsTool を SDK のツール登録 API へ移植" +``` + +--- + +### Task 4: 残りすべてのツールクラスの移植 + +**Files:** +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogContentsTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogCategoriesTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogTagsTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BaserCore/SearchIndexesTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BaserCore/FileUploadTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomTablesTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomContentsTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomFieldsTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomEntriesTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomLinksTool.php` +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php`(Task 3 で作成済み) + +**Interfaces:** +- Consumes: `BaseMcpTool::registerTools()` / `resolveLoginUserId()`(Task 3) +- Produces: 全ツールクラスの `registerTools()` + +- [ ] **Step 1: 対象ファイルを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -rln "addToolsToBuilder" plugins/bc-mcp/src/'` + +- [ ] **Step 2: 各ファイルに Task 3 Step 5 と同じ置換を適用する** + +`inputSchema` の中身とビジネスロジックは変更しない。`$loginUserId` を使っている箇所は `$this->resolveLoginUserId($loginUserId)` に置き換える。 + +- [ ] **Step 3: 旧 API の残存参照がないことを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -rn "ServerBuilder\|withTool\|PhpMcp" plugins/bc-mcp/ 2>&1'` + +Expected: 出力なし。 + +- [ ] **Step 4: 構文チェック** + +Run: `docker exec basercms sh -c 'cd /var/www/html && for f in $(grep -rl "registerTools" plugins/bc-mcp/src/Mcp); do php -l $f; done'` + +Expected: すべて `No syntax errors detected`。 + +- [ ] **Step 5: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php 2>&1 | tail -25'` + +Expected: PASS(4テスト)。`ttlMs` / `cacheScope` / `resultType` が SDK により付与され、`loginUserId` が公開されていないことも確認できる。 + +- [ ] **Step 6: コミット** + +```bash +git add plugins/bc-mcp/src/Mcp/ +git commit -m "残りの MCP ツールクラスを SDK のツール登録 API へ移植" +``` + +--- + +### Task 5: ツール実行テストの移植 + +既存のツール実行テストを新基盤に載せ替え、`McpContext` 経由でログインユーザーが伝わることを検証する。 + +**Files:** +- Modify: `plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php` + +**Interfaces:** +- Consumes: `McpTestTrait::callMcpTool()`(Task 2)、`McpContext`(Task 2) +- Produces: なし + +- [ ] **Step 1: テストを新基盤へ書き換える** + +Modify: `plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php` + +`Dispatcher` / `CallToolRequest` / `SubscriptionManager` への依存を捨て、`McpTestTrait` を使う。**検証内容(本番で発生した引数でブログ記事が登録できること)は変えない。** + +```php +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\TestSuite\BcTestCase; +use BcBlog\Test\Scenario\BlogContentScenario; +use BcMcp\Mcp\McpContext; +use BcMcp\Test\TestSuite\McpTestTrait; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; + +/** + * McpServerToolCallTest + * + * MCPサーバーを別プロセスで起動する事なく、JSON-RPC の tools/call と同じ経路 + * (スキーマ検証 → 引数マッピング → ツール実行)をプロセス内で実行するテスト + */ +class McpServerToolCallTest extends BcTestCase +{ + + use ScenarioAwareTrait; + use McpTestTrait; + + /** + * Tear down + */ + public function tearDown(): void + { + McpContext::clear(); + parent::tearDown(); + } +``` + +`setUp()` / 旧 `callTool()` を削除する。`testCallToolAddBlogPost()` は次のように変える。 + +- `McpContext::setLoginUserId(1);` を先頭(シナリオ読み込み後)に置く +- 引数配列から `'loginUserId' => 1,` を**削除する**(ボディに載せない) +- `$this->callTool(...)` → `$this->callMcpTool(...)` +- assertion はすべて維持する(`user_id` が 1 であることを含む) + +```php + public function testCallToolAddBlogPost() + { + $this->loadFixtureScenario(InitAppScenario::class); + $this->loadFixtureScenario(BlogContentScenario::class, 1, 1, null, 'news', '/news/'); + + // 認証済みの操作者はコンテキストから渡す(リクエストボディは改変しない) + McpContext::setLoginUserId(1); + + [$result, $isError] = $this->callMcpTool('addBlogPost', [ + 'title' => 'BcMcpについて', + 'name' => 'about-bcmcp', + 'status' => 0, + 'content' => '

BcMcpは、baserCMSを外部のAIエージェントから直接操作できるようにするMCP(Model Context Protocol)サーバーです。

', + 'detail' => $this->getDetail(), + ]); + + // ツール実行時に例外が発生していない事を確認 + $this->assertFalse($isError, 'ツールの実行に失敗しました。' . (is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE))); + // ブログ記事が登録されている事を確認 + $this->assertArrayHasKey('id', $result, 'ブログ記事の登録に失敗しました。' . json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertEquals('BcMcpについて', $result['title']); + $this->assertEquals('about-bcmcp', $result['name']); + $this->assertEquals(1, $result['blog_content_id']); + // McpContext 経由でログインユーザーが反映されている事を確認 + $this->assertEquals(1, $result['user_id']); + $this->assertFalse($result['status']); + } +``` + +- [ ] **Step 2: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php 2>&1 | tail -25'` + +Expected: PASS。`user_id` が 1 になっていれば `McpContext` 方式が機能している。 + +- [ ] **Step 3: コミット** + +```bash +git add plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php +git commit -m "ツール実行テストをプロセス内実行の新基盤へ移植" +``` + +--- + +### Task 6: プロキシの移植(内部 HTTP 転送の廃止) + +**Files:** +- Modify: `plugins/bc-mcp/src/Controller/McpProxyController.php` +- Test: `plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php` +- Modify: `plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php` + +**Interfaces:** +- Consumes: `McpRequestHandler::handle()`(Task 2)、`McpContext`(Task 2) +- Produces: + - `McpProxyController::toMcpMessage(array $mcpRequest): \Mcp\Server\Transport\Http\HttpMessage` + +**`OAuth2ControllerTest` の常駐サーバー依存の解消(必須)** + +`OAuth2ControllerTest` は MCP プロキシ経由の統合テストのために、`McpServerManger::startMcpServer()` で**実際に常駐 MCP サーバー(SSE / `127.0.0.1:3000`)を起動していた**。in-process 化により起動自体が不要になるため、次を削除する。 + +- `use BcMcp\Mcp\McpServerManger;` +- サーバーを起動・停止するセットアップ/ティアダウン(`startMcpServer()` / 停止処理 / ポートへの接続待ちループ / `bc_mcp_server.log` の読み出し) + +統合テストは常駐サーバーを起動せず `/bc-mcp` を POST するだけでよい(プロキシが同一プロセスで SDK を実行するため)。この修正を行うまで `OAuth2ControllerTest` はエラー1・失敗2の状態になる。 + +- [ ] **Step 1: リクエスト変換を検証する失敗するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Controller; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Controller\McpProxyController; +use Cake\Http\ServerRequest; + +/** + * McpProxyControllerTest + */ +class McpProxyControllerTest extends BcTestCase +{ + + /** + * test toMcpMessage が MCP の必須ヘッダを引き継ぐ + * + * 2026-07-28 では MCP-Protocol-Version / Mcp-Method / Mcp-Name が必須ヘッダで、 + * SDK がヘッダとボディの一致を検証する + */ + public function testToMcpMessageCarriesRequiredHeaders() + { + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_MCP_PROTOCOL_VERSION' => '2026-07-28', + 'HTTP_MCP_METHOD' => 'tools/call', + 'HTTP_MCP_NAME' => 'addBlogPost', + 'HTTP_AUTHORIZATION' => 'Bearer secret-token', + ], + ]); + $controller = new McpProxyController($request); + + $message = $controller->toMcpMessage(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call']); + + $this->assertEquals('2026-07-28', $message->getHeader('MCP-Protocol-Version')); + $this->assertEquals('tools/call', $message->getHeader('Mcp-Method')); + $this->assertEquals('addBlogPost', $message->getHeader('Mcp-Name')); + // 認証はプロキシで完結しているため SDK へ渡さない + $this->assertNull($message->getHeader('Authorization')); + $this->assertEquals('POST', $message->getMethod()); + } + +} +``` + +`HttpMessage::getHeader()` の実在とシグネチャを確認し、無ければ `getHeaders()` から取り出す形に合わせる。 + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -n "public function getHeader" -A 5 vendor/logiscape/mcp-sdk-php/src/Server/Transport/Http/HttpMessage.php'` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php 2>&1 | tail -20'` + +Expected: FAIL(`toMcpMessage()` が存在しない)。 + +- [ ] **Step 3: `toMcpMessage()` を実装する** + +Modify: `plugins/bc-mcp/src/Controller/McpProxyController.php` + +```php + /** + * CakePHP のリクエストを SDK の HttpMessage に変換する + * + * 2026-07-28 では MCP-Protocol-Version / Mcp-Method / Mcp-Name が必須ヘッダで、 + * SDK がヘッダとボディの一致を検証する。クライアントが送ってきたヘッダを + * そのまま引き継ぎ、ボディも改変しない事で整合性を保つ。 + * Authorization は認証がプロキシで完結しているため渡さない。 + * + * @param array $mcpRequest MCP リクエスト + * @return \Mcp\Server\Transport\Http\HttpMessage + */ + public function toMcpMessage(array $mcpRequest): HttpMessage + { + $message = new HttpMessage(json_encode($mcpRequest, JSON_UNESCAPED_UNICODE)); + $message->setMethod($this->request->getMethod()); + $message->setUri('/bc-mcp'); + $message->setHeader('Content-Type', 'application/json'); + $message->setHeader('Accept', 'application/json, text/event-stream'); + + $targets = ['MCP-Protocol-Version', 'Mcp-Method', 'Mcp-Name']; + foreach($targets as $target) { + $value = $this->request->getHeaderLine($target); + if ($value !== '') { + $message->setHeader($target, $value); + } + } + // x-mcp-header 由来の Mcp-Param-* も引き継ぐ + foreach($this->request->getHeaders() as $name => $values) { + if (stripos($name, 'Mcp-Param-') === 0) { + $message->setHeader($name, implode(', ', $values)); + } + } + return $message; + } +``` + +`use Mcp\Server\Transport\Http\HttpMessage;` を追加する。 + +- [ ] **Step 4: `index()` を書き換える** + +Modify: `plugins/bc-mcp/src/Controller/McpProxyController.php` + +内部 HTTP 転送・サーバー起動チェック・応答の偽装をすべて削除し、`McpRequestHandler` を呼ぶ。 + +```php + /** + * MCP リクエストの受け口 + * + * 常駐プロセスを持たず、CakePHP のリクエスト内で SDK を実行する。 + * プロトコルの世代判定・必須ヘッダ検証・resultType やキャッシュヒントの + * 付与はすべて SDK の責務であり、ここでは応答に手を加えない。 + */ + public function index() + { + // OPTIONSリクエストの場合はCORSレスポンスを返す + if ($this->request->getMethod() === 'OPTIONS') { + return $this->_handleOptionsRequest(); + } + + // Modern(2026-07-28)では GET ストリームが廃止されている + if (in_array($this->request->getMethod(), ['GET', 'DELETE'], true)) { + return $this->response + ->withStatus(405) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => ['code' => -32601, 'message' => 'Method not allowed. Use POST.'] + ], JSON_UNESCAPED_UNICODE)); + } + + try { + $requestBody = (string)$this->request->getBody(); + if (empty($requestBody)) { + return $this->response->withStatus(400); + } + + $mcpRequest = json_decode($requestBody, true); + if (!$mcpRequest || !isset($mcpRequest['jsonrpc']) || $mcpRequest['jsonrpc'] !== '2.0') { + throw new BadRequestException('Invalid MCP request format'); + } + + // クライアントの世代とプロトコルバージョンを記録する + NegotiationLogger::log($mcpRequest, $this->request->getHeaderLine('MCP-Protocol-Version')); + + // 認証済みの操作者をコンテキストに設定する(ボディは改変しない) + McpContext::setLoginUserId((int)$this->request->getAttribute('oauth_user_id')); + + if (!$this->checkPermission($mcpRequest)) { + return $this->response + ->withStatus(403) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => 403, + 'message' => 'Forbidden: You do not have permission to perform this action.' + ] + ], JSON_UNESCAPED_UNICODE)); + } + + $mcpResponse = (new McpRequestHandler())->handle($this->toMcpMessage($mcpRequest)); + + $response = $this->response + ->withStatus($mcpResponse->getStatusCode()) + ->withStringBody((string)$mcpResponse->getBody()); + foreach($mcpResponse->getHeaders() as $name => $value) { + $response = $response->withHeader($name, $value); + } + return $response; + } catch (BadRequestException $e) { + throw $e; + } catch (ForbiddenException $e) { + return $this->response + ->withStatus(403) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => ['code' => 403, 'message' => $e->getMessage()] + ], JSON_UNESCAPED_UNICODE)); + } catch (\Exception $e) { + return $this->response + ->withStatus(500) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => ['code' => 500, 'message' => 'MCPリクエストの処理に失敗しました: ' . $e->getMessage()] + ], JSON_UNESCAPED_UNICODE)); + } finally { + McpContext::clear(); + } + } +``` + +`checkPermission()` は `$mcpRequest['params']['arguments']['loginUserId']` を参照しているため、`McpContext::getLoginUserId()` を使うように書き換える。 + +```php + $user = $usersService->get(McpContext::getLoginUserId()); +``` + +`getProtocolVersion()` / `sendMcpRequest()` / `$this->mcpServerManager` の宣言と初期化を削除する。`use Cake\Http\Client;` / `use Cake\Http\Exception\ServiceUnavailableException;` / `use BcMcp\Mcp\McpServerManger;` も削除する。`use BcMcp\Mcp\McpContext;` / `use BcMcp\Mcp\McpRequestHandler;` / `use BcMcp\Mcp\NegotiationLogger;` を追加する。 + +**注意:** `NegotiationLogger` は Task 11 で作成する。Task 6 の時点では該当行をコメントアウトしておき、Task 11 で有効化する。 + +- [ ] **Step 5: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php 2>&1 | tail -20'` + +Expected: PASS。 + +- [ ] **Step 6: 構文チェック** + +Run: `docker exec basercms sh -c 'cd /var/www/html && php -l plugins/bc-mcp/src/Controller/McpProxyController.php'` + +Expected: `No syntax errors detected`。 + +- [ ] **Step 7: コミット** + +```bash +git add plugins/bc-mcp/src/Controller/McpProxyController.php plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php +git commit -m "プロキシの内部 HTTP 転送を廃止しプロセス内実行へ切り替え" +``` + +--- + +### Task 7: 常駐プロセス関連の削除と管理画面の再構成 + +> **このタスクは Task 11(ネゴシエーションのロギング)の完了後に着手する。** 管理画面の「直近の接続状況」が `NegotiationLogger::readRecent()` を使うため。実行順序は Task 6 → Task 11 → Task 7 → Task 8 とする。 + +**Files:** +- Delete: `plugins/bc-mcp/src/Mcp/McpServerManger.php` +- Modify: `plugins/bc-mcp/src/Command/McpServerCommand.php` +- Modify: `plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php` +- Modify: `plugins/bc-mcp/src/Controller/Admin/McpServerManagerController.php` +- Modify: `plugins/bc-mcp/src/BcMcpPlugin.php` +- Modify: `plugins/bc-mcp/templates/Admin/McpServerManager/index.php` +- Delete: `plugins/bc-mcp/templates/Admin/McpServerManager/configure.php`(ポート設定が不要になるため) +- Test: `plugins/bc-mcp/tests/TestCase/Controller/Admin/McpServerManagerControllerTest.php` + +**Interfaces:** +- Consumes: `BcMcp\Mcp\McpServer`(Task 3)、`BcMcp\Mcp\NegotiationLogger::readRecent()`(Task 11) +- Produces: `McpServerManagerController::getRegisteredTools(): array` — プラグイン単位にグループ化したツール情報(`['BcBlog' => [['name' => 'addBlogPost', 'description' => '…'], …], …]`) + +**管理画面の表示内容** + +現状の4ブロックを次のように再構成する。 + +| 現状のブロック | 扱い | +|---|---| +| MCPサーバー状態(稼働中/停止中・PID・内部URL・設定用URL) | 死活表示・PID・内部URL を削除し、接続情報のブロックに再構成 | +| サーバー操作(起動/停止/再起動ボタン) | 削除 | +| AIエージェントでの設定方法(手順1〜3) | 手順1「起動ボタンで起動してください」を削除し2手順にする | +| 利用可能な機能(手書き3行) | 登録済みツールからの自動生成に置き換える。**現状の手書きは実態とずれており、40件以上あるツールが3行しか書かれていない** | + +再構成後は次の4ブロックとする。 + +1. **接続情報** — MCP エンドポイント URL(コピーボタン付き、現状から流用)、`.well-known/oauth-authorization-server` と `.well-known/oauth-protected-resource` の URL、対応プロトコルバージョン(Modern `2026-07-28` と Legacy の両対応であること) +2. **利用可能なツール** — 登録済みツールの名前と説明をプラグイン単位でグループ化して一覧表示 +3. **AIエージェントでの設定方法** — URL 登録から始まる2手順 +4. **直近の接続状況** — `NegotiationLogger::readRecent()` から世代・プロトコルバージョン・クライアント名・日時を表示。**Claude が Modern に切り替わったことを管理画面から気づけるようにする**(死活表示の代わりになる実用情報) + +- [ ] **Step 1: `McpServerManger` の参照箇所を洗い出す** + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -rn "McpServerManger\|isServerRunning\|mcpServerManager" plugins/bc-mcp/ 2>&1'` + +- [ ] **Step 2: コマンドを stdio 専用にする** + +Modify: `plugins/bc-mcp/src/Command/McpServerCommand.php` + +`--transport` / `--host` / `--port` オプションを削除し、stdio 固定にする。`--connection` は残す(stdio 経由でテスト用接続を使う余地があるため)。`--config` は `setConfig()` の削除に伴い削除する。 + +```php + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser + ->setDescription('baserCMS MCP サーバーを標準入出力で起動します') + ->addOption('connection', [ + 'help' => 'サーバーが使用する DB 接続名。default 以外を指定すると default にエイリアスする(主にテストで test 接続を使う用途)。' + . 'プラグインのロード自体は bootstrap で環境変数 BC_CONNECTION により切り替わる。', + 'default' => 'default' + ]); + + return $parser; + } +``` + +`execute()` の transport 分岐を削除し、`$server->runStdio();` のみを呼ぶ。HTTP 経由の利用は `/bc-mcp` エンドポイントが担う旨をコメントに残す。 + +- [ ] **Step 3: コマンドのテストを更新する** + +Modify: `plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php` + +`testBuildOptionParser()` の `transport` / `host` / `port` に関する assertion を削除し、`connection` オプションの存在を検証する形に変える。 + +```php + public function testBuildOptionParser() + { + $command = new McpServerCommand(); + $parser = $command->getOptionParser(); + + $options = $parser->options(); + $this->assertArrayHasKey('connection', $options); + $this->assertEquals('default', $options['connection']->defaultValue()); + // HTTP 経由の利用は /bc-mcp エンドポイントが担うため、transport の選択肢は持たない + $this->assertArrayNotHasKey('transport', $options); + } +``` + +`testExecuteHelp()` の期待文字列を新しい説明文に合わせる。 + +- [ ] **Step 4: `McpServerManger` を削除する** + +```bash +git rm plugins/bc-mcp/src/Mcp/McpServerManger.php +``` + +- [ ] **Step 5: ツール一覧の取得を検証する失敗するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Controller/Admin/McpServerManagerControllerTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Controller\Admin; + +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Controller\Admin\McpServerManagerController; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use Cake\Http\ServerRequest; + +/** + * McpServerManagerControllerTest + */ +class McpServerManagerControllerTest extends BcTestCase +{ + + use ScenarioAwareTrait; + + /** + * test getRegisteredTools が登録済みツールをプラグイン単位で返す + * + * 移植前は「利用可能な機能」がテンプレートに手書きされており実態とずれていた + */ + public function testGetRegisteredTools() + { + $controller = new McpServerManagerController(new ServerRequest()); + + $tools = $controller->getRegisteredTools(); + + $this->assertArrayHasKey('BcBlog', $tools); + $this->assertArrayHasKey('BcCustomContent', $tools); + + $blogToolNames = array_column($tools['BcBlog'], 'name'); + $this->assertContains('addBlogPost', $blogToolNames); + + // 名前だけでなく説明も表示するため、説明が空でない事を確認する + foreach($tools['BcBlog'] as $tool) { + $this->assertNotEmpty($tool['description'], "ツール {$tool['name']} の説明が空です"); + } + } + + /** + * test 管理画面が表示される + */ + public function testIndex() + { + $this->loadFixtureScenario(InitAppScenario::class); + $this->loginAdmin($this->getRequest('/baser/admin/bc-mcp/mcp-server-manager')); + + $this->get('/baser/admin/bc-mcp/mcp-server-manager'); + + $this->assertResponseSuccess(); + // 接続情報と対応プロトコルバージョンが表示される + $this->assertResponseContains('/bc-mcp'); + $this->assertResponseContains('2026-07-28'); + // 起動・停止の操作は無くなっている + $this->assertResponseNotContains('mcp_server_manager/start'); + $this->assertResponseNotContains('mcp_server_manager/stop'); + } + +} +``` + +- [ ] **Step 6: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Controller/Admin/McpServerManagerControllerTest.php 2>&1 | tail -20'` + +Expected: FAIL(`getRegisteredTools()` が存在しない)。 + +- [ ] **Step 7: コントローラを再構成する** + +Modify: `plugins/bc-mcp/src/Controller/Admin/McpServerManagerController.php` + +`start()` / `stop()` / `restart()` / `configure()` アクションと `McpServerManger` への依存を削除する。`index()` は情報表示画面として残す。 + +```php + /** + * MCP サーバー情報 + * + * 常駐プロセスを持たないため、死活監視ではなく接続情報・提供ツール・ + * 直近の接続状況を表示する。 + */ + public function index() + { + $baseUrl = rtrim(Router::url('/', true), '/'); + + $this->set([ + 'endpointUrl' => $baseUrl . '/bc-mcp', + 'authorizationServerMetadataUrl' => $baseUrl . '/.well-known/oauth-authorization-server', + 'protectedResourceMetadataUrl' => $baseUrl . '/.well-known/oauth-protected-resource', + 'protocolVersions' => ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'], + 'tools' => $this->getRegisteredTools(), + 'negotiations' => NegotiationLogger::readRecent(10), + ]); + } + + /** + * 登録済みツールをプラグイン単位で取得する + * + * テンプレートへの手書きをやめ、実際に登録されているツールを表示する。 + * + * @return array + */ + public function getRegisteredTools(): array + { + $result = []; + $availableServers = Configure::read('BcMcp.availableServers', []); + foreach($availableServers as $pluginName => $serverClass) { + $server = new SdkMcpServer('info'); + foreach($serverClass::getToolClasses() as $toolClass) { + (new $toolClass())->registerTools($server); + } + $result[$pluginName] = array_map(fn($tool) => [ + 'name' => $tool->name, + 'description' => $tool->description, + ], $server->getServer()->getTools()); + } + return $result; + } +``` + +`getTools()` に相当する取得方法は SDK の実装に合わせる。存在しない場合は `tools/list` を `McpRequestHandler` 経由で1回実行し、その結果を使う(本番と同じ経路になるためこちらの方が確実)。 + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -n "public function getTools\|public function listTools" vendor/logiscape/mcp-sdk-php/src/Server/Server.php vendor/logiscape/mcp-sdk-php/src/Server/McpServer.php'` + +- [ ] **Step 8: ルートを整理する** + +Modify: `plugins/bc-mcp/src/BcMcpPlugin.php` + +`mcp-server-manager/configure`(GET / POST)、`start`、`stop`、`restart` のルートを削除する。`mcp-server-manager` の GET のみ残す。 + +- [ ] **Step 9: テンプレートを再構成する** + +Modify: `plugins/bc-mcp/templates/Admin/McpServerManager/index.php` + +「MCPサーバー状態」と「サーバー操作」のブロックを削除し、次の4ブロックに再構成する。既存の `bca-panel-box` / `bca-data-list` のマークアップと `copyToClipboard()` は流用する。 + +1. **接続情報** — `$endpointUrl`(コピーボタン付き)、`$authorizationServerMetadataUrl`、`$protectedResourceMetadataUrl`、`$protocolVersions` を「Modern(2026-07-28)と旧世代の両対応」として表示 +2. **利用可能なツール** — `$tools` をプラグイン単位に見出しを付け、ツール名と説明を一覧表示 +3. **AIエージェントでの設定方法** — 手順を2つにする(手順1: 上記 URL を AI エージェントの設定に登録する/手順2: 「ブログ記事を追加して」などの指示で操作できる) +4. **直近の接続状況** — `$negotiations` を日時・世代・プロトコルバージョン・クライアント名の表で表示。空の場合は「まだ接続がありません」と表示 + +Delete: `plugins/bc-mcp/templates/Admin/McpServerManager/configure.php` + +- [ ] **Step 10: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Controller/Admin/McpServerManagerControllerTest.php 2>&1 | tail -25'` + +Expected: PASS。 + +- [ ] **Step 11: 画面の描画に警告が出ていないことを確認する** + +`assertResponseSuccess()` は未定義変数の警告を握り潰すため、ログを確認する。 + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -c "Undefined variable" logs/debug.log 2>/dev/null; grep "Undefined variable" logs/debug.log 2>/dev/null | tail -5'` + +Expected: 新規の `Undefined variable` が出ていないこと。 + +- [ ] **Step 12: 残存参照がないことを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -rn "McpServerManger\|isServerRunning\|runSse\|--transport" plugins/bc-mcp/ 2>&1'` + +Expected: 出力なし。 + +- [ ] **Step 13: テストを実行する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage --testsuite BcMcp 2>&1 | tail -30'` + +Expected: PASS。 + +- [ ] **Step 14: コミット** + +```bash +git add -A plugins/bc-mcp/ +git commit -m "常駐 MCP サーバープロセスを廃止し管理画面を情報表示に再構成" +``` + +--- + +### Task 8: Dual-era 疎通テスト + +**Files:** +- Create: `plugins/bc-mcp/tests/TestCase/Mcp/DualEraTest.php` + +**Interfaces:** +- Consumes: `McpTestTrait`(Task 2) +- Produces: なし + +- [ ] **Step 1: 両世代の疎通を検証するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Mcp/DualEraTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Test\TestSuite\McpTestTrait; + +/** + * DualEraTest + * + * Modern(2026-07-28)と Legacy(initialize 方式)の両世代が + * 同一サーバーで動作することを検証する + */ +class DualEraTest extends BcTestCase +{ + + use McpTestTrait; + + /** + * test server/discover が対応バージョンを返す(Modern の MUST 要件) + */ + public function testServerDiscover() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'server/discover', + 'params' => ['_meta' => $this->modernMeta()], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertArrayNotHasKey('error', $response, json_encode($response, JSON_UNESCAPED_UNICODE)); + $this->assertArrayHasKey('capabilities', $response['result']); + $this->assertArrayHasKey('serverInfo', $response['result']); + } + + /** + * test Legacy の initialize が同一サーバーで応答する + */ + public function testLegacyInitialize() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => [], + 'clientInfo' => ['name' => 'LegacyTestClient', 'version' => '1.0.0'], + ], + ], [ + 'MCP-Protocol-Version' => '2025-06-18', + 'Mcp-Method' => 'initialize', + ]); + + $this->assertArrayNotHasKey('error', $response, json_encode($response, JSON_UNESCAPED_UNICODE)); + $this->assertArrayHasKey('protocolVersion', $response['result']); + $this->assertArrayHasKey('capabilities', $response['result']); + } + + /** + * test capabilities に未提供機能が申告されない + * + * 移植前は resources / prompts を listChanged: true と虚偽申告していた + */ + public function testCapabilitiesDoNotAdvertiseUnsupportedFeatures() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'server/discover', + 'params' => ['_meta' => $this->modernMeta()], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'server/discover', + ]); + + $capabilities = $response['result']['capabilities']; + $this->assertArrayHasKey('tools', $capabilities); + $this->assertArrayNotHasKey('resources', $capabilities); + $this->assertArrayNotHasKey('prompts', $capabilities); + } + + /** + * test 未対応バージョンは UnsupportedProtocolVersionError になる + */ + public function testUnsupportedProtocolVersion() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => $this->modernMeta('1900-01-01')], + ], [ + 'MCP-Protocol-Version' => '1900-01-01', + 'Mcp-Method' => 'tools/list', + ]); + + $this->assertEquals(-32022, $response['error']['code']); + $this->assertArrayHasKey('supported', $response['error']['data']); + } + + /** + * test ヘッダとボディの不一致は HeaderMismatch になる + */ + public function testHeaderMismatch() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'serverInfo', + 'arguments' => [], + '_meta' => $this->modernMeta(), + ], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/call', + // ボディの params.name と一致しない + 'Mcp-Name' => 'getBlogPosts', + ]); + + $this->assertEquals(-32020, $response['error']['code']); + } + +} +``` + +- [ ] **Step 2: テストを実行する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/DualEraTest.php 2>&1 | tail -30'` + +Expected: PASS。失敗した場合は SDK の実際のレスポンス構造に合わせて assertion のキー名を修正する。**エラーコード(-32022 / -32020)と「両世代が応答する」という検証内容は緩めない。** + +- [ ] **Step 3: コミット** + +```bash +git add plugins/bc-mcp/tests/TestCase/Mcp/DualEraTest.php +git commit -m "Modern と Legacy の両世代疎通を検証するテストを追加" +``` + +--- + +### Task 9: Origin ヘッダ検証 + +**Files:** +- Modify: `plugins/bc-mcp/src/Controller/McpProxyController.php` +- Modify: `plugins/bc-mcp/config/setting.php` +- Modify: `plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php` + +**Interfaces:** +- Consumes: `McpProxyController::toMcpMessage()`(Task 6) +- Produces: `McpProxyController::isAllowedOrigin(string $origin): bool` + +- [ ] **Step 1: 失敗するテストを書く** + +Modify: `plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php`(メソッドを追加) + +```php + /** + * test 許可オリジンの判定 + */ + public function testIsAllowedOrigin() + { + \Cake\Core\Configure::write('BcMcp.allowedOrigins', ['https://claude.ai']); + $controller = new McpProxyController(new ServerRequest()); + + $this->assertTrue($controller->isAllowedOrigin('https://claude.ai')); + $this->assertFalse($controller->isAllowedOrigin('https://evil.example.com')); + } + + /** + * test 許可されないオリジンからのリクエストは 403 になる + * + * Origin 検証は DNS リバインディング対策であり、認証より前に効かせる + */ + public function testDisallowedOriginReturnsForbidden() + { + \Cake\Core\Configure::write('BcMcp.allowedOrigins', ['https://claude.ai']); + + $this->configRequest([ + 'headers' => ['Origin' => 'https://evil.example.com', 'Content-Type' => 'application/json'] + ]); + $this->post('/bc-mcp', json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'])); + + $this->assertResponseCode(403); + } +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage --filter Origin plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php 2>&1 | tail -20'` + +Expected: FAIL(`isAllowedOrigin()` が存在しない)。 + +- [ ] **Step 3: 設定項目を追加する** + +Modify: `plugins/bc-mcp/config/setting.php` + +`'BcMcp' => [...]` の中に追加する。 + +```php + /** + * Origin ヘッダの許可リスト + * + * DNS リバインディング攻撃対策として、ブラウザから送信された Origin を検証する。 + * 空配列の場合は自サイトのオリジンのみを許可する。 + * Origin ヘッダを持たないリクエスト(サーバー間通信)は検証対象外。 + */ + 'allowedOrigins' => [], +``` + +- [ ] **Step 4: `isAllowedOrigin()` を実装し `beforeFilter()` で検証する** + +Modify: `plugins/bc-mcp/src/Controller/McpProxyController.php` + +```php + /** + * Origin が許可されているかを判定する + * + * Streamable HTTP の MUST 要件。DNS リバインディング攻撃により、 + * 悪意あるサイトからローカルの MCP サーバーが操作されるのを防ぐ。 + * + * @param string $origin Origin ヘッダの値 + * @return bool + */ + public function isAllowedOrigin(string $origin): bool + { + $allowed = (array)Configure::read('BcMcp.allowedOrigins', []); + if (!$allowed) { + $siteUrl = rtrim((string)env('SITE_URL', ''), '/'); + if ($siteUrl) { + $parts = parse_url($siteUrl); + $allowed = [$parts['scheme'] . '://' . $parts['host'] . (isset($parts['port'])? ':' . $parts['port'] : '')]; + } + } + return in_array($origin, $allowed, true); + } +``` + +`beforeFilter()` の OPTIONS 判定の直後、OAuth2 検証の**前**に置く。 + +```php + // Origin 検証は認証より前に行う(transport レベルの要件) + $origin = $this->request->getHeaderLine('Origin'); + if ($origin !== '' && !$this->isAllowedOrigin($origin)) { + $event->setResult($this->response + ->withStatus(403) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => ['code' => -32600, 'message' => 'Forbidden: invalid Origin.'] + ], JSON_UNESCAPED_UNICODE))); + return; + } +``` + +`initialize()` の `Access-Control-Allow-Origin: '*'` は、許可された Origin をそのまま返す形に修正する。 + +- [ ] **Step 5: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php 2>&1 | tail -25'` + +Expected: PASS。 + +- [ ] **Step 6: コミット** + +```bash +git add plugins/bc-mcp/src/Controller/McpProxyController.php plugins/bc-mcp/config/setting.php plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php +git commit -m "Origin ヘッダ検証を追加(DNS リバインディング対策)" +``` + +--- + +### Task 10: `iss` パラメータ(RFC 9207)の付与 + +**Files:** +- Modify: `plugins/bc-mcp/src/Lib/OAuth2Util.php` +- Modify: `plugins/bc-mcp/src/Controller/Admin/Oauth2Controller.php` +- Modify: `plugins/bc-mcp/src/Controller/Oauth2Controller.php` +- Create: `plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php` + +**Interfaces:** +- Consumes: なし +- Produces: + - `BcMcp\Lib\OAuth2Util::getIssuer(\Cake\Http\ServerRequest $request): string` + - `BcMcp\Lib\OAuth2Util::addIssuerToUrl(string $url, string $issuer): string` + +- [ ] **Step 1: 失敗するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Lib; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Lib\OAuth2Util; +use Cake\Http\ServerRequest; + +/** + * OAuth2UtilTest + */ +class OAuth2UtilTest extends BcTestCase +{ + + /** + * test getIssuer がメタデータの issuer と同じ値を返す + */ + public function testGetIssuer() + { + $request = new ServerRequest([ + 'environment' => ['HTTP_HOST' => 'example.com', 'HTTPS' => 'on'], + ]); + + $this->assertEquals('https://example.com/bc-mcp', OAuth2Util::getIssuer($request)); + } + + /** + * test addIssuerToUrl が iss クエリを付与する + */ + public function testAddIssuerToUrl() + { + $result = OAuth2Util::addIssuerToUrl( + 'https://claude.ai/callback?code=abc&state=xyz', + 'https://example.com/bc-mcp' + ); + + parse_str((string)parse_url($result, PHP_URL_QUERY), $query); + $this->assertEquals('https://example.com/bc-mcp', $query['iss']); + // 既存のクエリは保持される + $this->assertEquals('abc', $query['code']); + $this->assertEquals('xyz', $query['state']); + } + + /** + * test addIssuerToUrl はフラグメントを壊さない + */ + public function testAddIssuerToUrlWithFragment() + { + $result = OAuth2Util::addIssuerToUrl( + 'https://claude.ai/callback#code=abc', + 'https://example.com/bc-mcp' + ); + + $this->assertStringContainsString('iss=', $result); + $this->assertStringContainsString('#code=abc', $result); + } + +} +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php 2>&1 | tail -20'` + +Expected: FAIL。 + +- [ ] **Step 3: `OAuth2Util` にメソッドを追加する** + +Modify: `plugins/bc-mcp/src/Lib/OAuth2Util.php` + +```php + /** + * 認可サーバーの issuer 識別子を取得する + * + * RFC 8414 のメタデータで公開する issuer と同一の値でなければならないため、 + * 導出処理をここに集約する。 + * + * @param \Cake\Http\ServerRequest $request リクエスト + * @return string + */ + public static function getIssuer(\Cake\Http\ServerRequest $request): string + { + $scheme = $request->is('https')? 'https' : 'http'; + $host = $request->getHeaderLine('Host'); + if (!$host) { + $host = $request->getEnv('HTTP_HOST')?: 'localhost'; + } + return $scheme . '://' . $host . '/bc-mcp'; + } + + /** + * URL に iss クエリを付与する + * + * RFC 9207。認可レスポンスに issuer を含める事で mix-up 攻撃を防ぐ。 + * + * @param string $url 対象の URL + * @param string $issuer issuer 識別子 + * @return string + */ + public static function addIssuerToUrl(string $url, string $issuer): string + { + $fragment = ''; + $hashPos = strpos($url, '#'); + if ($hashPos !== false) { + $fragment = substr($url, $hashPos); + $url = substr($url, 0, $hashPos); + } + $separator = str_contains($url, '?')? '&' : '?'; + return $url . $separator . 'iss=' . rawurlencode($issuer) . $fragment; + } +``` + +- [ ] **Step 4: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php 2>&1 | tail -20'` + +Expected: PASS。 + +- [ ] **Step 5: 認可レスポンスに `iss` を付与する** + +Modify: `plugins/bc-mcp/src/Controller/Admin/Oauth2Controller.php` + +`approve` 分岐(160行目付近)を書き換える。 + +```php + $authResponse = $server->completeAuthorizationRequest($authRequest, $this->response); + + // RFC 9207: 認可レスポンスに issuer を含める + $location = $authResponse->getHeaderLine('Location'); + if ($location !== '') { + $authResponse = $authResponse->withHeader( + 'Location', + OAuth2Util::addIssuerToUrl($location, OAuth2Util::getIssuer($this->request)) + ); + } + return $authResponse; +``` + +`deny` 分岐のリダイレクト URL にも付与する(エラー応答も authorization response であるため)。 + +```php + $redirectUrl = OAuth2Util::addIssuerToUrl( + $redirectUri . '?' . http_build_query($params), + OAuth2Util::getIssuer($this->request) + ); + return $this->redirect($redirectUrl); +``` + +`use BcMcp\Lib\OAuth2Util;` が未 import なら追加する。 + +- [ ] **Step 6: メタデータに対応を宣言する** + +Modify: `plugins/bc-mcp/src/Controller/Oauth2Controller.php` + +`authorizationServerMetadata()` の `$metadata` に追加する。 + +```php + 'authorization_response_iss_parameter_supported' => true, +``` + +同メソッド内の `'issuer' => $baseUrl . '/bc-mcp',` を `'issuer' => OAuth2Util::getIssuer($this->request),` に置き換え、authorize 側と同じ導出処理を使う。`use BcMcp\Lib\OAuth2Util;` を追加する。 + +- [ ] **Step 7: OAuth2 の既存テストに回帰がないことを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Controller/ plugins/bc-mcp/tests/TestCase/Lib/ plugins/bc-mcp/tests/TestCase/Service/ 2>&1 | tail -25'` + +Expected: PASS。 + +- [ ] **Step 8: コミット** + +```bash +git add plugins/bc-mcp/src/Lib/OAuth2Util.php plugins/bc-mcp/src/Controller/Admin/Oauth2Controller.php plugins/bc-mcp/src/Controller/Oauth2Controller.php plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php +git commit -m "認可レスポンスに iss パラメータを付与(RFC 9207)" +``` + +--- + +### Task 11: ネゴシエーションのロギング + +**Files:** +- Create: `plugins/bc-mcp/src/Mcp/NegotiationLogger.php` +- Modify: `plugins/bc-mcp/src/Controller/McpProxyController.php` +- Create: `plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php` + +> **このタスクは Task 7(管理画面の再構成)より前に着手する。** 管理画面の「直近の接続状況」が `readRecent()` を使うため。実行順序は Task 6 → Task 11 → Task 7 → Task 8 とする。 + +**Interfaces:** +- Consumes: なし +- Produces: + - `BcMcp\Mcp\NegotiationLogger::describe(array $mcpRequest, string $protocolVersionHeader): array` + - `BcMcp\Mcp\NegotiationLogger::log(array $mcpRequest, string $protocolVersionHeader): void` + - `BcMcp\Mcp\NegotiationLogger::readRecent(int $limit = 10): array` — ログから直近の接続状況を新しい順に返す(`['loggedAt' => '2026-08-12 16:07:38', 'era' => 'modern', 'protocolVersion' => '2026-07-28', 'clientName' => 'claude-ai', 'clientVersion' => '2.0.0', 'method' => 'tools/call']` の配列) + +- [ ] **Step 1: 失敗するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\NegotiationLogger; + +/** + * NegotiationLoggerTest + */ +class NegotiationLoggerTest extends BcTestCase +{ + + /** + * test Modern リクエストを Modern と判定する + */ + public function testDescribeModern() + { + $result = NegotiationLogger::describe([ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'addBlogPost', + '_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientInfo' => ['name' => 'claude-ai', 'version' => '2.0.0'], + ], + ], + ], '2026-07-28'); + + $this->assertEquals('modern', $result['era']); + $this->assertEquals('2026-07-28', $result['protocolVersion']); + $this->assertEquals('claude-ai', $result['clientName']); + $this->assertEquals('2.0.0', $result['clientVersion']); + $this->assertEquals('tools/call', $result['method']); + } + + /** + * test Legacy の initialize を Legacy と判定する + */ + public function testDescribeLegacy() + { + $result = NegotiationLogger::describe([ + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'clientInfo' => ['name' => 'legacy-client', 'version' => '1.0.0'], + ], + ], ''); + + $this->assertEquals('legacy', $result['era']); + $this->assertEquals('2025-06-18', $result['protocolVersion']); + $this->assertEquals('legacy-client', $result['clientName']); + } + + /** + * test 引数の中身は記録対象に含まれない + * + * 機密情報がログに混入するのを防ぐ + */ + public function testDescribeOmitsArguments() + { + $result = NegotiationLogger::describe([ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'addBlogPost', + 'arguments' => ['title' => '秘密の記事'], + '_meta' => ['io.modelcontextprotocol/protocolVersion' => '2026-07-28'], + ], + ], '2026-07-28'); + + $this->assertStringNotContainsString('秘密の記事', json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertArrayNotHasKey('arguments', $result); + } + + /** + * test readRecent が記録した接続状況を新しい順に読み出す + * + * 管理画面の「直近の接続状況」で使う + */ + public function testReadRecent() + { + NegotiationLogger::log([ + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientInfo' => ['name' => 'claude-ai', 'version' => '2.0.0'], + ], + ], + ], '2026-07-28'); + + $recent = NegotiationLogger::readRecent(10); + + $this->assertNotEmpty($recent); + $this->assertEquals('modern', $recent[0]['era']); + $this->assertEquals('2026-07-28', $recent[0]['protocolVersion']); + $this->assertEquals('claude-ai', $recent[0]['clientName']); + $this->assertNotEmpty($recent[0]['loggedAt']); + } + + /** + * test readRecent はログが無い場合に空配列を返す + */ + public function testReadRecentWithoutLog() + { + $this->assertSame([], NegotiationLogger::readRecent(10, '/tmp/not_exists_mcp.log')); + } + +} +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php 2>&1 | tail -20'` + +Expected: FAIL。 + +- [ ] **Step 3: `NegotiationLogger` を実装する** + +Create: `plugins/bc-mcp/src/Mcp/NegotiationLogger.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp; + +use Cake\Log\Log; + +/** + * MCP のネゴシエーション内容を記録する + * + * クライアントがどの世代(Modern / Legacy)でどのプロトコルバージョンを + * 要求してきたかを残す事で、クライアント側の移行を検知できるようにする。 + * 引数やトークンの中身は記録しない。 + */ +class NegotiationLogger +{ + + /** + * 記録する内容を組み立てる + * + * Modern(2026-07-28 以降)はリクエストごとの _meta でバージョンを伝え、 + * Legacy は initialize の params でバージョンを伝える。 + * + * @param array $mcpRequest MCP リクエスト + * @param string $protocolVersionHeader MCP-Protocol-Version ヘッダの値 + * @return array + */ + public static function describe(array $mcpRequest, string $protocolVersionHeader): array + { + $meta = $mcpRequest['params']['_meta'] ?? []; + $isModern = isset($meta['io.modelcontextprotocol/protocolVersion']); + + if ($isModern) { + $protocolVersion = $meta['io.modelcontextprotocol/protocolVersion']; + $clientInfo = $meta['io.modelcontextprotocol/clientInfo'] ?? []; + } else { + $protocolVersion = $mcpRequest['params']['protocolVersion'] ?? $protocolVersionHeader; + $clientInfo = $mcpRequest['params']['clientInfo'] ?? []; + } + + return [ + 'era' => $isModern? 'modern' : 'legacy', + 'protocolVersion' => (string)$protocolVersion, + 'clientName' => (string)($clientInfo['name'] ?? ''), + 'clientVersion' => (string)($clientInfo['version'] ?? ''), + 'method' => (string)($mcpRequest['method'] ?? ''), + ]; + } + + /** + * ネゴシエーション内容をログに記録する + * + * @param array $mcpRequest MCP リクエスト + * @param string $protocolVersionHeader MCP-Protocol-Version ヘッダの値 + * @return void + */ + public static function log(array $mcpRequest, string $protocolVersionHeader): void + { + $info = self::describe($mcpRequest, $protocolVersionHeader); + Log::write('info', sprintf( + 'MCP negotiation: era=%s protocolVersion=%s client=%s/%s method=%s', + $info['era'], + $info['protocolVersion'], + $info['clientName'], + $info['clientVersion'], + $info['method'] + ), ['mcp']); + } + + /** + * 直近の接続状況をログから読み出す + * + * 管理画面で「クライアントがどの世代で接続しているか」を確認できるようにする。 + * 常駐プロセスが無くなり死活監視が不要になった代わりに、これが運用時の + * 主要な確認手段になる。 + * + * @param int $limit 取得件数 + * @param string|null $logFile ログファイルのパス(テスト用) + * @return array 新しい順の接続状況 + */ + public static function readRecent(int $limit = 10, ?string $logFile = null): array + { + $logFile ??= LOGS . 'mcp.log'; + if (!is_readable($logFile)) { + return []; + } + + $lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return []; + } + + $result = []; + foreach(array_reverse($lines) as $line) { + if (!str_contains($line, 'MCP negotiation:')) { + continue; + } + $pattern = '/^(?[\d\-]+ [\d:]+).*MCP negotiation: era=(?\S+) ' + . 'protocolVersion=(?\S*) client=(?[^\/]*)\/(?\S*) ' + . 'method=(?\S*)$/'; + if (!preg_match($pattern, $line, $matches)) { + continue; + } + $result[] = [ + 'loggedAt' => $matches['loggedAt'], + 'era' => $matches['era'], + 'protocolVersion' => $matches['protocolVersion'], + 'clientName' => $matches['clientName'], + 'clientVersion' => $matches['clientVersion'], + 'method' => $matches['method'], + ]; + if (count($result) >= $limit) { + break; + } + } + return $result; + } + +} +``` + +ログの行頭フォーマット(`2026-08-12 14:58:44 info: …`)は既存の `logs/mcp.log` で確認できる。正規表現が合わない場合は実際の出力に合わせて調整する。 + +- [ ] **Step 4: プロキシで有効化する** + +Modify: `plugins/bc-mcp/src/Controller/McpProxyController.php` + +Task 6 Step 4 でコメントアウトしていた `NegotiationLogger::log()` の行を有効化する。 + +- [ ] **Step 5: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php 2>&1 | tail -25'` + +Expected: PASS。 + +- [ ] **Step 6: コミット** + +```bash +git add plugins/bc-mcp/src/Mcp/NegotiationLogger.php plugins/bc-mcp/src/Controller/McpProxyController.php plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php +git commit -m "MCP ネゴシエーション内容のロギングを追加" +``` + +--- + +### Task 12: プラグイン全体テストとフルスイートでの回帰確認 + +**Files:** +- Modify: 必要に応じて既存テスト +- Modify: `docs/superpowers/specs/2026-08-12-bc-mcp-sdk-migration-design.md`(完了条件の確認結果) + +**Interfaces:** +- Consumes: Task 1〜11 のすべて +- Produces: なし + +- [ ] **Step 1: bc-mcp のテストをすべて実行する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage --testsuite BcMcp 2>&1 | tail -40'` + +Expected: PASS。失敗があれば個別に `--filter` で切り分けて修正する。 + +- [ ] **Step 2: 旧 SDK と常駐プロセスへの参照が残っていないことを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -rn "PhpMcp\|php-mcp\|McpServerManger\|isServerRunning" plugins/bc-mcp/ composer.json 2>&1'` + +Expected: 出力なし。 + +- [ ] **Step 3: フルスイートを実行して回帰がないことを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage > /tmp/phpunit_full.log 2>&1; tail -45 /tmp/phpunit_full.log'` + +Expected: 移植前と同じかそれ以上の結果。新規の失敗があれば根本原因単位で集計して切り分ける。 + +Run(失敗が多い場合): `docker exec basercms sh -c 'cd /var/www/html && grep -hoE "[A-Za-z\\\\]+Exception: .{0,80}|[A-Za-z\\\\]+Error: .{0,80}" /tmp/phpunit_full.log | sed -E "s/[0-9]+/N/g" | sort | uniq -c | sort -rn | head -30'` + +- [ ] **Step 4: stdio 起動を手動で確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && echo "" | timeout 5 bin/cake bc_mcp.server 2>&1 | head -10'` + +Expected: 例外が発生しないこと。`timeout` による終了は正常。 + +- [ ] **Step 5: 設計書の完了条件を照合する** + +設計書 第12章の完了条件を1つずつ確認し、達成状況を追記する。 + +- Modern と Legacy の両世代で `tools/list` → `tools/call` が通る(`DualEraTest` / `McpServerToolCallTest`) +- 常駐プロセスを起動しなくても `/bc-mcp` が応答する(`McpProxyControllerTest`) +- 既存の bc-mcp テストがすべて通り、フルスイートに回帰がない(Step 1・Step 3) +- `logs/mcp.log` から接続クライアントの世代とプロトコルバージョンが判別できる(`NegotiationLoggerTest`) +- 許可外 `Origin` からのリクエストが 403 で拒否される(`McpProxyControllerTest`) +- 認可レスポンスに `iss` が含まれ、メタデータの `issuer` と一致する(`OAuth2UtilTest`) +- `vendor/php-mcp` への依存が残っていない(Step 2) +- `McpServerManger` と管理画面の起動/停止 UI が削除されている(Step 2) + +- [ ] **Step 6: コミット** + +```bash +git add docs/superpowers/specs/2026-08-12-bc-mcp-sdk-migration-design.md +git commit -m "MCP 2026-07-28 対応の完了条件を確認して設計書に記録" +``` + +--- + +### Task 13: 固定ページ(Pages)ツールの追加 + +bc-mcp には固定ページを操作するツールが無いため新規に追加する。SDK 移植の完了後に着手することで、新 SDK の作法で最初から書ける。 + +**Files:** +- Create: `plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php` +- Modify: `plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php` +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php` + +**Interfaces:** +- Consumes: `BaseMcpTool::registerTools()` / `resolveLoginUserId()` / `executeWithErrorHandling()`(Task 3)、`McpTestTrait::callMcpTool()`(Task 2) +- Produces: + - `BcMcp\Mcp\BaserCore\PagesTool::registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer` + - `PagesTool::getPages()` / `getPage()` / `addPage()` / `editPage()` / `deletePage()` + - `PagesTool::getPermissionUrl($action, $args = [])`(static) + +**データ構造の注意点** + +固定ページは `pages` と `contents` の複合構造で、名前が紛らわしい。 + +| 保存先 | 意味 | +|---|---| +| `pages.contents` | **ページ本文**(HTML) | +| `pages.content`(`Contents` アソシエーション) | **コンテンツ情報**(タイトル・URL・公開状態・親フォルダ) | + +`PagesService::create()` に渡す構造(`baser-core` の `PagesControllerTest::testAdd()` で確認済み)。 + +```php +[ + 'contents' => '

本文

', + 'page_template' => '', + 'content' => [ + 'title' => 'ページタイトル', + 'name' => 'about', + 'parent_id' => 1, + 'site_id' => 1, + 'plugin' => 'BaserCore', + 'type' => 'Page', + 'self_status' => true, + ], +] +``` + +`plugin` = `'BaserCore'`、`type` = `'Page'` は固定値であり、**ツール側で自動的に補う**(AI クライアントに指定させない)。 + +- [ ] **Step 1: 失敗するテストを書く** + +Create: `plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php` + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BaserCore; + +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\BaserCore\PagesTool; +use BcMcp\Mcp\McpContext; +use BcMcp\Test\TestSuite\McpTestTrait; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; + +/** + * PagesToolTest + */ +class PagesToolTest extends BcTestCase +{ + + use ScenarioAwareTrait; + use McpTestTrait; + + /** + * Tear down + */ + public function tearDown(): void + { + McpContext::clear(); + parent::tearDown(); + } + + /** + * test addPage で固定ページが登録できる + */ + public function testAddPage() + { + $this->loadFixtureScenario(InitAppScenario::class); + McpContext::setLoginUserId(1); + + [$result, $isError] = $this->callMcpTool('addPage', [ + 'title' => '会社概要', + 'name' => 'about', + 'content' => '

会社概要のページです。

', + 'status' => 1, + ]); + + $this->assertFalse($isError, 'ツールの実行に失敗しました。' . (is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE))); + $this->assertArrayHasKey('id', $result, json_encode($result, JSON_UNESCAPED_UNICODE)); + // 本文は pages.contents に保存される + $this->assertEquals('

会社概要のページです。

', $result['contents']); + // タイトルと URL はコンテンツ情報に保存される + $this->assertEquals('会社概要', $result['content']['title']); + $this->assertEquals('about', $result['content']['name']); + // plugin と type はツール側で補われる + $this->assertEquals('BaserCore', $result['content']['plugin']); + $this->assertEquals('Page', $result['content']['type']); + } + + /** + * test editPage で固定ページが編集できる + */ + public function testEditPage() + { + $this->loadFixtureScenario(InitAppScenario::class); + McpContext::setLoginUserId(1); + + [$added] = $this->callMcpTool('addPage', [ + 'title' => '編集前', + 'name' => 'before-edit', + 'content' => '

編集前の本文

', + ]); + + [$result, $isError] = $this->callMcpTool('editPage', [ + 'id' => $added['id'], + 'title' => '編集後', + 'content' => '

編集後の本文

', + ]); + + $this->assertFalse($isError, 'ツールの実行に失敗しました。' . (is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE))); + $this->assertEquals('編集後', $result['content']['title']); + $this->assertEquals('

編集後の本文

', $result['contents']); + // 指定しなかった項目は変更されない + $this->assertEquals('before-edit', $result['content']['name']); + } + + /** + * test getPages と getPage で固定ページを取得できる + */ + public function testGetPages() + { + $this->loadFixtureScenario(InitAppScenario::class); + McpContext::setLoginUserId(1); + + [$added] = $this->callMcpTool('addPage', [ + 'title' => '取得テスト', + 'name' => 'get-test', + 'content' => '

取得テストの本文

', + ]); + + [$list, $listError] = $this->callMcpTool('getPages', ['limit' => 10]); + $this->assertFalse($listError, is_string($list)? $list : json_encode($list, JSON_UNESCAPED_UNICODE)); + $this->assertNotEmpty($list); + + [$single, $singleError] = $this->callMcpTool('getPage', ['id' => $added['id']]); + $this->assertFalse($singleError, is_string($single)? $single : json_encode($single, JSON_UNESCAPED_UNICODE)); + $this->assertEquals('取得テスト', $single['content']['title']); + } + + /** + * test deletePage で固定ページが削除できる + */ + public function testDeletePage() + { + $this->loadFixtureScenario(InitAppScenario::class); + McpContext::setLoginUserId(1); + + [$added] = $this->callMcpTool('addPage', [ + 'title' => '削除対象', + 'name' => 'to-be-deleted', + 'content' => '

削除対象の本文

', + ]); + + [$result, $isError] = $this->callMcpTool('deletePage', ['id' => $added['id']]); + $this->assertFalse($isError, is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE)); + + [$notFound, $notFoundError] = $this->callMcpTool('getPage', ['id' => $added['id']]); + $this->assertTrue($notFoundError, '削除したページが取得できてしまいました。'); + } + + /** + * test 権限チェック用のURL + */ + public function testGetPermissionUrl() + { + $this->assertEquals( + ['POST' => '/baser-core/pages/add.json'], + PagesTool::getPermissionUrl('addPage') + ); + $this->assertEquals( + ['POST' => '/baser-core/pages/edit/3.json'], + PagesTool::getPermissionUrl('editPage', ['id' => 3]) + ); + $this->assertEquals( + ['GET' => '/baser-core/pages/index.json'], + PagesTool::getPermissionUrl('getPages') + ); + // id が無い編集・削除は権限チェックの対象にできない + $this->assertFalse(PagesTool::getPermissionUrl('editPage')); + } + +} +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php 2>&1 | tail -20'` + +Expected: FAIL(`BcMcp\Mcp\BaserCore\PagesTool` が存在しない)。 + +- [ ] **Step 3: `PagesTool` を実装する** + +Create: `plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php` + +既存の `BlogPostsTool` の構成(`registerTools()` → `getPermissionUrl()` → 各アクションメソッド)を踏襲する。`executeWithErrorHandling()` / `createSuccessResponse()` / `resolveLoginUserId()` は `BaseMcpTool` のものを使う。 + +`inputSchema` は次のとおり(`loginUserId` は公開しない)。 + +```php + /** + * 固定ページ関連のツールをサーバーに登録する + * + * @param \Mcp\Server\McpServer $server SDK のサーバー + * @return \Mcp\Server\McpServer + */ + public function registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer + { + return $server + ->tool( + name: 'getPages', + description: '固定ページの一覧を取得します', + callback: [$this, 'getPages'], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'keyword' => ['type' => 'string', 'description' => '検索キーワード(本文を対象に検索)'], + 'siteId' => ['type' => 'number', 'description' => 'サイトID(省略時は全て)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)(省略時は全て)'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は10件)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + ->tool( + name: 'getPage', + description: '指定されたIDの固定ページを取得します', + callback: [$this, 'getPage'], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '固定ページID(必須)'], + ], + 'required' => ['id'] + ] + ) + ->tool( + name: 'addPage', + description: '固定ページを追加します', + callback: [$this, 'addPage'], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'description' => 'ページタイトル(必須)'], + 'content' => ['type' => 'string', 'description' => 'ページ本文、マークダウン不可、HTML推奨'], + 'name' => ['type' => 'string', 'description' => 'URLのスラッグ(省略時は自動採番)'], + 'parentId' => ['type' => 'number', 'description' => '親フォルダのコンテンツID(省略時はサイトルート)'], + 'siteId' => ['type' => 'number', 'description' => 'サイトID(省略時は1)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)(省略時は0)'], + 'description' => ['type' => 'string', 'description' => 'ページの説明'], + 'publishBegin' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開開始日時(省略時はなし)'], + 'publishEnd' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開終了日時(省略時はなし)'], + 'pageTemplate' => ['type' => 'string', 'description' => 'ページテンプレート名(省略時はデフォルト)'], + 'eyeCatch' => ['type' => 'string', 'description' => 'アイキャッチ画像。外部画像URLを直接指定'], + ], + 'required' => ['title'] + ] + ) + ->tool( + name: 'editPage', + description: '固定ページを編集します', + callback: [$this, 'editPage'], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '固定ページID(必須)'], + 'title' => ['type' => 'string', 'description' => 'ページタイトル'], + 'content' => ['type' => 'string', 'description' => 'ページ本文、マークダウン不可、HTML推奨'], + 'name' => ['type' => 'string', 'description' => 'URLのスラッグ'], + 'parentId' => ['type' => 'number', 'description' => '親フォルダのコンテンツID'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)'], + 'description' => ['type' => 'string', 'description' => 'ページの説明'], + 'publishBegin' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開開始日時'], + 'publishEnd' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開終了日時'], + 'pageTemplate' => ['type' => 'string', 'description' => 'ページテンプレート名'], + 'eyeCatch' => ['type' => 'string', 'description' => 'アイキャッチ画像。外部画像URLを直接指定'], + ], + 'required' => ['id'] + ] + ) + ->tool( + name: 'deletePage', + description: '固定ページを削除します', + callback: [$this, 'deletePage'], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '固定ページID(必須)'], + ], + 'required' => ['id'] + ] + ); + } +``` + +`getPermissionUrl()` は次のとおり。 + +```php + /** + * 権限チェック用のURLを取得する + * + * @param string $action アクション名 + * @param array $args 引数 + * @return array|false + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addPage': + return ['POST' => '/baser-core/pages/add.json']; + case 'editPage': + if (empty($args['id'])) return false; + return ['POST' => "/baser-core/pages/edit/{$args['id']}.json"]; + case 'deletePage': + if (empty($args['id'])) return false; + return ['POST' => "/baser-core/pages/delete/{$args['id']}.json"]; + case 'getPages': + return ['GET' => '/baser-core/pages/index.json']; + case 'getPage': + if (empty($args['id'])) return false; + return ['GET' => "/baser-core/pages/view/{$args['id']}.json"]; + default: + return false; + } + } +``` + +`addPage()` はフラットな引数を `PagesService::create()` の入れ子構造へ組み立てる。**引数の `$content`(本文)と保存先の `content`(コンテンツ情報)の対応関係をコメントで明示する。** + +```php + /** + * 固定ページを追加する + * + * 固定ページは pages テーブルと contents テーブルの複合構造である点に注意する。 + * 引数の $content(ページ本文)は pages.contents へ、タイトルや URL などは + * content キー(Contents アソシエーション)へ格納する。 + * + * @param string $title ページタイトル + * @param string|null $content ページ本文 + * @param string|null $name URLのスラッグ + * @param int|null $parentId 親フォルダのコンテンツID + * @param int|null $siteId サイトID + * @param int|null $status 公開ステータス + * @param string|null $description 説明 + * @param string|null $publishBegin 公開開始日時 + * @param string|null $publishEnd 公開終了日時 + * @param string|null $pageTemplate ページテンプレート + * @param string|null $eyeCatch アイキャッチ画像 + * @param int|null $loginUserId ログインユーザーID + * @return array + */ + public function addPage( + string $title, + ?string $content = null, + ?string $name = null, + ?int $parentId = null, + ?int $siteId = null, + ?int $status = 0, + ?string $description = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?string $pageTemplate = null, + ?string $eyeCatch = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $title, $content, $name, $parentId, $siteId, $status, + $description, $publishBegin, $publishEnd, $pageTemplate, $eyeCatch, $loginUserId + ) { + /** @var \BaserCore\Service\PagesService $pagesService */ + $pagesService = $this->getService(PagesServiceInterface::class); + + $contentData = [ + 'title' => $title, + 'plugin' => 'BaserCore', + 'type' => 'Page', + 'site_id' => $siteId ?? 1, + 'parent_id' => $parentId ?? $this->getSiteRootContentId($siteId ?? 1), + 'self_status' => (bool)$status, + ]; + if ($name !== null) $contentData['name'] = $name; + if ($description !== null) $contentData['description'] = $description; + if ($publishBegin !== null) $contentData['publish_begin'] = $publishBegin; + if ($publishEnd !== null) $contentData['publish_end'] = $publishEnd; + if ($eyeCatch !== null) $contentData['eyecatch'] = $this->processImageUpload($eyeCatch); + + $postData = [ + // ページ本文は pages.contents + 'contents' => $content ?? '', + 'content' => $contentData, + ]; + if ($pageTemplate !== null) $postData['page_template'] = $pageTemplate; + + $page = $pagesService->create($postData); + + return $this->createSuccessResponse( + $page->toArray(), + [], + '固定ページ「' . $title . '」を追加しました。', + $this->resolveLoginUserId($loginUserId) + ); + }); + } +``` + +`getSiteRootContentId()` は親フォルダ未指定時にサイトルートのコンテンツIDを引くためのヘルパ。`ContentsService` または `Contents` テーブルの `site_root` フラグから取得する。取得方法は `baser-core` の `ContentsTable`/`ContentFoldersService` を確認して決める。 + +Run: `docker exec basercms sh -c 'cd /var/www/html && grep -rn "site_root" plugins/baser-core/src/Model/Table/ContentsTable.php | head -5'` + +`editPage()` は `PagesService::get()` で対象を取得し、指定された項目のみを差分で `update()` に渡す。`deletePage()` は `PagesService::delete()` を呼ぶ。`getPages()` は `getIndex()`、`getPage()` は `get()` を使い、いずれも `content` を含めて返す(`contain` の指定が必要か確認する)。 + +- [ ] **Step 4: `BaserCoreServer` にツールクラスを登録する** + +Modify: `plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php` + +`getToolClasses()` の配列に `PagesTool::class` を追加する。 + +- [ ] **Step 5: 構文チェック** + +Run: `docker exec basercms sh -c 'cd /var/www/html && php -l plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php && php -l plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php'` + +Expected: `No syntax errors detected`。 + +- [ ] **Step 6: テストを実行して通ることを確認する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php 2>&1 | tail -30'` + +Expected: PASS(5テスト)。 + +- [ ] **Step 7: tools/list に固定ページツールが並ぶことを確認する** + +Modify: `plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php` + +`testToolsListContainsAllTools()` に assertion を追加する。 + +```php + // BaserCore(固定ページ) + $this->assertContains('getPages', $names); + $this->assertContains('getPage', $names); + $this->assertContains('addPage', $names); + $this->assertContains('editPage', $names); + $this->assertContains('deletePage', $names); +``` + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php 2>&1 | tail -20'` + +Expected: PASS。 + +- [ ] **Step 8: プラグイン全体のテストを実行する** + +Run: `docker exec basercms sh -c 'cd /var/www/html && vendor/bin/phpunit --no-coverage --testsuite BcMcp 2>&1 | tail -30'` + +Expected: PASS。 + +- [ ] **Step 9: コミット** + +```bash +git add plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php +git commit -m "固定ページの取得・作成・編集・削除ツールを追加" +``` diff --git a/docs/superpowers/plans/2026-08-17-bc-mcp-scope.md b/docs/superpowers/plans/2026-08-17-bc-mcp-scope.md new file mode 100644 index 0000000000..339b20b157 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-bc-mcp-scope.md @@ -0,0 +1,1085 @@ +# bc-mcp スコープ整理 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** bc-mcp を「運営者向けの認証付き MCP サーバー」として定義し直し、スコープ外のコード(`search` / `fetch`、チャンクアップロード、stdio 経路)を削除したうえで、全ツールにツール注釈を宣言する。 + +**Architecture:** 既存の SDK ベース構成(`McpServer` がツールクラスを集めて登録、`McpProxyController` が認証と権限を担い `McpRequestHandler` がプロセス内で実行)は変えない。変更は「登録するツールの取捨選択」「`tool()` へ渡す注釈」「認証を通らない経路の削除」の3点に閉じる。 + +**Tech Stack:** PHP 8.1+ / CakePHP 5.2 / baserCMS 5.4 / logiscape/mcp-sdk-php v2 / PHPUnit 10.5 + +**Spec:** [docs/superpowers/specs/2026-08-17-bc-mcp-scope-design.md](../specs/2026-08-17-bc-mcp-scope-design.md) + +## Global Constraints + +- **実行環境**: ユニットテストは必ず Docker コンテナ `basercms` 上で実行する。baserCMS の配置先は `/var/www/html`。 +- **テストコマンドの雛形**: `docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage <対象>"` +- **フルスイートと個別テストを並行実行しない**(テスト DB が競合する)。 +- **公開ツール数は51のまま変えない。** 本計画で削除するのは、いずれも `BaserCoreServer::getToolClasses()` に登録されていないコードである。 +- **コメント・説明は日本語で書く。** 既存コードのコメント密度と語り口に合わせる。 +- **権限チェックは Admin Web API に委ねる**(原則2)。独自の権限判定を追加しない。 +- 対象ブランチは `dev-agentic`。 + +--- + +## File Structure + +| ファイル | 役割 | 変更 | +|---|---|---| +| `plugins/bc-mcp/src/Mcp/BaseMcpTool.php` | 全ツールの基底。注釈定数の置き場 | 定数追加、`processChunkFile()` 削除、コメント修正 | +| `plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php` | BaserCore のツールクラス一覧 | コメントアウト行の削除 | +| `plugins/bc-mcp/src/Mcp/BaserCore/SearchIndexesTool.php` | `search` / `fetch` | **削除** | +| `plugins/bc-mcp/src/Mcp/BaserCore/FileUploadTool.php` | `sendFileChunk` | **削除** | +| `plugins/bc-mcp/src/Command/McpServerCommand.php` | stdio 起動コマンド | **削除** | +| `plugins/bc-mcp/src/BcMcpPlugin.php` | プラグイン定義 | `console()` の削除 | +| `plugins/bc-mcp/src/Mcp/McpServer.php` | サーバー組み立てと `serverInfo` | `runStdio()` 削除、`available_transports` 修正、`serverInfo` に注釈 | +| `plugins/bc-mcp/src/Mcp/**/*Tool.php`(11ファイル) | 各ツールの登録 | `tool()` に `annotations:` を追加(計50件) | +| `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomEntriesTool.php` | カスタムエントリー | 上記に加え `keyword` を公開 | +| `plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php` | 注釈の全数検証 | **新規作成** | +| `plugins/bc-mcp/README.md` | 利用者向け文書 | アップロードと Cloudflare の節を更新 | + +タスクの並びは「削除 → 注釈 → 補完 → 文書」の順とする。削除を先に済ませることで、注釈を付けて回る対象が確定する。 + +--- + +## Task 1: stdio トランスポートの削除 + +認証・権限・Origin 検証のいずれも通らない経路を塞ぐ。設計書 3.2 に対応。 + +**Files:** +- Delete: `plugins/bc-mcp/src/Command/McpServerCommand.php` +- Delete: `plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php` +- Modify: `plugins/bc-mcp/src/BcMcpPlugin.php`(`console()` メソッド) +- Modify: `plugins/bc-mcp/src/Mcp/McpServer.php`(`runStdio()` と `available_transports`) +- Modify: `plugins/bc-mcp/src/Mcp/BaseMcpTool.php`(`resolveLoginUserId()` の docblock) +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php` + +**Interfaces:** +- Consumes: なし(最初のタスク) +- Produces: `McpServer` から `runStdio()` が消える。以降のタスクは `McpServer::getServer()` と `McpRequestHandler` のみを使う。 + +- [ ] **Step 1: `available_transports` の期待値を変えるテストを書く** + +`plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php` に既存の `serverInfo` 検証があれば修正し、無ければ以下を追加する。 + +```php + /** + * test serverInfo が提供するトランスポートは HTTP のみ + * + * 認証と権限を通らない stdio 経路は提供しない + */ + public function testServerInfoReportsHttpOnly() + { + $result = (new McpServer())->serverInfo(); + + $this->assertEquals(['http'], $result['available_transports']); + } +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage --filter testServerInfoReportsHttpOnly plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php" +``` + +期待: FAIL(`['stdio', 'http']` が返るため差分で落ちる) + +- [ ] **Step 3: `McpServer` から stdio を取り除く** + +`plugins/bc-mcp/src/Mcp/McpServer.php` の `runStdio()` メソッド全体(docblock を含む)を削除する。 + +```php + /** + * 標準入力からサーバーを起動する + * + * HTTP 経由の利用は /bc-mcp エンドポイントが担うため、常駐プロセスとしての + * 起動は標準入出力のみを提供する。 + * + * @return void + */ + public function runStdio(): void + { + $this->server->runStdio(); + } +``` + +続いて `serverInfo()` 内の記述を変更する。 + +変更前: +```php + 'available_transports' => ['stdio', 'http'], +``` + +変更後: +```php + 'available_transports' => ['http'], +``` + +- [ ] **Step 4: テストを実行して成功を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage --filter testServerInfoReportsHttpOnly plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php" +``` + +期待: PASS + +- [ ] **Step 5: コマンドとそのテストを削除する** + +```bash +git rm plugins/bc-mcp/src/Command/McpServerCommand.php +git rm plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php +``` + +- [ ] **Step 6: プラグインからコマンド登録を外す** + +`plugins/bc-mcp/src/BcMcpPlugin.php` の `console()` メソッド全体を削除する。`Oauth2CleanupCommand` は CakePHP の自動探索で読み込まれるため、明示登録は不要になる。 + +削除する部分: +```php + /** + * 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 + { + // MCPサーバーコマンドを追加 + $commands->add('bc_mcp.server', \BcMcp\Command\McpServerCommand::class); + $commands = parent::console($commands); + return $commands; + } +``` + +あわせて未使用になる `use Cake\Console\CommandCollection;` も削除する。 + +- [ ] **Step 7: 残った stdio への言及を直す** + +`plugins/bc-mcp/src/Mcp/BaseMcpTool.php` の `resolveLoginUserId()` の docblock を修正する。 + +変更前: +```php + * MCP のツールは JSON-RPC の引数しか受け取らないため、認証済みの操作者は + * McpContext から取得する。引数で明示された場合はそれを優先する + * (stdio 経由の利用など、コンテキストを持たない経路のため)。 +``` + +変更後: +```php + * MCP のツールは JSON-RPC の引数しか受け取らないため、認証済みの操作者は + * McpContext から取得する。引数で明示された場合はそれを優先する + * (テストなど、コンテキストを持たない経路のため)。 +``` + +- [ ] **Step 8: `Oauth2CleanupCommand` が消えていないことを確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && bin/cake" 2>&1 | grep -c "bc_mcp" +``` + +期待: `1`(`bc_mcp.oauth2_cleanup` のみが残り、`bc_mcp.server` は消えている) + +実際のコマンド名が異なる場合は、`bin/cake` の出力を確認して `bc_mcp` で始まる行が `server` を含まないことを目視で確認する。 + +- [ ] **Step 9: bc-mcp のテストを通す** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests" +``` + +期待: PASS(削除したコマンドのテスト分だけ件数が減る) + +- [ ] **Step 10: コミット** + +```bash +git add -A plugins/bc-mcp +git commit -m "認証と権限を通らない stdio 経路を削除 + +PermissionManager を呼ぶのは McpProxyController のみで、bin/cake +bc_mcp.server は OAuth 認証・権限チェック・Origin 検証・ログイン +ユーザーの設定を全て素通りしていた。 + +管理画面のツール一覧は McpRequestHandler をプロセス内で呼ぶため +影響を受けない。 + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +## Task 2: スコープ外ツールとチャンクアップロードの削除 + +`search` / `fetch` / `sendFileChunk` の実装と、その受け取り側を削除する。設計書 3.1 と 3.3 に対応。 + +**Files:** +- Delete: `plugins/bc-mcp/src/Mcp/BaserCore/SearchIndexesTool.php` +- Delete: `plugins/bc-mcp/src/Mcp/BaserCore/FileUploadTool.php` +- Delete: `plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php` +- Delete: `plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php` +- Modify: `plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php` +- Modify: `plugins/bc-mcp/src/Mcp/BaseMcpTool.php`(`processFileUpload()` と `processChunkFile()`) +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php:716` 付近 + +**Interfaces:** +- Consumes: Task 1 完了後の状態 +- Produces: `BaseMcpTool::processFileUpload(string $fileData, string $fieldName = 'file'): array|false` は `data:` URI と `http(s)` URL のみを受け付け、それ以外は `false` を返す。 + +- [ ] **Step 1: `processFileUpload` の新しい振る舞いをテストで表現する** + +`plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php` が無ければ新規作成する。既にあれば追記する。 + +```php +processFileUpload($fileData); + } + }; + } + + /** + * test URL でも data: URI でもない指定は受け付けない + * + * チャンクアップロードを廃止したため、ローカルのファイル名を渡す経路は無い + */ + public function testProcessFileUploadRejectsBareFilename() + { + $this->assertFalse($this->createTool()->callProcessFileUpload('example.jpg')); + } + +} +``` + +- [ ] **Step 2: テストを実行して失敗を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage --filter testProcessFileUploadRejectsBareFilename plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php" +``` + +期待: FAIL または例外。現在は `processChunkFile()` に入り「チャンクファイルが存在しません」の例外が `catch` されて `false` が返るため、**PASS してしまう可能性がある**。その場合は Step 3 の削除後に「例外を経由せず素直に `false` を返す」ことを確認する意味のテストとして扱い、Step 2 の期待を PASS に読み替えてよい。 + +- [ ] **Step 3: チャンク処理を削除する** + +`plugins/bc-mcp/src/Mcp/BaseMcpTool.php` の `processFileUpload()` から3つ目の分岐を削除する。 + +変更前: +```php + // URLの場合はダウンロードして処理 + if (preg_match('/^https?:\/\//', $fileData)) { + return $this->processUrlFile($fileData); + } + + if (!empty($fileData)) { + return $this->processChunkFile($fileData); + } + + throw new \Exception('不正なファイルデータ形式です: ' . $fileData); +``` + +変更後: +```php + // URLの場合はダウンロードして処理 + if (preg_match('/^https?:\/\//', $fileData)) { + return $this->processUrlFile($fileData); + } + + throw new \Exception('不正なファイルデータ形式です: ' . $fileData); +``` + +続いて `processChunkFile()` メソッド全体(docblock を含む、`public function processChunkFile(string $fileData): array` から対応する閉じ括弧まで)を削除する。 + +あわせて `processFileUpload()` の docblock を実態に合わせる。 + +変更前: +```php + * @param string $fileData ファイルパス、URL、またはbase64エンコードされたデータ +``` + +変更後: +```php + * @param string $fileData 画像の URL、または data: URI 形式の base64 データ +``` + +- [ ] **Step 4: テストを実行して成功を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php" +``` + +期待: PASS + +- [ ] **Step 5: ツールクラスとテストを削除する** + +```bash +git rm plugins/bc-mcp/src/Mcp/BaserCore/SearchIndexesTool.php +git rm plugins/bc-mcp/src/Mcp/BaserCore/FileUploadTool.php +git rm plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php +git rm plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php +``` + +- [ ] **Step 6: `BaserCoreServer` からコメントアウト行を消す** + +`plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php` の `getToolClasses()` を書き換える。 + +変更前: +```php + return [ + PagesTool::class, + // SearchIndexesTool::class, // ChatGPTで動作しないため一旦、停止 + // FileUploadTool::class // AI側のメッセージ制限によりチャンクによるアップロードを実装したが、それでも、現実的でなかったため、一旦、停止 + ]; +``` + +変更後: +```php + return [ + PagesTool::class, + ]; +``` + +- [ ] **Step 7: チャンク前提のテストを URL 方式に置き換える** + +`plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php` の716行付近で `TMP . 'mcp_uploads/'` を参照している箇所を確認する。 + +```bash +grep -n "mcp_uploads" plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php +``` + +該当するテストメソッドを、`data:` URI を渡す形に書き換える。1x1 の PNG を使う。 + +```php + // チャンクアップロードは廃止したため、インラインの data: URI で検証する + $pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + $eyeCatch = 'data:image/png;base64,' . $pngBase64; +``` + +テストの意図が「チャンク経由でアップロードしたファイルがアイキャッチになる」ことであれば、意図ごと「`data:` URI で渡したデータがアイキャッチになる」に読み替える。テストメソッド名に `Chunk` が含まれる場合は `testAddBlogPostWithInlineEyeCatch` のように改名する。 + +- [ ] **Step 8: bc-mcp のテストを通す** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests" +``` + +期待: PASS + +- [ ] **Step 9: 削除対象への参照が残っていないことを確認する** + +```bash +grep -rn "processChunkFile\|SearchIndexesTool\|FileUploadTool\|mcp_uploads" plugins/bc-mcp/src plugins/bc-mcp/tests +``` + +期待: 出力が空。何か残っていれば、それが参照している側も合わせて直す。 + +登録ツール数が51のままであることは Task 3 で追加する `AnnotationsTest::testToolCount()` が検証する。 + +- [ ] **Step 10: コミット** + +```bash +git add -A plugins/bc-mcp +git commit -m "スコープ外の search / fetch とチャンクアップロードを削除 + +search / fetch は一般ユーザー向けの検索インデックスを露出しており、 +運営者向けという位置づけと客層が異なる。加えて単一ベンダー固有の +レスポンス形式を要求される。 + +チャンクアップロードは MCP の File Uploads WG でも仕様から外された +方式で、根本のボトルネックはホストがファイルの生バイトをサーバーへ +渡せない点にある。受け取り側の processChunkFile() も対で削除し、 +アイキャッチは URL と data: URI の2方式に絞る。 + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +## Task 3: ツール注釈の基盤と全数検証テスト + +先に「全ツールが注釈を持つ」ことを検証するテストを用意し、それを赤にしてから注釈を付けていく。設計書 4 に対応。 + +**Files:** +- Modify: `plugins/bc-mcp/src/Mcp/BaseMcpTool.php`(注釈定数の追加) +- Create: `plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php` + +**Interfaces:** +- Consumes: Task 2 完了後のツール構成(51件) +- Produces: `BaseMcpTool::ANNOTATION_READ` / `ANNOTATION_CREATE` / `ANNOTATION_UPDATE` / `ANNOTATION_DELETE`(いずれも `protected const`、`array` 型)。Task 4 以降の全ツールがこれを参照する。 + +- [ ] **Step 1: 注釈定数を追加する** + +`plugins/bc-mcp/src/Mcp/BaseMcpTool.php` の `OUTPUT_SCHEMA` 定数の直後に追加する。 + +```php + /** + * 読み取り専用ツールの注釈 + * + * クライアントが読み取りと書き込みを区別できるようにする。Claude の + * Research はツール呼び出しに都度承認を挟まないため、区別できる情報を + * 提供する意味がある。readOnlyHint が true のとき、destructiveHint と + * idempotentHint は意味を持たないため宣言しない。 + */ + protected const ANNOTATION_READ = [ + 'readOnlyHint' => true, + 'openWorldHint' => false, + ]; + + /** + * 追加系ツールの注釈 + * + * 追加のみで既存データを壊さない。同じ引数で繰り返すと重複が増えるため + * 冪等ではない。 + */ + protected const ANNOTATION_CREATE = [ + 'readOnlyHint' => false, + 'destructiveHint' => false, + 'idempotentHint' => false, + 'openWorldHint' => false, + ]; + + /** + * 更新系ツールの注釈 + * + * 既存データを上書きするため破壊的とみなす。同じ引数なら結果は同じ。 + */ + protected const ANNOTATION_UPDATE = [ + 'readOnlyHint' => false, + 'destructiveHint' => true, + 'idempotentHint' => true, + 'openWorldHint' => false, + ]; + + /** + * 削除系ツールの注釈 + * + * 削除済みのものを再度削除しても結果は変わらない。 + */ + protected const ANNOTATION_DELETE = [ + 'readOnlyHint' => false, + 'destructiveHint' => true, + 'idempotentHint' => true, + 'openWorldHint' => false, + ]; +``` + +- [ ] **Step 2: 全数検証テストを書く** + +`plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php` を新規作成する。 + +```php + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\McpRequestHandler; +use Mcp\Server\Transport\Http\HttpMessage; + +/** + * AnnotationsTest + * + * 全ツールがツール注釈を宣言していることを検証する + * + * 注釈は接頭辞ごとに手で指定する方針のため、ツールを追加したときの + * 付け忘れを検出する仕組みが要る。個別ツールごとではなく tools/list を + * 走査して全数を確認する。 + */ +class AnnotationsTest extends BcTestCase +{ + + /** + * 接頭辞ごとに期待する注釈 + * + * @var array + */ + private const EXPECTED = [ + 'get' => ['readOnlyHint' => true, 'openWorldHint' => false], + 'add' => ['readOnlyHint' => false, 'destructiveHint' => false, 'idempotentHint' => false, 'openWorldHint' => false], + 'edit' => ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => true, 'openWorldHint' => false], + 'delete' => ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => true, 'openWorldHint' => false], + ]; + + /** + * tools/list の結果を取得する + * + * @return array + */ + private function fetchTools(): array + { + $request = new HttpMessage(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 'annotations-test', + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientInfo' => ['name' => 'test', 'version' => '1.0.0'], + 'io.modelcontextprotocol/clientCapabilities' => [], + ], + ], + ], JSON_UNESCAPED_UNICODE)); + $request->setMethod('POST'); + $request->setUri('/bc-mcp'); + $request->setHeader('Content-Type', 'application/json'); + $request->setHeader('Accept', 'application/json'); + $request->setHeader('MCP-Protocol-Version', '2026-07-28'); + $request->setHeader('Mcp-Method', 'tools/list'); + + $response = (new McpRequestHandler())->handle($request); + $decoded = json_decode((string)$response->getBody(), true); + + return $decoded['result']['tools'] ?? []; + } + + /** + * test 全ツールが接頭辞に応じた注釈を宣言している + */ + public function testAllToolsDeclareAnnotations() + { + $tools = $this->fetchTools(); + $this->assertNotEmpty($tools, 'ツール一覧を取得できませんでした'); + + foreach($tools as $tool) { + $name = $tool['name']; + + // serverInfo は接頭辞を持たないが読み取り専用 + $prefix = ($name === 'serverInfo')? 'get' : null; + foreach(array_keys(self::EXPECTED) as $candidate) { + if (str_starts_with($name, $candidate)) { + $prefix = $candidate; + break; + } + } + + $this->assertNotNull($prefix, "ツール {$name} の接頭辞が想定外です。注釈の割り当てを決めてください。"); + $this->assertArrayHasKey('annotations', $tool, "ツール {$name} に注釈がありません"); + + foreach(self::EXPECTED[$prefix] as $key => $expected) { + $this->assertArrayHasKey($key, $tool['annotations'], "ツール {$name} の注釈に {$key} がありません"); + $this->assertSame($expected, $tool['annotations'][$key], "ツール {$name} の {$key} が想定と異なります"); + } + } + } + + /** + * test 公開しているツールの数が変わっていない + * + * スコープ整理でツールを増減させていないことの確認 + */ + public function testToolCount() + { + $this->assertCount(51, $this->fetchTools()); + } + +} +``` + +- [ ] **Step 3: テストを実行して失敗を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php" +``` + +期待: `testAllToolsDeclareAnnotations` が FAIL(「ツール getPages に注釈がありません」など)。`testToolCount` は PASS。 + +`testToolCount` が失敗する場合は Task 2 の削除で登録ツールを誤って減らしている。差分を確認して直す。 + +- [ ] **Step 4: コミット** + +この時点では赤いテストが1件残る。Task 4 以降で解消するため、テストを一時的に無効化せずそのままコミットする。 + +```bash +git add plugins/bc-mcp/src/Mcp/BaseMcpTool.php plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php +git commit -m "ツール注釈の定数と全数検証テストを追加 + +接頭辞から自動判定せず明示指定する方針のため、付け忘れを検出する +仕組みとして tools/list を全数走査するテストを用意する。 + +この時点では各ツールへの注釈付与が未了のためテストは失敗する。 + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +## Task 4: BaserCore と BcBlog のツールに注釈を付ける + +**Files:** +- Modify: `plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogContentsTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogCategoriesTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/BcBlog/BlogTagsTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/McpServer.php`(`serverInfo` 1件) +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php` + +**Interfaces:** +- Consumes: `BaseMcpTool::ANNOTATION_READ` / `ANNOTATION_CREATE` / `ANNOTATION_UPDATE` / `ANNOTATION_DELETE`(Task 3) +- Produces: なし(Task 5 と同じ作業を別ファイルに対して行う) + +- [ ] **Step 1: 各 `tool()` 呼び出しに `annotations:` を追加する** + +`outputSchema:` の直後に `annotations:` を置く。接頭辞と定数の対応は次のとおり。 + +| ツール名の接頭辞 | 指定する定数 | +|---|---| +| `get` | `self::ANNOTATION_READ` | +| `add` | `self::ANNOTATION_CREATE` | +| `edit` | `self::ANNOTATION_UPDATE` | +| `delete` | `self::ANNOTATION_DELETE` | + +`PagesTool` での例。 + +変更前: +```php + name: 'getPages', + description: '固定ページの一覧を取得します', + callback: [$this, 'getPages'], + outputSchema: self::OUTPUT_SCHEMA, + inputSchema: [ +``` + +変更後: +```php + name: 'getPages', + description: '固定ページの一覧を取得します', + callback: [$this, 'getPages'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + inputSchema: [ +``` + +削除系の例。 + +変更前: +```php + name: 'deletePage', + description: '固定ページを削除します', + callback: [$this, 'deletePage'], + outputSchema: self::OUTPUT_SCHEMA, +``` + +変更後: +```php + name: 'deletePage', + description: '固定ページを削除します', + callback: [$this, 'deletePage'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, +``` + +**引数の順序は問わない**(名前付き引数のため)が、既存の並びに合わせて `outputSchema` の直後に置くこと。 + +対象は次の25件。 + +- `PagesTool`: `getPages` / `getPage` / `addPage` / `editPage` / `deletePage` +- `BlogPostsTool`: `getBlogPosts` / `getBlogPost` / `addBlogPost` / `editBlogPost` / `deleteBlogPost` +- `BlogContentsTool`: `getBlogContents` / `getBlogContent` / `addBlogContent` / `editBlogContent` / `deleteBlogContent` +- `BlogCategoriesTool`: `getBlogCategories` / `getBlogCategory` / `addBlogCategory` / `editBlogCategory` / `deleteBlogCategory` +- `BlogTagsTool`: `getBlogTags` / `getBlogTag` / `addBlogTag` / `editBlogTag` / `deleteBlogTag` + +- [ ] **Step 2: `serverInfo` に注釈を付ける** + +`plugins/bc-mcp/src/Mcp/McpServer.php` の `serverInfo` 登録に追加する。`McpServer` は `BaseMcpTool` を継承していないため、定数を参照できない。配列を直接書く。 + +変更前: +```php + $this->server->tool( + name: 'serverInfo', + description: 'サーバーのバージョンや環境情報を返します', + callback: [$this, 'serverInfo'], + outputSchema: [ +``` + +変更後: +```php + $this->server->tool( + name: 'serverInfo', + description: 'サーバーのバージョンや環境情報を返します', + callback: [$this, 'serverInfo'], + // BaseMcpTool を継承していないため定数を参照できない。 + // ANNOTATION_READ と同じ内容を直接指定する。 + annotations: ['readOnlyHint' => true, 'openWorldHint' => false], + outputSchema: [ +``` + +- [ ] **Step 3: テストを実行して進捗を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php" +``` + +期待: まだ FAIL。ただしエラーメッセージが BcCustomContent 系のツール名(`getCustomContents` など)に変わっていること。BaserCore / BcBlog 系の名前が出なくなっていれば本タスクは成功している。 + +- [ ] **Step 4: 既存テストが壊れていないことを確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/BaserCore plugins/bc-mcp/tests/TestCase/Mcp/BcBlog" +``` + +期待: PASS + +- [ ] **Step 5: コミット** + +```bash +git add plugins/bc-mcp/src/Mcp/BaserCore plugins/bc-mcp/src/Mcp/BcBlog plugins/bc-mcp/src/Mcp/McpServer.php +git commit -m "固定ページ・ブログ・serverInfo のツールに注釈を宣言 + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +## Task 5: BcCustomContent のツールに注釈を付け、`keyword` を公開する + +**Files:** +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomContentsTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomEntriesTool.php`(5件 + `keyword`) +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomFieldsTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomTablesTool.php`(5件) +- Modify: `plugins/bc-mcp/src/Mcp/BcCustomContent/CustomLinksTool.php`(5件) +- Test: `plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php` + +**Interfaces:** +- Consumes: `BaseMcpTool::ANNOTATION_*`(Task 3) +- Produces: `CustomEntriesTool::getCustomEntries()` の第2引数が `?string $keyword` になる(旧 `?string $title`)。 + +- [ ] **Step 1: 注釈を追加する** + +Task 4 の Step 1 と同じ要領で、次の25件に `annotations:` を追加する。接頭辞と定数の対応も同じ。 + +- `CustomContentsTool`: `getCustomContents` / `getCustomContent` / `addCustomContent` / `editCustomContent` / `deleteCustomContent` +- `CustomEntriesTool`: `getCustomEntries` / `getCustomEntry` / `addCustomEntry` / `editCustomEntry` / `deleteCustomEntry` +- `CustomFieldsTool`: `getCustomFields` / `getCustomField` / `addCustomField` / `editCustomField` / `deleteCustomField` +- `CustomTablesTool`: `getCustomTables` / `getCustomTable` / `addCustomTable` / `editCustomTable` / `deleteCustomTable` +- `CustomLinksTool`: `getCustomLinks` / `getCustomLink` / `addCustomLink` / `editCustomLink` / `deleteCustomLink` + +`CustomEntriesTool` は `callback:` が先頭に来る書式なので、それに合わせる。 + +変更前: +```php + ->tool( + callback: [$this, 'getCustomEntries'], + outputSchema: self::OUTPUT_SCHEMA, + name: 'getCustomEntries', +``` + +変更後: +```php + ->tool( + callback: [$this, 'getCustomEntries'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomEntries', +``` + +- [ ] **Step 2: 注釈のテストが全て通ることを確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/AnnotationsTest.php" +``` + +期待: PASS(Task 3 で赤くしたテストがここで緑になる) + +- [ ] **Step 3: `keyword` の失敗するテストを書く** + +`plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php` に追加する。このテストクラスは MCP 経由ではなく `$this->CustomEntriesTool` のメソッドを直接呼ぶ書式なので、それに倣う(`testAddCustomEntryBasic` を参照)。 + +```php + /** + * Test getCustomEntries method - キーワード絞り込みテスト + * + * 他の一覧ツールと同じく keyword で指定できる。 + * 対象はタイトルとスラッグ(CustomEntriesService の title 条件)。 + * + * @return void + */ + public function testGetCustomEntriesWithKeyword() + { + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + $this->loadFixtureScenario(CustomFieldsScenario::class); + + $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact', + 'title' => 'お問い合わせタイトル', + 'display_field' => 'お問い合わせ' + ]); + + $this->CustomEntriesTool->addCustomEntry( + customTableId: 1, + title: '検索対象のエントリー', + name: 'keyword_target', + status: true, + creatorId: 1 + ); + $this->CustomEntriesTool->addCustomEntry( + customTableId: 1, + title: '関係のないエントリー', + name: 'keyword_other', + status: true, + creatorId: 1 + ); + + $result = $this->CustomEntriesTool->getCustomEntries( + customTableId: 1, + keyword: '検索対象' + ); + + $this->assertIsArray($result); + $this->assertCount(1, $result['results'], json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertEquals('検索対象のエントリー', $result['results'][0]['title']); + + $dataBaseService->dropTable('custom_entry_1_contact'); + } +``` + +`addCustomEntry` が失敗して結果が0件になる場合は、`CustomFieldsScenario` が用意するフィールド構成と `title` / `name` の必須条件を確認する。既存の `testAddCustomEntryBasic` が緑であれば同じ手順で作成できる。 + +- [ ] **Step 4: テストを実行して失敗を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage --filter testGetCustomEntriesWithKeyword plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php" +``` + +期待: FAIL(`keyword` が inputSchema に無いため無視され、絞り込まれない) + +- [ ] **Step 5: `keyword` を公開する** + +`CustomEntriesTool::getCustomEntries()` の第2引数を改名する。 + +変更前: +```php + public function getCustomEntries( + int $customTableId, + ?string $title = null, + ?int $creatorId = null, +``` + +変更後: +```php + public function getCustomEntries( + int $customTableId, + ?string $keyword = null, + ?int $creatorId = null, +``` + +メソッド本体の `use` と条件生成も合わせる。`CustomEntriesService` 側の条件キーは `title` のままである点に注意する(サービスの API は変えない)。 + +変更前: +```php + return $this->executeWithErrorHandling(function() use ($customTableId, $title, $creatorId, $published, $limit, $page, $status) { +``` + +変更後: +```php + return $this->executeWithErrorHandling(function() use ($customTableId, $keyword, $creatorId, $published, $limit, $page, $status) { +``` + +変更前: +```php + if (!is_null($title)) $conditions['title'] = $title; +``` + +変更後: +```php + // CustomEntriesService の title 条件はタイトルとスラッグの LIKE 検索 + if (!is_null($keyword)) $conditions['title'] = $keyword; +``` + +inputSchema に追加する。 + +変更前: +```php + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'limit' => ['type' => 'number', 'default' => 20, 'description' => '取得件数(デフォルト: 20)'], +``` + +変更後: +```php + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'keyword' => ['type' => 'string', 'description' => '検索キーワード(タイトル・スラッグを対象に検索)'], + 'limit' => ['type' => 'number', 'default' => 20, 'description' => '取得件数(デフォルト: 20)'], +``` + +- [ ] **Step 6: テストを実行して成功を確認する** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php" +``` + +期待: PASS + +- [ ] **Step 7: bc-mcp のテストを通す** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage plugins/bc-mcp/tests" +``` + +期待: PASS + +- [ ] **Step 8: コミット** + +```bash +git add plugins/bc-mcp/src/Mcp/BcCustomContent plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent +git commit -m "カスタムコンテンツのツールに注釈を宣言し、keyword を公開 + +getCustomEntries はタイトル・スラッグの絞り込みを実装として持ちながら +inputSchema に宣言しておらず、クライアントから指定できなかった。 +横断検索を持たない方針のため、各一覧ツールの絞り込みは揃っている +必要がある。 + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +## Task 6: README の更新 + +設計書 6.2 に対応。 + +**Files:** +- Modify: `plugins/bc-mcp/README.md` + +**Interfaces:** +- Consumes: Task 1〜5 の完了状態 +- Produces: なし(最終タスク) + +- [ ] **Step 1: ファイルアップロードの節を実態に合わせる** + +「ファイルアップロードについて」の節を次に置き換える。 + +変更前: +```markdown +### 現状の対応方法 +現状としてはSTDIO方式のアップロードツールで、BcMcpが参照可能な領域にアップロードして、そのURLを送信するしかありません。 + +### 将来的な対応予定 +将来的には、MPCの仕様として multipart/form-data に対応する予定との事ですので、その際にBcMcpも対応する予定です。 +``` + +変更後: +```markdown +### 現状の対応方法 +アイキャッチなどの画像は、次の2つの方法で指定できます。 + +- **画像のURL** — ネット上に公開された画像のURLを渡します +- **`data:` URI** — `data:image/png;base64,...` 形式で直接渡します。小さな画像に限ります + +### 将来的な対応予定 +MCP に [File Uploads Working Group](https://modelcontextprotocol.io/community/working-groups/file-uploads) +が設置され、ホストがファイルピッカーを表示してサーバーへファイルを渡す仕組みが検討されています +([SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631))。 + +現状はホスト側にファイルの中身をサーバーへ渡す手段が無いため、ローカルのファイルを +そのままアップロードすることはできません。規格とホストの対応が揃った段階で BcMcp も対応します。 +``` + +「制約事項」の節にある「約30KB以下でチャンク分割送信」の記述は、方式ごと廃止したため削除する。 + +- [ ] **Step 2: Cloudflare の節に制約と Named Tunnel を追記する** + +「2. トンネルの起動」の直後に追記する。 + +```markdown +Quick Tunnel には次の制約があります。 + +| 項目 | 内容 | +|---|---| +| 同時リクエスト | 200 in-flight まで | +| SSE(Server-Sent Events) | **非対応** | +| URL | 再起動のたびに変わる | +| 用途 | テストと開発のみ(本番非推奨、SLAなし) | + +固定のホスト名が必要な場合は Named Tunnel を使います。Cloudflare アカウントと、 +Cloudflare に登録済みのドメインが必要です。 + +```bash +cloudflared tunnel login +cloudflared tunnel create bc-mcp-verify +cloudflared tunnel route dns bc-mcp-verify mcp-dev.example.com +``` + +固定ホスト名にすると、OAuth の動的クライアント登録・`SITE_URL`・コネクタ登録を +毎回やり直す必要がなくなります。 +``` + +- [ ] **Step 3: 記述と実装が一致していることを確認する** + +```bash +grep -n -i "stdio\|チャンク\|sendFileChunk\|search\|fetch" plugins/bc-mcp/README.md +``` + +期待: bc-mcp が提供しない機能への言及が残っていないこと。「利用可能なツール」の一覧に `search` / `fetch` / `sendFileChunk` が含まれていないこと。 + +- [ ] **Step 4: フルスイートを実行する** + +他プラグインへの影響が無いことを確認する。**個別テストと同時に実行しないこと。** + +```bash +docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage" +``` + +期待: PASS(失敗0件) + +- [ ] **Step 5: コミット** + +```bash +git add plugins/bc-mcp/README.md +git commit -m "README をスコープ整理後の実態に合わせる + +ファイルアップロードは URL と data: URI の2方式であることを明記し、 +廃止したチャンク方式の記述を削除する。Cloudflare Quick Tunnel の +制約と、固定ホスト名が必要な場合の Named Tunnel を追記する。 + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +## 完了条件 + +- `docker exec basercms bash -c "cd /var/www/html && vendor/bin/phpunit --no-coverage"` が失敗0件 +- `AnnotationsTest` が緑(全51ツールが接頭辞に応じた注釈を持つ) +- `bin/cake` の一覧に `bc_mcp.server` が存在しない +- `grep -rn "processChunkFile\|SearchIndexesTool\|FileUploadTool" plugins/bc-mcp/src` が空 +- README に bc-mcp が提供しない機能の記述が残っていない diff --git a/docs/superpowers/specs/2026-08-12-bc-mcp-sdk-migration-design.md b/docs/superpowers/specs/2026-08-12-bc-mcp-sdk-migration-design.md new file mode 100644 index 0000000000..8d501ff3ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-bc-mcp-sdk-migration-design.md @@ -0,0 +1,406 @@ +# bc-mcp を MCP 2026-07-28(Dual-era)対応させる設計 + +- 作成日: 2026-08-12 +- 改訂日: 2026-08-12(SDK の実地調査により常駐プロセス廃止へ方針変更。第11章に経緯) +- 対象: `plugins/bc-mcp`(BcMcp / baserCMS コアプラグイン) +- ブランチ: `dev-mcp-2026-07-28`(`dev-agentic` から分岐) +- 前提調査: [2026-08-12-mcp-2026-07-28-bc-mcp-impact.md](2026-08-12-mcp-2026-07-28-bc-mcp-impact.md) + +## 1. 目的とゴール + +MCP 仕様 `2026-07-28`(ステートレスコア)に対応し、**Modern(`2026-07-28`)と Legacy(`initialize` 方式)の両世代を同時に提供する Dual-era サーバー**にする。あわせて、常駐 MCP サーバープロセスを廃止し、CakePHP のリクエスト内で処理を完結させる。 + +現状 bc-mcp は完全な Legacy 世代サーバーであり、Claude が Modern 専用クライアントに切り替えた時点で通信不能になる。現時点で通信不能になるリスクはないが(Claude は Dual-era として動作していることを実測で確認済み)、依存している `php-mcp/server` が `2025-03-26` 止まりでリリースも停滞しているため、上流の更新では解決しない。 + +### スコープ + +**含む** + +- MCP SDK を `php-mcp/server` から `logiscape/mcp-sdk-php` v2 へ移植し、Dual-era 対応を得る +- **常駐 MCP サーバープロセスの廃止(in-process 化)** — 移植先 SDK が listen 型サーバーを提供しないため必然(第11章) +- SDK では解決しない独立項目の対応 — `Origin` ヘッダ検証、`iss` パラメータ付与、ネゴシエーションのロギング +- **固定ページ(Pages)ツールの新規追加** — 取得・作成・編集・削除の5ツール(第13章) +- 上記に対する自動テスト(Modern / Legacy 両世代の疎通を含む) + +**含まない** + +- **`league/oauth2-server` の 9系アップデート** — MCP 対応と OAuth のメジャー更新を混ぜない。`iss` 付与は 9系でも自前実装が必要なため、8.5.5 のままで対応可能 +- **Client ID Metadata Documents 対応** — DCR は非推奨化されたが12ヶ月以上の猶予があり、互換のため残置してよい +- **自社プラグイン CuMcp(baserplugin リポジトリ)への反映** — 別途判断する +- **stdio トランスポートの廃止** — ローカルの stdio クライアント用途として `bc_mcp.server --transport=stdio` は残す + +## 2. 到達点のアーキテクチャ + +常駐プロセスと内部 HTTP 転送を廃止し、CakePHP のリクエスト内で SDK を実行する。 + +``` +Claude / ChatGPT / MCP Inspector + → POST /bc-mcp McpProxyController + ・OAuth2 トークン検証(既存) + ・権限チェック(既存) + ・Origin 検証(新規) + ・ネゴシエーションのロギング(新規) + ・CakePHP ServerRequest → HttpMessage 変換(新規) + → McpRequestHandler HttpServerRunner::handleRequest() をプロセス内で実行(新規) + → Mcp\Server\McpServer 各 *Tool → baserCMS Service 層 + ← HttpMessage → CakePHP Response 変換 +``` + +プロトコルの世代判定・`server/discover`・必須ヘッダ検証・`resultType` / `ttlMs` / `cacheScope` の付与はすべて SDK が担う。bc-mcp 側の責務は「ツールの定義と登録」「認証・認可」「CakePHP と SDK の間の変換」に純化する。 + +**廃止するもの** + +- `McpServerManger`(PID ファイル管理・`ps` による死活監視・起動/停止/再起動) +- 管理画面の MCP サーバー起動・停止 UI +- `McpServerCommand` の HTTP(`sse`)モード +- 内部サーバーへの HTTP 転送(`Cake\Http\Client` による `127.0.0.1:{port}` への POST) + +これにより次の問題が同時に解消する。 + +| 現状の問題 | 解消理由 | +|---|---| +| 起動忘れでプロキシが 503 を返す | 常駐プロセスが存在しない | +| ツール定義や設定の変更が常駐プロセスに反映されない | リクエストごとにサーバーを組み立てる | +| `shell_exec("ps -p …")` による脆い死活監視 | 不要になる | +| 常駐プロセスの DB 接続切り替え(`--connection` オプション) | CakePHP のリクエストコンテキストをそのまま使う | +| サーバー再起動・デプロイごとの手動起動 | 不要になる | + +## 3. SDK 移植の設計 + +### 3.1 ツール登録 API の対応 + +SDK の `tool()` は `inputSchema` / `outputSchema` を明示的に受け取れる。 + +```php +public function tool( + string $name, string $description, callable $callback, + ?string $title = null, ?array $icons = null, + ?array $outputSchema = null, ?array $inputSchema = null, + string $taskSupport = TaskSupport::FORBIDDEN, + array|ToolAnnotations|null $annotations = null, + string $taskInputMode = TaskInputMode::IN_TASK, +): self +``` + +したがって**既存の `inputSchema` 定義(description 付きの詳細な JSON Schema)はそのまま流用できる**。ツールの定義内容を書き直す必要はなく、登録の呼び出し方だけを変える。 + +SDK にはクラス単位の一括登録機構(`#[Tool]` 属性やクラススキャン)が存在しないため、「各 `*Tool` クラスが自分のツールを登録する」という現在の構造を維持する。 + +### 3.1.1 `outputSchema` の宣言(必須) + +**SDK はツールのコールバックの戻り値を、`string` または `CallToolResult` に限って受け付ける。** bc-mcp の全ツールは配列(エンティティを配列化したものや一覧)を返すため、そのままでは次のエラーになる。 + +``` +Invalid tool handler result: expected string or CallToolResult, got array +``` + +`outputSchema` を宣言したツールに限り、SDK は戻り値を任意の JSON 値として扱い(SEP-2106)、`structuredContent` に載せつつ JSON を `TextContent` にも出力する。**したがって全ツールの登録に `outputSchema` を指定する。** + +個々のツールの戻り値の構造はエンティティの構成に依存するため、`BaseMcpTool::OUTPUT_SCHEMA` に型のみを宣言した共通のスキーマを置き、各登録から参照する。 + +```php +protected const OUTPUT_SCHEMA = ['type' => ['object', 'array']]; +``` + +この方式には、`content[0].text` に JSON が載るため**既存のツールテストと従来のクライアントの互換が保たれる**という利点もある(戻り値を文字列化する方式では、配列を期待している既存テストを大量に書き換える必要が生じる)。 + +### 3.2 変更対象 + +| ファイル | 変更方針 | +|---|---| +| `src/Mcp/McpServer.php` | `PhpMcp\Server\ServerBuilder` → `Mcp\Server\McpServer` へ。ロガーはコンストラクタ第2引数で渡す(`__construct(string $name, ?LoggerInterface $logger = null, string $version = '1.0.0')`)。`withCapabilities()` の明示指定は廃止(SDK が登録実態から算出するため、`resources` / `prompts` の虚偽申告が解消される)。`runSse()` を削除 | +| `src/Mcp/McpRequestHandler.php`(新規) | `HttpServerRunner` を組み立ててプロセス内で1リクエストを処理する。本番とテストで共有する唯一の実行経路 | +| `src/Mcp/McpContext.php`(新規) | リクエストスコープのログインユーザー ID を保持する | +| `src/Mcp/BaseMcpTool.php` | `addToolsToBuilder(ServerBuilder $builder)` → `registerTools(McpServer $server)` へシグネチャ変更。`resolveLoginUserId()` を追加。ファイルアップロード・画像処理などの共通処理は無変更 | +| `src/Mcp/BaserCore/*Tool.php`
`src/Mcp/BcBlog/*Tool.php`
`src/Mcp/BcCustomContent/*Tool.php` | `->withTool(handler:, name:, description:, inputSchema:)` → `->tool(name:, description:, callback:, inputSchema:)` へ置換。`inputSchema` の中身とビジネスロジックは無変更 | +| `src/Mcp/*/*Server.php`(`getToolClasses()`) | 変更なし | +| `src/Controller/McpProxyController.php` | 内部 HTTP 転送を廃止し `McpRequestHandler` を呼ぶ。応答の偽装を削除。`Origin` 検証とロギングを追加 | +| `src/Command/McpServerCommand.php` | stdio モードのみ残す。`--transport=sse` / `http` と `--host` / `--port` オプションを削除 | +| `src/Mcp/McpServerManger.php` | **削除** | +| `src/Mcp/McpLogger.php` | PSR-3 実装(`Psr\Log\AbstractLogger` 継承)なのでそのまま流用 | +| `templates/Admin/McpServerManager/*` | 起動/停止 UI を廃止。画面の残し方は第10章 | + +### 3.3 プロセス内実行の設計 + +SDK の HTTP トランスポートは「1リクエストを処理して終わる」モデルであり、必要な部品はすべて public API として公開されている。 + +- `Mcp\Server\McpServer::getServer(): Mcp\Server\Server` +- `Mcp\Server\Server::createInitializationOptions(?NotificationOptions, ?array): InitializationOptions` +- `new Mcp\Server\HttpServerRunner(Server, InitializationOptions, array $httpOptions, ?LoggerInterface, ?SessionStoreInterface, ?HttpIoInterface)` +- `HttpServerRunner::handleRequest(?HttpMessage $request = null): HttpMessage` +- `new Mcp\Server\Transport\Http\HttpMessage(?string $body)` に `setMethod()` / `setUri()` / `setHeader()` +- `Mcp\Server\Transport\Http\BufferedIo` — 出力を SAPI へ書き出さずバッファに捕捉する `HttpIoInterface` 実装 + +`McpRequestHandler` はこれらを組み立て、**リクエストを渡してレスポンスを受け取る純粋な処理**として実装する。SAPI へ直接出力しないため、CakePHP のレスポンスに載せられ、テストからも同じ経路を呼べる。 + +### 3.4 Legacy セッションの保持 + +Modern リクエストは self-contained なのでセッション状態を必要としない。Legacy 世代はセッションを要するため、`sessionStore(SessionStoreInterface $store)` に同梱の `FileSessionStore` を渡し、保存先を baserCMS の一時ディレクトリ配下(`TMP . 'bc_mcp_sessions'`)とする。 + +なお移植前は `stateless: true` でセッションを作らない設定で運用しており、その状態で Claude の Legacy クライアントが正常に動作していた(実測済み)。したがってセッションは実質使われていないが、仕様準拠のため用意する。将来 CakePHP の Cache ベース実装に差し替える余地を残す。 + +## 4. プロキシの責務整理 + +### 4.1 リクエストの変換 + +内部 HTTP 転送が消えるため、外部から受けたリクエストを `HttpMessage` に変換して `McpRequestHandler` へ渡す。 + +Modern の必須ヘッダ(`MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*`)は、SDK がヘッダとボディの一致を検証する。**外部クライアントが送ってきたヘッダをそのまま `HttpMessage` に載せ、ボディも改変しない**ことで整合性を保つ。`Authorization` は SDK へ渡さない(認証はプロキシで完結している)。 + +### 4.2 プロトコル応答の偽装の削除 + +`initialize` 応答の `protocolVersion` を `2025-06-18` に書き換え、capabilities を `resources` / `prompts` ともに `listChanged: true` と申告している処理を削除する。SDK が Dual-era を正しく処理し、実態どおりの capabilities を返す。 + +GET に対してダミー JSON を 200 で返している処理も削除する。Modern では GET / DELETE は `405 Method Not Allowed` が期待される挙動である。 + +### 4.3 通知の 202 応答 + +現状 `$this->request->getData('method')` で通知を判定しているが JSON ボディを読めておらず機能していない。SDK が通知に対して 202 を返すため、**プロキシは SDK が返したステータスコードをそのまま CakePHP のレスポンスに反映する**だけでよい。独自の判定を持たない。 + +## 5. `loginUserId` の伝達 + +**方式を変更する。** 移植前はプロキシが OAuth トークンから解決した user_id を `params.arguments.loginUserId` に上書き注入していた。 + +```php +$mcpRequest['params']['arguments']['loginUserId'] = $this->request->getAttribute('oauth_user_id'); +``` + +in-process 化により、この注入は不要かつ有害になる。 + +- **有害な理由** — Modern ではヘッダとボディの一致が検証される。ボディを改変しない方が整合性の担保が単純で確実になる。また `inputSchema` に無い余剰プロパティがコールバック引数へマップされるかは SDK 実装依存であり、そこに賭ける理由がない +- **不要な理由** — 同一プロセス内で処理するため、CakePHP のリクエストコンテキストからツールが直接取得できる + +### 採用する方式 + +`BcMcp\Mcp\McpContext` にリクエストスコープの user_id を保持し、`BaseMcpTool::resolveLoginUserId()` 経由で取得する。 + +```php +// McpProxyController(認証後) +McpContext::setLoginUserId((int)$this->request->getAttribute('oauth_user_id')); + +// 各ツール(引数の $loginUserId は互換のため残す) +$userId = $this->resolveLoginUserId($loginUserId); +``` + +各ツールの `?int $loginUserId = null` 引数は残し、`resolveLoginUserId()` が「引数が渡されていればそれを使い、無ければコンテキストから取る」形にする。これにより既存のツール実装への変更を最小に留めつつ、ボディ改変をやめられる。 + +**`loginUserId` を `inputSchema` に公開してはならない。** AI クライアントに他ユーザーの ID を指定する余地を与えることになる。`McpContext` は必ずプロキシの認証後に設定し、リクエストの終わりにクリアする。 + +## 6. 独立項目 + +### 6.1 Origin ヘッダ検証 + +Streamable HTTP の MUST 要件(`2025-03-26` 以来)であり、DNS リバインディング攻撃対策として現状未実装。 + +プロキシで検証し、`Origin` が存在して許可リストに無い場合は **403 Forbidden** を返す。検証は**認証より前**に行う(transport レベルの要件であり、認証前に効かせるべきもの)。 + +許可オリジンは `config/setting.php` に設定項目(`BcMcp.allowedOrigins`)を追加する。既定は自サイトのオリジンのみを許可し、`Origin` ヘッダ自体が無いリクエスト(サーバー間通信)は通す。 + +なお SDK も `httpOptions(['allowed_origins' => [...]])` による同等の保護を持つため、同じ許可リストを SDK にも渡して二重に効かせる。 + +### 6.2 `iss` パラメータ(RFC 9207) + +Modern のクライアントは、認可レスポンスに `iss` があれば検証が MUST。認可サーバー側の付与は SHOULD だが、付与しておく方が安全側に立てる。`league/oauth2-server` 8.5 / 9系ともに機能を持たないため自前で実装する。 + +- `Admin/Oauth2Controller::authorize()` の approve / deny 双方のリダイレクト URL に `iss`(= issuer 識別子 `{baseUrl}/bc-mcp`)を付与する +- `Oauth2Controller::authorizationServerMetadata()` に `authorization_response_iss_parameter_supported: true` を追加する + +issuer 識別子は既存のメタデータの `issuer` と**同一の値**でなければならない。両者を `OAuth2Util::getIssuer()` という同じ導出処理から得る。 + +### 6.3 ネゴシエーションのロギング + +現状 `logs/mcp.log` にはリクエスト URL と POST ボディだけが記録され、MCP のネゴシエーション内容が残らない。**「Claude がいつ Modern に切り替えたか」を検知する手段が存在しない**ため、これを作る。 + +プロキシで次を `mcp` スコープのログに記録する。 + +- 世代(Modern / Legacy)の判定結果 +- プロトコルバージョン(`MCP-Protocol-Version` ヘッダ、または `_meta` / `params.protocolVersion`) +- クライアント情報(`_meta` の `io.modelcontextprotocol/clientInfo`、または `initialize` の `clientInfo`) +- メソッド名 + +トークンや引数の中身は記録しない(機密情報の混入を避ける)。 + +## 7. テスト戦略 + +`plugins/bc-mcp/tests/` に配置し、ローカル Docker(`basercms` コンテナ)で実行する。本番と同じ `McpRequestHandler` を経由するため、テストが実装の実経路を検証する。 + +| テスト | 内容 | +|---|---| +| `McpTestTraitTest` | プロセス内実行ヘルパ自身の疎通(`tools/list`) | +| `McpServerTest` | 全ツールが `tools/list` に並ぶ。`ttlMs` / `cacheScope` / `resultType` が付与される | +| `McpServerToolCallTest`(既存・書き換え) | `addBlogPost` が通り、`user_id` にログインユーザーが反映される(第5章の方式の検証を兼ねる) | +| `DualEraTest` | `server/discover`(Modern)と `initialize`(Legacy)が同一サーバーで応答する。`-32022`(未対応バージョン)と `-32020`(HeaderMismatch)が返る | +| `McpProxyControllerTest` | リクエスト変換、SDK のステータスコードの反映、GET / DELETE で 405、`Origin` 不正で 403 | +| `OAuth2UtilTest` | `iss` の付与と issuer の一致 | +| `NegotiationLoggerTest` | 世代判定とログ内容(引数が記録されないこと) | + +既存の `BaseMcpToolTest` / `OAuth2ControllerTest` / `OAuth2ControllerDynamicClientRegistrationTest` / `McpServerCommandTest` が回帰なく通ることも確認する。 + +## 8. 依存関係の切り替え + +**完了済み(2026-08-12)** + +1. `plugins/bc-mcp/composer.json` の `php-mcp/server: ^3.3` を `logiscape/mcp-sdk-php: ^2.0` に差し替えた +2. `monorepo-builder merge` は**既存の**バージョン不一致(`nyholm/psr7` の `^1.8` vs `~1.8.2`、`symfony/psr-http-message-bridge` の `^2.3` vs `~2.3.1`、`psr/http-message` の `^1.0` vs `~1.1`)で失敗するため、ルート `composer.json` を直接編集した。**この不一致は移植前から存在するもので、本件とは無関係** +3. `composer update` により `logiscape/mcp-sdk-php v2.0.0` が導入され、`php-mcp/server` と依存17パッケージ(`react/*` 6件、`opis/*` 3件、`phpdocumentor/*` 3件、`evenement` / `fig/http-message-util` / `webmozart/assert` ほか)が削除された + +`nyholm/psr7` は OAuth2 側(`OAuth2Service` / `Lib/OAuth2Util` / `Controller/Oauth2Controller`)が PSR-7 の生成に使用しているため残す。`ext-openssl` も OAuth2 の鍵処理で使用しているため残す。`symfony/psr-http-message-bridge` は直接参照されていないが、依存整理は本スコープ外として据え置く。 + +PHP 要件は問題ない。SDK は `php: >=8.1` / `ext-curl` / `ext-json` / `psr/log` のみを要求し、baserCMS 側(ルート `>=8.1`、`config.platform.php: 8.1`)と一致する。`ext-pcntl` と `monolog/monolog` は `suggest` 扱いで必須ではない。 + +## 9. 実装の順序 + +1. **依存関係の切り替え**(第8章)— 完了 +2. **プロセス内実行の基盤** — `McpRequestHandler` / `McpContext` とテストヘルパ +3. **ツール登録の移植** — `McpServer` → `BaseMcpTool` → 各 `*Tool`。`inputSchema` は流用 +4. **既存テストの移植** — `McpServerToolCallTest` を通す +5. **プロキシの移植**(第4・5章)— 内部 HTTP 転送の廃止、偽装削除、`McpContext` の設定 +6. **常駐プロセス関連の削除** — `McpServerManger`、コマンドの HTTP モード、管理画面 UI +7. **Dual-era 疎通テストの追加**(第7章) +8. **独立項目**(第6章)— `Origin` 検証 → `iss` → ロギング +9. **全体テストの実行** — `plugins/bc-mcp` 単体 → フルスイートで回帰確認 + +## 10. 管理画面の再構成 + +常駐プロセスが無くなるため、管理画面「MCPサーバー管理」の起動・停止・再起動ボタンと死活表示は意味を失う。**情報表示画面として再構成する。** + +現状の4ブロックの扱い。 + +| 現状のブロック | 扱い | +|---|---| +| MCPサーバー状態(稼働中/停止中・PID・内部URL・設定用URL) | 死活表示・PID・内部URL を削除し、接続情報のブロックへ再構成 | +| サーバー操作(起動/停止/再起動ボタン) | 削除 | +| AIエージェントでの設定方法(手順1〜3) | 手順1「起動ボタンで起動してください」を削除し2手順にする | +| 利用可能な機能(手書き3行) | 登録済みツールからの自動生成に置き換える。**現状の手書きは実態とずれており、40件以上あるツールが3行しか書かれていない** | + +再構成後の4ブロック。 + +1. **接続情報** — MCP エンドポイント URL(コピーボタン付き)、`.well-known/oauth-authorization-server` と `.well-known/oauth-protected-resource` の URL、対応プロトコルバージョン(Modern `2026-07-28` と旧世代の両対応であること) +2. **利用可能なツール** — 登録済みツールの名前と説明をプラグイン単位でグループ化して一覧表示。ツールを追加すれば自動で反映される +3. **AIエージェントでの設定方法** — URL 登録から始まる2手順 +4. **直近の接続状況** — `NegotiationLogger::readRecent()` から日時・世代・プロトコルバージョン・クライアント名を表示。**Claude が Modern に切り替わったことを管理画面から気づけるようにする。常駐プロセスの死活監視を失う代わりに、これが運用時の主要な確認手段になる** + +`configure` アクションとテンプレート(ポート番号などの設定画面)は削除する。 + +## 11. 方針変更の経緯(2026-08-12) + +当初は「常駐プロセス方式を維持し、その中身の SDK だけを入れ替える。in-process 化は次のブランチ」という計画だった。SDK 導入後に実物のソースを確認した結果、**この計画は技術的に成立しないことが判明したため方針を変更した**。 + +**判明した事実** + +- `logiscape/mcp-sdk-php` v2 の `runHttp()` は `StandardPhpAdapter::handle()` を呼ぶだけで、内容は `HttpMessage::fromGlobals()` により**現在の HTTP リクエストを1件処理して終了する**もの。ポートを bind して待ち受ける機能はない +- `php-mcp/server` は ReactPHP のイベントループで listen していた(今回削除された `react/*` 6パッケージがその実体)。logiscape v2 は「PHP/Apache/cPanel のような 1リクエスト 1プロセス環境」を前提に設計されており、listen 型サーバーを提供しない +- ソース全体に `stream_socket_server` 相当は存在せず、ReactPHP への参照はクライアント側の OAuth コールバック受信のみ + +**常駐であることに意味がなかったことの確認** + +移植前の `runSse()` は `enableJsonResponse: true` かつ `stateless: true` でトランスポートを起動していた。すなわち移植前から既に、セッションを持たず・SSE ストリームも使わず・「1 POST → 1 JSON 応答」で完結する使い方をしていた。プロキシも毎回 `POST http://127.0.0.1:{port}/` に JSON を投げるだけで、GET の SSE ストリームは使っていない(ダミー JSON を返していた)。 + +この構成で claude.ai の Legacy クライアントが正常に動作していた実測もあり、**セッションも常駐も実質的に使われていなかった**ことが確認できた。`php-mcp/server` が listen 型トランスポートしか提供していなかったため、それに合わせて常駐化されていたにすぎない。 + +したがって in-process 化は機能を落とさず、第2章の表に挙げた運用上の問題を解消する。 + +## 12. 固定ページ(Pages)ツールの追加 + +bc-mcp には現在、ブログ・カスタムコンテンツ・検索インデックス・ファイルアップロードのツールはあるが、**固定ページを操作するツールが無い**。SDK 移植の完了後に追加する(新 SDK の作法で最初から書けるため、移植との二重作業を避ける)。 + +### 12.1 データ構造の注意点 + +固定ページは `pages` テーブルと `contents` テーブルの複合構造であり、**名前が紛らわしい**点に注意する。 + +| 保存先 | 意味 | +|---|---| +| `pages.contents` | **ページ本文**(HTML) | +| `pages.content`(`Contents` アソシエーション) | **コンテンツ情報**(タイトル・URL・公開状態・親フォルダ) | + +`PagesService::create()` に渡す構造は次のとおり(`baser-core` の `PagesControllerTest::testAdd()` で確認済み)。 + +```php +[ + 'contents' => '

本文

', + 'page_template' => '', + 'content' => [ + 'title' => 'ページタイトル', + 'name' => 'about', + 'parent_id' => 1, + 'site_id' => 1, + 'plugin' => 'BaserCore', + 'type' => 'Page', + 'self_status' => true, + ], +] +``` + +`plugin` は `'BaserCore'`、`type` は `'Page'` の固定値であり、ツール側で自動的に補う(AI クライアントに指定させない)。 + +### 12.2 提供するツール + +`src/Mcp/BaserCore/PagesTool.php` を新規作成し、`BaserCoreServer::getToolClasses()` に登録する。 + +| ツール | 対応するサービス | 権限チェック用 URL | +|---|---|---| +| `getPages` | `PagesService::getIndex()` | `GET /baser-core/pages/index.json` | +| `getPage` | `PagesService::get()` | `GET /baser-core/pages/view/{id}.json` | +| `addPage` | `PagesService::create()` | `POST /baser-core/pages/add.json` | +| `editPage` | `PagesService::update()` | `POST /baser-core/pages/edit/{id}.json` | +| `deletePage` | `PagesService::delete()` | `POST /baser-core/pages/delete/{id}.json` | + +### 12.3 引数設計 + +既存ツールと同様に、AI クライアントが扱いやすいフラットな引数にし、内部で上記の入れ子構造へ組み立てる。 + +`addPage` の引数。 + +| 引数 | 対応先 | 説明 | +|---|---|---| +| `title`(必須) | `content.title` | ページタイトル | +| `content` | `pages.contents` | ページ本文(HTML) | +| `name` | `content.name` | URL のスラッグ(省略時は baserCMS が自動採番) | +| `parentId` | `content.parent_id` | 親フォルダのコンテンツID(省略時はサイトルート) | +| `siteId` | `content.site_id` | サイトID(省略時は 1) | +| `status` | `content.self_status` | 公開状態(0: 非公開, 1: 公開。省略時は 0) | +| `description` | `content.description` | 説明 | +| `publishBegin` / `publishEnd` | `content.publish_begin` / `publish_end` | 公開期間 | +| `pageTemplate` | `pages.page_template` | ページテンプレート | +| `eyeCatch` | `content.eyecatch` | アイキャッチ画像(外部画像 URL を直接指定) | + +引数名の `content`(本文)と保存先の `content`(コンテンツ情報)が紛らわしいため、**実装時のコメントで対応関係を明示する**。 + +`editPage` は `id`(必須)+上記の任意項目。`deletePage` は `id` のみ。`getPages` は `keyword` / `siteId` / `status` / `limit` / `page`。`getPage` は `id`。 + +**`loginUserId` は `inputSchema` に公開せず、`McpContext` から取得する**(他ツールと同じ方針)。 + +**サイトの指定は必須情報として扱う。** 固定ページは Content が必須で、Content にはサイトの指定が必須である(`ContentsTable` の `site_id` は `notEmptyString`)。したがって「どのサイトに作るのか」は必ず必要な情報であり、ID を決め打ちしない。`siteId` が省略された場合は `SitesTable::getRootMain()` でメインサイトを解決し、解決できなければエラーを返す。`parentId` も同様に、省略時は指定されたサイトのルートを解決する。 + +### 12.4 実装で判明した baserCMS の作法(2026-08-12 実測) + +固定ページの保存はコンテンツ管理の仕組みと深く絡んでおり、ブログ記事より前提が多い。実装時に判明した点を記録する。 + +| 事象 | 原因と対処 | +|---|---| +| `Record not found in table 'sites'` で保存に失敗する | **真因はテストDBに残っていた前回実行のデータ**だった。`InitAppScenario` の `SiteFactory` が一意制約に衝突して失敗し、`sites` が空のまま `PagesTable::createSearchIndex()` の `$this->Sites->get($content->site_id)` が走ったため。当初「ログイン状態が必要」と記述したが、**実験(ログインなし・Router のリクエストなしでも成功)により誤りと確認した**。なお固定ページは保存中の Content エンティティから `Sites->get()` を呼ぶ一方、ブログ記事は `blog_content_id` から既存の Content を引くだけで `Sites->get()` を呼ばないため、この経路の問題は固定ページ固有である | +| `Node '1' was not found in the tree.` | `ContentFactory` で作ったノードは `lft` / `rght` が整合しない。`RootContentScenario` を読んだ後に **`Contents->recover()`** でツリーを再構築する必要がある(`MultiSiteScenario` も同じことをしている) | +| 追加したページが `getPages` の一覧に出てこない | `PagesService::createIndexConditions()` が検索値を検査せずキーの存在だけで LIKE 条件を付けており、`getIndex()` の既定値 `draft => null` により常に `draft LIKE '%%'` が付いていた。SQL の `NULL LIKE '%%'` が偽となるため `draft` が NULL の行が必ず除外される。**baser-core 側のバグとして 5.4.x で修正済み**(回帰テストも追加)。bc-mcp 側の回避策(`draft` を空文字で保存)は不要になったため削除した | +| `deletePage` の挙動 | **解決。** `PagesService::delete()` は**完全削除**であり、`pages` と紐づく `contents` のレコードがいずれも消える(`withDeleted` を付けてもゴミ箱に残らない)。`baser-core` の `PagesServiceTest::testDelete()` が期待する挙動と一致する。当初「ゴミ箱へ移動」と想定したのは誤りだった | +| テスト間で一意制約に衝突する(`Duplicate entry '1' for key 'users.PRIMARY'`) | **解決。** 真因は**テストDBに前回実行の残骸が溜まっていたこと**であり、fixture 戦略の限界ではなかった。DB を掃除し vendor を `composer.lock` に揃えた環境では発生しない。念のため `setUp()` で `BcTestCase::truncateTable()` を呼び、関係テーブルを明示的に空にしている | +| エラーが MCP の `isError` にならない | `BaseMcpTool::executeWithErrorHandling()` が例外を捕まえて `createErrorResponse()` の戻り値(`content` キーにメッセージ)として返すため、SDK レベルでは正常な戻り値になる。**移植前からの設計**であり本移植では変更していないが、AI クライアントがエラーを検知しにくいという課題は残る | + +なお `BcTestCase::setFixtureTruncate()` は宣言のみで実際に `FixtureStrategy` を切り替えるコードが存在せず機能していなかった(旧 Fixture から FixtureFactory への移行期間中の残骸)。**5.4.x で削除済み**。 + +## 13. 完了条件と達成状況(2026-08-13 確認) + +| 条件 | 状況 | 根拠 | +|---|---|---| +| Modern(`2026-07-28`)と Legacy の両世代で `tools/list` → `tools/call` が通る | **達成** | `DualEraTest`(6 tests)。Legacy は `initialize` → セッションID → `tools/call` の正規フローを検証 | +| 常駐プロセスを起動しなくても `/bc-mcp` が応答する | **達成** | `McpProxyControllerTest` / `OAuth2ControllerTest`(統合テストから常駐サーバー起動処理を削除して通過) | +| 既存の bc-mcp テストがすべて通り、フルスイートに回帰がない | **達成** | bc-mcp: 218 tests / 1011 assertions。フルスイート: **4810 tests / 10544 assertions、失敗・エラー 0 件**(CakePHP 5.2.15 環境で検証。Skipped 2 / Incomplete 440 はいずれも移植前から存在する既存の状態) | +| `logs/mcp.log` から接続クライアントの世代とプロトコルバージョンが判別できる | **達成** | `NegotiationLoggerTest`(8 tests)。実ログでも確認(`era=modern protocolVersion=2026-07-28 client=…`) | +| 許可外 `Origin` からのリクエストが 403 で拒否される | **達成** | `McpProxyControllerTest` | +| 認可レスポンスに `iss` が含まれ、メタデータの `issuer` と一致する | **達成** | `OAuth2UtilTest` および `OAuth2ControllerTest`(実際の認可フローで一致を検証) | +| `vendor/php-mcp` への依存が残っていない | **達成** | `composer.json` / ソース全体の grep で参照ゼロ | +| `McpServerManger` と管理画面の起動/停止 UI が削除されている | **達成** | ファイル削除・ルート削除。grep で参照ゼロ | +| 固定ページの取得・作成・編集・削除がツールとして提供される | **達成** | `PagesToolTest`(6 tests / 32 assertions)。`tools/list` に5ツールが並ぶ事も `McpServerTest` で検証 | + +### 補足: stdio 起動の確認 + +`bin/cake bc_mcp.server` はこの開発環境では**コマンドとして登録されない**。BcMcp が `defaultInstallCorePlugins` に含まれず有効化されていないためで、**移植前から同じ状態**である(移植前の `OAuth2ControllerTest` にも同じ事情のコメントが残っていた)。`McpServer` 自体はプロセス内実行の各テストで動作を確認済み。stdio 経路の実起動確認はプラグインを有効化した環境で別途行う。 diff --git a/docs/superpowers/specs/2026-08-12-mcp-2026-07-28-bc-mcp-impact.md b/docs/superpowers/specs/2026-08-12-mcp-2026-07-28-bc-mcp-impact.md new file mode 100644 index 0000000000..161543d7d8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-mcp-2026-07-28-bc-mcp-impact.md @@ -0,0 +1,235 @@ +# MCP 仕様 2026-07-28 の bc-mcp への影響調査 + +- 調査日: 2026-08-12 +- 対象: `plugins/bc-mcp`(BcMcp / baserCMS コアプラグイン) +- 結論: **今回は対応を行わない。本ドキュメントを記録として残し、Claude が Modern 世代へ切り替わった時点で改めて着手する。** + +## 1. 調査の背景 + +2026-07-28、MCP(Model Context Protocol)の新リビジョン `2026-07-28` が最終仕様として公開され、Anthropic から Claude 各製品への展開が告知された。 + +- Anthropic 告知: +- 仕様 changelog: + +この仕様変更が baserCMS のコアプラグイン `bc-mcp` に与える影響と、対応の要否を確認した。 + +## 2. 仕様変更の要点 + +このリビジョンの中心は「**プロトコルのステートレス化**」であり、旧リビジョンとは互換性のない**別世代**として定義されている。仕様書自身が両者を次の用語で区別している。 + +| 用語 | 定義 | +|---|---| +| **Modern** | バージョン・識別情報・capability をリクエストごとのメタデータで運ぶリビジョン(`2026-07-28` 以降) | +| **Legacy** | `initialize` ハンドシェイクでセッションを確立するリビジョン(`2025-11-25` 以前) | +| **Dual-era** | Modern と Legacy の両方をサポートする実装 | + +### 2.1 破壊的変更 + +| 変更 | 内容 | +|---|---| +| `initialize` 廃止 | `initialize` / `notifications/initialized` のハンドシェイクが消滅。各リクエストの `_meta` に `io.modelcontextprotocol/protocolVersion` / `clientCapabilities` / `clientInfo` を載せる | +| `server/discover` 新設 | サーバーは **MUST 実装**。対応プロトコルバージョン・capabilities・identity を返す | +| セッション廃止 | `Mcp-Session-Id` ヘッダとプロトコルレベルのセッションを削除。`tools/list` 等の一覧は接続ごとに変化しない | +| GET ストリーム廃止 | スタンドアロン SSE ストリーム(HTTP GET)と `resources/subscribe` を廃止し、`subscriptions/listen` に統合 | +| SSE 再開の廃止 | `Last-Event-ID` とイベント ID による再送を削除。ストリーム断は新しいリクエスト ID での再送で対応 | +| `resultType` 必須 | 全 result に `"complete"` / `"input_required"` を付与 | +| 必須ヘッダ | POST に `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name`。ヘッダとボディの不一致は 400 + `-32020`(`HeaderMismatch`)で拒否が MUST | +| キャッシュ項目必須 | `tools/list` 等の結果に `ttlMs` / `cacheScope`(`CacheableResult`)を必須化 | +| エラーコード再編 | `-32000`〜`-32019` を実装定義、`-32020`〜`-32099` を仕様予約に分割。`UnsupportedProtocolVersion` = `-32022`、`HeaderMismatch` = `-32020`、resource not found は `-32602` へ | +| `ping` 等の削除 | `ping` / `logging/setLevel` / `notifications/roots/list_changed` を削除。ログレベルは `_meta` の `io.modelcontextprotocol/logLevel` でリクエストごとに指定 | +| MRTR 導入 | sampling / elicitation / roots のサーバー起点リクエストを廃止し、`InputRequiredResult` + クライアント再送(Multi Round-Trip Requests)へ置き換え | +| Tasks の拡張化 | コアから `io.modelcontextprotocol/tasks` 拡張へ分離。capabilities に `extensions` フィールドを新設 | + +### 2.2 非推奨化 + +最低12ヶ月の非推奨期間を定めた feature lifecycle ポリシーが導入された上で、次が非推奨となった。 + +- Roots / Sampling / Logging +- HTTP+SSE トランスポート(`2024-11-05` 由来) +- **OAuth 2.0 動的クライアント登録(DCR / RFC 7591)** → Client ID Metadata Documents 推奨 + +### 2.3 認可の強化 + +- 認可レスポンスへの `iss` パラメータ(RFC 9207)付与(認可サーバー側 SHOULD、クライアント側は検証が MUST) +- DCR 利用時の `application_type` 指定要求 +- クライアント資格情報を発行元 issuer にひも付けて管理する要求 + +## 3. bc-mcp の現状 + +### 3.1 アーキテクチャ + +``` +Claude / ChatGPT + → POST /bc-mcp McpProxyController(OAuth2 検証・権限チェック) + → POST http://127.0.0.1:{port}/ 常駐プロセス(php-mcp/server, StreamableHttpServerTransport, stateless: true) + → Registry / Dispatcher 各 *Tool クラス → baserCMS の Service 層 +``` + +管理画面(`McpServerManager`)から常駐プロセスを起動・停止する構成になっている。 + +### 3.2 世代の判定 + +**bc-mcp は完全な Legacy 世代サーバーである。** + +- 依存する `php-mcp/server` 3.3.0 は `LATEST_PROTOCOL_VERSION = '2025-03-26'`、サポートは `2025-03-26` / `2024-11-05` のみ(`vendor/php-mcp/server/src/Protocol.php`)。 +- `Protocol` / `Session` / `Dispatcher::handleInitialize` はいずれも `initialize` ハンドシェイク前提。 +- bc-mcp は `McpProxyController::sendMcpRequest()` で `initialize` のレスポンスを `2025-06-18` に書き換え、capabilities も `resources` / `prompts` を `listChanged: true` と申告している(実体は tools のみ)。 + +## 4. Modern 準拠に必要な項目(対照表) + +### A. プロトコル基盤 + +| # | 要件 | 現状 | +|---|---|---| +| A1 | `server/discover` を実装(MUST) | 未実装。php-mcp/server にも存在しない | +| A2 | 各リクエストの `_meta` から protocolVersion / clientCapabilities / clientInfo を読む | `initialize` 前提。応答を偽装している | +| A3 | 全 result に `resultType: "complete"` を付与 | 未対応 | +| A4 | 未対応バージョン → 400 + `-32022`(`data.supported` / `data.requested`) | 未対応。バージョン検証自体がない | +| A5 | 未実装メソッド → HTTP 404 + `-32601`(旧 HTTP+SSE サーバーとの区別のため 404 が要件) | 未対応 | +| A6 | JSON-RPC 通知の POST → 202 Accepted・ボディなし | 判定が `getData('method')` で本文を読めておらず実質機能していない | + +### B. Streamable HTTP トランスポート + +| # | 要件 | 現状 | +|---|---|---| +| B1 | `MCP-Protocol-Version` 必須、`_meta` の値と一致必須 | ヘッダは読むが検証なし。無ければ `2025-06-18` を既定に | +| B2 | `Mcp-Method` 必須(`method` と一致) | 未対応 | +| B3 | `Mcp-Name` 必須(`tools/call` は `params.name`、`resources/read` は `params.uri`)。`=?base64?…?=` はデコード後に比較 | 未対応 | +| B4 | 不一致・欠落・不正文字 → 400 + `-32020` | 未対応 | +| B5 | `x-mcp-header` 使用時は `Mcp-Param-*` も検証。未知の `Mcp-Param-*` は転送して無視 | 未使用のため対応不要 | +| B6 | **`Origin` ヘッダ検証 → 不正なら 403**(DNS リバインディング対策) | **未実装**。`Access-Control-Allow-Origin: *` のみ | +| B7 | 応答は `application/json` か `text/event-stream`。SSE 上でサーバー起点 request を送らない | JSON 固定のため実質適合 | +| B8 | SSE 応答ストリームのクローズ=キャンセル扱い | SSE 未使用のため該当せず | +| B9 | (Modern 専用時)GET / DELETE → 405、`Mcp-Session-Id` / `Last-Event-ID` は無視 | GET に 200 でダミー JSON を返している | + +### C. 結果スキーマ + +| # | 要件 | 現状 | +|---|---|---| +| C1 | `tools/list` 等の結果に `ttlMs` / `cacheScope` が必須 | 未対応 | +| C2 | `_meta` に logLevel の無いリクエストに `notifications/message` を出さない(MUST NOT) | ログ通知未使用のため適合 | +| C3 | resource not found を `-32602` へ | resources 未提供のため該当せず | +| C4 | capabilities を実態どおりに申告(`extensions` フィールド新設) | **`resources` / `prompts` を `listChanged: true` と虚偽申告中** | +| C5 | `tools/list` を決定的順序で返す(SHOULD、クライアント側キャッシュとプロンプトキャッシュ効率のため) | 未考慮 | + +### D. 認可(bc-mcp は認可サーバーも兼任) + +| # | 要件 | 現状 | +|---|---|---| +| D1 | 認可レスポンスに `iss`(RFC 9207)を含める(SHOULD) | 未対応。`league/oauth2-server` 8.5 にも機能がないため自前付与が必要 | +| D2 | DCR 非推奨 → Client ID Metadata Documents 推奨(DCR は互換のため残置可) | DCR のみ実装。当面は互換扱いで可 | +| D3 | リソースサーバーとしての PRM / `WWW-Authenticate` | **実装済み** | + +### E. 影響なし(未使用のため対応不要) + +MRTR / sampling / elicitation / roots / Logging / Tasks 拡張 / `subscriptions/listen` / `Mcp-Session-Id` 廃止(既に `stateless: true`)/ `Last-Event-ID` 廃止 / inputSchema の JSON Schema 2020-12 緩和。 + +## 5. 「今すぐ対応しない」と判断した根拠 + +### 5.1 Claude は Dual-era クライアントとして動作している(実測) + +仕様の互換性マトリクスでは「Dual-era クライアント × Legacy サーバー = Works」であり、Modern クライアントは旧サーバーに到達すると `initialize` へフォールバックする。 + +この点は実測で確認した。**2026-08-12(仕様公開から2週間後)の時点で、claude.ai の本番クライアントから Legacy 実装のまま(bc-mcp と同型コードの CuMcp)のサーバーへツール呼び出しが成功している。** + +``` +serverInfo → basercms_version: 5.2.4 / cakephp_version: 5.0.11 / server_time: 2026-08-12 16:07:38 +``` + +一方、互換性マトリクス上「**Modern クライアント × Legacy サーバー = Fails**」であるため、Claude が Modern 専用に切り替えた時点で通信不能になる。**期限には追われていないが、無期限に放置できるものでもない。** + +### 5.2 上流ライブラリに頼れない + +- Tier 1 SDK(TypeScript / Python / Go / C#)は仕様公開日に `2026-07-28` 対応済み。**PHP は Tier 1 に含まれない。** +- `php-mcp/server` は最終リリース 3.3.0(2025-07-12)で `2025-03-26` 止まり。「6ヶ月間リリースがない」旨の issue が立ち、リポジトリは issue 作成が制限された状態。 +- したがって「上流の Modern 対応を待つ」「fork して本家へのマージを図る」戦略は、baserCMS のリリース計画を他リポジトリの停滞に依存させることになり採用しがたい。 + +### 5.3 したがって + +Modern 対応は「現在の依存パッケージの更新を待つ」形では実現しない。今すぐ壊れない状況で先行して着手する費用対効果は低いため、今回は記録に留める。 + +なお、この判断の後に行った代替パッケージ調査(第8章)で、**Dual-era 対応済みの `logiscape/mcp-sdk-php` v2 が存在する**ことが分かった。したがって「プロトコル層を自前実装しなければならない」という前提は成り立たず、再着手時のコストは当初の見立てより小さい。それでも「今すぐ通信不能になるリスクがない」(5.1)ことと、SDK 移植が相応の規模になることから、結論そのものは維持する。 + +## 6. 将来 Modern 対応を行う場合の見通し + +再着手する際の出発点として、調査時点での見立てを残す。 + +### 6.1 実装方式の候補 + +| 方式 | 評価 | +|---|---| +| **SDK を `logiscape/mcp-sdk-php` v2 に乗り換える** | **最有力**。Dual-era 対応済みのため、プロトコル層を自前実装せずに済む(第8章参照)。ツール定義の書き換えが必要 | +| bc-mcp 内にプロトコル層を自前実装し、ツール実行のみ php-mcp/server の `Registry` / `Dispatcher::handleToolCall` に委譲 | 次善。ツール定義(各 `*Tool` クラス)を一切変更せずに済む。`Registry` / `handleToolCall` はツール登録・スキーマ検証・引数マッピング・実行を担い、いずれも世代非依存。ただしプロトコル層の実装と保守を自前で抱える | +| php-mcp/server を fork して Modern 対応 | 非推奨。上流の停滞に依存する(5.2 参照) | +| php-mcp/server 依存を全廃して完全自前実装 | 工数最大。乗り換え先がある以上、選ぶ理由が乏しい | + +### 6.2 アーキテクチャ上の好機 + +Modern はプロトコルレベルでステートレスになったため、**常駐 MCP サーバープロセス(`McpServerManger` / `McpServerCommand` / 管理画面の起動・停止・死活監視)が原理的に不要**になる。CakePHP のリクエスト内で `Dispatcher` を直接呼ぶ構成に置き換えられ、運用が大幅に簡素化する。 + +この経路が実際に成立することは、`plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php` が実証している(別プロセスを起動せず、`Dispatcher::handleToolCall()` をプロセス内で実行して `addBlogPost` が通ることを確認済み)。 + +### 6.3 Modern 対応と独立に着手できる項目 + +世代の切り替えを待たずに単独で価値がある項目。 + +1. **ネゴシエーション内容のロギング** — 現状 `logs/mcp.log` にはリクエスト URL と POST ボディだけが記録され、**MCP のネゴシエーション内容(protocolVersion / クライアント情報)が残っていない**。「Claude がいつ Modern に切り替えたか」を検知する手段が現時点で存在しないため、再検討トリガー(第7章)を機能させるにはこれが前提になる。 +2. **`Origin` ヘッダ検証**(B6) — Modern 固有ではなく `2025-03-26` 以来の MUST。DNS リバインディング対策として現状も未実装。 +3. **capabilities の虚偽申告の修正**(C4) — `resources` / `prompts` を `listChanged: true` と申告しているが実体は tools のみ。 +4. **`iss` パラメータの付与**(D1) — Modern クライアントは `iss` があれば検証が MUST。付与しておく方が安全側。 + +## 7. 再検討のトリガー + +次のいずれかを観測した時点で本ドキュメントを見直す。 + +- Claude / ChatGPT のクライアントが `2026-07-28` を要求し、Legacy フォールバックをしなくなる(またはその予告が出る) +- Anthropic が旧プロトコルバージョンのサポート終了日を公表する +- `php-mcp/server` に Modern 対応が入る、または PHP 向けの実用的な代替 SDK が現れる +- bc-mcp 側で MCP Apps / Tasks などの拡張機能を提供したくなる(Modern 前提の機能群) + +## 8. 依存パッケージの代替調査 + +`php-mcp/server` と `league/oauth2-server` について、後方互換性を含めた代替候補を調査した。 + +### 8.1 MCP サーバー SDK + +| パッケージ | `2026-07-28` | Legacy との後方互換 | 安定度・メンテ状況 | 依存 | +|---|---|---|---|---| +| `php-mcp/server` **3.3.0(現用)** | ✗ `2025-03-26` 止まり | Legacy のみ | 最終リリース 2025-07-12。「6ヶ月リリースなし」issue、issue 作成制限あり | やや多い | +| **`logiscape/mcp-sdk-php` v2.0.0** | **✓ 仕様公開日(2026-07-28)に安定版リリース** | **✓ Dual-era ネゴシエーション**(1コードベースで Modern と `2024-11-05`〜`2025-11-25` を同時提供。サーバーはリクエストごとに世代を判定、クライアントは probe → フォールバック) | 安定版。MCP Conformance Tests(必須項目)100% パス。v1 は `1.x` ブランチで継続サポート。MIT。インストール 145,887 / スター 367 | `php >=8.1` / `ext-curl` / `ext-json` / `psr/log` のみ。フレームワーク非依存 | +| `mcp/sdk`(公式 / PHP Foundation + Symfony) | 明示的な記述なし(README・ROADMAP ともプロトコルバージョンの明記なし) | Symfony の Backward Compatibility Promise に準拠と記載 | **experimental**(「最初のメジャーリリースまで実験的」と明記)。v0.7.0(2026-07-14)。インストール 245万超 / スター 1,576 | `php ^8.1` | +| `nexusphp/mcp` | `2026-07-28` を追随と表記 | 不明 | 情報が少なく採用実績も小規模 | 不明 | + +**評価** + +- **`logiscape/mcp-sdk-php` v2 が現時点の最有力**。「Dual-era を1コードベースで賄う」という要件が bc-mcp の状況(Claude の切り替え時期が読めない)に正面から合致し、第4章の MUST 項目(`server/discover`・必須ヘッダ検証・`resultType`・`ttlMs`/`cacheScope`・エラーコード)を SDK 側で担保できる。自前でプロトコル層を実装・保守する必要がなくなる。 +- ただし **API は `php-mcp/server` と互換ではない**ため、各 `*Tool` クラスのツール登録処理(`addToolsToBuilder()`)を書き換える移植作業が発生する。ツールのビジネスロジック(baserCMS Service 層の呼び出し)は流用できる。 +- `mcp/sdk`(公式)は**中長期的な本命候補**。PHP Foundation と Symfony の協業で採用も伸びているが、現時点では自ら experimental / pre-1.0 を宣言しており、`2026-07-28` 対応も明示されていない。**1.0 リリース時に再評価する。** + +### 8.2 OAuth 2.0 認可サーバー + +- bc-mcp が使用しているのは **`league/oauth2-server` 8.5.5**。本家は **9.x 系**が出ており(Device Authorization Grant の追加、PHP 8.5 対応など)、メンテナンスは継続している。**乗り換えではなく 9系へのアップデートが筋。** +- PHP における OAuth 2.0 **認可サーバー**の実用的な実装は `league/oauth2-server` がほぼ唯一。他は次のとおりで、代替とはならない。 + - `bshaffer/oauth2-server-php` — 事実上の旧世代 + - Laravel Passport / `thephpleague/oauth2-server-bundle` — いずれも `league/oauth2-server` のラッパー + - `league/oauth2-client` — クライアント側ライブラリであり用途が異なる +- **RFC 9207(`iss` パラメータ)と Client ID Metadata Documents は、league 9系でも明示的な対応がない。** したがってこの2点は SDK 側の対応を待つのではなく、bc-mcp 側で実装する必要がある。`iss` は認可レスポンスのリダイレクト URL にクエリを1つ足すだけなので軽微。 + +### 8.3 この調査が方針に与える影響 + +第5章では「Modern 対応は bc-mcp 側でプロトコル層を自前実装する規模の作業になる」ことを、対応を先送りする根拠の一つとした。**`logiscape/mcp-sdk-php` v2 の存在により、この前提は変わる**(プロトコル層の自前実装は不要、必要なのは SDK 移植)。 + +一方で、次の判断は変わらない。 + +- Claude が Dual-era として動作しており、**現時点で通信不能になるリスクはない**(5.1 の実測) +- SDK 移植は `*Tool` クラス群と `McpProxyController` に及ぶ相応の規模の作業であり、着手時期はリリース計画と合わせて決めるべきもの + +したがって「今回は記録に留める」という結論自体は維持し、再着手時の第一候補を `logiscape/mcp-sdk-php` v2 への移植として本ドキュメントに記録する。 + +## 9. 参照 + +- Anthropic 告知: +- 仕様 changelog: +- Streamable HTTP: +- バージョニングと互換性: +- feature lifecycle ポリシー: diff --git a/docs/superpowers/specs/2026-08-17-bc-mcp-scope-design.md b/docs/superpowers/specs/2026-08-17-bc-mcp-scope-design.md new file mode 100644 index 0000000000..95c0f74943 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-bc-mcp-scope-design.md @@ -0,0 +1,213 @@ +# bc-mcp のスコープと方針 + +作成日: 2026-08-17 + +## 1. 背景と目的 + +bc-mcp は MCP 2026-07-28 対応([SDK 移植設計書](2026-08-12-bc-mcp-sdk-migration-design.md))を完了したが、**プラグインとして何を提供し何を提供しないか**が文書化されていなかった。そのため機能追加の可否を判断する軸がなく、次の状態が生じていた。 + +- `search` / `fetch`(`SearchIndexesTool`)が無効化されたまま放置されている +- `sendFileChunk`(`FileUploadTool`)が無効化される一方、受け取り側の `processChunkFile()` だけが残っている +- 認証と権限を通らない stdio 経路が、HTTP 経路と同じツール群を公開している +- ツールが読み取り専用か破壊的かをクライアントへ伝える手段を持たない + +本書はスコープを定義し、上記を解消する。実装計画は別途作成する。 + +## 2. 位置づけと原則 + +**bc-mcp は、baserCMS を運営する人のための、認証付き MCP サーバーである。** + +### 原則1: 客層は運営者に限定する + +一般ユーザー(サイト訪問者)向けの機能は扱わない。運営者向けと一般ユーザー向けでは、必要な認証も、見せてよいデータも、権限の考え方も異なる。1つのエンドポイントに混在させると、運営用ツールが公開側へ漏れる事故を構造的に防げなくなる。 + +判断基準は「この機能は、ログインした運営者が権限の範囲で行う操作か?」である。 + +### 原則2: 権限は Admin Web API に委ねる + +独自の権限体系は作らない。各ツールは対応する Admin Web API の URL を `getPermissionUrl()` で宣言し、baserCMS のアクセスルールで制御する。**管理画面でできないことは MCP でもできない**という対応関係を保つ。 + +この原則から、**認証と権限チェックを通らない経路を設けない**ことが導かれる。 + +### 原則3: 特定クライアントの都合に合わせない + +単一ベンダーが要求するツール名・レスポンス形式には追従しない。MCP 標準の範囲で表現し、解釈はクライアントに委ねる。 + +ただし**標準化された拡張には追従する**。複数ベンダーが参加する仕様(ツール注釈、SEP-2631 の `x-mcp-file` など)は対象とする。 + +### 原則4: 機能追加は要望ベース + +Admin Web API には40以上のリソースがあるが、網羅を目的にしない。現在の11リソース(固定ページ・ブログ・カスタムコンテンツ)を起点に、実際の要望に応じて広げる。 + +## 3. ツール構成の変更 + +### 3.1 削除するもの + +| 対象 | 根拠 | +|---|---| +| `SearchIndexesTool`(`search` / `fetch`)とテスト | 原則1(客層が違う)、原則3(単一ベンダー形式) | +| `FileUploadTool`(`sendFileChunk`)とテスト | チャンク方式は標準化の議論でも見送られた。ホストが未対応(5.2 参照) | +| `BaseMcpTool::processChunkFile()` | 上記の受け取り側。送る手段が消えるため対で削除 | +| `BaserCoreServer` のコメントアウト行 | 死んだ選択肢を残さない | + +**公開ツール数は51のまま変わらない。** これらは元々 `BaserCoreServer::getToolClasses()` に登録されていないため、消えるのは「無効なのに残っているコード」である。 + +### 3.2 stdio トランスポートの削除 + +`PermissionManager` を呼ぶのは `McpProxyController` のみで、**HTTP 経路にしか権限チェックが存在しない**。`bin/cake bc_mcp.server` は次を全て素通りする。 + +- OAuth 認証 +- 権限チェック +- Origin 検証 +- ログインユーザーの設定(`McpContext` が空のまま実行される) + +シェルに触れる者が、誰の権限でもない状態で全ツールを実行できる経路であり、原則2 と矛盾する。SDK 移植設計書では「ローカルの stdio クライアント用途として残す」としていたが、本書で判断を覆す。 + +削除対象: + +| ファイル | 内容 | +|---|---| +| `src/Command/McpServerCommand.php` | 全体削除 | +| `tests/TestCase/Command/McpServerCommandTest.php` | 全体削除 | +| `src/BcMcpPlugin.php` の `console()` | `bc_mcp.server` の登録を削除。`Oauth2CleanupCommand` は自動探索されるため `console()` 自体が不要になる | +| `src/Mcp/McpServer.php::runStdio()` | 削除 | +| `src/Mcp/McpServer.php` の `available_transports` | `['stdio', 'http']` → `['http']` | +| `src/Mcp/BaseMcpTool.php` の stdio に言及するコメント | 実態に合わせる | + +管理画面の「MCPサーバー管理」は `McpRequestHandler` をプロセス内で呼んでツール一覧を取得しているため、影響を受けない。 + +### 3.3 ファイルアップロードの整理 + +`BaseMcpTool::processFileUpload()` は3分岐である。 + +```php +if (strpos($fileData, 'data:') === 0) → processBase64File() // インライン +if (preg_match('/^https?:\/\//', $fileData)) → processUrlFile() // URL +else → processChunkFile() // チャンク +``` + +`data:` URI は SEP-2631 が `transferModes` の一つとして認める inline 方式そのものであり、標準の方向と一致するため残す。削除するのはチャンク分岐のみで、結果は **URL と `data:` URI の2方式**となる。 + +将来 SEP-2631 が確定した際は、この2方式に `x-mcp-file` の宣言を被せる形で移行できるため、手戻りにならない。 + +### 3.4 揃えるもの + +`CustomEntriesTool` に `keyword` を追加する。`PagesTool` と `BlogPostsTool` には存在し、ここだけ欠けている。横断検索を作らない判断(5.1)の前提として、各ツールの絞り込みが揃っている必要がある。 + +## 4. ツール注釈 + +クライアントが読み取り専用ツールと破壊的ツールを区別できるよう、全ツールに MCP のツール注釈を宣言する。Claude の Research ではツール呼び出しに都度承認が入らないため、区別できる情報を提供する意味がある。 + +SDK の `ToolAnnotations` は `title` / `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint` を持つ。 + +| 接頭辞 | readOnly | destructive | idempotent | openWorld | 根拠 | +|---|---|---|---|---|---| +| `get*` / `serverInfo` | true | — | — | false | 読み取りのみ。他のヒントは意味を持たない | +| `add*` | false | false | false | false | 追加のみ。繰り返すと重複が増えるため冪等でない | +| `edit*` | false | true | true | false | 既存データを上書きする。同じ引数なら同じ結果 | +| `delete*` | false | true | true | false | 破壊的。削除済みを再度消しても結果は同じ | + +`openWorldHint` は全て `false` とする。操作対象が自サイトのデータに閉じているため。 + +実装は `BaseMcpTool` に定数として持たせ、各 `tool()` 呼び出しで明示指定する。 + +```php +protected const ANNOTATION_READ = ['readOnlyHint' => true, 'openWorldHint' => false]; +protected const ANNOTATION_CREATE = ['readOnlyHint' => false, 'destructiveHint' => false, 'idempotentHint' => false, 'openWorldHint' => false]; +protected const ANNOTATION_UPDATE = ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => true, 'openWorldHint' => false]; +protected const ANNOTATION_DELETE = ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => true, 'openWorldHint' => false]; +``` + +ツール名から自動判定はしない。命名規則から挙動を推測する仕組みは、規則を外れたツールが増えたときに静かに間違うため。代わりに全数走査のテスト(6.1)で付け忘れを検出する。 + +## 5. 将来構想 + +今回は実装しない。**何が揃ったら再検討するか**を記録する。 + +### 5.1 一般ユーザー向け公開エンドポイント + +**着手条件**: 発見規約の確定。 + +MCP 2026-07-28 のステートレス化により、公開コンテンツサイトが未認証の MCP エンドポイントを出すことが現実的になった。仕様も公開層と保護層の2モデル併存を前提としており、認証は任意である(管理操作には強く推奨)。 + +一方で、**サイトの公開エンドポイントをエージェントが自動発見する規約は複数案が並走中**である。 + +| 提案 | 状態(2026-08 時点) | +|---|---| +| SEP-2127(`.well-known/mcp.json` の Server Cards) | PR | +| Issue #1960(`.well-known/mcp`) | 提案 | +| IETF `draft-serra-mcp-discovery-uri` | Draft 04 | + +現時点で公開エンドポイントを出しても発見される手段がない。ChatGPT Apps のような消費者向け MCP は既に大規模に稼働しているが、それはアプリのディレクトリ経由であり、エージェントが任意のサイトを巡回しているわけではない。 + +なお **2026-07-28 対応で土台は既にある**。ステートレス化とキャッシュヒント(`ttlMs` / `cacheScope`)は SDK 経由で利用できる。着手時に作るのは認証なし経路と公開データに限定したツール群のみとなる。 + +CMS 同業(WordPress の MCP プラグイン群)も運営者向けに寄せており、公開エンドポイントを置かないことを利点として掲げる製品もある。 + +### 5.2 ファイルアップロード + +**着手条件**: SEP-2631 の確定 **かつ** ホスト(Claude / ChatGPT)のファイルピッカー対応。 + +根本のボトルネックは MCP でも SDK でもなく、**ファイルの生バイトをサーバーへ渡す手段をホストが持っていない**ことである。claude.ai に添付した画像はモデルへの視覚入力であり、モデルが同じバイト列を再出力できるわけではない。ローカルファイルを読める環境でも、100KB の画像で数万トークンとなり現実的でない。 + +MCP 公式に File Uploads Working Group が 2026-04-23 に発足しており(リード: Anthropic、メンバーに OpenAI)、憲章はこの穴を明示している。 + +> Today, servers that need a file from the user resort to prose instructions asking for base64 strings or local paths, which produces inconsistent UX and pushes encoding details onto end users. + +標準化の現在地: + +| SEP | 内容 | 状態 | +|---|---|---| +| SEP-2356 | 宣言的ファイル入力 | クローズ | +| **SEP-2631** | File Objects and Transfer | オープン(draft) | +| SEP-2532 | Resource Streaming(サーバー→クライアント) | オープン | + +SEP-2631 は `x-mcp-file` による宣言、`files/authorizeUpload` による帯域外転送、`transferModes` による方式指定の3本柱で、**チャンク分割は仕様から外されている**(完了マーキングと GC の仕組みが別途必要なため)。 + +`FileUploadTool` の30KBチャンク方式が破綻したのは実装の問題ではなく、迂回しようとした壁がホスト側にあったためである。 + +### 5.3 対象範囲の拡張 + +**着手条件**: 実際の要望。 + +Admin Web API のうち bc-mcp がカバーするのは11リソース。候補として `Contents` / `ContentFolders`(サイト構造・フォルダ・ゴミ箱)が最有力である。baserCMS がコンテンツをツリーで管理する以上、固定ページを作れてもサイト構造を操作できない点は空白として認識しておく。 + +## 6. テストとドキュメント + +### 6.1 テスト + +| 対象 | 内容 | +|---|---| +| 削除に伴う調整 | `SearchIndexesToolTest` / `FileUploadToolTest` / `McpServerCommandTest` を削除。`BlogPostsToolTest` の `mcp_uploads` を参照する箇所を URL / `data:` URI 方式へ置き換え | +| 注釈 | `tools/list` の応答に `annotations` が載ることを検証する。**種別ごとに1件ではなく全ツールを走査**し、接頭辞と注釈の対応が崩れていないことを確認する | +| `keyword` | `CustomEntriesTool` の絞り込みを検証 | + +注釈のテストを全数走査にするのは、ツール追加時の付け忘れを検出するためである。明示指定を選んだ以上、抜けを検出する仕組みが要る。 + +### 6.2 ドキュメント + +- **README**: アップロードの節を実態(URL と `data:` URI の2方式)に合わせる。Cloudflare Tunnel の節に Quick Tunnel の制約(同時200リクエスト、SSE 非対応、本番非推奨、URL が毎回変わる)を追記し、固定ホスト名が必要な場合の Named Tunnel(Cloudflare アカウントと所有ドメインが必要)を併記する +- **設計書**: 本書 + +## 7. スコープ外 + +以下は扱わない。要望があれば本書を改訂して判断する。 + +- 一般ユーザー(サイト訪問者)向けの機能 +- 横断検索(各一覧ツールの `keyword` で代替する) +- チャンク分割によるファイルアップロード +- MCP 独自の権限体系 +- Admin Web API の網羅 +- **HTTP 以外のトランスポート** — 認証と権限を通らない経路は設けない(原則2) + +## 付録: 出典 + +- [Understanding Authorization in MCP](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/authorization) — 認証は任意だが管理操作には強く推奨 +- [MCP 2026-07-28: the spec catches up with the static web](https://joost.blog/mcp-goes-stateless/) — ステートレス化と公開コンテンツ配信 +- [File Uploads Charter](https://modelcontextprotocol.io/community/working-groups/file-uploads) — WG の憲章と問題設定 +- [SEP-2631: File Objects and Transfer](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631) +- [SEP-2127: MCP Server Cards — HTTP Server Discovery via .well-known](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) +- [The "mcp" URI Scheme and MCP Server Discovery Mechanism](https://datatracker.ietf.org/doc/draft-serra-mcp-discovery-uri/04/) +- [Building MCP servers for ChatGPT and API deep research](https://developers.openai.com/api/docs/mcp) — `search` / `fetch` の要求形式 +- [Get started with custom connectors using remote MCP](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) — Research はツール呼び出しに承認を挟まない +- [TryCloudflare](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) — Quick Tunnel の制約 diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index 72059a696b..3aa51844e3 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -22,6 +22,7 @@ plugins/bc-front/src plugins/bc-installer/src plugins/bc-mail/src + plugins/bc-mcp/src plugins/bc-search-index/src plugins/bc-seo/src plugins/bc-theme-config/src diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ba6225414f..1a7cddbd84 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -35,6 +35,12 @@ plugins/bc-seo/tests/TestCase + + + plugins/bc-mcp/tests/TestCase + plugins/bc-installer/tests/TestCase diff --git a/plugins/baser-core/config/setting.php b/plugins/baser-core/config/setting.php index 3a09d7c4e5..52bd84a43c 100644 --- a/plugins/baser-core/config/setting.php +++ b/plugins/baser-core/config/setting.php @@ -271,6 +271,7 @@ 'BcEditorTemplate', 'BcFavorite', 'BcMail', + 'BcMcp', 'BcSeo', 'BcSearchIndex', 'BcThemeConfig', diff --git a/plugins/baser-core/tests/TestCase/BaserCorePluginTest.php b/plugins/baser-core/tests/TestCase/BaserCorePluginTest.php index 3098125ef8..2c0d13f837 100644 --- a/plugins/baser-core/tests/TestCase/BaserCorePluginTest.php +++ b/plugins/baser-core/tests/TestCase/BaserCorePluginTest.php @@ -352,6 +352,12 @@ public function testConsole() public function test_getSkipCsrfUrl() { $rs = $this->execPrivateMethod($this->Plugin, 'getSkipCsrfUrl', []); - $this->assertEquals(['/baser-core/users/login.json', '/baser-core/users/refresh_token.json'], $rs); + $this->assertEquals([ + '/baser-core/users/login.json', + '/baser-core/users/refresh_token.json', + '/bc-mcp', + '/bc-mcp/oauth2/*', + '/baser/admin/bc-mcp/oauth2/*' + ], $rs); } } diff --git a/plugins/baser-core/tests/TestCase/Service/BcDatabaseServiceTest.php b/plugins/baser-core/tests/TestCase/Service/BcDatabaseServiceTest.php index 1add58bee9..f2056f67db 100644 --- a/plugins/baser-core/tests/TestCase/Service/BcDatabaseServiceTest.php +++ b/plugins/baser-core/tests/TestCase/Service/BcDatabaseServiceTest.php @@ -979,6 +979,7 @@ private function test_deleteTablesForMigrations() 'BcEditorTemplate', 'BcFavorite', 'BcMail', + 'BcMcp', 'BcSearchIndex', 'BcThemeConfig', 'BcThemeFile', diff --git a/plugins/bc-installer/tests/TestCase/Service/Admin/InstallationsAdminServiceTest.php b/plugins/bc-installer/tests/TestCase/Service/Admin/InstallationsAdminServiceTest.php index 42f1cd9c96..d369785e06 100644 --- a/plugins/bc-installer/tests/TestCase/Service/Admin/InstallationsAdminServiceTest.php +++ b/plugins/bc-installer/tests/TestCase/Service/Admin/InstallationsAdminServiceTest.php @@ -465,6 +465,7 @@ public function test_deleteAllTables() 'BcEditorTemplate', 'BcFavorite', 'BcMail', + 'BcMcp', 'BcSearchIndex', 'BcThemeConfig', 'BcThemeFile', diff --git a/plugins/bc-mcp/README.md b/plugins/bc-mcp/README.md new file mode 100644 index 0000000000..d2ec77d617 --- /dev/null +++ b/plugins/bc-mcp/README.md @@ -0,0 +1,407 @@ +# BcMcp plugin for baserCMS + +baserCMS用のMCP(Model Context Protocol)サーバープラグインです。 +外部のAIツールやアプリケーションからbaserCMSのデータを操作することができます。 + +## 機能 + +- 固定ページの作成、取得、編集、削除 +- ブログ関連データの作成、取得、編集、削除 +- カスタムコンテンツ関連データの作成、取得、編集、削除 +- サーバー情報の取得 +- HTTP トランスポートサポート + +## 動作要件 +PHP 8.1 以降 +baserCMS 5.1.10 以降 + +## インストール + +### Composerを使用したインストール + +```bash +composer require ecatchup/bc-mcp --with-all-dependencies +``` + +### 手動インストール + +1. [baserマーケット](https://market.basercms.net) からダウンロード +2. `plugins/` ディレクトリ配下に配置 + +※ baserマーケット配布版は、依存しているパッケージを梱包いていますので、コマンドの実行が不要です。 + +## 設定 +### configフォルダの権限設定 +ルート直下の `config` フォルダと `.env` に書き込み権限が必要です。 + +```bash +chmod 777 config +chmod 666 config/.env +``` + +### プラグインの有効化 +baserCMSの管理画面から BcMcp プラグインを有効化してください。 + +## MCPサーバーの起動 +起動操作は不要です。MCPサーバーはbaserCMSのリクエスト内で動作するため、常駐プロセスを立てる必要はありません。 + +メニューの「MCPサーバー管理」では、接続用のURL・提供しているツールの一覧・直近の接続状況を確認できます。 + +## クライアント連携 + +### ChatGPT +ChatGPT Plus 以上の契約が必要です。 +※ 2025年9月19日現在、ChatGPT Business プランでは利用できません。 + +1. 「MCPサーバー管理」より、AIエージェント設定用URLをコピーします。 +2. 「設定」→「コネクタ」→「高度な設定」→「開発者モード」をオン +3. 「コネクタ」に戻り、「作成する」から以下のように設定します。 + +- **名前**: 任意の名前 +- **説明**: 任意の説明 +- **MCPサーバーのURL**: AIエージェント設定用URL +- **認証**: OAuth +- わたしはこのアプリケーションを信頼しますにチェック + +3. 「作成する」をクリック +4. 設置しているbaserCMSの画面に移動するので、「許可」をクリック + +チャット画面にて、開発者モードをオンにして、作成したコネクタを選択します。 + +### Claude +Claude Pro 以上の契約が必要です。 + +1. 「MCPサーバー管理」より、AIエージェント設定用URLをコピーします。 +2. 「設定」→「コネクタ」→「カスタムコネクタを追加」から以下のように設定します。 + +- **名前**: 任意の名前 +- **リモートMCPサーバーURL**: AIエージェント設定用URL + +3. 「連携/連携させる」をクリック +4. 設置しているbaserCMSの画面に移動するので、「許可」をクリック + +### Visual Studio Code +` ~/Library/Application Support/Code/User/mcp.json`、または、プロジェクト内の `.vscode/mcp.json` に以下のように設定します。 +```json +{ + "servers": { + "ryuring": { + "url": "AIエージェント設定用URL", + "type": "http" + } + } +} +``` + +### その他のMCPクライアント + +HTTPトランスポートをサポートする任意のMCPクライアントで使用できます。 + +## ローカル環境をHTTPSで公開して動作確認する + +ClaudeなどのMCPクライアントは、**自己署名証明書のサーバーには接続できません**。 +ローカル開発環境で実クライアントとの連携を確認するには、正式な証明書を持つHTTPSのURLが必要です。 + +ここでは [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) +を使い、ローカル環境を一時的にインターネットへ公開する手順を示します。独自ドメインもCloudflareアカウントも不要です。 + +> **注意**: この手順を実行すると、管理画面を含むサイト全体が一時的にインターネットへ公開されます。 +> 動作確認が終わったら必ずトンネルを停止してください。 + +### 1. cloudflaredのインストール + +```bash +brew install cloudflared +``` + +### 2. トンネルの起動 + +ローカル環境が `https://localhost` で動作している場合: + +```bash +cloudflared tunnel --url https://localhost --no-tls-verify +``` + +`--no-tls-verify` は、接続先(ローカル環境)が自己署名証明書のために必要です。 + +起動すると `https://<ランダムな文字列>.trycloudflare.com` というURLが発行されます。 +このURLは**トンネルを再起動するたびに変わります**。確認が終わるまでトンネルは起動したままにしてください。 + +Quick Tunnel には次の制約があります。 + +| 項目 | 内容 | +|---|---| +| 同時リクエスト | 200 in-flight まで | +| SSE(Server-Sent Events) | **非対応** | +| URL | 再起動のたびに変わる | +| 用途 | テストと開発のみ(本番非推奨、SLAなし) | + +固定のホスト名が必要な場合は Named Tunnel を使います。Cloudflare アカウントと、 +Cloudflare に登録済みのドメインが必要です。 + +まずトンネルを作成し、DNSレコードを登録します。 + +```bash +cloudflared tunnel login +cloudflared tunnel create bc-mcp-verify +cloudflared tunnel route dns bc-mcp-verify mcp-dev.example.com +``` + +次に `~/.cloudflared/config.yml` を作成します。`` は `create` が出力したIDです。 + +```yaml +tunnel: +credentials-file: /Users/<ユーザー名>/.cloudflared/.json + +ingress: + - hostname: mcp-dev.example.com + service: https://localhost + originRequest: + # ローカル環境が自己署名証明書のため + noTLSVerify: true + - service: http_404 +``` + +最後の `service: http_404` は、どのルールにも一致しなかった場合の受け皿です。**省略できません。** + +トンネルを起動します。 + +```bash +cloudflared tunnel run bc-mcp-verify +``` + +以降の手順では、発行された `https://<ランダムな文字列>.trycloudflare.com` の代わりに、 +ここで設定した固定ホスト名(例: `https://mcp-dev.example.com`)を使います。 + +固定ホスト名にすると、OAuth の動的クライアント登録・`SITE_URL`・コネクタ登録を +毎回やり直す必要がなくなります。 + +### 3. SITE_URLの変更 + +`config/.env` の `SITE_URL` を、発行されたURLに変更します。 + +``` +export SITE_URL="https://<発行されたURL>/" +``` + +`SITE_URL` はアクセストークン(JWT)の発行者・対象者や、動的クライアント登録のレスポンスに使われるため、 +公開URLと一致していないとクライアント側の検証に失敗します。 + +あわせて `TRUST_PROXY` が `true` であることを確認してください。トンネル経由のリクエストは +`X-Forwarded-Proto` でHTTPSを伝えるため、これが有効でないとHTTPと判定されます。 + +``` +export TRUST_PROXY="true" +``` + +変更後はキャッシュをクリアします。 + +```bash +bin/cake cache clear_all +``` + +### 4. リバースプロキシを使っている場合 + +`nginx-proxy` などのリバースプロキシでホスト名ごとに振り分けている環境では、 +発行されたURLのホスト名を振り分け対象に追加する必要があります。追加しないと、 +プロキシが転送先を判断できず **503 Service Temporarily Unavailable** になります。 + +`docker-compose.yml` の該当サービスの `VIRTUAL_HOST` に追記し、そのコンテナを再作成します。 + +```yaml +- VIRTUAL_HOST=localhost,<発行されたURLのホスト名> +``` + +```bash +docker compose up -d --no-deps <サービス名> +``` + +`LETSENCRYPT_HOST` への追加は不要です。証明書はCloudflare側が用意します。 + +### 5. 接続の確認 + +```bash +# サイトが表示されるか +curl -o /dev/null -w "%{http_code}\n" https://<発行されたURL>/ + +# 認可サーバーのメタデータを取得し、issuerが公開URLになっているか +curl https://<発行されたURL>/.well-known/oauth-authorization-server/bc-mcp +``` + +`-k` を付けずに成功すれば、正式な証明書で接続できています。 + +### 6. クライアントへの登録 + +`https://<発行されたURL>/bc-mcp` を、各クライアントのMCPサーバーURLとして登録します。 +登録手順は「クライアント連携」の各項目を参照してください。 + +### 7. 確認後の後片付け + +1. 動作確認で作成したコンテンツを削除する +2. `config/.env` の `SITE_URL` を元に戻し、キャッシュをクリアする +3. `VIRTUAL_HOST` に追加したホスト名を削除し、コンテナを再作成する +4. `cloudflared` のプロセスを停止する + +### MCP Inspectorでの確認 + +クライアントに登録する前に、[MCP Inspector](https://github.com/modelcontextprotocol/inspector) +で確認することもできます。ローカル環境(自己署名証明書)に対して直接実行できます。 + +```bash +NODE_TLS_REJECT_UNAUTHORIZED=0 npx -y @modelcontextprotocol/inspector +``` + +`NODE_TLS_REJECT_UNAUTHORIZED=0` は自己署名証明書を許可するための指定です。 +起動後、表示されるURLをブラウザで開き、次を設定して接続します。 + +- **Transport Type**: `Streamable HTTP` +- **URL**: `https://localhost/bc-mcp` + +CLIから直接実行することもできます。 + +```bash +NODE_TLS_REJECT_UNAUTHORIZED=0 \ + npx -y @modelcontextprotocol/inspector --cli https://localhost/bc-mcp \ + --transport http --method tools/list +``` + +## 利用可能なツール + +最新の一覧は「MCPサーバー管理」画面で確認できます(実際に登録されているツールを表示するため、 +常に実態と一致します)。 + +### 固定ページ関連 + +- `getPages`: 固定ページ一覧を取得 +- `getPage`: 単一の固定ページを取得 +- `addPage`: 固定ページを追加 +- `editPage`: 固定ページを編集 +- `deletePage`: 固定ページを削除 + +### ブログ関連 + +- `getBlogPosts` / `getBlogPost` / `addBlogPost` / `editBlogPost` / `deleteBlogPost`: ブログ記事 +- `getBlogContents` / `getBlogContent` / `addBlogContent` / `editBlogContent` / `deleteBlogContent`: ブログ +- `getBlogCategories` / `getBlogCategory` / `addBlogCategory` / `editBlogCategory` / `deleteBlogCategory`: ブログカテゴリ +- `getBlogTags` / `getBlogTag` / `addBlogTag` / `editBlogTag` / `deleteBlogTag`: ブログタグ + +### カスタムコンテンツ関連 + +- `getCustomContents` / `getCustomContent` / `addCustomContent` / `editCustomContent` / `deleteCustomContent`: カスタムコンテンツ +- `getCustomEntries` / `getCustomEntry` / `addCustomEntry` / `editCustomEntry` / `deleteCustomEntry`: カスタムエントリー +- `getCustomFields` / `getCustomField` / `addCustomField` / `editCustomField` / `deleteCustomField`: カスタムフィールド +- `getCustomTables` / `getCustomTable` / `addCustomTable` / `editCustomTable` / `deleteCustomTable`: カスタムテーブル +- `getCustomLinks` / `getCustomLink` / `addCustomLink` / `editCustomLink` / `deleteCustomLink`: カスタムリンク + +### システム情報 + +- `serverInfo`: サーバー情報を取得 + +## 使用例 + +### ブログ記事の追加 + +``` +「News」というブログにタイトル「AIの未来について」というタイトルで記事を作成して +``` + +### カスタムコンテンツ・カスタムエントリーの追加 + +``` +カスタムコンテンツを使って、「家具紹介」のコンテンツを作って +「家具紹介」に「カジュアルデスク」というタイトルでエントリーを追加して +``` + +## 権限について +設定時、連携を許可する際にログインしたユーザーの権限として動作します。 +また、権限については、Admin Web APIの権限に準じます。 +システム管理グループのユーザーは特に気にする必要はありませんが、それ以外のグループのユーザーで利用する場合は、`管理画面 > ユーザー管理 > ユーザーグループ > 対象グループ > 編集` より、Admin Web API を有効化します。 +その上で、アクセスルールグループより、権限設定を調整してください。 + + +## ファイルアップロードについて +ブログのアイキャッチなどの画像は、ローカルのファイルをそのままアップロードすることはできません。 +公開されたURLを渡すか、`data:` URI として埋め込む必要があります。 + +これは bc-mcp の制約ではなく、**ホスト(Claude や ChatGPT)がファイルの中身をMCPサーバーへ渡す手段を +まだ持っていない**ためです。 + +### 制約事項 +- multipart/form-dataに対応しておらず、JSONで送信するため base64エンコード行う必要があり、生成AI側のメッセージ送信のトークン制限に引っかかってしまい処理が中断される + +### 現状の対応方法 +アイキャッチなどの画像は、次の2つの方法で指定できます。 + +- **画像のURL** — ネット上に公開された画像のURLを渡します +- **`data:` URI** — `data:image/png;base64,...` 形式で直接渡します。小さな画像に限ります + +### 将来的な対応予定 +MCP に [File Uploads Working Group](https://modelcontextprotocol.io/community/working-groups/file-uploads) +が設置され、ホストがファイルピッカーを表示してサーバーへファイルを渡す仕組みが検討されています +([SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631))。 + +現状はホスト側にファイルの中身をサーバーへ渡す手段が無いため、ローカルのファイルを +そのままアップロードすることはできません。規格とホストの対応が揃った段階で BcMcp も対応します。 + +## 技術的な仕組み + +### プロセス内実行 + +BcMcpプラグインは以下の仕組みでクライアントと連携します: + +1. **クライアント** → HTTPリクエスト → **baserCMS(/bc-mcp)** +2. **McpProxyController** → OAuth2認証・権限チェック・Origin検証 +3. **McpRequestHandler** → 同一プロセス内でMCPサーバーを実行 → **各ツール** → baserCMS操作 +4. **McpProxyController** → HTTPレスポンス → **クライアント** + +常駐プロセスや内部へのHTTP転送は行いません。リクエストごとにMCPサーバーを組み立てるため、 +ツールや設定の変更が即座に反映されます。 + +### 対応プロトコルバージョン + +`2026-07-28`(ステートレスコア)と、それ以前の `initialize` 方式の世代の双方に対応しています。 +プロトコルの世代判定・`server/discover`・必須ヘッダの検証などはSDKが担います。 + +## トラブルシューティング + +### よくある問題 + +1. **クライアントから接続できない** + - PHP 8.1以上がインストールされているか確認 + - Composerの依存関係がインストールされているか確認 + - **自己署名証明書のURLを登録していないか確認**(多くのクライアントは接続を拒否します。 + 「ローカル環境をHTTPSで公開して動作確認する」を参照) + - ログファイルにエラーメッセージがないか確認 + +2. **403が返る** + - クライアントが送る `Origin` ヘッダが許可されていない可能性があります。 + 設定の `BcMcp.allowedOrigins` を確認してください。空の場合は検証を行いません。 + +3. **ツールが正常に動作しない** + - baserCMSのデータベースに接続できているか確認 + - 必要なプラグイン(BcBlog、BcCustomContent)が有効になっているか確認 + +4. **認可画面が表示されない** + - baserCMSを古いバージョンからアップデートした場合、`/.htaccess` が正しく設定されていない可能性があります。次のように変更をお願いします。 +```bash +# 変更前 +RewriteRule ^(\.well-known/.*)$ $1 [L] +# 変更後 +RewriteRule ^(\.well-known/.*)$ webroot/$1 [L] +``` + +### MCPサーバーのログの確認 + +```bash +# プロトコルのネゴシエーション状況を確認 +tail -f logs/mcp.log + +# MCPサーバー内部のエラーを確認 +tail -f logs/bc_mcp_error.log +``` + +`logs/mcp.log` には、接続ごとのプロトコル世代・クライアント名・呼び出されたメソッドが記録されます。 +「MCPサーバー管理」画面からも直近の内容を確認できます。 + +## 開発への貢献 +[CONTRIBUTING.md](.github/CONTRIBUTING.md) をご覧ください。 diff --git a/plugins/bc-mcp/composer.json b/plugins/bc-mcp/composer.json new file mode 100644 index 0000000000..08ca739bb0 --- /dev/null +++ b/plugins/bc-mcp/composer.json @@ -0,0 +1,26 @@ +{ + "name": "baserproject/bc-mcp", + "description": "BcMcp plugin for baserCMS", + "homepage": "https://basercms.net", + "type": "cakephp-plugin", + "license": "MIT", + "vendor-dir": "../../vendor", + "require": { + "php": "^8.1", + "ext-openssl": "*", + "league/oauth2-server": "^8.5", + "logiscape/mcp-sdk-php": "^2.0", + "nyholm/psr7": "^1.8", + "symfony/psr-http-message-bridge": "^2.3" + }, + "autoload": { + "psr-4": { + "BcMcp\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "BcMcp\\Test\\": "tests/" + } + } +} diff --git a/plugins/bc-mcp/config.php b/plugins/bc-mcp/config.php new file mode 100644 index 0000000000..3f79605239 --- /dev/null +++ b/plugins/bc-mcp/config.php @@ -0,0 +1,26 @@ + BcUtil::verpoint(BcUtil::getVersion())) { + $message[] = 'baserCMSのバージョンが5.1.10未満です。baserCMSを5.1.10以上にアップデートしてからインストールしてください。'; +} +$message[] = 'インストール時には、認証必要領域の Web API(baser Admin Api)を有効を有効化します。'; + +return [ + 'type' => 'Plugin', + 'title' => 'baserCMS MCP Server', + 'description' => 'baserCMSをAIエージェントから操作するためのMCPサーバーを提供します。', + 'author' => 'baserCMS User Community', + 'url' => 'https://basercms.net', + 'installMessage' =>implode("
", $message), + 'adminLink' => [ + 'plugin' => 'BcMcp', + 'controller' => 'McpServerManager', + 'action' => 'index' + ], +]; diff --git a/plugins/bc-mcp/config/Migrations/20250812000001_CreateOauth2Clients.php b/plugins/bc-mcp/config/Migrations/20250812000001_CreateOauth2Clients.php new file mode 100644 index 0000000000..956d022ec9 --- /dev/null +++ b/plugins/bc-mcp/config/Migrations/20250812000001_CreateOauth2Clients.php @@ -0,0 +1,66 @@ +table('oauth2_clients'); + $table->addColumn('client_id', 'string', [ + 'default' => null, + 'limit' => 80, + 'null' => false, + ]); + $table->addColumn('client_secret', 'string', [ + 'default' => null, + 'limit' => 80, + 'null' => true, + ]); + $table->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => false, + ]); + $table->addColumn('redirect_uris', 'text', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('grants', 'text', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('scopes', 'text', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('is_confidential', 'boolean', [ + 'default' => false, + 'null' => false, + ]); + $table->addColumn('registration_access_token', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addPrimaryKey(['id']); + $table->addIndex(['client_id'], ['unique' => true]); + $table->create(); + } +} diff --git a/plugins/bc-mcp/config/Migrations/20250812000002_CreateOauth2AccessTokens.php b/plugins/bc-mcp/config/Migrations/20250812000002_CreateOauth2AccessTokens.php new file mode 100644 index 0000000000..b001cd1aa7 --- /dev/null +++ b/plugins/bc-mcp/config/Migrations/20250812000002_CreateOauth2AccessTokens.php @@ -0,0 +1,59 @@ +table('oauth2_access_tokens'); + $table->addColumn('token_id', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => false, + ]); + $table->addColumn('user_id', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => true, + ]); + $table->addColumn('client_id', 'string', [ + 'default' => null, + 'limit' => 80, + 'null' => false, + ]); + $table->addColumn('scopes', 'text', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('revoked', 'boolean', [ + 'default' => false, + 'null' => false, + ]); + $table->addColumn('expires_at', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addPrimaryKey(['id']); + $table->addIndex(['token_id'], ['unique' => true]); + $table->addIndex(['client_id']); + $table->addIndex(['user_id']); + $table->create(); + } +} diff --git a/plugins/bc-mcp/config/Migrations/20250812000003_CreateOauth2AuthCodes.php b/plugins/bc-mcp/config/Migrations/20250812000003_CreateOauth2AuthCodes.php new file mode 100644 index 0000000000..54cd3f9db7 --- /dev/null +++ b/plugins/bc-mcp/config/Migrations/20250812000003_CreateOauth2AuthCodes.php @@ -0,0 +1,63 @@ +table('oauth2_auth_codes'); + $table->addColumn('code', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => false, + ]); + $table->addColumn('user_id', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => false, + ]); + $table->addColumn('client_id', 'string', [ + 'default' => null, + 'limit' => 80, + 'null' => false, + ]); + $table->addColumn('redirect_uri', 'text', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('scopes', 'text', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('revoked', 'boolean', [ + 'default' => false, + 'null' => false, + ]); + $table->addColumn('expires_at', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addPrimaryKey(['id']); + $table->addIndex(['code'], ['unique' => true]); + $table->addIndex(['client_id']); + $table->addIndex(['user_id']); + $table->create(); + } +} diff --git a/plugins/bc-mcp/config/Migrations/20250812000004_CreateOauth2RefreshTokens.php b/plugins/bc-mcp/config/Migrations/20250812000004_CreateOauth2RefreshTokens.php new file mode 100644 index 0000000000..270ddaf38b --- /dev/null +++ b/plugins/bc-mcp/config/Migrations/20250812000004_CreateOauth2RefreshTokens.php @@ -0,0 +1,49 @@ +table('oauth2_refresh_tokens'); + $table->addColumn('token_id', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => false, + ]); + $table->addColumn('access_token_id', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => false, + ]); + $table->addColumn('revoked', 'boolean', [ + 'default' => false, + 'null' => false, + ]); + $table->addColumn('expires_at', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addPrimaryKey(['id']); + $table->addIndex(['token_id'], ['unique' => true]); + $table->addIndex(['access_token_id']); + $table->create(); + } +} diff --git a/plugins/bc-mcp/config/bootstrap.php b/plugins/bc-mcp/config/bootstrap.php new file mode 100644 index 0000000000..031f88d3e7 --- /dev/null +++ b/plugins/bc-mcp/config/bootstrap.php @@ -0,0 +1,29 @@ + [ + /** + * System Navigation + */ + 'adminNavigation' => [ + 'Systems' => [ + 'BcMcpServerManager' => [ + 'title' => 'MCPサーバー管理', + 'type' => 'system', + 'url' => [ + 'prefix' => 'Admin', + 'plugin' => 'BcMcp', + 'controller' => 'McpServerManager', + 'action' => 'index' + ], + 'currentRegex' => '/\/bc-mcp\/admin\/mcp-server-manager.*/', + ], + ] + ], + /** + * CSRFチェックをスキップするURL + */ + 'skipCsrfUrl' => [ + 'Mcp' => '/bc-mcp', + // RFC 7591 動的クライアント登録プロトコル(ワイルドカードパターン使用) + 'OAuth2All' => '/bc-mcp/oauth2/*', + 'OAuth2AdminAll' => '/baser/admin/bc-mcp/oauth2/*' + ] + ], + 'BcPermission' => [ + /** + * デフォルトで許可するURL + */ + 'defaultAllows' => [ + 'Authorize' => '/bc-mcp/oauth2/authorize' + ] + ], + 'Log' => [ + 'mcp' => [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => 'mcp', + 'scopes' => ['mcp'], + 'levels' => ['info', 'error'] + ] + ], + 'BcMcp' => [ + /** + * Origin ヘッダの許可リスト + * + * DNS リバインディング攻撃対策として、ブラウザから送信された Origin を + * 検証する。Streamable HTTP の MUST 要件。 + * 空配列の場合は自サイトのオリジン(SITE_URL)のみを許可する。 + * Origin ヘッダを持たないリクエスト(サーバー間通信)は検証対象外。 + */ + 'allowedOrigins' => [], + /** + * 利用可能なMCPサーバー + */ + 'availableServers' => [ + 'BaserCore' => \BcMcp\Mcp\BaserCore\BaserCoreServer::class, + 'BcBlog' => \BcMcp\Mcp\BcBlog\BcBlogServer::class, + 'BcCustomContent' => \BcMcp\Mcp\BcCustomContent\BcCustomContentServer::class, + ] + ] +]; diff --git a/plugins/bc-mcp/src/BcMcpPlugin.php b/plugins/bc-mcp/src/BcMcpPlugin.php new file mode 100644 index 0000000000..47f0648873 --- /dev/null +++ b/plugins/bc-mcp/src/BcMcpPlugin.php @@ -0,0 +1,142 @@ +getService(SiteConfigsServiceInterface::class); + $oauth2EncKey = base64_encode(random_bytes(32)); + $siteConfigsService->putEnv('OAUTH2_ENC_KEY', $oauth2EncKey); + $siteConfigsService->putEnv('USE_CORE_API', "true"); + $siteConfigsService->putEnv('USE_CORE_ADMIN_API', "true"); + if (!file_exists(CONFIG . 'jwt.pem')) { + BcApiUtil::createJwt(); + } + return true; + } + + /** + * Add routes for the plugin. + * + * @param \Cake\Routing\RouteBuilder $routes The route builder to update. + * @return void + */ + public function routes(RouteBuilder $routes): void + { + // .well-known エンドポイントをルートレベルで設定(認証不要の通常コントローラーを指定) + $routes->scope('/', function(RouteBuilder $builder) { + $builder->setRouteClass(InflectedRoute::class); + + $builder->connect('/mcp', ['plugin' => 'BcMcp', 'controller' => 'McpProxy', 'action' => 'index'], ['routeClass' => InflectedRoute::class]); + $builder->connect('/bc-mcp', ['plugin' => 'BcMcp', 'controller' => 'McpProxy', 'action' => 'index'], ['routeClass' => InflectedRoute::class]); + + // OAuth 2.0 保護リソースメタデータエンドポイント (RFC 9728) + $builder->connect('/.well-known/oauth-protected-resource', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/.well-known/oauth-protected-resource', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'protectedResourceMetadata'])->setMethods(['GET']); + $builder->connect('/.well-known/oauth-protected-resource/bc-mcp', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/.well-known/oauth-protected-resource/bc-mcp', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'protectedResourceMetadata'])->setMethods(['GET']); + + // OAuth 2.0 認可サーバーメタデータエンドポイント (RFC 8414) + $builder->connect('/.well-known/oauth-authorization-server', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/.well-known/oauth-authorization-server', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'authorizationServerMetadata'])->setMethods(['GET']); + $builder->connect('/.well-known/oauth-authorization-server/bc-mcp', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/.well-known/oauth-authorization-server/bc-mcp', ['plugin' => 'BcMcp', 'controller' => 'Oauth2', 'action' => 'authorizationServerMetadata'])->setMethods(['GET']); + }); + + $routes->plugin('BcMcp', ['path' => '/bc-mcp'], function(RouteBuilder $builder) { + $builder->setRouteClass(InflectedRoute::class); + + // Oauth2エンドポイント(認証不要) + // トークン発行エンドポイント + $builder->connect('/oauth2/token', ['controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/oauth2/token', ['controller' => 'Oauth2', 'action' => 'token'])->setMethods(['POST']); + + // トークン検証エンドポイント + $builder->connect('/oauth2/verify', ['controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/oauth2/verify', ['controller' => 'Oauth2', 'action' => 'verify'])->setMethods(['POST', 'GET']); + + // クライアント情報取得エンドポイント + $builder->connect('/oauth2/client-info', ['controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/oauth2/client-info', ['controller' => 'Oauth2', 'action' => 'clientInfo'])->setMethods(['GET']); + + // RFC 7591 動的クライアント登録プロトコル(認証不要) + $builder->connect('/oauth2/register', ['controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/oauth2/register', ['controller' => 'Oauth2', 'action' => 'register'])->setMethods(['POST']); + + // クライアント設定エンドポイント(RFC 7591) + $builder->connect('/oauth2/register/{client_id}', ['controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS'])->setPass(['client_id']); + $builder->connect('/oauth2/register/{client_id}', ['controller' => 'Oauth2', 'action' => 'clientConfiguration'])->setMethods(['GET', 'PUT', 'DELETE'])->setPass(['client_id']); + + // Authorization Code Grant 認可エンドポイント(認証必要) + $builder->connect('/oauth2/authorize', ['prefix' => 'Admin', 'controller' => 'Oauth2', 'action' => 'options'])->setMethods(['OPTIONS']); + $builder->connect('/oauth2/authorize', ['prefix' => 'Admin', 'controller' => 'Oauth2', 'action' => 'authorize'])->setMethods(['GET', 'POST']); + + // その他のルート + $builder->fallbacks(\Cake\Routing\Route\DashedRoute::class); + }); + + // Admin prefix routes for Oauth2 endpoints(認証が必要なエンドポイントのみ) + $routes->prefix('Admin', ['path' => BcUtil::getPrefix()], function(RouteBuilder $builder) { + $builder->plugin('BcMcp', ['path' => '/bc-mcp'], function(RouteBuilder $routes) { + $routes->setRouteClass(InflectedRoute::class); + + // MCPサーバー管理 + // 常駐プロセスを廃止したため、起動・停止・再起動・設定のルートは持たない + $routes->get('/mcp-server-manager', ['controller' => 'McpServerManager', 'action' => 'index']); + }); + }); + + parent::routes($routes); + } + +} diff --git a/plugins/bc-mcp/src/Command/Oauth2CleanupCommand.php b/plugins/bc-mcp/src/Command/Oauth2CleanupCommand.php new file mode 100644 index 0000000000..52ed1dd766 --- /dev/null +++ b/plugins/bc-mcp/src/Command/Oauth2CleanupCommand.php @@ -0,0 +1,71 @@ +setDescription('期限切れのOAuth2トークンと認可コードをクリーンアップします'); + + return $parser; + } + + /** + * Implement this method with your command's logic. + * + * @param Arguments $args The command arguments. + * @param ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $io->out('OAuth2 認可コードとリフレッシュトークンのクリーンアップを開始します...'); + + try { + // 認可コードのクリーンアップ + $authCodesTable = TableRegistry::getTableLocator()->get('BcMcp.Oauth2AuthCodes'); + $expiredAuthCodes = $authCodesTable->cleanExpiredCodes(); + $io->success("期限切れの認可コード {$expiredAuthCodes} 件を削除しました"); + + // リフレッシュトークンのクリーンアップ + $refreshTokensTable = TableRegistry::getTableLocator()->get('BcMcp.Oauth2RefreshTokens'); + $expiredTokens = $refreshTokensTable->cleanExpiredTokens(); + $io->success("期限切れのリフレッシュトークン {$expiredTokens} 件を削除しました"); + + // 統計情報を表示 + $remainingAuthCodes = $authCodesTable->find()->count(); + $remainingRefreshTokens = $refreshTokensTable->find()->count(); + + $io->out(''); + $io->out('現在の状況:'); + $io->out("有効な認可コード: {$remainingAuthCodes} 件"); + $io->out("有効なリフレッシュトークン: {$remainingRefreshTokens} 件"); + + } catch (\Exception $e) { + $io->error('クリーンアップ中にエラーが発生しました: ' . $e->getMessage()); + return self::CODE_ERROR; + } + + $io->success('クリーンアップが完了しました'); + return self::CODE_SUCCESS; + } +} diff --git a/plugins/bc-mcp/src/Controller/Admin/McpServerManagerController.php b/plugins/bc-mcp/src/Controller/Admin/McpServerManagerController.php new file mode 100644 index 0000000000..c6872951a2 --- /dev/null +++ b/plugins/bc-mcp/src/Controller/Admin/McpServerManagerController.php @@ -0,0 +1,86 @@ +set('title', 'MCPサーバー管理'); + } + + /** + * MCPサーバー情報 + */ + public function index() + { + $baseUrl = rtrim(Router::url('/', true), '/'); + + $this->set([ + 'endpointUrl' => $baseUrl . '/bc-mcp', + 'authorizationServerMetadataUrl' => $baseUrl . '/.well-known/oauth-authorization-server/bc-mcp', + 'protectedResourceMetadataUrl' => $baseUrl . '/.well-known/oauth-protected-resource/bc-mcp', + 'protocolVersions' => ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'], + 'tools' => $this->getRegisteredTools(), + 'negotiations' => NegotiationLogger::readRecent(10), + ]); + } + + /** + * 登録済みツールを取得する + * + * SDK はツール一覧を取得する API を持たないため、本番と同じ経路で + * tools/list を実行して取得する。テンプレートへの手書きをやめる事で、 + * ツールを追加すれば表示にも反映される。 + * + * @return array ツールの配列(name / description を含む) + */ + public function getRegisteredTools(): array + { + $request = new HttpMessage(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 'admin-tools-list', + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientInfo' => [ + 'name' => 'baserCMS Admin', + 'version' => '1.0.0', + ], + 'io.modelcontextprotocol/clientCapabilities' => [], + ], + ], + ], JSON_UNESCAPED_UNICODE)); + $request->setMethod('POST'); + $request->setUri('/bc-mcp'); + $request->setHeader('Content-Type', 'application/json'); + $request->setHeader('Accept', 'application/json'); + $request->setHeader('MCP-Protocol-Version', '2026-07-28'); + $request->setHeader('Mcp-Method', 'tools/list'); + + $response = (new McpRequestHandler())->handle($request); + $decoded = json_decode((string)$response->getBody(), true); + + return $decoded['result']['tools'] ?? []; + } + +} diff --git a/plugins/bc-mcp/src/Controller/Admin/Oauth2Controller.php b/plugins/bc-mcp/src/Controller/Admin/Oauth2Controller.php new file mode 100644 index 0000000000..1bb12aa421 --- /dev/null +++ b/plugins/bc-mcp/src/Controller/Admin/Oauth2Controller.php @@ -0,0 +1,214 @@ +oauth2Service = new OAuth2Service(); + $this->loadComponent('FormProtection'); + $this->FormProtection->setConfig('validate', false); + // CORS設定 + $this->response = $this->response->withHeader('Access-Control-Allow-Origin', '*'); + $this->response = $this->response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $this->response = $this->response->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, MCP-Protocol-Version'); + } + + /** + * OPTIONSリクエスト対応 + * + * @return Response + */ + public function options(): Response + { + return $this->response->withStatus(200); + } + + /** + * 認可エンドポイント + * Authorization Code Grantの開始点 + * baserCMSのAdmin認証が必要 + * + * @return Response|\Psr\Http\Message\ResponseInterface + */ + public function authorize() + { + try { + // ユーザーがログインしているかチェック + $user = $this->Authentication->getIdentity(); + if (!$user) { + // baserCMS標準のログインページにリダイレクト + $this->Flash->set('認証が必要です。ログインしてください。'); + return $this->redirect([ + 'plugin' => 'BaserCore', + 'prefix' => 'Admin', + 'controller' => 'Users', + 'action' => 'login', + '?' => [ + 'redirect' => $this->request->getRequestTarget() + ] + ]); + } + + $request = $this->request; + + // 必須パラメータをチェック + $clientId = $request->getQuery('client_id'); + $responseType = $request->getQuery('response_type'); + $redirectUri = $request->getQuery('redirect_uri'); + $state = $request->getQuery('state'); + $scope = $request->getQuery('scope'); + if (!$scope) { + $scope = 'mcp:read mcp:write'; // デフォルトスコープ + } + + if (!$clientId || !$responseType || !$redirectUri) { + return $this->response + ->withStatus(400) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_request', + 'error_description' => 'Missing required parameters: client_id, response_type, redirect_uri' + ])); + } + + if ($responseType !== 'code') { + return $this->response + ->withStatus(400) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'unsupported_response_type', + 'error_description' => 'Only response_type=code is supported' + ])); + } + + // クライアントの妥当性をチェック + $clientRepository = new OAuth2ClientRepository(); + $client = $clientRepository->getClientEntity($clientId); + + if (!$client) { + $siteUrl = env('SITE_URL', 'https://localhost'); + $baseUrl = rtrim($siteUrl, '/'); + $resourceMetadataUrl = $baseUrl . '/.well-known/oauth-protected-resource/bc-mcp'; + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withHeader('WWW-Authenticate', 'Bearer resource_metadata="' . $resourceMetadataUrl . '"') + ->withStringBody(json_encode([ + 'error' => 'invalid_client', + 'error_description' => 'Client registration required. Please register a new client.' + ])); + } + + // リダイレクトURIの妥当性をチェック + if (!in_array($redirectUri, $client->getRedirectUri())) { + return $this->response + ->withStatus(400) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_redirect_uri', + 'error_description' => 'Invalid redirect_uri' + ])); + } + + // POSTリクエストの場合は認可処理 + if ($this->request->is('post')) { + $action = $this->request->getData('action'); + + if ($action === 'approve') { + $server = $this->oauth2Service->getAuthorizationServer(); + + // PSR-7リクエストを作成(クエリパラメータとPOSTデータの両方を含む) + $psrRequest = OAuth2Util::createPsr7Request($this->request); + + // 認可リクエストを検証(PKCEパラメータも含む) + $authRequest = $server->validateAuthorizationRequest($psrRequest); + + $userEntity = new User(); + $userEntity->setIdentifier($user->getIdentifier()); + $authRequest->setUser($userEntity); + $authRequest->setAuthorizationApproved(true); + + $authResponse = $server->completeAuthorizationRequest($authRequest, $this->response); + + // RFC 9207: 認可レスポンスに issuer を含める。 + // 2026-07-28 のクライアントは iss があれば検証が MUST。 + $location = $authResponse->getHeaderLine('Location'); + if ($location !== '') { + $authResponse = $authResponse->withHeader( + 'Location', + OAuth2Util::addIssuerToUrl($location, OAuth2Util::getIssuer($this->request)) + ); + } + return $authResponse; + } elseif ($action === 'deny') { + // アクセス拒否 + $params = [ + 'error' => 'access_denied', + 'error_description' => 'The user denied the request' + ]; + if ($state) { + $params['state'] = $state; + } + + // エラー応答も認可レスポンスであるため issuer を付与する(RFC 9207) + $redirectUrl = OAuth2Util::addIssuerToUrl( + $redirectUri . '?' . http_build_query($params), + OAuth2Util::getIssuer($this->request) + ); + return $this->redirect($redirectUrl); + } + } + + // 認可画面を表示 + $this->set([ + 'client' => $client, + 'clientId' => $clientId, + 'redirectUri' => $redirectUri, + 'scope' => $scope, + 'state' => $state, + 'user' => $user + ]); + + return $this->render('authorize'); + + } catch (\Exception $exception) { + return $this->response + ->withStatus(500) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'server_error', + 'error_description' => 'An unexpected error occurred.', + 'message' => $exception->getMessage() + ])); + } + } +} diff --git a/plugins/bc-mcp/src/Controller/McpProxyController.php b/plugins/bc-mcp/src/Controller/McpProxyController.php new file mode 100644 index 0000000000..4f97118d62 --- /dev/null +++ b/plugins/bc-mcp/src/Controller/McpProxyController.php @@ -0,0 +1,392 @@ +FormProtection->setConfig('validate', false); + // OAuth2サービスを初期化 + $this->oauth2Service = new OAuth2Service(); + + // CORS設定。許可したオリジンのみを返す(ワイルドカードは使わない) + $origin = $this->request->getHeaderLine('Origin'); + if ($origin !== '' && $this->isAllowedOrigin($origin)) { + $this->response = $this->response->withHeader('Access-Control-Allow-Origin', $origin); + } + $this->response = $this->response->withHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + $this->response = $this->response->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id'); + } + + /** + * リクエスト処理前の認証チェック + */ + public function beforeFilter(EventInterface $event): void + { + parent::beforeFilter($event); + + $method = $this->request->getMethod(); + + // Origin 検証はトランスポートレベルの要件であり、認証より前に効かせる。 + // ブラウザから送信された Origin のみが対象で、サーバー間通信のように + // Origin を持たないリクエストは検証しない。 + $origin = $this->request->getHeaderLine('Origin'); + if ($origin !== '' && !$this->isAllowedOrigin($origin)) { + $event->setResult($this->returnForbiddenOriginResponse()); + return; + } + + // OPTIONS は認証不要 + if ($method === 'OPTIONS') { + return; + } + + // 2026-07-28 では GET ストリームとセッションの DELETE が廃止されている。 + // メソッド自体が許可されないため認証より前に応答する。 + if (in_array($method, ['GET', 'DELETE'], true)) { + $event->setResult($this->returnMethodNotAllowedResponse()); + return; + } + + $response = $this->validateOAuth2Token(); + if ($response) { + $event->setResult($response); + return; + } + } + + /** + * OAuth2トークンの検証 + */ + private function validateOAuth2Token(): Response|null + { + $authHeader = $this->request->getHeaderLine('Authorization'); + + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + return $this->returnUnauthorizedResponse('Missing or invalid authorization header'); + } + + $token = substr($authHeader, 7); + $tokenData = $this->oauth2Service->validateAccessToken($token); + + if (!$tokenData) { + return $this->returnUnauthorizedResponse('Invalid or expired access token'); + } + + // トークン情報をリクエストに保存 + $this->request = $this->request + ->withAttribute('oauth_client_id', $tokenData['client_id']) + ->withAttribute('oauth_user_id', $tokenData['user_id']) + ->withAttribute('oauth_scopes', $tokenData['scope']); + return null; + } + + /** + * 認証エラーのレスポンスを返す + * @param string $message + * @return Response + */ + private function returnUnauthorizedResponse(string $message): \Cake\Http\Response + { + $siteUrl = rtrim((string)env('SITE_URL', 'https://localhost'), '/'); + $resourceMetadataUrl = $siteUrl . '/.well-known/oauth-protected-resource/bc-mcp'; + + $wwwAuthenticate = sprintf( + 'Bearer resource_metadata="%s"', + $resourceMetadataUrl + ); + + return $this->response + ->withStatus(401) + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('Cache-Control', 'no-store') + ->withHeader('Pragma', 'no-cache') + ->withHeader('WWW-Authenticate', $wwwAuthenticate) + ->withStringBody(json_encode([ + 'error' => 'invalid_client', + 'message' => $message + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * Origin が許可されているかを判定する + * + * Streamable HTTP の MUST 要件。悪意あるサイトが DNS リバインディングにより + * ローカルの MCP サーバーを操作するのを防ぐ。 + * + * @param string $origin Origin ヘッダの値 + * @return bool + */ + public function isAllowedOrigin(string $origin): bool + { + $allowed = (array)Configure::read('BcMcp.allowedOrigins', []); + if (!$allowed) { + // 設定が無い場合は自サイトのオリジンのみを許可する + $siteUrl = rtrim((string)env('SITE_URL', ''), '/'); + if ($siteUrl) { + $parts = parse_url($siteUrl); + if (!empty($parts['scheme']) && !empty($parts['host'])) { + $allowed = [ + $parts['scheme'] . '://' . $parts['host'] + . (isset($parts['port'])? ':' . $parts['port'] : '') + ]; + } + } + } + // 部分一致で通さないよう厳密に比較する + return in_array($origin, $allowed, true); + } + + /** + * 許可されない Origin のレスポンスを返す + * + * @return Response + */ + private function returnForbiddenOriginResponse(): Response + { + return $this->response + ->withStatus(403) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => -32600, + 'message' => 'Forbidden: invalid Origin.' + ] + ], JSON_UNESCAPED_UNICODE)); + } + + /** + * 許可されないメソッドのレスポンスを返す + * + * @return Response + */ + private function returnMethodNotAllowedResponse(): Response + { + return $this->response + ->withStatus(405) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Allow', 'POST, OPTIONS') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => -32601, + 'message' => 'Method not allowed. Use POST.' + ] + ], JSON_UNESCAPED_UNICODE)); + } + + /** + * MCP リクエストの受け口 + * + * /bc-mcp へのアクセスを同一プロセス内の MCP サーバーで処理する。 + * OPTIONS リクエストも含めて全てここで処理する。 + */ + public function index() + { + // OPTIONSリクエストの場合はCORSレスポンスを返す + if ($this->request->getMethod() === 'OPTIONS') { + return $this->_handleOptionsRequest(); + } + + try { + $requestBody = (string)$this->request->getBody(); + + if (empty($requestBody)) { + // 空ボディは不正 + return $this->response->withStatus(400); + } + + // JSONをパースしてMCPリクエストを検証 + $mcpRequest = json_decode($requestBody, true); + if (!$mcpRequest || !isset($mcpRequest['jsonrpc']) || $mcpRequest['jsonrpc'] !== '2.0') { + throw new BadRequestException('Invalid MCP request format'); + } + + // クライアントの世代とプロトコルバージョンを記録する。 + // クライアント側が Modern へ移行した事を検知できるようにするため。 + NegotiationLogger::log($mcpRequest, $this->request->getHeaderLine('MCP-Protocol-Version')); + + // 認証済みの操作者をコンテキストに設定する。 + // リクエストボディへ注入しないのは、2026-07-28 でヘッダとボディの + // 一致が検証されるため。 + McpContext::setLoginUserId((int)$this->request->getAttribute('oauth_user_id')); + + if (!$this->checkPermission($mcpRequest)) { + return $this->response + ->withStatus(403) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => 403, + 'message' => 'Forbidden: You do not have permission to perform this action.' + ] + ], JSON_UNESCAPED_UNICODE)); + } + + $mcpResponse = (new McpRequestHandler())->handle($this->toMcpMessage($mcpRequest)); + + $response = $this->response + ->withStatus($mcpResponse->getStatusCode()) + ->withStringBody((string)$mcpResponse->getBody()); + foreach($mcpResponse->getHeaders() as $name => $value) { + $response = $response->withHeader($name, $value); + } + return $response; + } catch (BadRequestException $e) { + throw $e; + } catch (ForbiddenException $e) { + return $this->response + ->withStatus(403) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => 403, + 'message' => $e->getMessage() + ] + ], JSON_UNESCAPED_UNICODE)); + } catch (\Exception $e) { + return $this->response + ->withStatus(500) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => 500, + 'message' => 'MCPリクエストの処理に失敗しました: ' . $e->getMessage() + ] + ], JSON_UNESCAPED_UNICODE)); + } finally { + McpContext::clear(); + } + } + + /** + * CakePHP のリクエストを SDK の HttpMessage に変換する + * + * 2026-07-28 では MCP-Protocol-Version / Mcp-Method / Mcp-Name が必須ヘッダで、 + * SDK がヘッダとボディの一致を検証する。クライアントが送ってきたヘッダを + * そのまま引き継ぎ、ボディも改変しない事で整合性を保つ。 + * Authorization は認証がプロキシで完結しているため渡さない。 + * + * @param array $mcpRequest MCP リクエスト + * @return \Mcp\Server\Transport\Http\HttpMessage + */ + public function toMcpMessage(array $mcpRequest): HttpMessage + { + $message = new HttpMessage(json_encode($mcpRequest, JSON_UNESCAPED_UNICODE)); + $message->setMethod($this->request->getMethod()); + $message->setUri('/bc-mcp'); + $message->setHeader('Content-Type', 'application/json'); + $message->setHeader('Accept', 'application/json, text/event-stream'); + + // Mcp-Session-Id は Legacy 世代(initialize 方式)のクライアントが + // セッションを維持するために使う。Modern では廃止されているが、 + // Dual-era サーバーとして両方に応じるため透過する。 + $targets = ['MCP-Protocol-Version', 'Mcp-Method', 'Mcp-Name', 'Mcp-Session-Id', 'Last-Event-ID']; + foreach($targets as $target) { + $value = $this->request->getHeaderLine($target); + if ($value !== '') { + $message->setHeader($target, $value); + } + } + // x-mcp-header 由来の Mcp-Param-* も引き継ぐ + foreach($this->request->getHeaders() as $name => $values) { + if (stripos($name, 'Mcp-Param-') === 0) { + $message->setHeader($name, implode(', ', $values)); + } + } + return $message; + } + + /** + * 権限チェック + * @param array $mcpRequest + * @return bool + */ + public function checkPermission(array $mcpRequest): bool + { + if($mcpRequest['method'] !== 'tools/call') return true; + + if (!filter_var(env('USE_CORE_ADMIN_API', false), FILTER_VALIDATE_BOOLEAN)) { + throw new ForbiddenException(__d('baser_core', 'baser Admin APIは許可されていません。')); + } + + /** @var UsersService $usersService */ + $usersService = $this->getService(UsersServiceInterface::class); + $user = $usersService->get(McpContext::getLoginUserId()); + if(!$user) return false; + if (BcUtil::isAdminUser($user)) { + return true; + } + $userGroupsIds = Hash::extract($user->toArray()['user_groups'], '{n}.id'); + $permissionManager = new PermissionManager(); + return $permissionManager->checkPermission( + $mcpRequest['params']['name'], + $userGroupsIds, + $mcpRequest['params']['arguments'] ?? [] + ); + } + + /** + * OPTIONSリクエストの処理(CORS プリフライト対応) + */ + private function _handleOptionsRequest() + { + $this->response = $this->response + ->withHeader('Access-Control-Max-Age', '86400') + ->withStatus(200); + return $this->response; + } + + /** + * OPTIONSリクエストの処理(CORS プリフライト対応) + * 後方互換性のため残しているが、実際は_handleOptionsRequestが使用される + */ + public function options() + { + return $this->_handleOptionsRequest(); + } + +} diff --git a/plugins/bc-mcp/src/Controller/Oauth2Controller.php b/plugins/bc-mcp/src/Controller/Oauth2Controller.php new file mode 100644 index 0000000000..9acd002dfb --- /dev/null +++ b/plugins/bc-mcp/src/Controller/Oauth2Controller.php @@ -0,0 +1,560 @@ +FormProtection->setConfig('validate', false); + $this->oauth2Service = new OAuth2Service(); + + // クライアント登録サービスを初期化 + $clientRepository = new OAuth2ClientRepository(); + $this->clientRegistrationService = new OAuth2ClientRegistrationService($clientRepository); + + // CORS設定 + $this->response = $this->response->withHeader('Access-Control-Allow-Origin', '*'); + $this->response = $this->response->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $this->response = $this->response->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, MCP-Protocol-Version'); + } + + /** + * OPTIONSリクエスト対応(CORS対応) + * + * @return Response + */ + public function options(): Response + { + return $this->response->withStatus(200); + } + + /** + * JWKSエンドポイント + * @return \Cake\Http\Response + */ + public function jwks(): \Cake\Http\Response + { + // 公開鍵の取得(例: config/jwt.pem から) + $publicKeyPath = CONFIG . 'jwt.pem'; + $publicKey = file_get_contents($publicKeyPath); + // 公開鍵をJWK形式に変換(簡易例: RS256のみ対応) + $details = openssl_pkey_get_details(openssl_pkey_get_public($publicKey)); + + // kidを生成(公開鍵のSHA-256ハッシュを使用) + $publicKeyDer = $details['key']; + $kid = rtrim(strtr(base64_encode(hash('sha256', $publicKeyDer, true)), '+/', '-_'), '='); + + $jwk = [ + 'kty' => 'RSA', + 'n' => rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '='), + 'e' => rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '='), + 'alg' => 'RS256', + 'use' => 'sig', + 'kid' => $kid, + ]; + $jwks = ['keys' => [$jwk]]; + $response = $this->response + ->withType('application/json') + ->withStringBody(json_encode($jwks)); + return $response; + } + + /** + * トークン発行エンドポイント + * + * @return Response + */ + public function token(): Response + { + try { + // PSR-7リクエストを作成 + $psrRequest = OAuth2Util::createPsr7Request($this->request); + + // OAuth2サーバーでアクセストークンリクエストを処理 + $psrResponse = $this->oauth2Service->getAuthorizationServer() + ->respondToAccessTokenRequest($psrRequest, new Psr7Response()); + + // PSR-7レスポンスをCakePHPレスポンスに変換 + // 一部のPSR-7実装では、書き込み後にストリームポインタが末尾にあるため、 + // getContents() が空文字を返すのを防ぐために rewind してから取得する + $psrBody = $psrResponse->getBody(); + if ($psrBody->isSeekable()) { + $psrBody->rewind(); + } + $bodyString = $psrBody->getContents(); + + return $this->response + ->withStatus($psrResponse->getStatusCode()) + ->withType('application/json') + ->withStringBody($bodyString); + } catch (OAuthServerException $exception) { + // OAuth2の仕様に沿ったエラーレスポンスを返す + $errorPsrResponse = $exception->generateHttpResponse(new Psr7Response()); + $errorBody = $errorPsrResponse->getBody(); + if ($errorBody->isSeekable()) { + $errorBody->rewind(); + } + $errorString = $errorBody->getContents(); + + $cakeResponse = $this->response + ->withStatus($errorPsrResponse->getStatusCode()) + ->withType('application/json') + ->withStringBody($errorString); + + // 必要に応じてヘッダーも反映(例: WWW-Authenticate) + foreach($errorPsrResponse->getHeaders() as $name => $values) { + foreach($values as $value) { + $cakeResponse = $cakeResponse->withHeader($name, $value); + } + } + + return $cakeResponse; + } catch (\Exception $exception) { + // 一般的なエラーレスポンス + return $this->response + ->withStatus(500) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'server_error', + 'error_description' => 'An unexpected error occurred.', + 'message' => $exception->getMessage() + ])); + } + } + + /** + * トークン検証エンドポイント + * + * @return Response + */ + public function verify(): Response + { + try { + $authHeader = $this->request->getHeaderLine('Authorization'); + + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_token', + 'error_description' => 'The access token is missing or invalid.' + ])); + } + + $token = substr($authHeader, 7); // "Bearer "を除去 + $tokenData = $this->oauth2Service->validateAccessToken($token); + + if (!$tokenData) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_token', + 'error_description' => 'The access token is invalid or expired.' + ])); + } + + return $this->response + ->withType('application/json') + ->withStringBody(json_encode([ + 'valid' => true, + 'client_id' => $tokenData['client_id'], + 'user_id' => $tokenData['user_id'], + 'scope' => $tokenData['scope'] + ])); + + } catch (\Exception $exception) { + return $this->response + ->withStatus(500) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'server_error', + 'error_description' => 'An unexpected error occurred.', + 'message' => $exception->getMessage() + ])); + } + } + + /** + * クライアント情報取得エンドポイント + * + * @return Response + */ + public function clientInfo(): Response + { + try { + $authHeader = $this->request->getHeaderLine('Authorization'); + + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'unauthorized', + 'error_description' => 'Authentication required.' + ])); + } + + $token = substr($authHeader, 7); + $tokenData = $this->oauth2Service->validateAccessToken($token); + + if (!$tokenData) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_token', + 'error_description' => 'The access token is invalid or expired.' + ])); + } + + return $this->response + ->withType('application/json') + ->withStringBody(json_encode([ + 'client_id' => $tokenData['client_id'], + 'scopes' => $tokenData['scopes'], + 'authenticated' => true + ])); + + } catch (\Exception $exception) { + return $this->response + ->withStatus(500) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'server_error', + 'error_description' => 'An unexpected error occurred.' + ])); + } + } + + /** + * OAuth 2.0 保護リソースメタデータエンドポイント (RFC 9728) + * + * @return Response + */ + public function protectedResourceMetadata(): Response + { + try { + // 現在のリクエストからベースURLを動的に取得 + $scheme = $this->request->is('https')? 'https' : 'http'; + $host = $this->request->getHeaderLine('Host'); + if (!$host) { + $host = $this->request->getEnv('HTTP_HOST')?: 'localhost'; + } + $baseUrl = $scheme . '://' . $host; + + $metadata = [ + 'resource' => $baseUrl . '/bc-mcp', + 'authorization_servers' => [$baseUrl . '/bc-mcp'], + 'scopes_supported' => ['mcp:read', 'mcp:write'], + 'bearer_methods_supported' => ['header'], + 'introspection_endpoint' => $baseUrl . '/bc-mcp/oauth2/verify', + 'resource_registration_endpoint' => $baseUrl . '/bc-mcp/oauth2/client-info' + ]; + + return $this->response + ->withHeader('Cache-Control', 'no-cache') + ->withType('application/json') + ->withStringBody(json_encode($metadata, JSON_PRETTY_PRINT)); + + } catch (\Exception $exception) { + return $this->response + ->withStatus(500) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'server_error', + 'error_description' => 'Failed to generate protected resource metadata.', + 'debug_message' => $exception->getMessage() + ])); + } + } + + /** + * OAuth 2.0 認可サーバーメタデータエンドポイント (RFC 8414) + * + * @return Response + */ + public function authorizationServerMetadata(): Response + { + try { + // 現在のリクエストからベースURLを動的に取得 + $scheme = $this->request->is('https')? 'https' : 'http'; + $host = $this->request->getHeaderLine('Host'); + if (!$host) { + $host = $this->request->getEnv('HTTP_HOST')?: 'localhost'; + } + $baseUrl = $scheme . '://' . $host; + + $metadata = [ + // RFC 8414 必須項目 + // issuer は認可レスポンスの iss(RFC 9207)と同一でなければならないため、 + // OAuth2Util の導出処理を共有する + 'issuer' => OAuth2Util::getIssuer($this->request), + 'authorization_endpoint' => $baseUrl . '/bc-mcp/oauth2/authorize', + 'token_endpoint' => $baseUrl . '/bc-mcp/oauth2/token', + 'registration_endpoint' => $baseUrl . '/bc-mcp/oauth2/register', + 'jwks_uri' => $baseUrl . '/bc-mcp/oauth2/jwks', + 'response_types_supported' => ['code'], + + // 両方のGrantをサポート + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'token_endpoint_auth_methods_supported' => ['none'], + // PKCE サポート(ChatGPTで推奨される) + 'code_challenge_methods_supported' => ['S256'], + 'scopes_supported' => ['mcp:read', 'mcp:write'], + + // 実装済みエンドポイント + 'revocation_endpoint' => $baseUrl . '/bc-mcp/oauth2/revoke', + 'introspection_endpoint' => $baseUrl . '/bc-mcp/oauth2/verify', + + // RFC 9207: 認可レスポンスに iss を含める事をクライアントへ通知する + 'authorization_response_iss_parameter_supported' => true, + + 'client_registration_types_supported' => ['dynamic'], + 'registration_endpoint_auth_methods_supported' => ['none'], + 'dpop_signing_alg_values_supported' => ['ES256', 'RS256'], + ]; + + return $this->response + ->withHeader('Cache-Control', 'no-cache') + ->withType('application/json') + ->withStringBody(json_encode($metadata, JSON_PRETTY_PRINT)); + + } catch (\Exception $exception) { + return $this->response + ->withStatus(500) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'server_error', + 'error_description' => 'Failed to generate authorization server metadata.', + 'debug_message' => $exception->getMessage() + ])); + } + } + + /** + * 動的クライアント登録エンドポイント (RFC 7591) + * POST /bc-mcp/oauth2/register + * + * @return Response + */ + public function register(): Response + { + if (!$this->request->is('post')) { + return $this->response + ->withStatus(405) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_request', + 'error_description' => 'Only POST method is supported' + ])); + } + + try { + // JSONリクエストデータを取得 + $requestData = []; + $contentType = $this->request->getHeaderLine('Content-Type'); + + // CakePHPは自動的にJSONデータをパースしてgetData()で取得可能 + $requestData = $this->request->getData(); + + // データが空の場合のみ、手動でJSONパースを実行 + if (empty($requestData) && strpos($contentType, 'application/json') !== false) { + $body = $this->request->getBody()->getContents(); + if (!empty($body)) { + $requestData = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->response + ->withStatus(400) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_request', + 'error_description' => 'Invalid JSON in request body' + ])); + } + } + } + + // 環境変数からサイトURLを取得 + $siteUrl = env('SITE_URL', 'https://localhost'); + $baseUrl = rtrim($siteUrl, '/'); + + // クライアントを登録 + $client = $this->clientRegistrationService->registerClient($requestData, $baseUrl); + + // RFC7591準拠のレスポンスを返す + return $this->response + ->withStatus(201) + ->withType('application/json') + ->withStringBody(json_encode($client->toRegistrationResponse(), JSON_PRETTY_PRINT)); + + } catch (Exception $exception) { + return $this->response + ->withStatus(400) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_client_metadata', + 'error_description' => $exception->getMessage() + ])); + } + } + + /** + * クライアント設定エンドポイント (RFC 7591) + * GET /bc-mcp/oauth2/register/{client_id} + * PUT /bc-mcp/oauth2/register/{client_id} + * DELETE /bc-mcp/oauth2/register/{client_id} + * + * @param string $clientId クライアントID + * @return Response + */ + public function clientConfiguration(string $clientId): Response + { + // 登録アクセストークンを取得 + $authHeader = $this->request->getHeaderLine('Authorization'); + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_token', + 'error_description' => 'Registration access token is required' + ])); + } + + $registrationAccessToken = substr($authHeader, 7); + + try { + if ($this->request->is('get')) { + // クライアント情報の取得 + $client = $this->clientRegistrationService->getClient($clientId, $registrationAccessToken); + + if (!$client) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_token', + 'error_description' => 'Invalid registration access token or client not found' + ])); + } + + return $this->response + ->withType('application/json') + ->withStringBody(json_encode($client->toRegistrationResponse(), JSON_PRETTY_PRINT)); + + } elseif ($this->request->is('put')) { + // クライアント情報の更新 + // CakePHPは自動的にJSONデータをパースしてgetData()で取得可能 + $requestData = $this->request->getData(); + + // データが空の場合のみ、手動でJSONパースを実行 + $contentType = $this->request->getHeaderLine('Content-Type'); + if (empty($requestData) && strpos($contentType, 'application/json') !== false) { + $body = $this->request->getBody()->getContents(); + if (!empty($body)) { + $requestData = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->response + ->withStatus(400) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_request', + 'error_description' => 'Invalid JSON in request body' + ])); + } + } + } + + $client = $this->clientRegistrationService->updateClient($clientId, $registrationAccessToken, $requestData); + + if (!$client) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_token', + 'error_description' => 'Invalid registration access token or client not found' + ])); + } + + return $this->response + ->withType('application/json') + ->withStringBody(json_encode($client->toRegistrationResponse(), JSON_PRETTY_PRINT)); + + } elseif ($this->request->is('delete')) { + // クライアントの削除 + $success = $this->clientRegistrationService->deleteClient($clientId, $registrationAccessToken); + + if (!$success) { + return $this->response + ->withStatus(401) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_token', + 'error_description' => 'Invalid registration access token or client not found' + ])); + } + + return $this->response->withStatus(204); // No Content + + } else { + return $this->response + ->withStatus(405) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_request', + 'error_description' => 'Only GET, PUT, DELETE methods are supported' + ])); + } + + } catch (Exception $exception) { + return $this->response + ->withStatus(400) + ->withType('application/json') + ->withStringBody(json_encode([ + 'error' => 'invalid_client_metadata', + 'error_description' => $exception->getMessage() + ])); + } + } + +} diff --git a/plugins/bc-mcp/src/Lib/OAuth2Util.php b/plugins/bc-mcp/src/Lib/OAuth2Util.php new file mode 100644 index 0000000000..074b2548da --- /dev/null +++ b/plugins/bc-mcp/src/Lib/OAuth2Util.php @@ -0,0 +1,122 @@ +is('https')? 'https' : 'http'; + $host = $request->getHeaderLine('Host'); + if (!$host) { + $host = $request->getEnv('HTTP_HOST')?: 'localhost'; + } + return $scheme . '://' . $host . '/bc-mcp'; + } + + /** + * URL に iss クエリを付与する + * + * RFC 9207。認可レスポンスに issuer を含める事で mix-up 攻撃を防ぐ。 + * 2026-07-28 のクライアントは iss があれば検証が MUST とされている。 + * + * @param string $url 対象の URL + * @param string $issuer issuer 識別子 + * @return string + */ + public static function addIssuerToUrl(string $url, string $issuer): string + { + $fragment = ''; + $hashPos = strpos($url, '#'); + if ($hashPos !== false) { + $fragment = substr($url, $hashPos); + $url = substr($url, 0, $hashPos); + } + $separator = str_contains($url, '?')? '&' : '?'; + return $url . $separator . 'iss=' . rawurlencode($issuer) . $fragment; + } + + /** + * CakePHPリクエストをPSR-7リクエストに変換 + * + * @return \Psr\Http\Message\ServerRequestInterface + */ + public static function createPsr7Request(\Cake\Http\ServerRequest $request): \Psr\Http\Message\ServerRequestInterface + { + // 環境変数からサイトURLを取得 + $siteUrl = env('SITE_URL', 'https://localhost'); + $uri = $siteUrl . $request->getRequestTarget(); + + // ヘッダーを取得 + $headers = []; + foreach($request->getHeaders() as $name => $values) { + if ($values) { + $headers[$name] = $values; + } + } + + // client_credentials認証のためにAuthorizationヘッダーを処理 + $postData = []; + if ($request->is('post')) { + $postData = $request->getData(); + + // POSTデータにclient_idとclient_secretがある場合、Basic認証ヘッダーに変換 + if (isset($postData['client_id']) && isset($postData['client_secret'])) { + $credentials = base64_encode($postData['client_id'] . ':' . $postData['client_secret']); + $headers['Authorization'] = ['Basic ' . $credentials]; + + // client_secretをPOSTデータから除去(OAuth2ライブラリがAuthorizationヘッダーから取得するため) + unset($postData['client_secret']); + } + } + + // ボディコンテンツを取得 + $body = Stream::create(''); + if ($request->is('post')) { + // client_secretが除去された後のPOSTデータを使用 + if (!empty($postData)) { + $bodyContent = http_build_query($postData); + $body = Stream::create($bodyContent); + $headers['Content-Type'] = ['application/x-www-form-urlencoded']; + } + } + + // PSR-7リクエストを作成 + $psrRequest = new ServerRequest( + $request->getMethod(), + $uri, + $headers, + $body + ); + + // クエリパラメータを設定(PKCEパラメータなどを含む) + $queryParams = $request->getQueryParams(); + if ($request->getData('scope')) { + // スコープがPOSTデータに含まれている場合、クエリパラメータに追加 + $queryParams['scope'] = $request->getData('scope'); + } + if (!empty($queryParams)) { + $psrRequest = $psrRequest->withQueryParams($queryParams); + } + + // POSTデータをparsedBodyとして設定 + if ($request->is('post') && !empty($postData)) { + $psrRequest = $psrRequest->withParsedBody($postData); + } + + return $psrRequest; + } +} diff --git a/plugins/bc-mcp/src/Mcp/BaseMcpTool.php b/plugins/bc-mcp/src/Mcp/BaseMcpTool.php new file mode 100644 index 0000000000..987931c884 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BaseMcpTool.php @@ -0,0 +1,703 @@ + 'object']; + + /** + * 読み取り専用ツールの注釈 + * + * クライアントが読み取りと書き込みを区別できるようにする。Claude の + * Research はツール呼び出しに都度承認を挟まないため、区別できる情報を + * 提供する意味がある。readOnlyHint が true のとき、destructiveHint と + * idempotentHint は意味を持たないため宣言しない。 + */ + protected const ANNOTATION_READ = [ + 'readOnlyHint' => true, + 'openWorldHint' => false, + ]; + + /** + * 追加系ツールの注釈 + * + * 追加のみで既存データを壊さない。同じ引数で繰り返すと重複が増えるため + * 冪等ではない。 + */ + protected const ANNOTATION_CREATE = [ + 'readOnlyHint' => false, + 'destructiveHint' => false, + 'idempotentHint' => false, + 'openWorldHint' => false, + ]; + + /** + * 更新系ツールの注釈 + * + * 既存データを上書きするため破壊的とみなす。同じ引数なら結果は同じ。 + */ + protected const ANNOTATION_UPDATE = [ + 'readOnlyHint' => false, + 'destructiveHint' => true, + 'idempotentHint' => true, + 'openWorldHint' => false, + ]; + + /** + * 削除系ツールの注釈 + * + * 削除済みのものを再度削除しても結果は変わらない。 + */ + protected const ANNOTATION_DELETE = [ + 'readOnlyHint' => false, + 'destructiveHint' => true, + 'idempotentHint' => true, + 'openWorldHint' => false, + ]; + + /** + * 自身が提供するツールをサーバーに登録する + * + * @param \Mcp\Server\McpServer $server SDK のサーバー + * @return \Mcp\Server\McpServer + */ + abstract public function registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer; + + /** + * 操作者のユーザーIDを解決する + * + * MCP のツールは JSON-RPC の引数しか受け取らないため、認証済みの操作者は + * McpContext から取得する。引数で明示された場合はそれを優先する + * (テストなど、コンテキストを持たない経路のため)。 + * + * @param int|null $loginUserId 引数で渡されたユーザーID + * @return int|null + */ + protected function resolveLoginUserId(?int $loginUserId = null): ?int + { + return $loginUserId ?? McpContext::getLoginUserId(); + } + + /** + * 成功時の戻り値を作成 + * + * @param mixed $content 戻り値のコンテンツ + * @param array $meta 追加のメタデータ(paginationなど) + * @return array MCP仕様に準拠した成功レスポンス + */ + protected function createSuccessResponse($content, array $meta = [], $message = '', $userId = null): array + { + if($message) { + $this->saveDblog($userId, $message); + } + return array_merge($content, $meta); + } + + /** + * 操作ログを保存する + * @param $userId + * @param $message + * @return void + */ + protected function saveDblog($userId, $message) + { + try { + $data = [ + 'message' => $message, + 'controller' => 'McpProxy', + 'action' => 'index', + 'user_id' => $userId + ]; + $dbLogsTable = TableRegistry::getTableLocator()->get('BaserCore.Dblogs'); + $dblog = $dbLogsTable->newEntity($data); + $dbLogsTable->saveOrFail($dblog); + } catch (\Exception) {} + } + + /** + * エラー時の戻り値を作成 + * + * @param string $message エラーメッセージ + * @param \Throwable|null $exception 例外オブジェクト(トレース情報用) + * @return array MCP仕様に準拠したエラーレスポンス + */ + protected function createErrorResponse(string $message, ?\Throwable $exception = null): array + { + $response = [ + 'content' => $message + ]; + if ($exception) { + $response['trace'] = $exception->getTraceAsString(); + } + return $response; + } + + /** + * try-catchブロックを共通化してエラーハンドリングを実行 + * + * @param callable $callback 実行する処理 + * @return array MCP仕様に準拠したレスポンス + */ + protected function executeWithErrorHandling(callable $callback): array + { + try { + return $callback(); + } catch (\Throwable $e) { + // \Error を捕捉しない場合、MCPサーバー側でトレースが失われ、 + // 発生箇所を特定できなくなるため、\Throwable にて捕捉する + return $this->createErrorResponse($e->getMessage(), $e); + } + } + + /** + * 値がファイルアップロード可能な形式かどうかを判定 + * + * ファイルアップロード可能な形式は data: URI と http(s) URL の2方式のみ + * (チャンクアップロードは廃止したため、拡張子付きの文字列は対象外) + * + * @param mixed $value 判定対象の値 + * @return bool ファイルアップロード可能な形式の場合true + */ + protected function isFileUploadable($value): bool + { + if (is_array($value)) { + return true; + } + + // Base64データの場合 + if (strpos($value, 'data:') === 0) { + return true; + } + + // URLの場合(http/httpsで始まる) + if (preg_match('/^https?:\/\//', $value)) { + return true; + } + + return false; + } + + /** + * ファイルアップロード処理 + * + * @param string $fileData 画像の URL、または data: URI 形式の base64 データ + * @param string $fieldName フィールド名(ログ用) + * @return array|false アップロード情報の配列、失敗時はfalse + */ + protected function processFileUpload(string $fileData, string $fieldName = 'file'): array|false + { + try { + // Base64データの場合 + if (strpos($fileData, 'data:') === 0) { + return $this->processBase64File($fileData); + } + + // URLの場合はダウンロードして処理 + if (preg_match('/^https?:\/\//', $fileData)) { + return $this->processUrlFile($fileData); + } + + throw new \Exception('不正なファイルデータ形式です: ' . $fileData); + + } catch (\Exception $e) { + // エラーログを出力 + if (!BcUtil::isTest()) { + error_log($fieldName . 'の処理に失敗しました: ' . $e->getMessage()); + } + return false; + } + } + + /** + * Base64エンコードされたファイルデータを処理 + * + * @param string $base64Data base64エンコードされたファイルデータ + * @return array アップロード情報の配列 + * @throws \Exception + */ + protected function processBase64File(string $base64Data): array + { + // data:mime/type;base64,... の形式から必要な情報を抽出 + if (!preg_match('/^data:([^;]+);base64,(.+)$/', $base64Data, $matches)) { + throw new \Exception('不正なbase64ファイル形式です'); + } + + $mimeType = $matches[1]; + $encodedData = $matches[2]; + + // base64として有効かチェック + if (!preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $encodedData)) { + throw new \Exception('base64デコードに失敗しました'); + } + + $decodedData = base64_decode($encodedData, true); + + if ($decodedData === false) { + throw new \Exception('base64デコードに失敗しました'); + } + + // ファイル拡張子を取得 + $extension = $this->getExtensionFromMimeType($mimeType); + + // 一意のファイル名を生成 + $fileName = 'upload_' . uniqid() . '.' . $extension; + $tmpPath = sys_get_temp_dir() . '/' . $fileName; + + // 一時ファイルに保存 + if (file_put_contents($tmpPath, $decodedData) === false) { + throw new \Exception('一時ファイルの作成に失敗しました'); + } + + // アップロード情報として返す + return [ + 'name' => $fileName, + 'type' => $mimeType, + 'tmp_name' => $tmpPath, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen($decodedData), + 'ext' => $extension + ]; + } + + /** + * URLからファイルをダウンロードして処理 + * + * @param string $url ファイルのURL + * @return array アップロード情報の配列 + * @throws \Exception + */ + protected function processUrlFile(string $url): array + { + // URLの妥当性チェック + if (!filter_var($url, FILTER_VALIDATE_URL)) { + throw new \Exception('不正なURL形式です: ' . $url); + } + + // HTTPSまたはHTTPのみ許可 + if (!preg_match('/^https?:\/\//', $url)) { + throw new \Exception('HTTPまたはHTTPSのURLのみサポートされています: ' . $url); + } + + // ユーザーエージェントを設定してファイルをダウンロード + $option = [ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: baserCMS-MCP-Client/1.0\r\n", + 'timeout' => 30, + 'follow_location' => true, + 'max_redirects' => 3 + ] + ]; + if (BcUtil::isTest()) { + $option['ssl'] = [ + 'verify_peer' => false, + 'verify_peer_name' => false + ]; + } + $context = stream_context_create($option); + $fileData = @file_get_contents($url, false, $context); + + if ($fileData === false) { + throw new \Exception('URLからファイルをダウンロードできませんでした: ' . $url); + } + + // ファイルサイズをチェック(10MBまで) + $fileSize = strlen($fileData); + if ($fileSize > 10 * 1024 * 1024) { + throw new \Exception('ファイルサイズが大きすぎます(10MB以下にしてください)'); + } + + // レスポンスヘッダーからContent-Typeを取得 + $headerMimeType = 'application/octet-stream'; + if(function_exists('http_get_last_response_headers')) { + $http_response_header = http_get_last_response_headers(); + } + if (isset($http_response_header)) { + foreach($http_response_header as $header) { + if (stripos($header, 'content-type:') === 0) { + $headerMimeType = trim(substr($header, 13)); + // パラメータを除去(例: "image/jpeg; charset=utf-8" -> "image/jpeg") + if (strpos($headerMimeType, ';') !== false) { + $headerMimeType = trim(explode(';', $headerMimeType)[0]); + } + break; + } + } + } + + // ファイル内容から実際のMIMEタイプを検出 + $actualMimeType = $this->detectMimeTypeFromContent($fileData); + + // URLから拡張子を推測 + $urlPath = parse_url($url, PHP_URL_PATH); + $urlExtension = ''; + if ($urlPath) { + $pathInfo = pathinfo($urlPath); + $urlExtension = strtolower($pathInfo['extension'] ?? ''); + } + + // 最終的なMIMEタイプと拡張子を決定(優先順位: ファイル内容 > URL拡張子 > HTTPヘッダー) + $mimeType = $actualMimeType; + $extension = $this->getExtensionFromMimeType($actualMimeType); + + // ファイル内容から検出できなかった場合、URL拡張子を使用 + if ($actualMimeType === 'application/octet-stream' && !empty($urlExtension)) { + $extension = $urlExtension; + $mimeType = $this->getMimeTypeFromExtension($urlExtension); + } + + // それでも不明な場合はHTTPヘッダーを使用 + if ($mimeType === 'application/octet-stream' && $headerMimeType !== 'application/octet-stream') { + $mimeType = $headerMimeType; + if (empty($extension)) { + $extension = $this->getExtensionFromMimeType($headerMimeType); + } + } + + // ファイル形式のチェック + if (!$this->isAllowedExtension($extension)) { + throw new \Exception('サポートされていないファイル形式です: ' . $extension); + } + + // 一意のファイル名を生成 + $fileName = 'download_' . uniqid() . '.' . $extension; + $tmpPath = sys_get_temp_dir() . '/' . $fileName; + + // 一時ファイルに保存 + if (file_put_contents($tmpPath, $fileData) === false) { + throw new \Exception('一時ファイルの作成に失敗しました'); + } + + return [ + 'name' => $fileName, + 'type' => $mimeType, + 'tmp_name' => $tmpPath, + 'error' => UPLOAD_ERR_OK, + 'size' => $fileSize, + 'ext' => $extension + ]; + } + + /** + * ファイル内容からMIMEタイプを検出 + * + * @param string $fileData ファイルのバイナリデータ + * @return string MIMEタイプ + */ + protected function detectMimeTypeFromContent(string $fileData): string + { + // ファイルデータが空の場合 + if (empty($fileData)) { + return 'application/octet-stream'; + } + + // マジックナンバーを確認してファイル形式を判定 + $header = substr($fileData, 0, 20); // 最初の20バイトを取得 + + // JPEG + if (substr($header, 0, 3) === "\xFF\xD8\xFF") { + return 'image/jpeg'; + } + + // PNG + if (substr($header, 0, 8) === "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") { + return 'image/png'; + } + + // GIF87a, GIF89a + if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') { + return 'image/gif'; + } + + // WebP + if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') { + return 'image/webp'; + } + + // BMP + if (substr($header, 0, 2) === 'BM') { + return 'image/bmp'; + } + + // SVG (XMLなのでテキストベース) + if (strpos($header, ' 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'bmp' => 'image/bmp', + 'ico' => 'image/x-icon', + + // ドキュメント + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt' => 'text/plain', + 'csv' => 'text/csv', + + // アーカイブ + 'zip' => 'application/zip', + 'rar' => 'application/x-rar-compressed', + 'tar' => 'application/x-tar', + 'gz' => 'application/gzip', + + // 音声・動画 + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'mp4' => 'video/mp4', + 'avi' => 'video/x-msvideo', + 'mov' => 'video/quicktime', + ]; + + return $mimeTypes[$extension] ?? 'application/octet-stream'; + } + + /** + * MIMEタイプから拡張子を取得 + * + * @param string $mimeType MIMEタイプ + * @return string ファイル拡張子 + */ + protected function getExtensionFromMimeType(string $mimeType): string + { + $extensions = [ + // 画像 + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/svg+xml' => 'svg', + 'image/bmp' => 'bmp', + 'image/x-icon' => 'ico', + + // ドキュメント + 'application/pdf' => 'pdf', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'text/plain' => 'txt', + 'text/csv' => 'csv', + + // アーカイブ + 'application/zip' => 'zip', + 'application/x-rar-compressed' => 'rar', + 'application/x-tar' => 'tar', + 'application/gzip' => 'gz', + + // 音声・動画 + 'audio/mpeg' => 'mp3', + 'audio/wav' => 'wav', + 'video/mp4' => 'mp4', + 'video/x-msvideo' => 'avi', + 'video/quicktime' => 'mov', + ]; + + return $extensions[$mimeType] ?? 'bin'; + } + + /** + * 許可された拡張子かチェック + * + * @param string $extension ファイル拡張子 + * @return bool 許可されている場合はtrue + */ + protected function isAllowedExtension(string $extension): bool + { + // デフォルトで許可する拡張子(baserCMSの設定を参考) + $allowedExtensions = [ + // 画像 + 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg', 'bmp', 'ico', + // ドキュメント + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', + // アーカイブ + 'zip', 'rar', 'tar', 'gz', + // 音声・動画(必要に応じて有効化) + // 'mp3', 'wav', 'mp4', 'avi', 'mov' + ]; + + return in_array(strtolower($extension), $allowedExtensions); + } + + /** + * 画像ファイル専用のアップロード処理 + * + * @param string $imageData 画像の URL、または data: URI 形式の base64 データ + * @return array|false アップロード情報の配列、失敗時はfalse + */ + protected function processImageUpload(string $imageData): array|false + { + $result = $this->processFileUpload($imageData, 'image'); + + // 配列の場合は画像ファイルかチェック + if (is_array($result)) { + $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico']; + if (!in_array($result['ext'], $imageExtensions)) { + throw new \Exception('画像ファイルではありません: ' . $result['ext']); + } + } + + return $result; + } + + /** + * 一時ファイルをクリーンアップ + * + * @param string $tmpPath 一時ファイルのパス + */ + protected function cleanupTempFile(string $tmpPath): void + { + if (file_exists($tmpPath) && strpos($tmpPath, sys_get_temp_dir()) === 0) { + unlink($tmpPath); + } + } + + /** + * 配列データからCakePHPのUploadedFileオブジェクトを作成 + * + * @param array $fileData ファイル情報の配列 + * @return \Psr\Http\Message\UploadedFileInterface + */ + protected function createUploadedFileFromArray(array $fileData): \Psr\Http\Message\UploadedFileInterface + { + // ファイルストリームを作成 + $stream = fopen($fileData['tmp_name'], 'r'); + + return new \Laminas\Diactoros\UploadedFile( + $stream, // stream + $fileData['size'], // size + $fileData['error'], // error + $fileData['name'], // clientFilename + $fileData['type'] // clientMediaType + ); + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php b/plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php new file mode 100644 index 0000000000..892adc7402 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php @@ -0,0 +1,26 @@ + ツールクラス名の配列 + */ + public static function getToolClasses(): array + { + return [ + PagesTool::class, + ]; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php b/plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php new file mode 100644 index 0000000000..7453a0d70e --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BaserCore/PagesTool.php @@ -0,0 +1,474 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp\BaserCore; + +use BaserCore\Service\ContentsService; +use BaserCore\Service\ContentsServiceInterface; +use BaserCore\Service\PagesService; +use BaserCore\Service\PagesServiceInterface; +use BcMcp\Mcp\BaseMcpTool; +use Cake\ORM\TableRegistry; + +/** + * 固定ページツールクラス + * + * 固定ページのCRUD操作を提供する。 + * + * 固定ページは pages テーブルと contents テーブルの複合構造であり、名前が + * 紛らわしい点に注意する。 + * - pages.contents … ページ本文(HTML) + * - pages.content(Contents アソシエーション)… タイトル・URL・公開状態・親フォルダ + */ +class PagesTool extends BaseMcpTool +{ + + /** + * 固定ページ関連のツールをサーバーに登録する + * + * @param \Mcp\Server\McpServer $server SDK のサーバー + * @return \Mcp\Server\McpServer + */ + public function registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer + { + return $server + ->tool( + name: 'getPages', + description: '固定ページの一覧を取得します', + callback: [$this, 'getPages'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'keyword' => ['type' => 'string', 'description' => '検索キーワード(ページ本文を対象に検索)'], + 'siteId' => ['type' => 'number', 'description' => 'サイトID(省略時は全て)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(1: 公開のみ)(省略時は全て)'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は10件)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + ->tool( + name: 'getPage', + description: '指定されたIDの固定ページを取得します', + callback: [$this, 'getPage'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '固定ページID(必須)'], + ], + 'required' => ['id'] + ] + ) + ->tool( + name: 'addPage', + description: '固定ページを追加します', + callback: [$this, 'addPage'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'description' => 'ページタイトル(必須)'], + 'content' => ['type' => 'string', 'description' => 'ページ本文、マークダウン不可、HTML推奨'], + 'name' => ['type' => 'string', 'description' => 'URLのスラッグ。URLにおけるページを特定する識別子(省略時は自動採番)'], + 'parentId' => ['type' => 'number', 'description' => '親フォルダのコンテンツID(省略時はサイトルート)'], + 'siteId' => ['type' => 'number', 'description' => 'どのサイトに作成するかを指定するサイトID(省略時はメインサイト)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)(省略時は0)'], + 'description' => ['type' => 'string', 'description' => 'ページの説明'], + 'publishBegin' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開開始日時(省略時はなし)'], + 'publishEnd' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開終了日時(省略時はなし)'], + 'pageTemplate' => ['type' => 'string', 'description' => 'ページテンプレート名(省略時はデフォルト)'], + 'eyeCatch' => ['type' => 'string', 'description' => 'アイキャッチ画像。外部画像URLを直接指定'], + ], + 'required' => ['title'] + ] + ) + ->tool( + name: 'editPage', + description: '固定ページを編集します', + callback: [$this, 'editPage'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '固定ページID(必須)'], + 'title' => ['type' => 'string', 'description' => 'ページタイトル'], + 'content' => ['type' => 'string', 'description' => 'ページ本文、マークダウン不可、HTML推奨'], + 'name' => ['type' => 'string', 'description' => 'URLのスラッグ。URLにおけるページを特定する識別子'], + 'parentId' => ['type' => 'number', 'description' => '親フォルダのコンテンツID'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)'], + 'description' => ['type' => 'string', 'description' => 'ページの説明'], + 'publishBegin' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開開始日時'], + 'publishEnd' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開終了日時'], + 'pageTemplate' => ['type' => 'string', 'description' => 'ページテンプレート名'], + 'eyeCatch' => ['type' => 'string', 'description' => 'アイキャッチ画像。外部画像URLを直接指定'], + ], + 'required' => ['id'] + ] + ) + ->tool( + name: 'deletePage', + description: '指定されたIDの固定ページを削除します。ゴミ箱には残らず完全に削除されます', + callback: [$this, 'deletePage'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '固定ページID(必須)'], + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * + * @param string $action アクション名 + * @param array $args 引数 + * @return array|false + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addPage': + return ['POST' => '/baser-core/pages/add.json']; + case 'editPage': + if (empty($args['id'])) return false; + return ['POST' => "/baser-core/pages/edit/{$args['id']}.json"]; + case 'deletePage': + if (empty($args['id'])) return false; + return ['POST' => "/baser-core/pages/delete/{$args['id']}.json"]; + case 'getPages': + return ['GET' => '/baser-core/pages/index.json']; + case 'getPage': + if (empty($args['id'])) return false; + return ['GET' => "/baser-core/pages/view/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * 固定ページの一覧を取得する + * + * @param string|null $keyword 検索キーワード + * @param int|null $siteId サイトID + * @param int|null $status 公開ステータス + * @param int|null $limit 取得件数 + * @param int|null $page ページ番号 + * @return array + */ + public function getPages( + ?string $keyword = null, + ?int $siteId = null, + ?int $status = null, + ?int $limit = 10, + ?int $page = 1 + ): array + { + return $this->executeWithErrorHandling(function() use ($keyword, $siteId, $status, $limit, $page) { + /** @var PagesService $pagesService */ + $pagesService = $this->getService(PagesServiceInterface::class); + + $params = ['limit' => $limit ?? 10]; + // getIndex は contents(ページ本文)に対する LIKE 検索に対応する + if ($keyword !== null) $params['contents'] = $keyword; + if ($status === 1) $params['status'] = 'publish'; + + $query = $pagesService->getIndex($params); + if ($siteId !== null) { + $query->where(['Contents.site_id' => $siteId]); + } + $page = $page ?? 1; + if ($page > 1) { + $query->offset(($page - 1) * ($limit ?? 10)); + } + + $pages = []; + foreach($query->all() as $entity) { + $pages[] = $entity->toArray(); + } + + // 他の一覧系ツールと同じ形式(data / pagination)で返す。 + // 素の配列を返すと outputSchema で宣言している object 型と矛盾する + return $this->createSuccessResponse([ + 'data' => $pages, + 'pagination' => [ + 'page' => $page, + 'limit' => $limit ?? 10, + 'count' => count($pages), + ] + ]); + }); + } + + /** + * 指定されたIDの固定ページを取得する + * + * @param int $id 固定ページID + * @return array + */ + public function getPage(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + /** @var PagesService $pagesService */ + $pagesService = $this->getService(PagesServiceInterface::class); + $page = $pagesService->get($id); + return $this->createSuccessResponse($page->toArray()); + }); + } + + /** + * 固定ページを追加する + * + * 引数の $content(ページ本文)は pages.contents へ、タイトルや URL などは + * content キー(Contents アソシエーション)へ格納する。 + * + * @param string $title ページタイトル + * @param string|null $content ページ本文 + * @param string|null $name URLのスラッグ + * @param int|null $parentId 親フォルダのコンテンツID + * @param int|null $siteId サイトID + * @param int|null $status 公開ステータス + * @param string|null $description 説明 + * @param string|null $publishBegin 公開開始日時 + * @param string|null $publishEnd 公開終了日時 + * @param string|null $pageTemplate ページテンプレート + * @param string|null $eyeCatch アイキャッチ画像 + * @param int|null $loginUserId ログインユーザーID + * @return array + */ + public function addPage( + string $title, + ?string $content = null, + ?string $name = null, + ?int $parentId = null, + ?int $siteId = null, + ?int $status = 0, + ?string $description = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?string $pageTemplate = null, + ?string $eyeCatch = null, + ?int $loginUserId = null + ): array + { + // 認証済みの操作者はリクエストのコンテキストから解決する + $loginUserId = $this->resolveLoginUserId($loginUserId); + return $this->executeWithErrorHandling(function() use ( + $title, $content, $name, $parentId, $siteId, $status, + $description, $publishBegin, $publishEnd, $pageTemplate, $eyeCatch, $loginUserId + ) { + if (empty($title)) { + return $this->createErrorResponse('タイトルは必須です'); + } + + /** @var PagesService $pagesService */ + $pagesService = $this->getService(PagesServiceInterface::class); + + // 固定ページは Content が必須で、Content にはサイトの指定が必須である。 + // つまり「どのサイトに作るのか」という情報が必ず必要になるため、 + // 省略された場合はメインサイトを解決する(ID の決め打ちはしない)。 + $siteId = $siteId ?? $this->getMainSiteId(); + if (!$siteId) { + return $this->createErrorResponse( + 'サイトを特定できませんでした。siteId を指定してください。' + ); + } + + // 固定ページはサイト内のいずれかのフォルダに属する必要がある。 + // 省略された場合は指定されたサイトのルートに配置する。 + $parentId = $parentId ?? $this->getSiteRootContentId($siteId); + if (!$parentId) { + return $this->createErrorResponse( + sprintf('サイトID %s のルートフォルダを特定できませんでした。parentId を指定してください。', $siteId) + ); + } + + $contentData = [ + 'title' => $title, + // plugin と type は固定値のためツール側で補う + 'plugin' => 'BaserCore', + 'type' => 'Page', + 'site_id' => $siteId, + 'parent_id' => $parentId, + 'self_status' => (bool)$status, + 'author_id' => $loginUserId, + ]; + if ($name !== null) $contentData['name'] = $name; + if ($description !== null) $contentData['description'] = $description; + if ($publishBegin !== null) $contentData['publish_begin'] = $publishBegin; + if ($publishEnd !== null) $contentData['publish_end'] = $publishEnd; + if ($eyeCatch !== null) $contentData['eyecatch'] = $eyeCatch; + + $postData = [ + // ページ本文は pages.contents に保存する + 'contents' => $content ?? '', + 'content' => $contentData, + ]; + if ($pageTemplate !== null) $postData['page_template'] = $pageTemplate; + + $page = $pagesService->create($postData); + + return $this->createSuccessResponse( + $page->toArray(), + [], + sprintf('固定ページ「%s」を追加しました。', $title), + $loginUserId + ); + }); + } + + /** + * 固定ページを編集する + * + * 指定された項目のみを更新する。 + * + * @param int $id 固定ページID + * @param string|null $title ページタイトル + * @param string|null $content ページ本文 + * @param string|null $name URLのスラッグ + * @param int|null $parentId 親フォルダのコンテンツID + * @param int|null $status 公開ステータス + * @param string|null $description 説明 + * @param string|null $publishBegin 公開開始日時 + * @param string|null $publishEnd 公開終了日時 + * @param string|null $pageTemplate ページテンプレート + * @param string|null $eyeCatch アイキャッチ画像 + * @param int|null $loginUserId ログインユーザーID + * @return array + */ + public function editPage( + int $id, + ?string $title = null, + ?string $content = null, + ?string $name = null, + ?int $parentId = null, + ?int $status = null, + ?string $description = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?string $pageTemplate = null, + ?string $eyeCatch = null, + ?int $loginUserId = null + ): array + { + // 認証済みの操作者はリクエストのコンテキストから解決する + $loginUserId = $this->resolveLoginUserId($loginUserId); + return $this->executeWithErrorHandling(function() use ( + $id, $title, $content, $name, $parentId, $status, + $description, $publishBegin, $publishEnd, $pageTemplate, $eyeCatch, $loginUserId + ) { + /** @var PagesService $pagesService */ + $pagesService = $this->getService(PagesServiceInterface::class); + $target = $pagesService->get($id); + + $contentData = ['id' => $target->content->id]; + if ($title !== null) $contentData['title'] = $title; + if ($name !== null) $contentData['name'] = $name; + if ($parentId !== null) $contentData['parent_id'] = $parentId; + if ($status !== null) $contentData['self_status'] = (bool)$status; + if ($description !== null) $contentData['description'] = $description; + if ($publishBegin !== null) $contentData['publish_begin'] = $publishBegin; + if ($publishEnd !== null) $contentData['publish_end'] = $publishEnd; + if ($eyeCatch !== null) $contentData['eyecatch'] = $eyeCatch; + + $postData = ['id' => $id]; + // ページ本文は pages.contents に保存する + if ($content !== null) $postData['contents'] = $content; + if ($pageTemplate !== null) $postData['page_template'] = $pageTemplate; + if (count($contentData) > 1) $postData['content'] = $contentData; + + $page = $pagesService->update($target, $postData); + + return $this->createSuccessResponse( + $page->toArray(), + [], + sprintf('固定ページ「%s」を編集しました。', $page->content->title), + $loginUserId + ); + }); + } + + /** + * 固定ページを削除する + * + * PagesService::delete() は完全削除であり、pages のレコードと + * 紐づく contents のレコードがいずれも削除される。ゴミ箱にも残らないため + * 復元できない点に注意する。 + * + * @param int $id 固定ページID + * @param int|null $loginUserId ログインユーザーID + * @return array + */ + public function deletePage(int $id, ?int $loginUserId = null): array + { + // 認証済みの操作者はリクエストのコンテキストから解決する + $loginUserId = $this->resolveLoginUserId($loginUserId); + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + /** @var PagesService $pagesService */ + $pagesService = $this->getService(PagesServiceInterface::class); + $page = $pagesService->get($id); + $title = $page->content->title; + + if (!$pagesService->delete($id)) { + return $this->createErrorResponse('固定ページの削除に失敗しました'); + } + + return $this->createSuccessResponse( + ['id' => $id, 'title' => $title], + [], + sprintf('固定ページ「%s」を削除しました。', $title), + $loginUserId + ); + }); + } + + /** + * メインサイトのIDを取得する + * + * 固定ページは Content が必須で、Content にはサイトの指定が必須である。 + * サイトIDが省略された場合の既定値として、ID を決め打ちせずメインサイトを + * DB から解決する。 + * + * @return int|null + */ + public function getMainSiteId(): ?int + { + // getRootMain() は SitesTable が提供する(SitesService には無い) + $mainSite = TableRegistry::getTableLocator()->get('BaserCore.Sites')->getRootMain(); + return $mainSite? $mainSite->id : null; + } + + /** + * サイトルートのコンテンツIDを取得する + * + * 親フォルダが指定されなかった場合の配置先として使う。 + * + * @param int $siteId サイトID + * @return int|null + */ + public function getSiteRootContentId(int $siteId): ?int + { + /** @var ContentsService $contentsService */ + $contentsService = $this->getService(ContentsServiceInterface::class); + $siteRoot = $contentsService->getSiteRoot($siteId); + return $siteRoot? $siteRoot->id : null; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BcBlog/BcBlogServer.php b/plugins/bc-mcp/src/Mcp/BcBlog/BcBlogServer.php new file mode 100644 index 0000000000..81d553c8f2 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BcBlogServer.php @@ -0,0 +1,29 @@ + ツールクラス名の配列 + */ + public static function getToolClasses(): array + { + return [ + BlogContentsTool::class, + BlogPostsTool::class, + BlogCategoriesTool::class, + BlogTagsTool::class, + ]; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BcBlog/BlogCategoriesTool.php b/plugins/bc-mcp/src/Mcp/BcBlog/BlogCategoriesTool.php new file mode 100644 index 0000000000..640632ac11 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogCategoriesTool.php @@ -0,0 +1,375 @@ +tool( + callback: [$this, 'addBlogCategory'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addBlogCategory', + description: 'ブログカテゴリを追加します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'description' => 'カテゴリタイトル(必須)'], + 'name' => ['type' => 'string', 'description' => 'カテゴリ名(省略時はタイトルから自動生成)'], + 'blogContentId' => ['type' => 'number', 'description' => 'ブログコンテンツID(省略時はデフォルト)'], + 'parentId' => ['type' => 'number', 'description' => '親カテゴリID(省略時はルートカテゴリ)'], + 'status' => ['type' => 'number', 'default' => 1, 'description' => '公開ステータス(0: 非公開, 1: 公開)'] + ], + 'required' => ['title'] + ] + ) + ->tool( + callback: [$this, 'getBlogCategories'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getBlogCategories', + description: 'ブログカテゴリの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'blogContentId' => ['type' => 'number', 'description' => 'ブログコンテンツID(省略時はデフォルト)'], + 'title' => ['type' => 'string', 'description' => 'タイトル(部分一致)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(null: 全て, publish: 公開)'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は制限なし)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + ->tool( + callback: [$this, 'getBlogCategory'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getBlogCategory', + description: '指定されたIDのブログカテゴリを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カテゴリID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'editBlogCategory'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editBlogCategory', + description: '指定されたIDのブログカテゴリを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カテゴリID(必須)'], + 'title' => ['type' => 'string', 'description' => 'カテゴリタイトル'], + 'name' => ['type' => 'string', 'description' => 'カテゴリ名'], + 'blogContentId' => ['type' => 'number', 'description' => 'ブログコンテンツID(省略時はデフォルト)'], + 'parentId' => ['type' => 'number', 'description' => '親カテゴリID(省略時はルートカテゴリ)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'deleteBlogCategory'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteBlogCategory', + description: '指定されたIDのブログカテゴリを削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カテゴリID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addBlogCategory': + if(empty($args['blogContentId'])) return false; + return ['POST' => "/bc-blog/blog_categories/add/{$args['blogContentId']}.json"]; + case 'editBlogCategory': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_categories/edit/{$args['id']}.json"]; + case 'getBlogCategories': + $blogContentId = $args['blogContentId'] ?? 1; + return ['GET' => "/bc-blog/blog_categories/index/{$blogContentId}.json"]; + case 'getBlogCategory': + if(empty($args['id'])) return false; + return ['GET' => "/bc-blog/blog_categories/view/{$args['id']}.json"]; + case 'deleteBlogCategory': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_categories/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * ブログカテゴリを追加 + * @param string $title + * @param string|null $name + * @param int|null $blogContentId + * @param int|null $parentId + * @param int|null $status + * @param int|null $loginUserId + * @return array + */ + public function addBlogCategory( + string $title, + ?string $name = null, + ?int $blogContentId = 1, + ?int $parentId = null, + ?int $status = 1, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ($title, $name, $blogContentId, $parentId, $status, $loginUserId) { + // 必須パラメータのチェック + if (empty($title)) return $this->createErrorResponse('titleは必須です'); + + $blogCategoriesService = $this->getService(BlogCategoriesServiceInterface::class); + + $result = $blogCategoriesService->create($blogContentId, [ + 'title' => $title, + 'name' => $name ?? 'category_' . uniqid(), + 'parent_id' => $parentId, + 'status' => $status + ]); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログカテゴリ「%s」を追加しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログカテゴリの保存に失敗しました'); + } + }); + } + + /** + * ブログカテゴリの一覧を取得 + * @param int|null $blogContentId + * @param int|null $limit + * @param int|null $page + * @param string|null $title + * @param string|null $status + * @return array + */ + public function getBlogCategories( + ?int $blogContentId = 1, + ?string $title = null, + ?string $status = null, + ?int $limit = null, + ?int $page = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $blogContentId, + $title, + $status, + $limit, + $page + ) { + /** @var BlogCategoriesService $blogCategoriesService */ + $blogCategoriesService = $this->getService(BlogCategoriesServiceInterface::class); + + $conditions = []; + if (!empty($title)) $conditions['title'] = $title; + if (!empty($status)) $conditions['status'] = $status; + if (!empty($limit)) $conditions['limit'] = $limit; + if (!empty($page)) $conditions['page'] = $page; + + // + $query = $blogCategoriesService->getIndex($blogContentId ?? 1, $conditions); + + // 総件数を取得(ページネーション前) + $totalCount = $blogCategoriesService->getIndex($blogContentId ?? 1, array_diff_key($conditions, array_flip(['limit', 'page'])))->count(); + + $results = $query->toArray(); + + return $this->createSuccessResponse($results, [ + 'pagination' => [ + 'page' => $page ?? 1, + 'limit' => $limit ?? null, + 'count' => count($results), + 'total' => $totalCount + ] + ]); + }); + } + + /** + * 指定されたIDのブログカテゴリを取得 + * @param int $id + * @param int|null $blogContentId + * @return array + */ + public function getBlogCategory(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + // 必須パラメータのチェック + if (empty($id)) return $this->createErrorResponse('idは必須です'); + + $blogCategoriesService = $this->getService(BlogCategoriesServiceInterface::class); + $result = $blogCategoriesService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのブログカテゴリが見つかりません'); + } + }); + } + + /** + * ブログカテゴリを編集 + * @param int $id + * @param string|null $title + * @param string|null $name + * @param int|null $blogContentId + * @param int|null $parentId + * @param int|null $status + * @param int|null $loginUserId + * @return array + */ + public function editBlogCategory( + int $id, + ?string $title = null, + ?string $name = null, + ?int $blogContentId = null, + ?int $parentId = null, + ?int $status = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $id, + $title, + $name, + $blogContentId, + $parentId, + $status, + $loginUserId + ) { + // 必須パラメータのチェック + if (empty($id)) return $this->createErrorResponse('idは必須です'); + + $blogCategoriesService = $this->getService(BlogCategoriesServiceInterface::class); + $entity = $blogCategoriesService->get($id); + + if (!$entity) return $this->createErrorResponse('指定されたIDのブログカテゴリが見つかりません'); + + // 更新データを構築(null以外の値のみ) + $data = []; + if ($title !== null) $data['title'] = $title; + if ($name !== null) $data['name'] = $name; + if ($blogContentId !== null) $data['blog_content_id'] = $blogContentId; + if ($parentId !== null) $data['parent_id'] = $parentId; + if ($status !== null) $data['status'] = $status; + + // nameを更新する場合、バリデーションエラーを避けるために + // 現在のblog_content_idを明示的に含める + if (isset($data['name']) && !isset($data['blog_content_id'])) { + $data['blog_content_id'] = $entity->blog_content_id; + } + + // バリデーションコンテキストを設定 + $options = []; + if (isset($data['name'])) $options['validate'] = false; // 重複チェックのバリデーションを一時的に無効化 + + // バリデーションを無効化した場合は手動で重複チェックを実行 + if (isset($data['name']) && isset($options['validate']) && $options['validate'] === false) { + // 同じblog_content_id内での重複をチェック + $existingCategory = $blogCategoriesService->getIndex($entity->blog_content_id, [ + 'name' => $data['name'] + ])->first(); + + if ($existingCategory && $existingCategory->id !== $id) { + return $this->createErrorResponse('指定されたカテゴリ名は既に使用されています'); + } + } + + $result = $blogCategoriesService->update($entity, $data, $options); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログカテゴリ「%s」を編集しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログカテゴリの更新に失敗しました'); + } + }); + } + + /** + * ブログカテゴリを削除 + * @param int $id + * @param int|null $loginUserId + * @return array + */ + public function deleteBlogCategory(int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + // 必須パラメータのチェック + if (empty($id)) return $this->createErrorResponse('idは必須です'); + + $blogCategoriesService = $this->getService(BlogCategoriesServiceInterface::class); + $entity = $blogCategoriesService->get($id); + + if (!$entity) return $this->createErrorResponse('指定されたIDのブログカテゴリが見つかりません'); + + $title = $entity->title; + $result = $blogCategoriesService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + ['message' => 'ブログカテゴリを削除しました'], + [], + sprintf('ブログカテゴリ「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログカテゴリの削除に失敗しました'); + } + }); + } +} diff --git a/plugins/bc-mcp/src/Mcp/BcBlog/BlogContentsTool.php b/plugins/bc-mcp/src/Mcp/BcBlog/BlogContentsTool.php new file mode 100644 index 0000000000..7f08e68cf3 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogContentsTool.php @@ -0,0 +1,462 @@ +tool( + callback: [$this, 'addBlogContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addBlogContent', + description: 'baserCMSは複数のブログを持つことができます。一つ一つのブログをブログコンテンツと呼び、そのブログコンテンツを追加します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'ブログコンテンツ名、URLに影響します(必須)'], + 'title' => ['type' => 'string', 'description' => 'ブログコンテンツのタイトル(必須)'], + 'siteId' => ['type' => 'number', 'description' => 'サイトID(省略時は1)'], + 'parentId' => ['type' => 'number', 'description' => '親ID(省略時は1)'], + 'description' => ['type' => 'string', 'description' => '説明文'], + 'authorId' => ['type' => 'number', 'default' => 1, 'description' => '作成者ID'], + 'layoutTemplate' => ['type' => 'string', 'description' => 'レイアウトテンプレート名(初期値: default)'], + 'status' => ['type' => 'number', 'description' => '公開状態(0: 非公開状態, 1: 公開状態)、(省略時は0)'], + 'publishBegin' => ['type' => 'string', 'description' => '公開開始日時(YYYY-MM-DD HH:MM:SS形式)'], + 'publishEnd' => ['type' => 'string', 'description' => '公開終了日時(YYYY-MM-DD HH:MM:SS形式)'], + 'excludeSearch' => ['type' => 'boolean', 'description' => '検索結果から除外するかどうか(初期値: false)'], + 'excludeMenu' => ['type' => 'boolean', 'description' => 'メニューから除外するかどうか(初期値: false)'], + 'blankLink' => ['type' => 'boolean', 'description' => 'リンクを新しいタブで開くかどうか(初期値: false)'], + 'template' => ['type' => 'string', 'description' => 'テンプレート名(省略時は "default")'], + 'listCount' => ['type' => 'number', 'description' => '一覧表示件数(省略時は10)'], + 'listDirection' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'description' => '一覧表示方向(ASC|DESC)、(省略時はDESC)'], + 'feedCount' => ['type' => 'number', 'description' => 'RSSフィードに表示する件数(省略時は10)'], + 'commentUse' => ['type' => 'boolean', 'description' => 'コメント機能を使用するか(省略時はfalse)'], + 'commentApprove' => ['type' => 'boolean', 'description' => 'コメント機能について各コメントの公開について承認制にするか(省略時はfalse)'], + 'tagUse' => ['type' => 'boolean', 'description' => 'タグ機能を使用するか(省略時はfalse)'], + 'eyeCatchSizeThumbWidth' => ['type' => 'number', 'description' => 'アイキャッチサムネイル幅(PC)(省略時はシステムデフォルト値)'], + 'eyeCatchSizeThumbHeight' => ['type' => 'number', 'description' => 'アイキャッチサムネイル高さ(PC)(省略時はシステムデフォルト値)'], + 'eyeCatchSizeMobileThumbWidth' => ['type' => 'number', 'description' => 'アイキャッチサムネイル幅(モバイル)(省略時はシステムデフォルト値)'], + 'eyeCatchSizeMobileThumbHeight' => ['type' => 'number', 'description' => 'アイキャッチサムネイル高さ(モバイル)(省略時はシステムデフォルト値)'], + 'useContent' => ['type' => 'boolean', 'description' => '概要入力欄を使用するか(省略時はfalse)'], + 'widgetArea' => ['type' => 'number', 'description' => 'ウィジェットエリアID(省略時はシステムデフォルト値)'] + ], + 'required' => ['name', 'title'] + ] + ) + ->tool( + callback: [$this, 'editBlogContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editBlogContent', + description: 'baserCMSは複数のブログを持つことができます。一つ一つのブログをブログコンテンツと呼び、指定されたIDのブログコンテンツを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログコンテンツID(必須)'], + 'name' => ['type' => 'string', 'description' => 'ブログコンテンツ名、URLに影響します'], + 'title' => ['type' => 'string', 'description' => 'ブログコンテンツのタイトル'], + 'siteId' => ['type' => 'number', 'description' => 'サイトID'], + 'parentId' => ['type' => 'number', 'description' => '親ID'], + 'description' => ['type' => 'string', 'description' => '説明文'], + 'authorId' => ['type' => 'number', 'default' => 1, 'description' => '作成者ID'], + 'layoutTemplate' => ['type' => 'string', 'description' => 'レイアウトテンプレート名'], + 'status' => ['type' => 'number', 'description' => '公開状態(0: 非公開状態, 1: 公開状態)'], + 'publishBegin' => ['type' => 'string', 'description' => '公開開始日時(YYYY-MM-DD HH:MM:SS形式)'], + 'publishEnd' => ['type' => 'string', 'description' => '公開終了日時(YYYY-MM-DD HH:MM:SS形式)'], + 'excludeSearch' => ['type' => 'boolean', 'description' => '検索結果から除外するかどうか'], + 'excludeMenu' => ['type' => 'boolean', 'description' => 'メニューから除外するかどうか'], + 'blankLink' => ['type' => 'boolean', 'description' => 'リンクを新しいタブで開くかどうか'], + 'template' => ['type' => 'string', 'description' => 'テンプレート名'], + 'listCount' => ['type' => 'number', 'description' => '一覧表示件数'], + 'listDirection' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'description' => '一覧表示方向(ASC|DESC)'], + 'feedCount' => ['type' => 'number', 'description' => 'RSSフィードに表示する件数'], + 'commentUse' => ['type' => 'boolean', 'description' => 'コメント機能を使用するか'], + 'commentApprove' => ['type' => 'boolean', 'description' => 'コメント機能について各コメントの公開について承認制にするか'], + 'tagUse' => ['type' => 'boolean', 'description' => 'タグ機能を使用するか'], + 'eyeCatchSizeThumbWidth' => ['type' => 'number', 'description' => 'アイキャッチサムネイル幅(PC)'], + 'eyeCatchSizeThumbHeight' => ['type' => 'number', 'description' => 'アイキャッチサムネイル高さ(PC)'], + 'eyeCatchSizeMobileThumbWidth' => ['type' => 'number', 'description' => 'アイキャッチサムネイル幅(モバイル)'], + 'eyeCatchSizeMobileThumbHeight' => ['type' => 'number', 'description' => 'アイキャッチサムネイル高さ(モバイル)'], + 'useContent' => ['type' => 'boolean', 'description' => '概要入力欄を使用するか'], + 'widgetArea' => ['type' => 'number', 'description' => 'ウィジェットエリアID'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'getBlogContents'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getBlogContents', + description: 'baserCMSは複数のブログを持つことができます。一つ一つのブログをブログコンテンツと呼び、そのブログコンテンツの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'description' => 'ブログコンテンツのタイトル(部分一致)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(null: 全て, publish: 公開)'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は制限なし)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + ->tool( + callback: [$this, 'getBlogContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getBlogContent', + description: 'baserCMSは複数のブログを持つことができます。一つ一つのブログをブログコンテンツと呼び、指定されたIDのブログコンテンツを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログコンテンツID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'deleteBlogContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteBlogContent', + description: 'baserCMSは複数のブログを持つことができます。一つ一つのブログをブログコンテンツと呼び、指定されたIDのブログコンテンツを削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログコンテンツID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addBlogContent': + return ['POST' => "/bc-blog/blog_contents/add.json"]; + case 'editBlogContent': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_contents/edit/{$args['id']}.json"]; + case 'getBlogContents': + return ['GET' => "/bc-blog/blog_contents/index.json"]; + case 'getBlogContent': + if(empty($args['id'])) return false; + return ['GET' => "/bc-blog/blog_contents/view/{$args['id']}.json"]; + case 'deleteBlogContent': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_contents/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * ブログコンテンツを追加 + */ + public function addBlogContent( + string $name, + string $title, + ?int $siteId = 1, + ?int $parentId = 1, + ?string $description = null, + ?int $authorId = null, + ?string $layoutTemplate = null, + ?bool $status = false, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?bool $excludeSearch = false, + ?bool $excludeMenu = false, + ?bool $blankLink = false, + ?string $template = 'default', + ?int $listCount = 10, + ?string $listDirection = 'DESC', + ?int $feedCount = 10, + ?bool $commentUse = false, + ?bool $commentApprove = false, + ?bool $tagUse = false, + ?int $eyeCatchSizeThumbWidth = null, + ?int $eyeCatchSizeThumbHeight = null, + ?int $eyeCatchSizeMobileThumbWidth = null, + ?int $eyeCatchSizeMobileThumbHeight = null, + ?bool $useContent = false, + ?int $widgetArea = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $name, $title, $siteId, $parentId, $description, $authorId, $layoutTemplate, $status, + $publishBegin, $publishEnd, $excludeSearch, $excludeMenu, $blankLink, $template, $listCount, + $listDirection, $feedCount, $commentUse, $commentApprove, $tagUse, $eyeCatchSizeThumbWidth, + $eyeCatchSizeThumbHeight, $eyeCatchSizeMobileThumbWidth, $eyeCatchSizeMobileThumbHeight, + $useContent, $widgetArea, $loginUserId + ) { + $blogContentsService = $this->getService(BlogContentsServiceInterface::class); + + // baserCMSでは、BlogContentとContentの両方を作成する必要があります + // Contentエンティティの基本データ + $contentData = [ + 'name' => $name, + 'plugin' => 'BcBlog', + 'type' => 'BlogContent', + 'title' => $title, + 'site_id' => $siteId, + 'parent_id' => $parentId, + 'description' => $description ?? '', + 'author_id' => $authorId ?? ($loginUserId ?? 1), // 作成者ID、指定がなければデフォルトユーザー + 'layout_template' => $layoutTemplate ?? '', + 'self_status' => (bool)$status, + 'publish_begin' => $publishBegin, + 'publish_end' => $publishEnd, + 'exclude_search' => $excludeSearch, + 'exclude_menu' => $excludeMenu, + 'blank_link' => $blankLink + ]; + + // BlogContentエンティティの基本データ + $blogContentData = [ + 'description' => $description ?? '', + 'template' => $template, + 'list_count' => $listCount, + 'list_direction' => $listDirection, + 'feed_count' => $feedCount, + 'comment_use' => $commentUse, + 'comment_approve' => $commentApprove, + 'tag_use' => $tagUse, + 'eye_catch_size_thumb_width' => $eyeCatchSizeThumbWidth ?? Configure::read('BcBlog.eye_catch_size_thumb_width'), + 'eye_catch_size_thumb_height' => $eyeCatchSizeThumbHeight ?? Configure::read('BcBlog.eye_catch_size_thumb_height'), + 'eye_catch_size_mobile_thumb_width' => $eyeCatchSizeMobileThumbWidth ?? Configure::read('BcBlog.eye_catch_size_mobile_thumb_width'), + 'eye_catch_size_mobile_thumb_height' => $eyeCatchSizeMobileThumbHeight ?? Configure::read('BcBlog.eye_catch_size_mobile_thumb_height'), + 'use_content' => $useContent, + 'widget_area' => $widgetArea + ]; + + // Contentデータを含めた統合データ構造 + $data = array_merge($blogContentData, [ + 'content' => $contentData + ]); + + $result = $blogContentsService->create($data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログコンテンツ「%s」を追加しました。', $result->content->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログコンテンツの保存に失敗しました'); + } + }); + } + + /** + * ブログコンテンツを編集 + */ + public function editBlogContent( + int $id, + ?string $name = null, + ?string $title = null, + ?int $siteId = null, + ?int $parentId = null, + ?string $description = null, + ?int $authorId = null, + ?string $layoutTemplate = null, + ?bool $status = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?bool $excludeSearch = null, + ?bool $excludeMenu = null, + ?bool $blankLink = null, + ?string $template = null, + ?int $listCount = null, + ?string $listDirection = null, + ?int $feedCount = null, + ?bool $commentUse = null, + ?bool $commentApprove = null, + ?bool $tagUse = null, + ?int $eyeCatchSizeThumbWidth = null, + ?int $eyeCatchSizeThumbHeight = null, + ?int $eyeCatchSizeMobileThumbWidth = null, + ?int $eyeCatchSizeMobileThumbHeight = null, + ?bool $useContent = null, + ?int $widgetArea = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $id, $name, $title, $siteId, $parentId, $description, $authorId, $layoutTemplate, $status, + $publishBegin, $publishEnd, $excludeSearch, $excludeMenu, $blankLink, $template, $listCount, + $listDirection, $feedCount, $commentUse, $commentApprove, $tagUse, $eyeCatchSizeThumbWidth, + $eyeCatchSizeThumbHeight, $eyeCatchSizeMobileThumbWidth, $eyeCatchSizeMobileThumbHeight, + $useContent, $widgetArea, $loginUserId + ) { + if (empty($id)) return $this->createErrorResponse('IDは必須です'); + + /** @var BlogContentsService $blogContentsService */ + $blogContentsService = $this->getService(BlogContentsServiceInterface::class); + $entity = $blogContentsService->get($id); + + if (!$entity) return $this->createErrorResponse('指定されたIDのブログコンテンツが見つかりません'); + + // 更新データを構築(null以外の値のみ) + $data = []; + if ($description !== null) $data['description'] = $description; + if ($template !== null) $data['template'] = $template; + if ($listCount !== null) $data['list_count'] = $listCount; + if ($listDirection !== null) $data['list_direction'] = $listDirection; + if ($feedCount !== null) $data['feed_count'] = $feedCount; + if ($commentUse !== null) $data['comment_use'] = $commentUse; + if ($commentApprove !== null) $data['comment_approve'] = $commentApprove; + if ($tagUse !== null) $data['tag_use'] = $tagUse; + if ($eyeCatchSizeThumbWidth !== null) $data['eye_catch_size_thumb_width'] = $eyeCatchSizeThumbWidth; + if ($eyeCatchSizeThumbHeight !== null) $data['eye_catch_size_thumb_height'] = $eyeCatchSizeThumbHeight; + if ($eyeCatchSizeMobileThumbWidth !== null) $data['eye_catch_size_mobile_thumb_width'] = $eyeCatchSizeMobileThumbWidth; + if ($eyeCatchSizeMobileThumbHeight !== null) $data['eye_catch_size_mobile_thumb_height'] = $eyeCatchSizeMobileThumbHeight; + if ($useContent !== null) $data['use_content'] = $useContent; + if ($widgetArea !== null) $data['widget_area'] = $widgetArea; + + // Contentエンティティの更新データも含める(もし関連するContentフィールドが変更される場合) + $contentData = []; + if ($name !== null) $contentData['name'] = $name; + if ($title !== null) $contentData['title'] = $title; + if ($siteId !== null) $contentData['site_id'] = $siteId; + if ($parentId !== null) $contentData['parent_id'] = $parentId; + if ($description !== null) $contentData['description'] = $description; + if ($authorId !== null) $contentData['author_id'] = $authorId; + if ($layoutTemplate !== null) $contentData['layout_template'] = $layoutTemplate; + if ($status !== null) $contentData['self_status'] = (bool)$status; + if ($publishBegin !== null) $contentData['publish_begin'] = $publishBegin; + if ($publishEnd !== null) $contentData['publish_end'] = $publishEnd; + if ($excludeSearch !== null) $contentData['exclude_search'] = (bool)$excludeSearch; + if ($excludeMenu !== null) $contentData['exclude_menu'] = (bool)$excludeMenu; + if ($blankLink !== null) $contentData['blank_link'] = (bool)$blankLink; + + if (!empty($contentData)) $data['content'] = $contentData; + $result = $blogContentsService->update($entity, $data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログコンテンツ「%s」を編集しました。', $result->content->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログコンテンツの更新に失敗しました'); + } + }); + } + + /** + * ブログコンテンツ一覧を取得 + */ + public function getBlogContents( + ?string $title = null, + ?int $status = null, + ?int $limit = null, + ?int $page = null + ): array + { + return $this->executeWithErrorHandling(function() use ($title, $status, $limit, $page) { + /** @var BlogContentsService $blogContentsService */ + $blogContentsService = $this->getService(BlogContentsServiceInterface::class); + + $conditions = []; + if (!empty($title)) $conditions['title'] = $title; + if (!empty($status)) $conditions['status'] = $status; + if (!empty($limit)) $conditions['limit'] = $limit; + if (!empty($page)) $conditions['page'] = $page; + + $results = $blogContentsService->getIndex($conditions)->toArray(); + + return $this->createSuccessResponse([ + 'data' => $results, + 'pagination' => [ + 'page' => $page ?? 1, + 'limit' => $limit ?? null, + 'count' => count($results) + ] + ]); + }); + } + + /** + * ブログコンテンツを取得 + */ + public function getBlogContent(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + if (empty($id)) return $this->createErrorResponse('IDは必須です'); + /** @var BlogContentsService $blogContentsService */ + $blogContentsService = $this->getService(BlogContentsServiceInterface::class); + + $result = $blogContentsService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのブログコンテンツが見つかりません'); + } + }); + } + + /** + * ブログコンテンツを削除 + */ + public function deleteBlogContent(int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + if (empty($id)) return $this->createErrorResponse('IDは必須です'); + /** @var BlogContentsService $blogContentsService */ + $blogContentsService = $this->getService(BlogContentsServiceInterface::class); + + // 削除前にタイトルを取得 + $entity = $blogContentsService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのブログコンテンツが見つかりません'); + } + + $title = $entity->content->title; + $result = $blogContentsService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + ['message' => 'ブログコンテンツを削除しました'], + [], + sprintf('ブログコンテンツ「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログコンテンツの削除に失敗しました'); + } + }); + } +} diff --git a/plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php b/plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php new file mode 100644 index 0000000000..27cfec37c1 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php @@ -0,0 +1,515 @@ +tool( + name: 'getBlogPosts', + description: 'ブログ記事の一覧を取得します', + callback: [$this, 'getBlogPosts'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'blogContentId' => ['type' => 'number', 'description' => 'ブログコンテンツID(省略時はデフォルト)'], + 'keyword' => ['type' => 'string', 'description' => '検索キーワード'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(null: 全て, publish: 公開)(省略時は全て)'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は10件)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + ->tool( + name: 'getBlogPost', + description: '指定されたIDのブログ記事を取得します', + callback: [$this, 'getBlogPost'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '記事ID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + name: 'addBlogPost', + description: 'ブログ記事を追加します', + callback: [$this, 'addBlogPost'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'description' => '記事タイトル(必須)'], + 'detail' => ['type' => 'string', 'description' => '記事詳細(必須)、マークダウン不可、HTML推奨'], + 'blogContent' => ['type' => 'string', 'description' => 'ブログコンテンツ名(省略時はデフォルト)'], + 'name' => ['type' => 'string', 'description' => '記事のスラッグ。URLにおける記事を特定する識別子(省略時はなし)'], + 'content' => ['type' => 'string', 'description' => '記事概要(省略時はなし)、マークダウン不可、HTML推奨'], + 'category' => ['type' => 'string', 'description' => 'カテゴリ名(省略時はカテゴリなし)'], + 'email' => ['type' => 'string', 'format' => 'email', 'description' => 'ユーザーのメールアドレス(省略時はログインユーザー)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)、(省略時は0)'], + 'posted' => ['type' => 'string', 'format' => 'date-time', 'description' => '投稿日(省略時は現在日時)'], + 'publishBegin' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開開始日時(省略時はなし)'], + 'publishEnd' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開終了日時(省略時はなし)'], + 'eyeCatch' => ['type' => 'string', 'description' => 'アイキャッチ画像。外部画像URLを直接指定'], + ], + 'required' => ['title', 'detail'] + ] + ) + ->tool( + name: 'editBlogPost', + description: 'ブログ記事を編集します', + callback: [$this, 'editBlogPost'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '記事ID(必須)'], + 'title' => ['type' => 'string', 'description' => '記事タイトル'], + 'detail' => ['type' => 'string', 'description' => '記事詳細、マークダウン不可、HTML推奨'], + 'blogContent' => ['type' => 'string', 'description' => 'ブログコンテンツ名'], + 'name' => ['type' => 'string', 'description' => '記事のスラッグ。URLにおける記事を特定する識別子'], + 'content' => ['type' => 'string', 'description' => '記事概要、マークダウン不可、HTML推奨'], + 'category' => ['type' => 'string', 'description' => 'カテゴリ名'], + 'email' => ['type' => 'string', 'format' => 'email', 'description' => 'ユーザーのメールアドレス'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(0: 非公開, 1: 公開)'], + 'posted' => ['type' => 'string', 'format' => 'date-time', 'description' => '投稿日'], + 'publishBegin' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開開始日時'], + 'publishEnd' => ['type' => 'string', 'format' => 'date-time', 'description' => '公開終了日時'], + 'eyeCatch' => ['type' => 'string', 'description' => 'アイキャッチ画像。外部画像URLを直接指定'], + ], + 'required' => ['id'] + ] + ) + ->tool( + name: 'deleteBlogPost', + description: '指定されたIDのブログ記事を削除します', + callback: [$this, 'deleteBlogPost'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '記事ID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addBlogPost': + return ['POST' => "/bc-blog/blog_posts/add.json"]; + case 'editBlogPost': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_posts/edit/{$args['id']}.json"]; + case 'getBlogPosts': + return ['GET' => '/bc-blog/blog_posts/index.json']; + case 'getBlogPost': + if(empty($args['id'])) return false; + return ['GET' => "/bc-blog/blog_posts/view/{$args['id']}.json"]; + case 'deleteBlogPost': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_posts/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * ブログ記事を追加 + */ + public function addBlogPost( + string $title, + string $detail, + ?string $blogContent = null, + ?string $name = null, + ?string $content = null, + ?string $category = null, + ?string $email = null, + ?int $status = 0, + ?string $posted = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?string $eyeCatch = null, + ?int $loginUserId = null + ): array + { + // 認証済みの操作者はリクエストのコンテキストから解決する + $loginUserId = $this->resolveLoginUserId($loginUserId); + return $this->executeWithErrorHandling(function() use ( + $title, + $detail, + $blogContent, + $name, + $content, + $category, + $email, + $status, + $posted, + $publishBegin, + $publishEnd, + $eyeCatch, + $loginUserId + ) { + // 必須パラメータのチェック + if (empty($title)) { + return $this->createErrorResponse('タイトルは必須です'); + } + if (empty($detail)) { + return $this->createErrorResponse('詳細は必須です'); + } + + $blogContentId = $this->getBlogContentId($blogContent); + $blogCategoryId = $this->getBlogCategoryId($category, $blogContentId); + + $data = [ + 'title' => $title, + 'detail' => $detail, + 'blog_content_id' => $blogContentId, + 'name' => $name, + 'content' => $content, + 'blog_category_id' => $blogCategoryId, + 'user_id' => $this->getAuthorId($email, $loginUserId), + 'status' => $status, + 'posted' => $posted ?? date('Y-m-d H:i:s'), + 'publish_begin' => $publishBegin, + 'publish_end' => $publishEnd, + ]; + + // アイキャッチ画像の処理 + if (!empty($eyeCatch) && $this->isFileUploadable($eyeCatch)) { + if (!is_array($eyeCatch)) { + $eyeCatchData = $this->processFileUpload($eyeCatch, 'eye_catch'); + } + if ($eyeCatchData !== false && is_array($eyeCatchData)) { + // 配列データをCakePHPのUploadedFileオブジェクトに変換 + $data['eye_catch'] = $this->createUploadedFileFromArray($eyeCatchData); + } else { + // アップロード処理自体が失敗した場合はエラーとして扱う + return $this->createErrorResponse('アイキャッチ画像のアップロードに失敗しました'); + } + } elseif (!empty($eyeCatch)) { + // その他の形式の場合はエラーとして扱う + return $this->createErrorResponse('アイキャッチ画像の形式が不正です'); + } + + $blogPostsService = $this->getService(BlogPostsServiceInterface::class); + + // ファイルアップロードの設定を実施 + $blogPostsService->setupUpload($blogContentId); + + $result = $blogPostsService->create($data); + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログ記事「%s」を追加しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログ記事の保存に失敗しました'); + } + }); + } + + + /** + * ブログ記事を編集 + */ + public function editBlogPost( + int $id, + ?string $title = null, + ?string $detail = null, + ?string $blogContent = null, + ?string $name = null, + ?string $content = null, + ?string $category = null, + ?string $email = null, + ?int $status = null, + ?string $posted = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?string $eyeCatch = null, + ?int $loginUserId = null + ): array + { + // 認証済みの操作者はリクエストのコンテキストから解決する + $loginUserId = $this->resolveLoginUserId($loginUserId); + return $this->executeWithErrorHandling(function() use ( + $id, + $title, + $detail, + $blogContent, + $name, + $content, + $category, + $email, + $status, + $posted, + $publishBegin, + $publishEnd, + $eyeCatch, + $loginUserId + ) { + // 必須パラメータのチェック + if (empty($id)) { + return $this->createErrorResponse('IDは必須です'); + } + + $blogPostsService = $this->getService(BlogPostsServiceInterface::class); + $entity = $blogPostsService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのブログ記事が見つかりません'); + } + + // ファイルアップロードの設定を実施 + $blogPostsService->setupUpload($entity->blog_content_id); + + // 更新データを構築(null以外の値のみ) + $data = []; + if ($title !== null) $data['title'] = $title; + if ($detail !== null) $data['detail'] = $detail; + if ($blogContent !== null) $data['blog_content_id'] = $this->getBlogContentId($blogContent); + if ($name !== null) $data['name'] = $name; + if ($content !== null) $data['content'] = $content; + if ($category !== null) $data['blog_category_id'] = $this->getBlogCategoryId($category, $data['blog_content_id'] ?? $entity->blog_content_id); + if ($email !== null) $data['user_id'] = $this->getAuthorId($email); + if ($status !== null) $data['status'] = $status; + if ($posted !== null) $data['posted'] = $posted; + if ($publishBegin !== null) $data['publish_begin'] = $publishBegin; + if ($publishEnd !== null) $data['publish_end'] = $publishEnd; + + // アイキャッチ画像の処理 + if ($eyeCatch !== null) { + if ($eyeCatch === '') { + // 空文字列の場合は削除 + $data['eye_catch'] = null; + } elseif ($this->isFileUploadable($eyeCatch)) { + if (!is_array($eyeCatch)) { + $eyeCatchData = $this->processFileUpload($eyeCatch, 'eye_catch'); + } + if ($eyeCatchData !== false && is_array($eyeCatchData)) { + // 配列データをCakePHPのUploadedFileオブジェクトに変換 + $data['eye_catch'] = $this->createUploadedFileFromArray($eyeCatchData); + } else { + // アップロード処理自体が失敗した場合はエラーとして扱う + return $this->createErrorResponse('アイキャッチ画像のアップロードに失敗しました'); + } + } else { + // 空文字列でも既知のアップロード可能形式でもない場合はエラーとして扱う + // (既存のアイキャッチを黙って削除しない) + return $this->createErrorResponse('アイキャッチ画像の形式が不正です'); + } + } + + $result = $blogPostsService->update($entity, $data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログ記事「%s」を編集しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログ記事の更新に失敗しました'); + } + }); + } + + /** + * 投稿者のユーザーIDを取得 + * @param string|null $email + * @param int $loginUserId + * @return mixed + * @throws \Exception + */ + public function getAuthorId(?string $email, ?int $loginUserId = null) + { + $usersService = $this->getService(UsersServiceInterface::class); + if (!empty($email)) { + $conditions = ['email' => $email]; + $user = $usersService->getIndex($conditions)->first(); + } elseif ($loginUserId) { + $user = $usersService->get($loginUserId); + } + if (empty($user)) { + throw new \Exception('投稿者を指定できませんでした。'); + } + return $user->id; + } + + /** + * ブログ記事一覧を取得 + */ + public function getBlogPosts( + ?int $blogContentId = null, + ?string $keyword = null, + ?string $status = null, + ?int $limit = 10, + ?int $page = 1 + ): array + { + return $this->executeWithErrorHandling(function() use ( + $blogContentId, + $keyword, + $status, + $limit, + $page + ) { + /** @var \BcBlog\Service\BlogPostsService $blogPostsService */ + $blogPostsService = $this->getService(BlogPostsServiceInterface::class); + + $conditions = []; + if (!empty($blogContentId)) $conditions['blog_content_id'] = $blogContentId; + if (!empty($keyword)) $conditions['keyword'] = $keyword; + if ($status) $conditions['status'] = $status; + $conditions['limit'] = $limit ?? 10; + $conditions['page'] = $page ?? 1; + + $results = $blogPostsService->getIndex($conditions)->toArray(); + + return $this->createSuccessResponse([ + 'data' => $results, + 'pagination' => [ + 'page' => $page ?? 1, + 'limit' => $limit ?? 10, + 'count' => count($results) + ] + ]); + }); + } + + /** + * ブログ記事を取得 + */ + public function getBlogPost(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + // 必須パラメータのチェック + if (empty($id)) return $this->createErrorResponse('IDは必須です'); + + /** @var \BcBlog\Service\BlogPostsService $blogPostsService */ + $blogPostsService = $this->getService(BlogPostsServiceInterface::class); + $result = $blogPostsService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのブログ記事が見つかりません'); + } + }); + } + + /** + * ブログ記事を削除 + */ + public function deleteBlogPost(int $id, ?int $loginUserId = null): array + { + // 認証済みの操作者はリクエストのコンテキストから解決する + $loginUserId = $this->resolveLoginUserId($loginUserId); + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + // 必須パラメータのチェック + if (empty($id)) return $this->createErrorResponse('IDは必須です'); + + /** @var \BcBlog\Service\BlogPostsService $blogPostsService */ + $blogPostsService = $this->getService(BlogPostsServiceInterface::class); + + // 削除前にタイトルを取得 + $entity = $blogPostsService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのブログ記事が見つかりません'); + } + + $title = $entity->title; + $result = $blogPostsService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + ['message' => 'ブログ記事を削除しました'], + [], + sprintf('ブログ記事「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログ記事の削除に失敗しました'); + } + }); + } + + /** + * ブログコンテンツIDを取得 + */ + protected function getBlogContentId(?string $blogContentName): int + { + try { + $blogContentsService = $this->getService(BlogContentsServiceInterface::class); + $conditions = []; + if($blogContentName) { + $conditions = ['name' => $blogContentName]; + } + $blogContent = $blogContentsService->getIndex($conditions)->first(); + if(!$blogContent) { + throw new \Exception('ブログコンテンツが見つかりません。'); + } + return $blogContent->id; + } catch (\Exception $e) { + throw new \Exception('ブログコンテンツ検索中にエラーが発生しました。' . $e->getMessage()); + } + } + + /** + * ブログカテゴリIDを取得 + */ + protected function getBlogCategoryId(?string $categoryName, int $blogContentId): ?int + { + try { + $blogCategoriesService = $this->getService(BlogCategoriesServiceInterface::class); + $conditions = [ + 'name' => $categoryName + ]; + $category = $blogCategoriesService->getIndex($blogContentId, $conditions)->first(); + + return $category? $category->id : null; + } catch (\Exception $e) { + return null; // エラー時はnull + } + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BcBlog/BlogTagsTool.php b/plugins/bc-mcp/src/Mcp/BcBlog/BlogTagsTool.php new file mode 100644 index 0000000000..cfcdeb2139 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogTagsTool.php @@ -0,0 +1,260 @@ +tool( + callback: [$this, 'addBlogTag'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addBlogTag', + description: 'ブログタグを追加します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'タグ名(必須)'] + ], + 'required' => ['name'] + ] + ) + ->tool( + callback: [$this, 'getBlogTags'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getBlogTags', + description: 'ブログタグの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'タグ名での検索'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は10件)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + ->tool( + callback: [$this, 'getBlogTag'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getBlogTag', + description: '指定されたIDのブログタグを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログタグID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'editBlogTag'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editBlogTag', + description: '指定されたIDのブログタグを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログタグID(必須)'], + 'name' => ['type' => 'string', 'description' => 'タグ名(必須)'] + ], + 'required' => ['id', 'name'] + ] + ) + ->tool( + callback: [$this, 'deleteBlogTag'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteBlogTag', + description: '指定されたIDのブログタグを削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログタグID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addBlogTag': + return ['POST' => "/bc-blog/blog_tags/add.json"]; + case 'editBlogTag': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_tags/edit/{$args['id']}.json"]; + case 'getBlogTags': + return ['GET' => "/bc-blog/blog_tags/index.json"]; + case 'getBlogTag': + if(empty($args['id'])) return false; + return ['GET' => "/bc-blog/blog_tags/view/{$args['id']}.json"]; + case 'deleteBlogTag': + if(empty($args['id'])) return false; + return ['POST' => "/bc-blog/blog_tags/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * ブログタグを追加 + */ + public function addBlogTag(string $name, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($name, $loginUserId) { + /** @var BlogTagsService $blogTagsService */ + $blogTagsService = $this->getService(BlogTagsServiceInterface::class); + $result = $blogTagsService->create([ + 'name' => $name + ]); + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログタグ「%s」を追加しました。', $result->name), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログタグの保存に失敗しました'); + } + }); + } + + /** + * ブログタグ一覧を取得 + */ + public function getBlogTags( + ?string $name = null, + ?int $limit = 10, + ?int $page = 1 + ): array + { + return $this->executeWithErrorHandling(function() use ($name, $limit, $page) { + + /** @var BlogTagsService $blogTagsService */ + $blogTagsService = $this->getService(BlogTagsServiceInterface::class); + + $conditions = []; + if (!empty($name)) $conditions['name'] = $name; + if (!empty($limit)) $conditions['limit'] = $limit; + if (!empty($page)) $conditions['page'] = $page; + $results = $blogTagsService->getIndex($conditions)->toArray(); + + return $this->createSuccessResponse([ + 'data' => $results, + 'pagination' => [ + 'page' => $page ?? 1, + 'limit' => $limit ?? null, + 'count' => count($results) + ] + ]); + }); + } + + /** + * ブログタグを取得 + */ + public function getBlogTag(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + /** @var BlogTagsService $blogTagsService */ + $blogTagsService = $this->getService(BlogTagsServiceInterface::class); + $result = $blogTagsService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのブログタグが見つかりません'); + } + }); + } + + /** + * ブログタグを編集 + */ + public function editBlogTag(int $id, string $name, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $name, $loginUserId) { + /** @var BlogTagsService $blogTagsService */ + $blogTagsService = $this->getService(BlogTagsServiceInterface::class); + $entity = $blogTagsService->get($id); + + if (!$entity) return $this->createErrorResponse('指定されたIDのブログタグが見つかりません'); + + $result = $blogTagsService->update($entity, [ + 'name' => $name + ]); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('ブログタグ「%s」を編集しました。', $result->name), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログタグの更新に失敗しました'); + } + }); + } + + /** + * ブログタグを削除 + */ + public function deleteBlogTag(int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + /** @var BlogTagsService $blogTagsService */ + $blogTagsService = $this->getService(BlogTagsServiceInterface::class); + + // 削除前にタグ名を取得 + $entity = $blogTagsService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのブログタグが見つかりません'); + } + + $name = $entity->name; + $result = $blogTagsService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + ['message' => 'ブログタグを削除しました'], + [], + sprintf('ブログタグ「%s」を削除しました。', $name), + $loginUserId + ); + } else { + return $this->createErrorResponse('ブログタグの削除に失敗しました'); + } + }); + } +} diff --git a/plugins/bc-mcp/src/Mcp/BcCustomContent/BcCustomContentServer.php b/plugins/bc-mcp/src/Mcp/BcCustomContent/BcCustomContentServer.php new file mode 100644 index 0000000000..bdccb0cd35 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/BcCustomContentServer.php @@ -0,0 +1,30 @@ + ツールクラス名の配列 + */ + public static function getToolClasses(): array + { + return [ + CustomFieldsTool::class, + CustomTablesTool::class, + CustomContentsTool::class, + CustomEntriesTool::class, + CustomLinksTool::class, + ]; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomContentsTool.php b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomContentsTool.php new file mode 100644 index 0000000000..1444da9ff9 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomContentsTool.php @@ -0,0 +1,398 @@ +tool( + callback: [$this, 'addCustomContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addCustomContent', + description: 'カスタムテーブルと紐づくカスタムコンテンツを追加します。カスタムコンテンツを追加するにはカスタムテーブルのIDが必要です。事前に作成するか既存のカスタムテーブルIDを指定してください。', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'カスタムコンテンツ名、URLに影響します(必須)'], + 'title' => ['type' => 'string', 'description' => 'カスタムコンテンツのタイトル(必須)'], + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'siteId' => ['type' => 'number', 'default' => 1, 'description' => 'サイトID(初期値: 1)'], + 'parentId' => ['type' => 'number', 'default' => 1, 'description' => '親フォルダID(初期値: 1)'], + 'description' => ['type' => 'string', 'description' => '説明文'], + 'authorId' => ['type' => 'number', 'default' => 1, 'description' => '作成者ID'], + 'layoutTemplate' => ['type' => 'string', 'description' => 'レイアウトテンプレート名(初期値: default)'], + 'status' => ['type' => 'number', 'description' => '公開状態(0: 非公開状態, 1: 公開状態)'], + 'publishBegin' => ['type' => 'string', 'description' => '公開開始日時(YYYY-MM-DD HH:MM:SS形式)'], + 'publishEnd' => ['type' => 'string', 'description' => '公開終了日時(YYYY-MM-DD HH:MM:SS形式)'], + 'excludeSearch' => ['type' => 'boolean', 'description' => '検索結果から除外するかどうか(初期値: false)'], + 'excludeMenu' => ['type' => 'boolean', 'description' => 'メニューから除外するかどうか(初期値: false)'], + 'blankLink' => ['type' => 'boolean', 'description' => 'リンクを新しいタブで開くかどうか(初期値: false)'], + 'template' => ['type' => 'string', 'default' => 'default', 'description' => 'テンプレート名(初期値: default)'], + 'widgetArea' => ['type' => 'number', 'description' => 'ウィジェットエリアID(初期値: システムのデフォルト)'], + 'listCount' => ['type' => 'number', 'default' => 10, 'description' => 'リスト表示件数(初期値: 10)'], + 'listOrder' => ['type' => 'string', 'default' => 'id', 'description' => 'リスト表示順序(初期値: published)'], + 'listDirection' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC', 'description' => 'リスト表示方向(ASC|DESC、初期値: DESC)'], + ], + 'required' => ['name', 'title', 'customTableId'] + ] + ) + ->tool( + callback: [$this, 'getCustomContents'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomContents', + description: 'カスタムテーブルと紐づくカスタムコンテンツの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は制限なし)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(null: 非公開, publish: 公開)'] + ] + ] + ) + ->tool( + callback: [$this, 'getCustomContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomContent', + description: 'カスタムテーブルと紐づくカスタムコンテンツをIDを指定して取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムコンテンツID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'editCustomContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editCustomContent', + description: 'カスタムテーブルと紐づくカスタムコンテンツを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムコンテンツID(必須)'], + 'name' => ['type' => 'string', 'description' => 'カスタムコンテンツ名、URLに影響します'], + 'title' => ['type' => 'string', 'description' => 'カスタムコンテンツのタイトル'], + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID'], + 'siteId' => ['type' => 'number', 'default' => 1, 'description' => 'サイトID'], + 'parentId' => ['type' => 'number', 'default' => 1, 'description' => '親フォルダID'], + 'description' => ['type' => 'string', 'description' => '説明文'], + 'authorId' => ['type' => 'number', 'default' => 1, 'description' => '作成者ID'], + 'layoutTemplate' => ['type' => 'string', 'description' => 'レイアウトテンプレート名'], + 'status' => ['type' => 'number', 'description' => '公開状態(0: 非公開状態, 1: 公開状態)'], + 'publishBegin' => ['type' => 'string', 'description' => '公開開始日時(YYYY-MM-DD HH:MM:SS形式)'], + 'publishEnd' => ['type' => 'string', 'description' => '公開終了日時(YYYY-MM-DD HH:MM:SS形式)'], + 'excludeSearch' => ['type' => 'boolean', 'description' => '検索結果から除外するかどうか'], + 'excludeMenu' => ['type' => 'boolean', 'description' => 'メニューから除外するかどうか'], + 'blankLink' => ['type' => 'boolean', 'description' => 'リンクを新しいタブで開くかどうか'], + 'template' => ['type' => 'string', 'default' => 'default', 'description' => 'テンプレート名'], + 'widgetArea' => ['type' => 'number', 'description' => 'ウィジェットエリアID'], + 'listCount' => ['type' => 'number', 'default' => 10, 'description' => 'リスト表示件数'], + 'listOrder' => ['type' => 'string', 'default' => 'id', 'description' => 'リスト表示順序'], + 'listDirection' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC', 'description' => 'リスト表示方向(ASC|DESC)'], + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'deleteCustomContent'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteCustomContent', + description: 'カスタムテーブルと紐づくカスタムコンテンツをIDを指定して削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムコンテンツID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addCustomContent': + return ['POST' => "/bc-custom-content/custom_contents/add.json"]; + case 'editCustomContent': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_contents/edit/{$args['id']}.json"]; + case 'getCustomContents': + return ['GET' => "/bc-custom-content/custom_contents/index.json"]; + case 'getCustomContent': + if(empty($args['id'])) return false; + return ['GET' => "/bc-custom-content/custom_contents/view/{$args['id']}.json"]; + case 'deleteCustomContent': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_contents/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * カスタムコンテンツを追加 + */ + public function addCustomContent( + string $name, + string $title, + int $customTableId, + ?int $siteId = 1, + ?int $parentId = 1, + ?string $description = null, + ?int $authorId = null, + ?string $layoutTemplate = null, + ?bool $status = false, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?bool $excludeSearch = false, + ?bool $excludeMenu = false, + ?bool $blankLink = false, + ?string $template = 'default', + ?int $widgetArea = null, + ?int $listCount = 10, + ?string $listOrder = 'published', + ?string $listDirection = 'DESC', + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $name, $title, $customTableId, $siteId, $parentId, $description, $authorId, $layoutTemplate, $status, + $publishBegin, $publishEnd, $excludeSearch, $excludeMenu, $blankLink, $template, $widgetArea, $listCount, + $listOrder, $listDirection, $loginUserId + ) { + + /** @var CustomContentsService $customContentsService */ + $customContentsService = $this->getService(CustomContentsServiceInterface::class); + + // Content entity data structure required by baserCMS + $data = [ + 'name' => $name, + 'title' => $title, + 'custom_table_id' => $customTableId, + 'description' => $description, + 'template' => $template, + 'widget_area' => $widgetArea, + 'list_count' => $listCount, + 'list_direction' => $listDirection, + 'list_order' => $listOrder, + 'content' => [ + 'name' => $name, + 'plugin' => 'BcCustomContent', + 'type' => 'CustomContent', + 'title' => $title, + 'description' => $description ?? '', + 'site_id' => $siteId, + 'parent_id' => $parentId, + 'author_id' => $authorId ?? $loginUserId ?? 1, + 'layout_template' => $layoutTemplate ?? '', + 'exclude_search' => $excludeSearch, + 'self_status' => $status ?? false, + 'publish_begin' => $publishBegin ?? null, + 'publish_end' => $publishEnd ?? null, + 'exclude_menu' => $excludeMenu ?? false, + 'blank_link' => $blankLink ?? false + ] + ]; + + $result = $customContentsService->create($data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('カスタムコンテンツ「%s」を追加しました。', $result->content->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムコンテンツの保存に失敗しました'); + } + }); + } + + /** + * カスタムコンテンツを編集 + */ + public function editCustomContent( + int $id, + ?string $name = null, + ?string $title = null, + ?int $customTableId = null, + ?int $siteId = null, + ?int $parentId = null, + ?string $description = null, + ?string $authorId = null, + ?string $layoutTemplate = null, + ?bool $status = false, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?bool $excludeSearch = false, + ?bool $excludeMenu = false, + ?bool $blankLink = false, + ?string $template = null, + ?int $widgetArea = null, + ?int $listCount = null, + ?string $listOrder = null, + ?string $listDirection = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $id, $name, $title, $customTableId, $siteId, $parentId, $description, $authorId, $layoutTemplate, $status, + $publishBegin, $publishEnd, $excludeSearch, $excludeMenu, $blankLink, $template, $widgetArea, $listCount, + $listOrder, $listDirection, $loginUserId + ) { + /** @var CustomContentsService $customContentsService */ + $customContentsService = $this->getService(CustomContentsServiceInterface::class); + + $entity = $customContentsService->get($id); + + if (!$entity) return $this->createErrorResponse('指定されたIDのカスタムコンテンツが見つかりません'); + + $data = []; + if ($name !== null) $data['name'] = $name; + if ($title !== null) $data['title'] = $title; + if ($customTableId !== null) $data['custom_table_id'] = $customTableId; + if ($siteId !== null) $data['content']['site_id'] = $siteId; + if ($parentId !== null) $data['content']['parent_id'] = $parentId; + if ($description !== null) $data['content']['description'] = $description; + if ($authorId !== null) $data['content']['author_id'] = $authorId; + if ($layoutTemplate !== null) $data['content']['layout_template'] = $layoutTemplate; + if ($status !== null) $data['content']['self_status'] = $status; + if ($publishBegin !== null) $data['content']['publish_begin'] = $publishBegin; + if ($publishEnd !== null) $data['content']['publish_end'] = $publishEnd; + if ($excludeSearch !== null) $data['content']['exclude_search'] = $excludeSearch; + if ($excludeMenu !== null) $data['content']['exclude_menu'] = $excludeMenu; + if ($blankLink !== null) $data['content']['blank_link'] = $blankLink; + if ($description !== null) $data['description'] = $description; + if ($template !== null) $data['template'] = $template; + if ($widgetArea !== null) $data['widget_area'] = $widgetArea; + if ($listCount !== null) $data['list_count'] = $listCount; + if ($listOrder !== null) $data['list_order'] = $listOrder; + if ($listDirection !== null) $data['list_direction'] = $listDirection; + + $result = $customContentsService->update($entity, $data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('カスタムコンテンツ「%s」を編集しました。', $result->content->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムコンテンツの更新に失敗しました'); + } + }); + } + + /** + * カスタムコンテンツ一覧を取得 + */ + public function getCustomContents( + ?string $status = null, + ?int $limit = null, + ?int $page = 1 + ): array + { + return $this->executeWithErrorHandling(function() use ($status, $limit, $page) { + $customContentsService = $this->getService(CustomContentsServiceInterface::class); + + $conditions = []; + if (isset($status)) $conditions['status'] = $status; + if (!empty($limit)) $conditions['limit'] = $limit; + if (!empty($page)) $conditions['page'] = $page; + + $results = $customContentsService->getIndex($conditions)->toArray(); + + return $this->createSuccessResponse([ + 'data' => $results, + 'pagination' => [ + 'page' => $page ?? 1, + 'limit' => $limit ?? null, + 'count' => count($results) + ] + ]); + }); + } + + /** + * カスタムコンテンツを取得 + */ + public function getCustomContent(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + /** @var CustomContentsService $customContentsService */ + $customContentsService = $this->getService(CustomContentsServiceInterface::class); + $result = $customContentsService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのカスタムコンテンツが見つかりません'); + } + }); + } + + /** + * カスタムコンテンツを削除 + */ + public function deleteCustomContent(int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + /** @var CustomContentsService $customContentsService */ + $customContentsService = $this->getService(CustomContentsServiceInterface::class); + + // 削除前にタイトルを取得 + $entity = $customContentsService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのカスタムコンテンツが見つかりません'); + } + + $title = $entity->content->title; + $result = $customContentsService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + 'カスタムコンテンツを削除しました', + [], + sprintf('カスタムコンテンツ「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムコンテンツの削除に失敗しました'); + } + }); + } +} diff --git a/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomEntriesTool.php b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomEntriesTool.php new file mode 100644 index 0000000000..0f70444532 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomEntriesTool.php @@ -0,0 +1,449 @@ +tool( + callback: [$this, 'addCustomEntry'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addCustomEntry', + description: 'カスタムエントリーを追加します。カスタムエントリーを追加するには、カスタムテーブルが必要です。事前に作成するか既存のカスタムテーブルIDを指定してください。フロントエンドに表示させるには、カスタムテーブルがカスタムコンテンツと紐づいている必要があります。', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'title' => ['type' => 'string', 'description' => 'タイトル(必須)'], + 'parentId' => ['type' => 'number', 'description' => '親カスタムリンクID(対象となるカスタムテーブルの type がマスタの場合のみ指定可能)'], + 'name' => ['type' => 'string', 'default' => '', 'description' => 'スラッグ(初期値空文字)'], + 'creatorId' => ['type' => 'number', 'description' => '投稿者ID(省略時はログインユーザーID)'], + 'status' => ['type' => 'boolean', 'default' => false, 'description' => '公開状態(デフォルト:false)'], + 'publishBegin' => ['type' => 'string', 'description' => '公開開始日(YYYY-MM-DD HH:mm:ss形式、省略可)'], + 'publishEnd' => ['type' => 'string', 'description' => '公開終了日(YYYY-MM-DD HH:mm:ss形式、省略可)'], + 'published' => ['type' => 'string', 'description' => '公開日(YYYY-MM-DD HH:mm:ss形式、省略時は当日)'], + 'customFields' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'カスタムフィールドの値(フィールド名をキーとするオブジェクト)、ファイルアップロードのフィールドの場合は、外部画像URLを直接指定' + ] + ], + 'required' => ['customTableId'] + ] + ) + ->tool( + callback: [$this, 'editCustomEntry'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editCustomEntry', + description: '指定されたIDのカスタムエントリーを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'id' => ['type' => 'number', 'description' => 'カスタムエントリーID(必須)'], + 'title' => ['type' => 'string', 'description' => 'タイトル(必須)'], + 'parentId' => ['type' => 'number', 'description' => '親カスタムリンクID(対象となるカスタムテーブルの type がマスタの場合のみ指定可能)'], + 'name' => ['type' => 'string', 'default' => '', 'description' => 'スラッグ(初期値空文字)'], + 'creatorId' => ['type' => 'number', 'description' => '投稿者ID'], + 'status' => ['type' => 'boolean', 'default' => false, 'description' => '公開状態(デフォルト:false)'], + 'publishBegin' => ['type' => 'string', 'description' => '公開開始日(YYYY-MM-DD HH:mm:ss形式、省略可)'], + 'publishEnd' => ['type' => 'string', 'description' => '公開終了日(YYYY-MM-DD HH:mm:ss形式、省略可)'], + 'published' => ['type' => 'string', 'description' => '公開日(YYYY-MM-DD HH:mm:ss形式、省略時は当日)'], + 'customFields' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'カスタムフィールドの値(フィールド名をキーとするオブジェクト)、ファイルアップロードのフィールドの場合は、外部画像URLを直接指定' + ] + ], + 'required' => ['customTableId', 'id'] + ] + ) + ->tool( + callback: [$this, 'getCustomEntries'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomEntries', + description: 'カスタムエントリーの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'keyword' => ['type' => 'string', 'description' => '検索キーワード(タイトル・スラッグを対象に検索)'], + 'limit' => ['type' => 'number', 'default' => 20, 'description' => '取得件数(デフォルト: 20)'], + 'page' => ['type' => 'number', 'default' => 1, 'description' => 'ページ番号(デフォルト: 1)'], + 'status' => ['type' => 'number', 'description' => 'ステータス(null: 非公開, publish: 公開)'] + ], + 'required' => ['customTableId'] + ] + ) + ->tool( + callback: [$this, 'getCustomEntry'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomEntry', + description: '指定されたIDのカスタムエントリーを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'id' => ['type' => 'number', 'description' => 'カスタムエントリーID(必須)'] + ], + 'required' => ['customTableId', 'id'] + ] + ) + ->tool( + callback: [$this, 'deleteCustomEntry'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteCustomEntry', + description: '指定されたIDのカスタムエントリーを削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'id' => ['type' => 'number', 'description' => 'カスタムエントリーID(必須)'] + ], + 'required' => ['customTableId', 'id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addCustomEntry': + return ['POST' => "/bc-custom-content/custom_entries/add.json"]; + case 'editCustomEntry': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_entries/edit/{$args['id']}.json"]; + case 'getCustomEntries': + return ['GET' => "/bc-custom-content/custom_entries.json"]; + case 'getCustomEntry': + if(empty($args['id'])) return false; + return ['GET' => "/bc-custom-content/custom_entries/view/{$args['id']}.json"]; + case 'deleteCustomEntry': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_entries/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * カスタムエントリーを追加 + */ + public function addCustomEntry( + int $customTableId, + string $title, + ?int $parentId = null, + ?string $name = null, + ?int $creatorId = null, + ?bool $status = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?string $published = null, + ?array $customFields = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $customTableId, $title, $parentId, $name, $creatorId, $status, + $publishBegin, $publishEnd, $published, $customFields, $loginUserId + ) { + /** @var CustomEntriesService $customEntriesService */ + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + // BcCcFileUtil::setupUploader 内で呼び出されるテーブルと確実に同一インスタンスとなるように改めてテーブルを設定 + $customEntriesService->CustomEntries = TableRegistry::getTableLocator()->get('BcCustomContent.CustomEntries'); + BcCcFileUtil::setupUploader($customTableId); + $customEntriesService->setup($customTableId); + + $data = [ + 'custom_table_id' => $customTableId, + 'title' => $title, + 'parentId' => $parentId ?? null, + 'name' => $name ?? '', + 'creator_id' => $creatorId ?? 1, + 'status' => $status ?? false, + 'publish_begin' => $publishBegin ?? null, + 'publish_end' => $publishEnd ?? null, + 'published' => $published ?? date('Y-m-d H:i:s'), + ]; + + // カスタムフィールドの値を追加(ファイルアップロード処理を含む) + if (!empty($customFields)) { + $processedFields = $this->processCustomFields( + $customFields, + $this->buildFieldTypeMap($customEntriesService) + ); + $data = array_merge($data, $processedFields); + } + + $result = $customEntriesService->create($data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('カスタムエントリー「%s」を追加しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムエントリーの保存に失敗しました'); + } + }); + } + + /** + * カスタムエントリーを編集 + */ + public function editCustomEntry( + int $customTableId, + int $id, + ?string $title = null, + ?int $parentId = null, + ?string $name = null, + ?int $creatorId = null, + ?bool $status = null, + ?string $publishBegin = null, + ?string $publishEnd = null, + ?string $published = null, + ?array $customFields = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $id, $customTableId, $title, $parentId, $name, $creatorId, $status, + $publishBegin, $publishEnd, $published, $customFields, $loginUserId + ) { + /** @var CustomEntriesService $customEntriesService */ + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + // BcCcFileUtil::setupUploader 内で呼び出されるテーブルと確実に同一インスタンスとなるように改めてテーブルを設定 + $customEntriesService->CustomEntries = TableRegistry::getTableLocator()->get('BcCustomContent.CustomEntries'); + BcCcFileUtil::setupUploader($customTableId); + $customEntriesService->setup($customTableId); + + $entity = $customEntriesService->get($id); + + if (!$entity) return $this->createErrorResponse('指定されたIDのカスタムエントリーが見つかりません'); + + $data = []; + if ($title !== null) $data['title'] = $title; + if ($parentId !== null) $data['parent_id'] = $parentId; + if ($name !== null) $data['name'] = $name; + if ($creatorId !== null) $data['creator_id'] = $creatorId; + if ($status !== null) $data['status'] = $status; + if ($publishBegin !== null) $data['publish_begin'] = $publishBegin; + if ($publishEnd !== null) $data['publish_end'] = $publishEnd; + if ($published !== null) $data['published'] = $published; + + // カスタムフィールドの値を追加(ファイルアップロード処理を含む) + if (!empty($customFields)) { + $processedFields = $this->processCustomFields( + $customFields, + $this->buildFieldTypeMap($customEntriesService) + ); + $data = array_merge($data, $processedFields); + } + + $result = $customEntriesService->update($entity, $data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('カスタムエントリー「%s」を編集しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムエントリーの更新に失敗しました'); + } + }); + } + + /** + * カスタムフィールドの値を処理(ファイルアップロードを含む) + * + * @param array $customFields + * @param int $customTableId カスタムテーブルID + * @return array + */ + protected function processCustomFields(array $customFields, array $fieldTypes): array + { + $processedFields = []; + + foreach($customFields as $fieldName => $value) { + if (is_array($value)) { + // 配列の場合、json形式またはファイルアップロードの可能性をチェック + $processedFields[$fieldName] = $value; + continue; + } + + $isFileField = (($fieldTypes[$fieldName] ?? null) === 'BcCcFile'); + + if ($isFileField && $this->isFileUploadable($value)) { + // ファイルアップロードデータの処理 + $uploadResult = $this->processFileUpload($value); + if ($uploadResult !== false) { + // 戻り値が配列の場合はUploadedFileオブジェクトに変換、文字列の場合はそのまま + if (is_array($uploadResult)) { + $processedFields[$fieldName] = $this->createUploadedFileFromArray($uploadResult); + } else { + $processedFields[$fieldName] = $uploadResult; + } + } else { + throw new InvalidArgumentException("ファイルアップロードに失敗しました ({$fieldName})"); + } + } elseif ($isFileField && !empty($value)) { + // BcCcFile型のフィールドに、アップロード可能な形式(data: URI・URL・配列) + // ではない値が渡された場合は、実在しないファイル名等がそのままDBへ + // 書き込まれてしまうため、明示的にエラーとして扱う + throw new InvalidArgumentException("ファイルアップロードに失敗しました ({$fieldName})"); + } else { + // 通常の値 + $processedFields[$fieldName] = $value; + } + } + + return $processedFields; + } + + /** + * フィールド名からフィールドタイプを引くマップを作る + * + * CustomEntriesService::setup() が CustomEntriesTable::setLinks() を通じて + * CustomLinks を CustomFields 付きで読み込み済みのため、それを参照する。 + * フィールドごとに DB を引き直すと N+1 になる。 + * + * @param \BcCustomContent\Service\CustomEntriesService $customEntriesService setup() 済みのサービス + * @return array フィールド名 => フィールドタイプ + */ + protected function buildFieldTypeMap($customEntriesService): array + { + $fieldTypes = []; + foreach((array)$customEntriesService->CustomEntries->links as $link) { + $fieldTypes[$link->name] = $link->custom_field->type ?? null; + } + + return $fieldTypes; + } + + /** + * カスタムエントリー一覧を取得 + */ + public function getCustomEntries( + int $customTableId, + ?string $keyword = null, + ?int $creatorId = null, + ?string $published = null, + ?int $limit = 20, + ?int $page = 1, + ?string $status = null + ): array + { + return $this->executeWithErrorHandling(function() use ($customTableId, $keyword, $creatorId, $published, $limit, $page, $status) { + /** @var CustomEntriesService $customEntriesService */ + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + $customEntriesService->setup($customTableId); + $conditions = [ + 'limit' => $limit ?? 20, + 'page' => $page ?? 1 + ]; + if (isset($status)) $conditions['status'] = $status; + // CustomEntriesService の title 条件はタイトルとスラッグの LIKE 検索 + if (!is_null($keyword)) $conditions['title'] = $keyword; + if (!is_null($creatorId)) $conditions['creator_id'] = $creatorId; + if (!is_null($published)) $conditions['published'] = $published; + + $results = $customEntriesService->getIndex($conditions)->toArray(); + + return $this->createSuccessResponse([ + 'results' => $results, + 'pagination' => [ + 'page' => $conditions['page'], + 'limit' => $conditions['limit'], + 'count' => count($results) + ] + ]); + }); + } + + /** + * カスタムエントリーを取得 + */ + public function getCustomEntry(int $customTableId, int $id): array + { + return $this->executeWithErrorHandling(function() use ($customTableId, $id) { + /** @var CustomEntriesService $customEntriesService */ + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + $customEntriesService->setup($customTableId); + $result = $customEntriesService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのカスタムエントリーが見つかりません'); + } + }); + } + + /** + * カスタムエントリーを削除 + */ + public function deleteCustomEntry(int $customTableId, int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($customTableId, $id, $loginUserId) { + /** @var CustomEntriesService $customEntriesService */ + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + $customEntriesService->setup($customTableId); + + // 削除前にタイトルを取得 + $entity = $customEntriesService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのカスタムエントリーが見つかりません'); + } + + $title = $entity->title; + $result = $customEntriesService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + 'カスタムエントリーを削除しました', + [], + sprintf('カスタムエントリー「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムエントリーの削除に失敗しました'); + } + }); + } +} diff --git a/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomFieldsTool.php b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomFieldsTool.php new file mode 100644 index 0000000000..d1c00dd790 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomFieldsTool.php @@ -0,0 +1,406 @@ + '郵便番号', + 'BcCcCheckbox' => 'チェックボックス', + 'BcCcDate' => '日付', + 'BcCcDateTime' => '日時', + 'BcCcEmail' => 'メールアドレス', + 'BcCcFile' => 'ファイルアップロード', + 'BcCcHidden' => '隠しフィールド', + 'BcCcMultiple' => '複数選択', + 'BcCcPassword' => 'パスワード', + 'BcCcPref' => '都道府県リスト', + 'BcCcRadio' => 'ラジオボタン', + 'BcCcRelated' => '関連データ', + 'BcCcSelect' => 'セレクトボックス', + 'BcCcTel' => '電話番号', + 'BcCcText' => '1行テキスト', + 'BcCcTextarea' => '複数行テキスト', + 'BcCcWysiwyg' => 'WYSIWYGエディタ', + 'CuCcBurgerEditor' => 'ブロックエディタ', + ]; + + private const VALIDATION_RULES = [ + 'EMAIL' => 'Eメール形式チェック', + 'EMAIL_CONFIRM' => 'Eメール比較チェック、比較対象のフィールド名を、`meta` フィールドに配列として、キー `BcCustomContent` 配下に、キー `email_confirm` として指定', + 'NUMBER' => '数値チェック', + 'HANKAKU' => '半角英数チェック', + 'ZENKAKU_KATAKANA' => '全角カタカナチェック', + 'ZENKAKU_HIRAGANA' => '全角ひらがなチェック', + 'DATETIME' => '日付チェック', + 'MAX_FILE_SIZE' => 'ファイルアップロードサイズ制限、上限となる数値を単位MBで、`meta` フィールドに配列として、キー `BcCustomContent` 配下に、キー `max_file_size` として指定', + 'FILE_EXT' => 'ファイル拡張子チェック、アップロードを許可する拡張子をカンマ区切りで、`meta` フィールドに配列として、キー `BcCustomContent` 配下に、キー `file_ext` として指定', + ]; + + /** + * カスタムフィールド関連のツールをサーバーに登録する + * + * @param \Mcp\Server\McpServer $server SDK のサーバー + * @return \Mcp\Server\McpServer + */ + public function registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer + { + $typeEnums = array_keys(self::TYPES); + $validationRuleEnums = array_keys(self::VALIDATION_RULES); + $typeDescriptions = implode('、', array_map(fn($key) => "{$key}(" . self::TYPES[$key] . ")", $typeEnums)); + $validationRuleDescriptions = implode('、', array_map(fn($key) => "{$key}(" . self::VALIDATION_RULES[$key] . ")", $validationRuleEnums)); + return $server + ->tool( + callback: [$this, 'addCustomField'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addCustomField', + description: 'カスタムエントリーの入力欄を定義する、カスタムフィールドを追加します。', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'フィールド名(必須)'], + 'title' => ['type' => 'string', 'description' => 'フィールドタイトル(必須)'], + 'type' => ['type' => 'string', 'enum' => $typeEnums, 'description' => 'フィールドタイプ(必須):' . $typeDescriptions], + 'status' => ['type' => 'number', 'description' => 'ステータス(0: 無効, 1: 有効)(初期値は1)'], + 'defaultValue' => ['type' => 'string', 'description' => 'カスタムエントリーの入力欄の初期値'], + 'validate' => ['type' => 'string', 'enum' => $validationRuleEnums, 'description' => 'バリデーションルール(配列で複数選択可):' . $validationRuleDescriptions], + 'regex' => ['type' => 'string', 'description' => '正規表現バリデーション(正規表現でバリデーションを実行したい場合に指定する)'], + 'regexErrorMessage' => ['type' => 'string', 'description' => '正規表現エラーメッセージ(`regex` を指定した場合に、正規表現にマッチしなかった場合に表示するエラーメッセージを指定する)'], + 'counter' => ['type' => 'boolean', 'description' => '文字数カウンター(`true` を指定した場合、入力欄の下に文字数カウンターを表示する、1行テキスト、複数行テキストで利用可能)'], + 'autoConvert' => ['type' => 'string', 'enum' => ['CONVERT_HANKAKU(半角変換)', 'CONVERT_ZENKAKU(全角変換)'], 'description' => '自動変換(入力値を自動で変換する)'], + 'placeholder' => ['type' => 'string', 'description' => 'プレースホルダー(入力欄に薄く表示されるヒントテキスト)'], + 'size' => ['type' => 'number', 'description' => '横幅サイズ(1行テキスト、複数行テキスト、パスワード、メールアドレス、電話番号、郵便番号で利用可能)'], + 'line' => ['type' => 'number', 'description' => '行数(複数行テキストで利用可能)'], + 'maxLength' => ['type' => 'number', 'description' => '最大文字数(1行テキスト、複数行テキスト、パスワード、メールアドレス、電話番号で利用可能)'], + 'source' => ['type' => 'string', 'description' => '選択肢(ラジオボタンやセレクトボックスの場合、改行で区切って指定する)'], + 'meta' => ['type' => 'string', 'description' => 'メタ情報(多次元配列形式で追加情報を指定する、バリデーションルールの詳細設定や、WYSIWYGエディタの幅指定などに利用、WYSIWYG幅:[BcCcWysiwyg][width] / WYSIWYG高さ:[BcCcWysiwyg][height] / WYSIWYGツールタイプ(simple / normal):[BcCcWysiwyg][editor_tool_type])'] + ], + 'required' => ['name', 'title', 'type'] + ] + ) + ->tool( + callback: [$this, 'editCustomField'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editCustomField', + description: 'カスタムエントリーの入力欄を定義する、カスタムフィールドを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムフィールドID(必須)'], + 'name' => ['type' => 'string', 'description' => 'フィールド名'], + 'title' => ['type' => 'string', 'description' => 'フィールドタイトル'], + 'type' => ['type' => 'string', 'enum' => $typeEnums, 'description' => 'フィールドタイプ:' . $typeDescriptions], + 'status' => ['type' => 'number', 'description' => 'ステータス(0: 無効, 1: 有効)(初期値は1)'], + 'defaultValue' => ['type' => 'string', 'description' => 'カスタムエントリーの入力欄の初期値'], + 'validate' => ['type' => 'string', 'enum' => $validationRuleEnums, 'description' => 'バリデーションルール(配列で複数選択可):' . $validationRuleDescriptions], + 'regex' => ['type' => 'string', 'description' => '正規表現バリデーション(正規表現でバリデーションを実行したい場合に指定する)'], + 'regexErrorMessage' => ['type' => 'string', 'description' => '正規表現エラーメッセージ(`regex` を指定した場合に、正規表現にマッチしなかった場合に表示するエラーメッセージを指定する)'], + 'counter' => ['type' => 'boolean', 'description' => '文字数カウンター(`true` を指定した場合、入力欄の下に文字数カウンターを表示する、1行テキスト、複数行テキストで利用可能)'], + 'autoConvert' => ['type' => 'string', 'enum' => ['CONVERT_HANKAKU(半角変換)', 'CONVERT_ZENKAKU(全角変換)'], 'description' => '自動変換(入力値を自動で変換する)'], + 'placeholder' => ['type' => 'string', 'description' => 'プレースホルダー(入力欄に薄く表示されるヒントテキスト)'], + 'size' => ['type' => 'number', 'description' => '横幅サイズ(1行テキスト、複数行テキスト、パスワード、メールアドレス、電話番号、郵便番号で利用可能)'], + 'line' => ['type' => 'number', 'description' => '行数(複数行テキストで利用可能)'], + 'maxLength' => ['type' => 'number', 'description' => '最大文字数(1行テキスト、複数行テキスト、パスワード、メールアドレス、電話番号で利用可能)'], + 'source' => ['type' => 'string', 'description' => '選択肢(ラジオボタンやセレクトボックスの場合、改行で区切って指定する)'], + 'meta' => ['type' => 'string', 'description' => 'メタ情報(多次元配列形式で追加情報を指定する、バリデーションルールの詳細設定や、WYSIWYGエディタの幅指定などに利用、WYSIWYG幅:[BcCcWysiwyg][width] / WYSIWYG高さ:[BcCcWysiwyg][height] / WYSIWYGツールタイプ(simple / normal):[BcCcWysiwyg][editor_tool_type])'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'getCustomFields'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomFields', + description: 'カスタムエントリーの入力欄を定義する、カスタムフィールドの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'フィールド名での絞り込み'], + 'title' => ['type' => 'string', 'description' => 'フィールドタイトルでの絞り込み(部分一致)'], + 'type' => ['type' => 'string', 'description' => 'フィールドタイプでの絞り込み'], + 'status' => ['type' => 'number', 'description' => 'ステータス(0: 無効, 1: 有効)'] + ] + ] + ) + ->tool( + callback: [$this, 'getCustomField'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomField', + description: 'カスタムエントリーの入力欄を定義する、カスタムフィールドをIDを指定して取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムフィールドID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'deleteCustomField'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteCustomField', + description: 'カスタムエントリーの入力欄を定義する、カスタムフィールドをIDを指定して削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムフィールドID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addCustomField': + return ['POST' => "/bc-custom-content/custom_fields/add.json"]; + case 'editCustomField': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_fields/edit/{$args['id']}.json"]; + case 'getCustomFields': + return ['GET' => "/bc-custom-content/custom_fields/index.json"]; + case 'getCustomField': + if(empty($args['id'])) return false; + return ['GET' => "/bc-custom-content/custom_fields/view/{$args['id']}.json"]; + case 'deleteCustomField': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_fields/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * カスタムフィールドを追加 + */ + public function addCustomField( + string $name, + string $title, + string $type, + int $status = 1, + ?string $defaultValue = null, + ?array $validate = null, + ?string $regex = null, + ?string $regexErrorMessage = null, + ?bool $counter = null, + ?string $autoConvert = null, + ?string $placeholder = null, + ?int $size = null, + ?int $line = null, + ?int $maxLength = null, + ?string $source = null, + ?string $meta = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $name, $title, $type, $status, $defaultValue, $validate, $regex, $regexErrorMessage, + $counter, $autoConvert, $placeholder, $size, $line, $maxLength, $source, $meta, $loginUserId + ) { + /** @var CustomFieldsService $customFieldsService */ + $customFieldsService = $this->getService(CustomFieldsServiceInterface::class); + + $data = [ + 'name' => $name, + 'title' => $title, + 'type' => $type, + 'source' => $source ?? null, + 'status' => $status, + 'default_value' => $defaultValue ?? null, + 'validate' => $validate ?? null, + 'regex' => $regex ?? null, + 'regex_error_message' => $regexErrorMessage ?? null, + 'counter' => $counter ?? null, + 'auto_convert' => $autoConvert ?? null, + 'placeholder' => $placeholder ?? null, + 'size' => $size ?? null, + 'line' => $line ?? null, + 'max_length' => $maxLength ?? null, + 'meta' => $meta ?? null + ]; + + $result = $customFieldsService->create($data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('カスタムフィールド「%s」を追加しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムフィールドの保存に失敗しました'); + } + }); + } + + /** + * カスタムフィールド一覧を取得 + */ + public function getCustomFields( + ?string $name = null, + ?string $title = null, + ?string $type = null, + ?int $status = null + ): array + { + return $this->executeWithErrorHandling(function() use ($name, $title, $type, $status) { + /** @var CustomFieldsService $customFieldsService */ + $customFieldsService = $this->getService(CustomFieldsServiceInterface::class); + + $conditions = []; + if (!empty($name)) $conditions['name'] = $name; + if (!empty($title)) $conditions['title'] = $title; + if (!empty($type)) $conditions['type'] = $type; + if (isset($status)) $conditions['status'] = $status; + + $results = $customFieldsService->getIndex($conditions)->toArray(); + + return $this->createSuccessResponse($results); + }); + } + + /** + * カスタムフィールドを取得 + */ + public function getCustomField(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + /** @var CustomFieldsService $customFieldsService */ + $customFieldsService = $this->getService(CustomFieldsServiceInterface::class); + + $result = $customFieldsService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのカスタムフィールドが見つかりません'); + } + }); + } + + /** + * カスタムフィールドを編集 + */ + public function editCustomField( + int $id, + ?string $name = null, + ?string $title = null, + ?string $type = null, + ?int $status = null, + ?string $defaultValue = null, + ?array $validate = null, + ?string $regex = null, + ?string $regexErrorMessage = null, + ?bool $counter = null, + ?string $autoConvert = null, + ?string $placeholder = null, + ?int $size = null, + ?int $line = null, + ?int $maxLength = null, + ?string $source = null, + ?string $meta = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $id, $name, $title, $type, $status, $defaultValue, $validate, $regex, $regexErrorMessage, + $counter, $autoConvert, $placeholder, $size, $line, $maxLength, $source, $meta, $loginUserId + ) { + $customFieldsService = $this->getService(CustomFieldsServiceInterface::class); + + $entity = $customFieldsService->get($id); + + if (!$entity) { + return $this->createErrorResponse('指定されたIDのカスタムフィールドが見つかりません'); + } + + $data = []; + if ($name !== null) $data['name'] = $name; + if ($title !== null) $data['title'] = $title; + if ($type !== null) $data['type'] = $type; + if ($status !== null) $data['status'] = $status; + if ($defaultValue !== null) $data['default_value'] = $defaultValue; + if ($validate !== null) $data['validate'] = $validate; + if ($regex !== null) $data['regex'] = $regex; + if ($regexErrorMessage !== null) $data['regex_error_message'] = $regexErrorMessage; + if ($counter !== null) $data['counter'] = $counter; + if ($autoConvert !== null) $data['auto_convert'] = $autoConvert; + if ($placeholder !== null) $data['placeholder'] = $placeholder; + if ($size !== null) $data['size'] = $size; + if ($line !== null) $data['line'] = $line; + if ($maxLength !== null) $data['max_length'] = $maxLength; + if ($source !== null) $data['source'] = $source; + if ($meta !== null) $data['meta'] = $meta; + + $result = $customFieldsService->update($entity, $data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + [], + sprintf('カスタムフィールド「%s」を編集しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムフィールドの更新に失敗しました'); + } + }); + } + + /** + * カスタムフィールドを削除 + */ + public function deleteCustomField(int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + /** @var CustomFieldsService $customFieldsService */ + $customFieldsService = $this->getService(CustomFieldsServiceInterface::class); + + // 削除前にタイトルを取得 + $entity = $customFieldsService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのカスタムフィールドが見つかりません'); + } + + $title = $entity->title; + $result = $customFieldsService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + 'カスタムフィールドを削除しました', + [], + sprintf('カスタムフィールド「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムフィールドの削除に失敗しました'); + } + }); + } +} diff --git a/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomLinksTool.php b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomLinksTool.php new file mode 100644 index 0000000000..f098473f74 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomLinksTool.php @@ -0,0 +1,402 @@ +tool( + callback: [$this, 'addCustomLink'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addCustomLink', + description: 'カスタムリンクを追加します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'カスタムリンク名(必須)'], + 'title' => ['type' => 'string', 'description' => 'カスタムリンクのタイトル(必須)'], + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'customFieldId' => ['type' => 'number', 'description' => 'カスタムフィールドID(必須)'], + 'parentId' => ['type' => 'number', 'description' => '親カスタムリンクID'], + 'beforeHead' => ['type' => 'string', 'description' => '入力欄の前見出し'], + 'afterHead' => ['type' => 'string', 'description' => '入力欄の後見出し'], + 'description' => ['type' => 'string', 'description' => 'ヘルプメッセージ'], + 'attention' => ['type' => 'string', 'description' => '注意書き'], + 'options' => ['type' => 'string', 'description' => 'フィールド属性。フィールドのコントロールに対して追加の属性を指定する場合に入力します。 属性名と値をパイプ(|)で区切って指定します。複数属性を連続で指定する事ができます。例)data-sample1|value1|data-sample2|value2'], + 'class' => ['type' => 'string', 'description' => 'フィールドのクラス属性'], + 'beforeLinefeed' => ['type' => 'string', 'description' => '入力欄の前に改行を入れる'], + 'afterLinefeed' => ['type' => 'string', 'description' => '入力欄の後に改行を入れる'], + 'displayAdminList' => ['type' => 'boolean', 'description' => '管理画面のエントリー一覧に項目を表示する'], + 'displayFront' => ['type' => 'boolean', 'description' => 'テーマのヘルパーで呼び出せる'], + 'searchTargetAdmin' => ['type' => 'boolean', 'description' => '管理画面で検索対象とする'], + 'searchTargetFront' => ['type' => 'boolean', 'description' => 'テーマ、Web API において検索対象にする'], + 'useApi' => ['type' => 'boolean', 'description' => 'Web API の返却値に含める'], + 'required' => ['type' => 'boolean', 'description' => '必須項目とする'], + 'status' => ['type' => 'boolean', 'description' => '公開状態(0: 無効, 1: 有効)'], + ], + 'required' => ['name', 'title', 'customTableId', 'customFieldId'] + ] + ) + ->tool( + callback: [$this, 'getCustomLinks'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomLinks', + description: 'カスタムリンクの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'name' => ['type' => 'string', 'description' => 'カスタムリンク名'], + 'status' => ['type' => 'number', 'description' => 'ステータス(null: 無効, publish: 有効)'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は制限なし)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ], + 'required' => ['customTableId'] + ] + ) + ->tool( + callback: [$this, 'getCustomLink'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomLink', + description: '指定されたIDのカスタムリンクを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムリンクID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'editCustomLink'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editCustomLink', + description: '指定されたIDのカスタムリンクを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムリンクID(必須)'], + 'name' => ['type' => 'string', 'description' => 'カスタムリンク名'], + 'title' => ['type' => 'string', 'description' => 'カスタムリンクのタイトル'], + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID'], + 'customFieldId' => ['type' => 'number', 'description' => 'カスタムフィールドID'], + 'parentId' => ['type' => 'number', 'description' => '親カスタムリンクID'], + 'beforeHead' => ['type' => 'string', 'description' => '入力欄の前見出し'], + 'afterHead' => ['type' => 'string', 'description' => '入力欄の後見出し'], + 'description' => ['type' => 'string', 'description' => 'ヘルプメッセージ'], + 'attention' => ['type' => 'string', 'description' => '注意書き'], + 'options' => ['type' => 'string', 'description' => 'フィールド属性。フィールドのコントロールに対して追加の属性を指定する場合に入力します。 属性名と値をパイプ(|)で区切って指定します。複数属性を連続で指定する事ができます。例)data-sample1|value1|data-sample2|value2'], + 'class' => ['type' => 'string', 'description' => 'フィールドのクラス属性'], + 'beforeLinefeed' => ['type' => 'string', 'description' => '入力欄の前に改行を入れる'], + 'afterLinefeed' => ['type' => 'string', 'description' => '入力欄の後に改行を入れる'], + 'displayAdminList' => ['type' => 'boolean', 'description' => '管理画面のエントリー一覧に項目を表示する'], + 'displayFront' => ['type' => 'boolean', 'description' => 'テーマのヘルパーで呼び出せる'], + 'searchTargetAdmin' => ['type' => 'boolean', 'description' => '管理画面で検索対象とする'], + 'searchTargetFront' => ['type' => 'boolean', 'description' => 'テーマ、Web API において検索対象にする'], + 'useApi' => ['type' => 'boolean', 'description' => 'Web API の返却値に含める'], + 'required' => ['type' => 'boolean', 'description' => '必須項目とする'], + 'status' => ['type' => 'boolean', 'description' => '公開状態(0: 無効, 1: 有効)'], + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'deleteCustomLink'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteCustomLink', + description: '指定されたIDのカスタムリンクを削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムリンクID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addCustomLink': + return ['POST' => "/bc-custom-content/custom_links/add.json"]; + case 'editCustomLink': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_links/edit/{$args['id']}.json"]; + case 'getCustomLinks': + return ['GET' => "/bc-custom-content/custom_links.json"]; + case 'getCustomLink': + if(empty($args['id'])) return false; + return ['GET' => "/bc-custom-content/custom_links/view/{$args['id']}.json"]; + case 'deleteCustomLink': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_links/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * カスタムリンクを追加 + */ + public function addCustomLink( + string $name, + string $title, + int $customTableId, + int $customFieldId, + ?int $parentId = null, + ?string $beforeHead = null, + ?string $afterHead = null, + ?string $description = null, + ?string $attention = null, + ?string $options = null, + ?string $class = null, + ?string $beforeLinefeed = null, + ?string $afterLinefeed = null, + ?bool $displayAdminList = null, + ?bool $displayFront = null, + ?bool $searchTargetFront = null, + ?bool $searchTargetAdmin = null, + ?bool $useApi = null, + ?bool $required = null, + ?bool $status = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $name, $title, $customTableId, $customFieldId, $parentId, $beforeHead, $afterHead, $description, + $attention, $options, $class, $beforeLinefeed, $afterLinefeed, $displayAdminList, $displayFront, + $searchTargetFront, $searchTargetAdmin, $useApi, $required, $status, $loginUserId + ) { + $customLinksService = $this->getService(CustomLinksServiceInterface::class); + + $data = [ + 'name' => $name, + 'title' => $title, + 'customTableId' => $customTableId, + 'customFieldId' => $customFieldId, + 'parentId' => $parentId, + 'beforeHead' => $beforeHead, + 'afterHead' => $afterHead, + 'description' => $description, + 'attention' => $attention, + 'options' => $options, + 'class' => $class, + 'beforeLinefeed' => $beforeLinefeed, + 'afterLinefeed' => $afterLinefeed, + 'displayAdminList' => $displayAdminList, + 'displayFront' => $displayFront, + 'searchTargetFront' => $searchTargetFront, + 'searchTargetAdmin' => $searchTargetAdmin, + 'useApi' => $useApi, + 'required' => $required, + 'status' => $status, + ]; + + $result = $customLinksService->create($data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + ['customLink' => $result->toArray()], + sprintf('カスタムリンク「%s」を追加しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムリンクの保存に失敗しました'); + } + }); + } + + /** + * カスタムリンク一覧を取得 + */ + public function getCustomLinks( + int $customTableId, + ?string $name = null, + ?string $status = null, + ?int $limit = null, + ?int $page = 1 + ): array + { + return $this->executeWithErrorHandling(function() use ($customTableId, $name, $status, $limit, $page) { + /** @var CustomLinksService $customLinksService */ + $customLinksService = $this->getService(CustomLinksServiceInterface::class); + + $conditions = ['finder' => 'all']; + if (!empty($name)) $conditions['name'] = $name; + if (isset($status)) $conditions['status'] = $status; + if (!empty($limit)) $conditions['limit'] = $limit; + if (!empty($page)) $conditions['page'] = $page; + + // CustomLinksService::getIndex() は custom_table_id を最初の引数として期待している + $results = $customLinksService->getIndex($customTableId, $conditions)->toArray(); + + return $this->createSuccessResponse([ + 'results' => $results, + 'pagination' => [ + 'page' => $page ?? 1, + 'limit' => $limit ?? null, + 'count' => count($results) + ] + ]); + }); + } + + /** + * カスタムリンクを取得 + */ + public function getCustomLink(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + /** @var CustomLinksService $customLinksService */ + $customLinksService = $this->getService(CustomLinksServiceInterface::class); + $result = $customLinksService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのカスタムリンクが見つかりません'); + } + }); + } + + /** + * カスタムリンクを編集 + */ + public function editCustomLink( + int $id, + ?string $name = null, + ?string $title = null, + ?int $customTableId = null, + ?int $customFieldId = null, + ?int $parentId = null, + ?string $beforeHead = null, + ?string $afterHead = null, + ?string $description = null, + ?string $attention = null, + ?string $options = null, + ?string $class = null, + ?string $beforeLinefeed = null, + ?string $afterLinefeed = null, + ?bool $displayAdminList = null, + ?bool $displayFront = null, + ?bool $searchTargetFront = null, + ?bool $searchTargetAdmin = null, + ?bool $useApi = null, + ?bool $required = null, + ?bool $status = null, + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $id, $name, $title, $customTableId, $customFieldId, $parentId, $beforeHead, $afterHead, $description, + $attention, $options, $class, $beforeLinefeed, $afterLinefeed, $displayAdminList, $displayFront, + $searchTargetFront, $searchTargetAdmin, $useApi, $required, $status, $loginUserId + ) { + $customLinksService = $this->getService(CustomLinksServiceInterface::class); + + $entity = $customLinksService->get($id); + + if (!$entity) { + return $this->createErrorResponse('指定されたIDのカスタムリンクが見つかりません'); + } + + $data = []; + if ($name !== null) $data['name'] = $name; + if ($title !== null) $data['title'] = $title; + if ($customTableId !== null) $data['custom_table_id'] = $customTableId; + if ($customFieldId !== null) $data['custom_field_id'] = $customFieldId; + if ($parentId !== null) $data['parent_id'] = $parentId; + if ($beforeHead !== null) $data['before_head'] = $beforeHead; + if ($afterHead !== null) $data['after_head'] = $afterHead; + if ($description !== null) $data['description'] = $description; + if ($attention !== null) $data['attention'] = $attention; + if ($options !== null) $data['options'] = $options; + if ($class !== null) $data['class'] = $class; + if ($beforeLinefeed !== null) $data['before_linefeed'] = $beforeLinefeed; + if ($afterLinefeed !== null) $data['after_linefeed'] = $afterLinefeed; + if ($displayAdminList !== null) $data['display_admin_list'] = $displayAdminList; + if ($displayFront !== null) $data['display_front'] = $displayFront; + if ($searchTargetFront !== null) $data['search_target_front'] = $searchTargetFront; + if ($searchTargetAdmin !== null) $data['search_target_admin'] = $searchTargetAdmin; + if ($useApi !== null) $data['useApi'] = $useApi; + if ($required !== null) $data['required'] = $required; + if ($status !== null) $data['status'] = $status; + + $result = $customLinksService->update($entity, $data); + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + ['customLink' => $result->toArray()], + sprintf('カスタムリンク「%s」を編集しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムリンクの更新に失敗しました'); + } + }); + } + + /** + * カスタムリンクを削除 + */ + public function deleteCustomLink(int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + /** @var CustomLinksService $customLinksService */ + $customLinksService = $this->getService(CustomLinksServiceInterface::class); + + // 削除前にタイトルを取得してログ用に保存 + $entity = $customLinksService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのカスタムリンクが見つかりません'); + } + $title = $entity->title; + + $result = $customLinksService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + ['message' => 'カスタムリンクを削除しました'], + ['customLink' => ['title' => $title]], + sprintf('カスタムリンク「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムリンクの削除に失敗しました'); + } + }); + } +} diff --git a/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomTablesTool.php b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomTablesTool.php new file mode 100644 index 0000000000..52abcef42b --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomTablesTool.php @@ -0,0 +1,351 @@ +tool( + callback: [$this, 'addCustomTable'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_CREATE, + name: 'addCustomTable', + description: 'カスタムテーブルを追加し、指定されたカスタムフィールドを関連付けます。フィールドを関連付けるためには、事前にカスタムフィールドが作成されている必要があります。', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'description' => 'テーブルタイトル(必須)'], + 'name' => ['type' => 'string', 'description' => 'テーブル名(英数小文字、アンダースコアのみ)'], + 'type' => ['type' => 'number', 'enum' => [1, 2], 'description' => 'テーブルタイプ(1:コンテンツ, 2:マスタ)(初期値は1)'], + 'displayField' => ['type' => 'string', 'description' => '表示フィールド(type がコンテンツの場合に指定要、title / name / 関連付いたカスタムリンクの name から選択、初期値は title)'], + 'hasChild' => ['type' => 'boolean', 'description' => '子テーブルを持つかどうか(false:持たない, true:持つ)(type がマスタの場合に指定が可能。初期値は0)'], + 'customFieldNames' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => '関連付けるカスタムフィールドの名前配列' + ] + ], + 'required' => ['title'] + ] + ) + ->tool( + callback: [$this, 'editCustomTable'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_UPDATE, + name: 'editCustomTable', + description: '指定されたIDのカスタムテーブルを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'title' => ['type' => 'string', 'description' => 'テーブルタイトル'], + 'name' => ['type' => 'string', 'description' => 'テーブル名(英数小文字、アンダースコアのみ)'], + 'type' => ['type' => 'number', 'enum' => [1, 2], 'description' => 'テーブルタイプ(1:コンテンツ, 2:マスタ)'], + 'displayField' => ['type' => 'string', 'description' => '表示フィールド(type がコンテンツの場合に指定要、title / name / 関連付いたカスタムリンクの name から選択、初期値は title)'], + 'hasChild' => ['type' => 'boolean', 'description' => '子テーブルを持つかどうか(false:持たない, true:持つ)(type がマスタの場合に指定が可能。初期値は0)'], + 'customFieldNames' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => '関連付けるカスタムフィールドの名前配列' + ] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'getCustomTables'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomTables', + description: 'カスタムテーブルの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string', 'description' => 'テーブルタイプ'] + ] + ] + ) + ->tool( + callback: [$this, 'getCustomTable'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_READ, + name: 'getCustomTable', + description: '指定されたIDのカスタムテーブルを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->tool( + callback: [$this, 'deleteCustomTable'], + outputSchema: self::OUTPUT_SCHEMA, + annotations: self::ANNOTATION_DELETE, + name: 'deleteCustomTable', + description: '指定されたIDのカスタムテーブルを削除します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * 権限チェック用のURLを取得する + * @param $action + * @param $args + * @return false|string[] + */ + public static function getPermissionUrl($action, $args = []) + { + switch ($action) { + case 'addCustomTable': + return ['POST' => "/bc-custom-content/custom_tables/add.json"]; + case 'editCustomTable': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_tables/edit/{$args['id']}.json"]; + case 'getCustomTables': + return ['GET' => "/bc-custom-content/custom_tables/index.json"]; + case 'getCustomTable': + if(empty($args['id'])) return false; + return ['GET' => "/bc-custom-content/custom_tables/view/{$args['id']}.json"]; + case 'deleteCustomTable': + if(empty($args['id'])) return false; + return ['POST' => "/bc-custom-content/custom_tables/delete/{$args['id']}.json"]; + default: + return false; + } + } + + /** + * カスタムテーブルを追加 + */ + public function addCustomTable( + string $title, + ?string $name = null, + ?int $type = 1, + ?string $displayField = 'title', + ?int $hasChild = 0, + ?array $customFieldNames = [], + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $title, $name, $type, $displayField, $hasChild, $customFieldNames, $loginUserId + ) { + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + $data = [ + 'title' => $title, + 'name' => $name ?? 'table_' . time(), + 'type' => $type ?? 1, + 'display_field' => $displayField ?? 'title', + 'has_child' => $hasChild ?? 0 + ]; + + $result = $customTablesService->create($data); + + if ($result && !empty($customFieldNames)) { + // カスタムフィールドとの関連付け + $customLinks = $this->createCustomLinks($customFieldNames); + if ($customLinks) { + $customTable = $result->toArray(); + $customTable['custom_links'] = $customLinks; + $result = $customTablesService->update($result, $customTable); + } + } + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + ['customTable' => $result->toArray()], + sprintf('カスタムテーブル「%s」を追加しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムテーブルの保存に失敗しました'); + } + }); + } + + /** + * カスタムフィールド名の配列からカスタムリンクの配列を作成 + * @param $customFieldNames + * @return array + */ + private function createCustomLinks($customFieldNames) + { + $customFieldsService = $this->getService(CustomFieldsServiceInterface::class); + $customLinks = []; + if (!empty($customFieldNames)) { + $i = 0; + foreach($customFieldNames as $fieldName) { + $customField = $customFieldsService->getIndex(['name' => $fieldName])->first(); + if ($customField) { + $customLinks["new_" . $i + 1] = [ + "name" => $customField->name, + "custom_field_id" => $customField->id, + "type" => $customField->type, + "display_front" => true, + "use_api" => true, + "status" => true, + "title" => $customField->title, + "search_target_admin" => true, + "search_target_front" => true + ]; + $i++; + } + } + } + return $customLinks; + } + + /** + * カスタムテーブル一覧を取得 + */ + public function getCustomTables($type = null): array + { + return $this->executeWithErrorHandling(function() use ($type) { + /** @var CustomTablesService $customTablesService */ + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + $conditions = []; + if (!empty($type)) $conditions['type'] = $type; + + $results = $customTablesService->getIndex($conditions)->toArray(); + return $this->createSuccessResponse($results); + }); + } + + /** + * カスタムテーブルを取得 + */ + public function getCustomTable(int $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + /** @var CustomFieldsService $customTablesService */ + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + $result = $customTablesService->get($id); + + if ($result) { + return $this->createSuccessResponse($result->toArray()); + } else { + return $this->createErrorResponse('指定されたIDのカスタムテーブルが見つかりません'); + } + }); + } + + /** + * カスタムテーブルを編集 + */ + public function editCustomTable( + int $id, + string $title, + ?string $name = null, + ?int $type = 1, + ?string $displayField = 'title', + ?int $hasChild = 0, + ?array $customFieldNames = [], + ?int $loginUserId = null + ): array + { + return $this->executeWithErrorHandling(function() use ( + $id, $title, $name, $type, $displayField, $hasChild, $customFieldNames, $loginUserId + ) { + /** @var CustomTablesService $customTablesService */ + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + $entity = $customTablesService->get($id); + + if (!$entity) return $this->createErrorResponse('指定されたIDのカスタムテーブルが見つかりません'); + + $data = []; + if ($title !== null) $data['title'] = $title; + if ($name !== null) $data['name'] = $name; + if ($type !== null) $data['type'] = $type; + if ($displayField !== null) $data['displayField'] = $displayField; + if ($hasChild !== null) $data['hasChild'] = $hasChild; + + $result = $customTablesService->update($entity, $data); + + // カスタムフィールドとの関連付けを更新 + if ($result && !empty($customFieldNames)) { + // カスタムフィールドとの関連付け + $customLinks = $this->createCustomLinks($customFieldNames); + if ($customLinks) { + $customTable = $result->toArray(); + $customTable['custom_links'] = $customLinks; + $result = $customTablesService->update($result, $customTable); + } + } + + if ($result) { + return $this->createSuccessResponse( + $result->toArray(), + ['customTable' => $result->toArray()], + sprintf('カスタムテーブル「%s」を編集しました。', $result->title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムテーブルの更新に失敗しました'); + } + }); + } + + /** + * カスタムテーブルを削除 + */ + public function deleteCustomTable(int $id, ?int $loginUserId = null): array + { + return $this->executeWithErrorHandling(function() use ($id, $loginUserId) { + /** @var CustomTablesService $customTablesService */ + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + // 削除前にタイトルを取得してログ用に保存 + $entity = $customTablesService->get($id); + if (!$entity) { + return $this->createErrorResponse('指定されたIDのカスタムテーブルが見つかりません'); + } + $title = $entity->title; + + $result = $customTablesService->delete($id); + + if ($result) { + return $this->createSuccessResponse( + ['message' => 'カスタムテーブルを削除しました'], + ['customTable' => ['title' => $title]], + sprintf('カスタムテーブル「%s」を削除しました。', $title), + $loginUserId + ); + } else { + return $this->createErrorResponse('カスタムテーブルの削除に失敗しました'); + } + }); + } + +} diff --git a/plugins/bc-mcp/src/Mcp/McpContext.php b/plugins/bc-mcp/src/Mcp/McpContext.php new file mode 100644 index 0000000000..49746e7a1b --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/McpContext.php @@ -0,0 +1,64 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp; + +/** + * MCP リクエストのコンテキスト + * + * MCP のツールは JSON-RPC の引数だけを受け取るため、認証済みの操作者を知る + * 手段がない。リクエストボディに引数を注入する方式は、2026-07-28 でヘッダと + * ボディの一致が検証されるようになったため採らず、同一プロセス内のコンテキスト + * として保持する。 + * + * 値は必ず認証後に設定し、リクエストの終わりに clear() する。 + */ +class McpContext +{ + + /** + * ログインユーザーID + * @var int|null + */ + private static ?int $loginUserId = null; + + /** + * ログインユーザーIDを設定する + * + * @param int|null $userId ユーザーID + * @return void + */ + public static function setLoginUserId(?int $userId): void + { + self::$loginUserId = $userId; + } + + /** + * ログインユーザーIDを取得する + * + * @return int|null + */ + public static function getLoginUserId(): ?int + { + return self::$loginUserId; + } + + /** + * コンテキストを破棄する + * + * @return void + */ + public static function clear(): void + { + self::$loginUserId = null; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/McpLogger.php b/plugins/bc-mcp/src/Mcp/McpLogger.php new file mode 100644 index 0000000000..4205f26d2c --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/McpLogger.php @@ -0,0 +1,82 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp; + +use Psr\Log\AbstractLogger; +use Stringable; + +/** + * MCPサーバー用ロガー + * + * MCPサーバーは常駐プロセスとして動作しており、ツール実行時の例外は + * メッセージのみに丸められてクライアントへ返却されるため、そのままでは + * 発生箇所を追跡できない。 + * 例外のトレースまで含めてログに記録する事で、発生箇所を追跡できるようにする。 + */ +class McpLogger extends AbstractLogger +{ + + /** + * ログファイルのパス + * @var string + */ + private string $logFile; + + /** + * 記録対象のログレベル + * @var array + */ + private array $levels; + + /** + * コンストラクタ + * + * @param string $logFile ログファイルのパス + * @param array $levels 記録対象のログレベル + */ + public function __construct(string $logFile, array $levels = ['emergency', 'alert', 'critical', 'error', 'warning']) + { + $this->logFile = $logFile; + $this->levels = $levels; + } + + /** + * ログを記録する + * + * @param mixed $level + * @param string|Stringable $message + * @param array $context + * @return void + */ + public function log($level, string|Stringable $message, array $context = []): void + { + if (!in_array((string)$level, $this->levels, true)) return; + + $log = sprintf('%s %s: %s', date('Y-m-d H:i:s'), strtoupper((string)$level), (string)$message); + if (!empty($context['tool'])) { + $log .= ' (tool: ' . $context['tool'] . ')'; + } + if (!empty($context['exception']) && $context['exception'] instanceof \Throwable) { + $exception = $context['exception']; + $log .= PHP_EOL . sprintf( + '%s: %s in %s(%s)', + get_class($exception), + $exception->getMessage(), + $exception->getFile(), + $exception->getLine() + ); + $log .= PHP_EOL . $exception->getTraceAsString(); + } + file_put_contents($this->logFile, $log . PHP_EOL, FILE_APPEND); + } + +} diff --git a/plugins/bc-mcp/src/Mcp/McpRequestHandler.php b/plugins/bc-mcp/src/Mcp/McpRequestHandler.php new file mode 100644 index 0000000000..0768478be7 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/McpRequestHandler.php @@ -0,0 +1,90 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp; + +use Cake\Core\Configure; +use Mcp\Server\HttpServerRunner; +use Mcp\Server\Transport\Http\BufferedIo; +use Mcp\Server\Transport\Http\FileSessionStore; +use Mcp\Server\Transport\Http\HttpMessage; + +/** + * MCP リクエストをプロセス内で処理する + * + * SDK の HTTP トランスポートは「1リクエストを処理して終わる」モデルであり、 + * 常駐プロセスを必要としない。BufferedIo により出力が SAPI へ直接書き出される + * のを防ぎ、レスポンスを CakePHP のレスポンスに載せられるようにする。 + * + * 本番(McpProxyController)とテストがこの経路を共有する。 + */ +class McpRequestHandler +{ + + /** + * MCP リクエストを処理する + * + * @param \Mcp\Server\Transport\Http\HttpMessage $request リクエスト + * @return \Mcp\Server\Transport\Http\HttpMessage レスポンス + */ + public function handle(HttpMessage $request): HttpMessage + { + $logger = new McpLogger(LOGS . 'bc_mcp_error.log'); + $coreServer = (new McpServer())->getServer()->getServer(); + + $runner = new HttpServerRunner( + $coreServer, + $coreServer->createInitializationOptions(), + $this->getHttpOptions(), + $logger, + new FileSessionStore($this->getSessionStorePath()), + new BufferedIo() + ); + + return $runner->handleRequest($request); + } + + /** + * HTTP トランスポートのオプションを取得する + * + * allowed_origins は SDK 側の DNS リバインディング対策。 + * プロキシでも検証しているため二重に効かせる。 + * + * @return array + */ + public function getHttpOptions(): array + { + $options = []; + $allowedOrigins = (array)Configure::read('BcMcp.allowedOrigins', []); + if ($allowedOrigins) { + $options['allowed_origins'] = $allowedOrigins; + } + return $options; + } + + /** + * Legacy セッションの保存先を取得する + * + * Modern(2026-07-28)はセッションを使わないが、Legacy 世代のクライアントは + * セッションを必要とするためディスクへ永続する。 + * + * @return string + */ + public function getSessionStorePath(): string + { + $path = TMP . 'bc_mcp_sessions'; + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + return $path; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/McpServer.php b/plugins/bc-mcp/src/Mcp/McpServer.php new file mode 100644 index 0000000000..81ed451806 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/McpServer.php @@ -0,0 +1,115 @@ +buildServer(); + } + + /** + * サーバーのビルド + */ + private function buildServer(): void + { + $this->server = new SdkMcpServer( + 'baserCMS MCP Server', + new McpLogger(LOGS . 'bc_mcp_error.log'), + '1.0.0' + ); + + $availableServers = Configure::read('BcMcp.availableServers', []); + foreach($availableServers as $serverClass) { + foreach($serverClass::getToolClasses() as $toolClass) { + (new $toolClass())->registerTools($this->server); + } + } + + // サーバー情報ツールを追加 + // outputSchema を宣言しないと SDK が配列の戻り値を受け付けない + $this->server->tool( + name: 'serverInfo', + description: 'サーバーのバージョンや環境情報を返します', + callback: [$this, 'serverInfo'], + // BaseMcpTool を継承していないため定数を参照できない。 + // ANNOTATION_READ と同じ内容を直接指定する。 + annotations: ['readOnlyHint' => true, 'openWorldHint' => false], + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'php_version' => ['type' => 'string', 'description' => 'PHP のバージョン'], + 'basercms_version' => ['type' => 'string', 'description' => 'baserCMS のバージョン'], + 'cakephp_version' => ['type' => 'string', 'description' => 'CakePHP のバージョン'], + 'server_time' => ['type' => 'string', 'description' => 'サーバーの現在日時'], + 'timezone' => ['type' => 'string', 'description' => 'タイムゾーン'], + 'mcp_server_version' => ['type' => 'string', 'description' => 'MCP サーバーのバージョン'], + 'supported_clients' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => '対応クライアント'], + 'available_transports' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => '利用可能なトランスポート'], + ] + ], + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ID'], + ] + ] + ); + } + + /** + * MCPサーバーの実体を取得する + * + * @return \Mcp\Server\McpServer + */ + public function getServer(): SdkMcpServer + { + return $this->server; + } + + /** + * サーバー情報を取得する + * + * @param int|null $id ID + * @return array + */ + public function serverInfo(?int $id = null): array + { + return [ + 'php_version' => PHP_VERSION, + 'basercms_version' => BcUtil::getVersion(), + 'cakephp_version' => Configure::version(), + 'server_time' => date('Y-m-d H:i:s'), + 'timezone' => date_default_timezone_get(), + 'mcp_server_version' => '1.0.0', + 'supported_clients' => ['ChatGPT', 'Claude', 'Custom MCP Clients'], + 'available_transports' => ['http'], + ]; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/NegotiationLogger.php b/plugins/bc-mcp/src/Mcp/NegotiationLogger.php new file mode 100644 index 0000000000..25dae6e007 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/NegotiationLogger.php @@ -0,0 +1,131 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Mcp; + +use Cake\Log\Log; + +/** + * MCP のネゴシエーション内容を記録する + * + * クライアントがどの世代(Modern / Legacy)でどのプロトコルバージョンを + * 要求してきたかを残す事で、クライアント側の移行を検知できるようにする。 + * 常駐プロセスの死活監視が不要になった代わりに、これが運用時の主要な + * 確認手段になる。 + * + * 引数やトークンの中身は記録しない。 + */ +class NegotiationLogger +{ + + /** + * 記録する内容を組み立てる + * + * Modern(2026-07-28 以降)はリクエストごとの _meta でバージョンを伝え、 + * Legacy は initialize の params でバージョンを伝える。 + * + * @param array $mcpRequest MCP リクエスト + * @param string $protocolVersionHeader MCP-Protocol-Version ヘッダの値 + * @return array + */ + public static function describe(array $mcpRequest, string $protocolVersionHeader): array + { + $meta = $mcpRequest['params']['_meta'] ?? []; + $isModern = isset($meta['io.modelcontextprotocol/protocolVersion']); + + if ($isModern) { + $protocolVersion = $meta['io.modelcontextprotocol/protocolVersion']; + $clientInfo = $meta['io.modelcontextprotocol/clientInfo'] ?? []; + } else { + $protocolVersion = $mcpRequest['params']['protocolVersion'] ?? $protocolVersionHeader; + $clientInfo = $mcpRequest['params']['clientInfo'] ?? []; + } + + return [ + 'era' => $isModern? 'modern' : 'legacy', + 'protocolVersion' => (string)$protocolVersion, + 'clientName' => (string)($clientInfo['name'] ?? ''), + 'clientVersion' => (string)($clientInfo['version'] ?? ''), + 'method' => (string)($mcpRequest['method'] ?? ''), + ]; + } + + /** + * ネゴシエーション内容をログに記録する + * + * @param array $mcpRequest MCP リクエスト + * @param string $protocolVersionHeader MCP-Protocol-Version ヘッダの値 + * @return void + */ + public static function log(array $mcpRequest, string $protocolVersionHeader): void + { + $info = self::describe($mcpRequest, $protocolVersionHeader); + Log::write('info', sprintf( + 'MCP negotiation: era=%s protocolVersion=%s client=%s/%s method=%s', + $info['era'], + $info['protocolVersion'], + $info['clientName'], + $info['clientVersion'], + $info['method'] + // Log::write() は $context['scope'] でスコープを判定するため、 + // 配列を直接渡してはならない(scope が空になり mcp.log へ書かれない) + ), ['scope' => ['mcp']]); + } + + /** + * 直近の接続状況をログから読み出す + * + * 管理画面で「クライアントがどの世代で接続しているか」を確認できるようにする。 + * + * @param int $limit 取得件数 + * @param string|null $logFile ログファイルのパス(テスト用) + * @return array 新しい順の接続状況 + */ + public static function readRecent(int $limit = 10, ?string $logFile = null): array + { + $logFile ??= LOGS . 'mcp.log'; + if (!is_readable($logFile)) { + return []; + } + + $lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return []; + } + + $pattern = '/^(?[\d\-]+ [\d:]+).*MCP negotiation: era=(?\S+) ' + . 'protocolVersion=(?\S*) client=(?[^\/]*)\/(?\S*) ' + . 'method=(?\S*)$/'; + + $result = []; + foreach(array_reverse($lines) as $line) { + if (!str_contains($line, 'MCP negotiation:')) { + continue; + } + if (!preg_match($pattern, $line, $matches)) { + continue; + } + $result[] = [ + 'loggedAt' => $matches['loggedAt'], + 'era' => $matches['era'], + 'protocolVersion' => $matches['protocolVersion'], + 'clientName' => $matches['clientName'], + 'clientVersion' => $matches['clientVersion'], + 'method' => $matches['method'], + ]; + if (count($result) >= $limit) { + break; + } + } + return $result; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/PermissionManager.php b/plugins/bc-mcp/src/Mcp/PermissionManager.php new file mode 100644 index 0000000000..19c4d4e33f --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/PermissionManager.php @@ -0,0 +1,58 @@ +getPermissionUrl($action, $arguments); + if (!$permissionUrl) return false; + /** @var PermissionsService $permissionsService */ + $permissionsService = $this->getService(PermissionsServiceInterface::class); + return $permissionsService->check($permissionUrl[key($permissionUrl)], $loginGroupIds, key($permissionUrl)); + } + + /** + * 権限チェック用のURLを取得する + * @param string $action + * @param array $arguments + * @return array|false + */ + public function getPermissionUrl($action, $arguments): array|false + { + foreach(Configure::read('BcMcp.availableServers') as $serverClass) { + + $resourceClasses = $serverClass::getToolClasses(); + foreach($resourceClasses as $resourceClass) { + if (!method_exists($resourceClass, 'getPermissionUrl')) { + throw new \RuntimeException(sprintf('Tool class %s must implement getPermissionUrls method.', $resourceClass)); + } + $permissionUrl = $resourceClass::getPermissionUrl($action, $arguments); + if($permissionUrl) { + $permissionUrl[key($permissionUrl)] = '/' . BcUtil::getBaserCorePrefix() . '/api/' . BcUtil::getAdminPrefix() . $permissionUrl[key($permissionUrl)]; + return $permissionUrl; + } + } + } + return false; + } + +} diff --git a/plugins/bc-mcp/src/Model/Entity/Oauth2AccessToken.php b/plugins/bc-mcp/src/Model/Entity/Oauth2AccessToken.php new file mode 100644 index 0000000000..ba63579dfe --- /dev/null +++ b/plugins/bc-mcp/src/Model/Entity/Oauth2AccessToken.php @@ -0,0 +1,39 @@ + true, + 'client_id' => true, + 'user_id' => true, + 'scopes' => true, + 'revoked' => true, + 'expires_at' => true, + 'created' => true, + 'modified' => true, + ]; + +} diff --git a/plugins/bc-mcp/src/Model/Entity/Oauth2AuthCode.php b/plugins/bc-mcp/src/Model/Entity/Oauth2AuthCode.php new file mode 100644 index 0000000000..b43c94a42b --- /dev/null +++ b/plugins/bc-mcp/src/Model/Entity/Oauth2AuthCode.php @@ -0,0 +1,65 @@ + + */ + protected array $_accessible = [ + 'code' => true, + 'user_id' => true, + 'client_id' => true, + 'redirect_uri' => true, + 'scopes' => true, + 'revoked' => true, + 'expires_at' => true, + 'created' => true, + 'modified' => true, + ]; + + /** + * スコープを配列として取得 + * + * @return array + */ + public function getScopesArray(): array + { + if (empty($this->scopes)) { + return []; + } + return explode(' ', trim($this->scopes)); + } + + /** + * スコープを文字列として設定 + * + * @param array $scopes + * @return void + */ + public function setScopesFromArray(array $scopes): void + { + $this->scopes = implode(' ', $scopes); + } + +} diff --git a/plugins/bc-mcp/src/Model/Entity/Oauth2Client.php b/plugins/bc-mcp/src/Model/Entity/Oauth2Client.php new file mode 100644 index 0000000000..07633b2a27 --- /dev/null +++ b/plugins/bc-mcp/src/Model/Entity/Oauth2Client.php @@ -0,0 +1,215 @@ + + */ + protected array $_accessible = [ + 'client_id' => true, + 'client_secret' => true, + 'name' => true, + 'redirect_uris' => true, + 'grants' => true, + 'scopes' => true, + 'is_confidential' => true, + 'registration_access_token' => true, + 'created' => true, + 'modified' => true, + ]; + + /** + * hidden properties + * + * @var array + */ + protected array $_hidden = [ + 'client_secret', + 'registration_access_token', + ]; + + /** + * json fields + * + * @var array + */ + protected array $_jsonFields = [ + 'redirect_uris', + 'grants', + 'scopes', + ]; + + /** + * Dynamic Client Registration response payload(RFC 7591) + * + * メモ: + * - client_secret は「登録時のみ」返すのが原則(再取得・更新時は返さない)。 + * - client_secret_expires_at は有効期限がない場合 0 を返す実装もあるが、本実装では未設定時は省略。 + * - token_endpoint_auth_method は is_confidential に応じて既定値を補完(true=client_secret_basic / false=none)。 + * - 以下の項目は任意(クライアントメタデータ)。提供された場合のみ反映する: + * contacts, client_uri, logo_uri, tos_uri, policy_uri, software_id, software_version + * + * @return array + */ + public function toRegistrationResponse(): array + { + $scopes = $this->scopes ?? []; + // 追加の一時プロパティは存在すれば利用 + $registrationClientUri = $this->get('registration_client_uri'); + $tokenEndpointAuthMethod = $this->get('token_endpoint_auth_method') ?? ($this->is_confidential? 'client_secret_basic' : 'none'); + $clientIdIssuedAt = $this->get('client_id_issued_at') ?? ($this->created? $this->created->getTimestamp() : null); + $clientSecretExpiresAt = $this->get('client_secret_expires_at'); + $contacts = $this->get('contacts'); + $clientUri = $this->get('client_uri'); + $logoUri = $this->get('logo_uri'); + $tosUri = $this->get('tos_uri'); + $policyUri = $this->get('policy_uri'); + $softwareId = $this->get('software_id'); + $softwareVersion = $this->get('software_version'); + + $response = [ + 'client_id' => $this->client_id, + // シークレットは登録時のみ返す仕様だが、ここでは保持していれば返す + 'client_secret' => $this->client_secret ?? null, + 'client_id_issued_at' => $clientIdIssuedAt, + 'client_secret_expires_at' => $clientSecretExpiresAt, + 'registration_access_token' => $this->registration_access_token ?? null, + 'registration_client_uri' => $registrationClientUri, + 'token_endpoint_auth_method' => $tokenEndpointAuthMethod, + 'client_name' => $this->name, + 'redirect_uris' => $this->redirect_uris ?? [], + 'grant_types' => $this->grants ?? [], + 'scope' => implode(' ', $scopes), + // 任意メタデータ(提供時のみ出力) + 'contacts' => $contacts, + 'client_uri' => $clientUri, + 'logo_uri' => $logoUri, + 'tos_uri' => $tosUri, + 'policy_uri' => $policyUri, + 'software_id' => $softwareId, + 'software_version' => $softwareVersion, + ]; + + // null を含めたくないキーをフィルタ(client_secret_expires_at は null を許可) + foreach(['client_secret', 'registration_access_token', 'registration_client_uri', 'contacts', 'client_uri', 'logo_uri', 'tos_uri', 'policy_uri', 'software_id', 'software_version'] as $nullableKey) { + if ($response[$nullableKey] === null) { + unset($response[$nullableKey]); + } + } + + return $response; + } + + // 旧サービス層からの呼び出しに対応するための簡易ゲッター + public function getName(): string + { + return (string)$this->name; + } + + public function getRedirectUri(): array + { + return (array)($this->redirect_uris ?? []); + } + + public function getGrants(): array + { + return (array)($this->grants ?? []); + } + + public function getScopes(): array + { + return (array)($this->scopes ?? []); + } + + public function getRegistrationAccessToken(): ?string + { + return $this->registration_access_token ?? null; + } + + public function getRegistrationClientUri(): ?string + { + return $this->get('registration_client_uri'); + } + + public function getClientIdIssuedAt(): ?int + { + return $this->get('client_id_issued_at'); + } + + public function getClientSecretExpiresAt(): ?int + { + return $this->get('client_secret_expires_at'); + } + + public function getTokenEndpointAuthMethod(): ?string + { + return $this->get('token_endpoint_auth_method'); + } + + public function getContacts(): array + { + return (array)($this->get('contacts') ?? []); + } + + public function getClientUri(): ?string + { + return $this->get('client_uri'); + } + + public function getLogoUri(): ?string + { + return $this->get('logo_uri'); + } + + public function getTosUri(): ?string + { + return $this->get('tos_uri'); + } + + public function getPolicyUri(): ?string + { + return $this->get('policy_uri'); + } + + public function getSoftwareId(): ?string + { + return $this->get('software_id'); + } + + public function getSoftwareVersion(): ?string + { + return $this->get('software_version'); + } + + public function getSecret(): ?string + { + return $this->client_secret ?? null; + } + + public function getIdentifier(): string + { + return (string)$this->client_id; + } +} diff --git a/plugins/bc-mcp/src/Model/Entity/Oauth2RefreshToken.php b/plugins/bc-mcp/src/Model/Entity/Oauth2RefreshToken.php new file mode 100644 index 0000000000..ac3505a692 --- /dev/null +++ b/plugins/bc-mcp/src/Model/Entity/Oauth2RefreshToken.php @@ -0,0 +1,35 @@ + + */ + protected array $_accessible = [ + 'token_id' => true, + 'access_token_id' => true, + 'revoked' => true, + 'expires_at' => true, + 'created' => true, + 'modified' => true, + ]; + +} diff --git a/plugins/bc-mcp/src/Model/Table/Oauth2AccessTokensTable.php b/plugins/bc-mcp/src/Model/Table/Oauth2AccessTokensTable.php new file mode 100644 index 0000000000..f221da471b --- /dev/null +++ b/plugins/bc-mcp/src/Model/Table/Oauth2AccessTokensTable.php @@ -0,0 +1,85 @@ +setTable('oauth2_access_tokens'); + $this->setDisplayField('token_id'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + } + + /** + * Default validation rules. + * + * @param Validator $validator Validator instance. + * @return Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->scalar('token_id') + ->maxLength('token_id', 100) + ->requirePresence('token_id', 'create') + ->notEmptyString('token_id') + ->add('token_id', 'unique', ['rule' => 'validateUnique', 'provider' => 'table']); + + $validator + ->scalar('client_id') + ->maxLength('client_id', 100) + ->requirePresence('client_id', 'create') + ->notEmptyString('client_id'); + + $validator + ->scalar('user_id') + ->maxLength('user_id', 100) + ->allowEmptyString('user_id'); + + $validator + ->scalar('scopes') + ->maxLength('scopes', 500) + ->allowEmptyString('scopes'); + + $validator + ->boolean('revoked') + ->notEmptyString('revoked'); + + $validator + ->dateTime('expires_at') + ->requirePresence('expires_at', 'create') + ->notEmptyDateTime('expires_at'); + + return $validator; + } + + /** + * 期限切れのアクセストークンをクリーンアップ + * + * @return int 削除された件数 + */ + public function cleanExpiredTokens(): int + { + return $this->deleteAll(['expires_at <' => new \DateTime()]); + } + +} diff --git a/plugins/bc-mcp/src/Model/Table/Oauth2AuthCodesTable.php b/plugins/bc-mcp/src/Model/Table/Oauth2AuthCodesTable.php new file mode 100644 index 0000000000..6235781332 --- /dev/null +++ b/plugins/bc-mcp/src/Model/Table/Oauth2AuthCodesTable.php @@ -0,0 +1,100 @@ +setTable('oauth2_auth_codes'); + $this->setDisplayField('code'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + } + + /** + * Default validation rules. + * + * @param Validator $validator Validator instance. + * @return Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->scalar('code') + ->maxLength('code', 100) + ->requirePresence('code', 'create') + ->notEmptyString('code') + ->add('code', 'unique', ['rule' => 'validateUnique', 'provider' => 'table']); + + $validator + ->scalar('user_id') + ->maxLength('user_id', 100) + ->requirePresence('user_id', 'create') + ->notEmptyString('user_id'); + + $validator + ->scalar('client_id') + ->maxLength('client_id', 80) + ->requirePresence('client_id', 'create') + ->notEmptyString('client_id'); + + $validator + ->scalar('redirect_uri') + ->requirePresence('redirect_uri', 'create') + ->notEmptyString('redirect_uri'); + + $validator + ->scalar('scopes') + ->allowEmptyString('scopes'); + + $validator + ->boolean('revoked') + ->notEmptyString('revoked'); + + $validator + ->dateTime('expires_at') + ->requirePresence('expires_at', 'create') + ->notEmptyDateTime('expires_at'); + + $validator + ->scalar('code_challenge') + ->maxLength('code_challenge', 255) + ->allowEmptyString('code_challenge'); + + $validator + ->scalar('code_challenge_method') + ->maxLength('code_challenge_method', 255) + ->allowEmptyString('code_challenge_method'); + + return $validator; + } + + /** + * 期限切れの認可コードをクリーンアップ + * + * @return int 削除された件数 + */ + public function cleanExpiredCodes(): int + { + return $this->deleteAll(['expires_at <' => new \DateTime()]); + } + +} diff --git a/plugins/bc-mcp/src/Model/Table/Oauth2ClientsTable.php b/plugins/bc-mcp/src/Model/Table/Oauth2ClientsTable.php new file mode 100644 index 0000000000..be518626da --- /dev/null +++ b/plugins/bc-mcp/src/Model/Table/Oauth2ClientsTable.php @@ -0,0 +1,128 @@ +setTable('oauth2_clients'); + $this->setDisplayField('name'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + + // JSON文字列として保存し、取得時は配列として扱う + // DBカラム型はtextだが、CakePHPの型マッピングでjsonを指定することで + // 保存時に自動でエンコード、取得時に自動でデコードされる + $this->getSchema() + ->setColumnType('redirect_uris', 'json') + ->setColumnType('grants', 'json') + ->setColumnType('scopes', 'json'); + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->scalar('client_id') + ->maxLength('client_id', 80) + ->requirePresence('client_id', 'create') + ->notEmptyString('client_id') + ->add('client_id', 'unique', ['rule' => 'validateUnique', 'provider' => 'table']); + + $validator + ->scalar('client_secret') + ->maxLength('client_secret', 80) + ->allowEmptyString('client_secret'); + + $validator + ->scalar('name') + ->maxLength('name', 100) + ->requirePresence('name', 'create') + ->notEmptyString('name'); + + // JSONカラムは型を強制しない(スキーマのjson型マッピングで処理) + $validator->allowEmptyString('redirect_uris'); + + $validator->allowEmptyString('grants'); + + $validator->allowEmptyString('scopes'); + + $validator + ->boolean('is_confidential') + ->notEmptyString('is_confidential'); + + $validator + ->scalar('registration_access_token') + ->maxLength('registration_access_token', 255) + ->allowEmptyString('registration_access_token'); + + return $validator; + } + + /** + * Returns a rules checker object that will be used for validating + * application integrity. + * + * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. + * @return \Cake\ORM\RulesChecker + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add($rules->isUnique(['client_id']), ['errorField' => 'client_id']); + + return $rules; + } + + /** + * Find client by client_id + * + * @param string $clientId + * @return \BcMcp\Model\Entity\Oauth2Client|null + */ + public function findByClientId(string $clientId): ?EntityInterface + { + return $this->find() + ->where(['client_id' => $clientId]) + ->first(); + } + +} diff --git a/plugins/bc-mcp/src/Model/Table/Oauth2RefreshTokensTable.php b/plugins/bc-mcp/src/Model/Table/Oauth2RefreshTokensTable.php new file mode 100644 index 0000000000..c49f0ca872 --- /dev/null +++ b/plugins/bc-mcp/src/Model/Table/Oauth2RefreshTokensTable.php @@ -0,0 +1,75 @@ +setTable('oauth2_refresh_tokens'); + $this->setDisplayField('token_id'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + } + + /** + * Default validation rules. + * + * @param Validator $validator Validator instance. + * @return Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->scalar('token_id') + ->maxLength('token_id', 100) + ->requirePresence('token_id', 'create') + ->notEmptyString('token_id') + ->add('token_id', 'unique', ['rule' => 'validateUnique', 'provider' => 'table']); + + $validator + ->scalar('access_token_id') + ->maxLength('access_token_id', 100) + ->requirePresence('access_token_id', 'create') + ->notEmptyString('access_token_id'); + + $validator + ->boolean('revoked') + ->notEmptyString('revoked'); + + $validator + ->dateTime('expires_at') + ->requirePresence('expires_at', 'create') + ->notEmptyDateTime('expires_at'); + + return $validator; + } + + /** + * 期限切れのリフレッシュトークンをクリーンアップ + * + * @return int 削除された件数 + */ + public function cleanExpiredTokens(): int + { + return $this->deleteAll(['expires_at <' => new \DateTime()]); + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Entity/AccessToken.php b/plugins/bc-mcp/src/OAuth2/Entity/AccessToken.php new file mode 100644 index 0000000000..77e5504744 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Entity/AccessToken.php @@ -0,0 +1,55 @@ +client = $client; + } + + /** + * Add Scope + * @param ScopeEntityInterface $scope + * @return void + */ + public function addScope(ScopeEntityInterface $scope): void + { + $this->scopes[$scope->getIdentifier()] = $scope; + } + + /** + * Set User Identifier + * @param string|int|null $identifier + */ + public function setUserIdentifier($identifier): void + { + $this->userIdentifier = $identifier; + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Entity/AuthCode.php b/plugins/bc-mcp/src/OAuth2/Entity/AuthCode.php new file mode 100644 index 0000000000..99d898cd2a --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Entity/AuthCode.php @@ -0,0 +1,97 @@ +redirectUri; + } + + /** + * Set Redirect URI + * @param string $uri + * @return void + */ + public function setRedirectUri($uri): void + { + $this->redirectUri = $uri; + } + + /** + * Get Code Challenge + * @return string|null + */ + public function getCodeChallenge(): ?string + { + return $this->codeChallenge; + } + + /** + * Set Code Challenge + * @param string|null $codeChallenge + * @return void + */ + public function setCodeChallenge(?string $codeChallenge): void + { + $this->codeChallenge = $codeChallenge; + } + + /** + * Get Code Challenge Method + * @return string + */ + public function getCodeChallengeMethod(): string + { + return $this->codeChallengeMethod; + } + + /** + * Set Code Challenge Method + * @param string $codeChallengeMethod + * @return void + */ + public function setCodeChallengeMethod(string $codeChallengeMethod): void + { + $this->codeChallengeMethod = $codeChallengeMethod; + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Entity/Client.php b/plugins/bc-mcp/src/OAuth2/Entity/Client.php new file mode 100644 index 0000000000..71ca2ee151 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Entity/Client.php @@ -0,0 +1,90 @@ +isConfidential = true; + } + + /** + * Set Name + * @param string $name + * @return void + */ + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * Get Name + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Set Redirect URI + * @param array $uri + */ + public function setRedirectUri(array $uri): void + { + $this->redirectUri = $uri; + } + + /** + * Get Redirect URI + * @return array + */ + public function getRedirectUri(): array + { + return $this->redirectUri; + } + + /** + * Set Confidential Client + * @var bool + */ + public function setIsConfidential(bool $isConfidential): void + { + $this->isConfidential = $isConfidential; + } + + /** + * Is Confidential + * @return bool + */ + public function isConfidential(): bool + { + return $this->isConfidential; + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Entity/RefreshToken.php b/plugins/bc-mcp/src/OAuth2/Entity/RefreshToken.php new file mode 100644 index 0000000000..abeb8a0fd0 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Entity/RefreshToken.php @@ -0,0 +1,21 @@ +identifier = $identifier; + $this->description = $description; + } + + /** + * Get Description + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * JSON Serialize + * @return string + */ + public function jsonSerialize(): string + { + return $this->getIdentifier(); + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Entity/Trait/Rfc9068AccessTokenTrait.php b/plugins/bc-mcp/src/OAuth2/Entity/Trait/Rfc9068AccessTokenTrait.php new file mode 100644 index 0000000000..5457a4df06 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Entity/Trait/Rfc9068AccessTokenTrait.php @@ -0,0 +1,169 @@ +privateKey = $privateKey; + } + + /** + * Initialise the JWT Configuration. + */ + public function initJwtConfiguration() + { + $this->jwtConfiguration = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->privateKey->getKeyContents(), $this->privateKey->getPassPhrase() ?? ''), + InMemory::plainText('empty', 'empty') + ); + } + + /** + * RFC 9068準拠のアクセストークンのためのissuer URLを取得 + * + * @return string + */ + private function getIssuer(): string + { + return env('SITE_URL') . 'bc-mcp/oauth2'; + } + + /** + * RFC 9068準拠のアクセストークンのためのResource URLを取得 + * @return string + */ + private function getResource(): string + { + return env('SITE_URL') . 'bc-mcp'; + } + + /** + * Generate a JWT from the access token (RFC 9068 compliant) + * + * @return Token + */ + private function convertToJWT() + { + $this->initJwtConfiguration(); + + // kidを生成(公開鍵のSHA-256ハッシュを使用) + $kid = $this->generateKid(); + + // RFC 9068のBuilderを使用 + $builder = new Rfc9068JwtBuilder($this->jwtConfiguration); + $scope = $this->getScopeString(); + return $builder + ->withHeader('kid', $kid) // kid (Key ID) + ->issuedBy($this->getIssuer()) // iss (issuer) + ->permittedFor($this->getResource()) // aud (audience) + ->identifiedBy($this->getIdentifier()) // jti (JWT ID) + ->issuedAt(new DateTimeImmutable()) // iat (issued at) + ->canOnlyBeUsedAfter(new DateTimeImmutable()) // nbf (not before) + ->expiresAt($this->getExpiryDateTime()) // exp (expires at) + ->relatedTo((string)$this->getUserIdentifier()) // sub (subject) + ->withClaim('client_id', $this->getClient()->getIdentifier()) // client_id (RFC 9068 必須) + ->withClaim('scopes', $scope) // scopes oauth2-server 2.0 互換 + ->withClaim('scope', $scope) // scope (RFC 9068 推奨、文字列形式) + ->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey()); + } + + /** + * 公開鍵からkid (Key ID) を生成 + * + * @return string + */ + private function generateKid(): string + { + // 公開鍵の取得 + $publicKeyPath = CONFIG . 'jwt.pem'; + $publicKey = file_get_contents($publicKeyPath); + $details = openssl_pkey_get_details(openssl_pkey_get_public($publicKey)); + + // kidを生成(公開鍵のSHA-256ハッシュを使用) + $publicKeyDer = $details['key']; + return rtrim(strtr(base64_encode(hash('sha256', $publicKeyDer, true)), '+/', '-_'), '='); + } + + /** + * スコープを文字列形式で取得(RFC 9068準拠) + * + * @return string + */ + private function getScopeString(): string + { + $scopes = $this->getScopes(); + $scopeNames = []; + + foreach($scopes as $scope) { + $scopeNames[] = $scope->getIdentifier(); + } + + return implode(' ', $scopeNames); + } + + /** + * Generate a string representation from the access token + */ + public function __toString() + { + return $this->convertToJWT()->toString(); + } + + /** + * @return ClientEntityInterface + */ + abstract public function getClient(); + + /** + * @return DateTimeImmutable + */ + abstract public function getExpiryDateTime(); + + /** + * @return string|int + */ + abstract public function getUserIdentifier(); + + /** + * @return ScopeEntityInterface[] + */ + abstract public function getScopes(); + + /** + * @return string + */ + abstract public function getIdentifier(); +} diff --git a/plugins/bc-mcp/src/OAuth2/Entity/User.php b/plugins/bc-mcp/src/OAuth2/Entity/User.php new file mode 100644 index 0000000000..ee7fbc14ce --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Entity/User.php @@ -0,0 +1,38 @@ +identifier; + } + + /** + * Set Identifier + * @param string|int $identifier + */ + public function setIdentifier(string|int $identifier): void + { + $this->identifier = $identifier; + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Grant/AuthCodeGrant.php b/plugins/bc-mcp/src/OAuth2/Grant/AuthCodeGrant.php new file mode 100644 index 0000000000..8c186b032c --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Grant/AuthCodeGrant.php @@ -0,0 +1,46 @@ +getRedirectUri()); + if (!$validator->validateRedirectUri($redirectUri)) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + throw OAuthServerException::invalidClient($request); + } + } +} diff --git a/plugins/bc-mcp/src/OAuth2/Jwt/Rfc9068JwtBuilder.php b/plugins/bc-mcp/src/OAuth2/Jwt/Rfc9068JwtBuilder.php new file mode 100644 index 0000000000..34b74e4a38 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Jwt/Rfc9068JwtBuilder.php @@ -0,0 +1,181 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\OAuth2\Jwt; + +use DateTimeImmutable; +use Lcobucci\JWT\Builder; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer; +use Lcobucci\JWT\Signer\Key; +use Lcobucci\JWT\Token; + +/** + * RFC 9068 準拠の JWT ビルダー + * + * JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens (RFC 9068) に準拠した + * JWTを構築するためのビルダークラス + */ +class Rfc9068JwtBuilder +{ + /** + * @var Builder + */ + private $builder; + + /** + * @var Configuration + */ + private $configuration; + + /** + * コンストラクタ + * + * @param Configuration $configuration JWT設定 + */ + public function __construct(Configuration $configuration) + { + $this->configuration = $configuration; + $this->builder = $configuration->builder(); + } + + /** + * iss (issuer) クレームを設定 + * RFC 9068では必須 + * + * @param string $issuer 発行者のURL + * @return self + */ + public function issuedBy(string $issuer): self + { + $this->builder = $this->builder->issuedBy($issuer); + return $this; + } + + /** + * aud (audience) クレームを設定 + * RFC 9068では必須(クライアントID) + * + * @param string $audience 対象者 + * @return self + */ + public function permittedFor(string $audience): self + { + $this->builder = $this->builder->permittedFor($audience); + return $this; + } + + /** + * jti (JWT ID) クレームを設定 + * RFC 9068では必須(ユニークなトークン識別子) + * + * @param string $id JWT ID + * @return self + */ + public function identifiedBy(string $id): self + { + $this->builder = $this->builder->identifiedBy($id); + return $this; + } + + /** + * iat (issued at) クレームを設定 + * RFC 9068では必須 + * + * @param DateTimeImmutable $issuedAt 発行日時 + * @return self + */ + public function issuedAt(DateTimeImmutable $issuedAt): self + { + $this->builder = $this->builder->issuedAt($issuedAt); + return $this; + } + + /** + * nbf (not before) クレームを設定 + * RFC 9068では推奨 + * + * @param DateTimeImmutable $notBefore 有効開始日時 + * @return self + */ + public function canOnlyBeUsedAfter(DateTimeImmutable $notBefore): self + { + $this->builder = $this->builder->canOnlyBeUsedAfter($notBefore); + return $this; + } + + /** + * exp (expires at) クレームを設定 + * RFC 9068では必須 + * + * @param DateTimeImmutable $expiration 有効期限 + * @return self + */ + public function expiresAt(DateTimeImmutable $expiration): self + { + $this->builder = $this->builder->expiresAt($expiration); + return $this; + } + + /** + * sub (subject) クレームを設定 + * RFC 9068では推奨(ユーザー識別子) + * + * @param string $subject サブジェクト + * @return self + */ + public function relatedTo(string $subject): self + { + $this->builder = $this->builder->relatedTo($subject); + return $this; + } + + /** + * カスタムクレームを設定 + * RFC 9068では、アプリケーション固有のクレームを追加可能 + * + * @param string $name クレーム名 + * @param mixed $value クレーム値 + * @return self + */ + public function withClaim(string $name, $value): self + { + $this->builder = $this->builder->withClaim($name, $value); + return $this; + } + + /** + * JWTヘッダーにkid (Key ID) を設定 + * + * @param string $kid Key ID + * @return self + */ + public function withHeader(string $name, string $value): self + { + $this->builder = $this->builder->withHeader($name, $value); + return $this; + } + + /** + * JWTトークンを生成 + * RFC 9068に準拠したトークンを作成 + * + * @param Signer $signer 署名アルゴリズム + * @param Key $key 署名キー + * @return Token + */ + public function getToken(Signer $signer, Key $key): Token + { + return $this->builder->getToken($signer, $key); + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/RedirectUriValidators/RedirectUriValidator.php b/plugins/bc-mcp/src/OAuth2/RedirectUriValidators/RedirectUriValidator.php new file mode 100644 index 0000000000..cad5759508 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/RedirectUriValidators/RedirectUriValidator.php @@ -0,0 +1,123 @@ +allowedRedirectUris = [$allowedRedirectUri]; + } elseif (is_array($allowedRedirectUri)) { + $this->allowedRedirectUris = $allowedRedirectUri; + } else { + $this->allowedRedirectUris = []; + } + } + + /** + * Validates the redirect uri. + * + * @param string $redirectUri + * @return bool Return true if valid, false otherwise + */ + public function validateRedirectUri($redirectUri) + { + if ($this->isLoopbackUri($redirectUri)) { + return $this->matchUriExcludingPort($redirectUri); + } + + return $this->matchExactUri($redirectUri); + } + + /** + * According to section 7.3 of rfc8252, loopback uris are: + * - "http://127.0.0.1:{port}/{path}" for IPv4 + * - "http://[::1]:{port}/{path}" for IPv6 + * + * @param string $redirectUri + * @return bool + */ + private function isLoopbackUri($redirectUri) + { + try { + $uri = Uri::new($redirectUri); + } catch (SyntaxError $e) { + return false; + } + + return $uri->getScheme() === 'http' + && (in_array($uri->getHost(), ['127.0.0.1', '[::1]'], true)); + } + + /** + * Find an exact match among allowed uris + * + * @param string $redirectUri + * @return bool Return true if an exact match is found, false otherwise + */ + private function matchExactUri($redirectUri) + { + return in_array($redirectUri, $this->allowedRedirectUris, true); + } + + /** + * Find a match among allowed uris, allowing for different port numbers + * + * @param string $redirectUri + * @return bool Return true if a match is found, false otherwise + */ + private function matchUriExcludingPort($redirectUri) + { + $parsedUrl = $this->parseUrlAndRemovePort($redirectUri); + + foreach ($this->allowedRedirectUris as $allowedRedirectUri) { + if ($parsedUrl === $this->parseUrlAndRemovePort($allowedRedirectUri)) { + return true; + } + } + + return false; + } + + /** + * Parse an url like \parse_url, excluding the port + * + * @param string $url + * @return string + */ + private function parseUrlAndRemovePort($url) + { + $uri = Uri::new($url); + + return (string)$uri->withPort(null); + } +} diff --git a/plugins/bc-mcp/src/OAuth2/Repository/OAuth2AccessTokenRepository.php b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2AccessTokenRepository.php new file mode 100644 index 0000000000..2ea6c91deb --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2AccessTokenRepository.php @@ -0,0 +1,208 @@ +accessTokensTable = TableRegistry::getTableLocator()->get('BcMcp.Oauth2AccessTokens'); + } + + /** + * 新しいアクセストークンを取得 + * + * @param ClientEntityInterface $clientEntity + * @param array $scopes + * @param string|int|null $userIdentifier + * @return AccessTokenEntityInterface + */ + public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null): AccessTokenEntityInterface + { + $accessToken = new OAuth2AccessToken(); + $accessToken->setClient($clientEntity); + $accessToken->setUserIdentifier($userIdentifier); + + foreach($scopes as $scope) { + $accessToken->addScope($scope); + } + + return $accessToken; + } + + /** + * アクセストークンを永続化 + * + * @param AccessTokenEntityInterface $accessTokenEntity + * @return void + * @throws UniqueTokenIdentifierConstraintViolationException + */ + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void + { + $identifier = $accessTokenEntity->getIdentifier(); + + // 重複チェック + $existingToken = $this->accessTokensTable->find() + ->where(['token_id' => $identifier]) + ->first(); + + if ($existingToken) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + $scopes = $accessTokenEntity->getScopes(); + $scopeArray = []; + foreach($scopes as $scope) { + $scopeArray[] = $scope->getIdentifier(); + } + // データベースに保存 + $accessToken = $this->accessTokensTable->newEntity([ + 'token_id' => $identifier, + 'client_id' => $accessTokenEntity->getClient()->getIdentifier(), + 'user_id' => $accessTokenEntity->getUserIdentifier(), + 'scopes' => implode(' ', $scopeArray), + 'expires_at' => DateTime::createFromInterface($accessTokenEntity->getExpiryDateTime()), + 'revoked' => false + ]); + + if (!$this->accessTokensTable->save($accessToken)) { + throw new \RuntimeException('Failed to save access token to database'); + } + } + + /** + * アクセストークンを取り消し + * + * @param string $tokenId + * @return void + */ + public function revokeAccessToken($tokenId): void + { + // データベースで無効化 + $accessToken = $this->accessTokensTable->find() + ->where(['token_id' => $tokenId]) + ->first(); + + if ($accessToken) { + $accessToken->revoked = true; + $this->accessTokensTable->save($accessToken); + } + } + + /** + * アクセストークンが取り消されているかチェック + * + * @param string $tokenId + * @return bool + */ + public function isAccessTokenRevoked($tokenId): bool + { + // データベースから確認 + $accessToken = $this->accessTokensTable->find() + ->where(['token_id' => $tokenId]) + ->first(); + + if (!$accessToken) { + return true; // 見つからない場合は無効扱い + } + + // 期限切れもチェック + $now = new DateTime(); + if ($accessToken->expires_at < $now) { + return true; + } + + return $accessToken->revoked; + } + + /** + * アクセストークンのデータを取得(検証用) + * + * @param string $tokenId + * @return array|null + */ + public function getAccessTokenData(string $tokenId): ?array + { + // データベースから取得 + $accessToken = $this->accessTokensTable->find() + ->where(['token_id' => $tokenId]) + ->first(); + + if (!$accessToken) { + return null; + } + + if ($accessToken->revoked) { + return null; + } + + // 期限切れチェック + $now = new DateTime(); + if ($accessToken->expires_at < $now) { + return null; + } + + return [ + 'identifier' => $accessToken->token_id, + 'client_id' => $accessToken->client_id, + 'user_id' => $accessToken->user_id, + 'scopes' => explode(' ', $accessToken->scopes), + 'expires_at' => $accessToken->expires_at, + 'revoked' => $accessToken->revoked + ]; + } + + /** + * 期限切れのアクセストークンをクリーンアップ + * + * @return int 削除された件数 + */ + public function cleanExpiredTokens(): int + { + return $this->accessTokensTable->cleanExpiredTokens(); + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Repository/OAuth2AuthCodeRepository.php b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2AuthCodeRepository.php new file mode 100644 index 0000000000..066c6b9912 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2AuthCodeRepository.php @@ -0,0 +1,178 @@ +authCodesTable = TableRegistry::getTableLocator()->get('BcMcp.Oauth2AuthCodes'); + } + + /** + * 新しい認可コードエンティティを作成 + * + * @return AuthCodeEntityInterface + */ + public function getNewAuthCode(): AuthCodeEntityInterface + { + return new OAuth2AuthCode(); + } + + /** + * 認可コードを永続化 + * + * @param AuthCodeEntityInterface $authCodeEntity + * @return void + */ + public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void + { + // データベースに保存 + $entityData = [ + 'code' => $authCodeEntity->getIdentifier(), + 'client_id' => $authCodeEntity->getClient()->getIdentifier(), + 'user_id' => $authCodeEntity->getUserIdentifier(), + 'scopes' => implode(' ', array_map(fn($scope) => $scope->getIdentifier(), $authCodeEntity->getScopes())), + 'expires_at' => DateTime::createFromInterface($authCodeEntity->getExpiryDateTime()), + 'redirect_uri' => $authCodeEntity->getRedirectUri(), + 'revoked' => false + ]; + + $authCode = $this->authCodesTable->newEntity($entityData); + + if (!$this->authCodesTable->save($authCode)) { + throw new \RuntimeException('Failed to save authorization code to database'); + } + } + + /** + * 認可コードを無効化 + * + * @param string $codeId + * @return void + */ + public function revokeAuthCode($codeId): void + { + // データベースで無効化 + $authCode = $this->authCodesTable->find() + ->where(['code' => $codeId]) + ->first(); + + if ($authCode) { + $authCode->revoked = true; + $this->authCodesTable->save($authCode); + } + } + + /** + * 認可コードが無効化されているかチェック + * + * @param string $codeId + * @return bool + */ + public function isAuthCodeRevoked($codeId): bool + { + // データベースから確認 + $authCode = $this->authCodesTable->find() + ->where(['code' => $codeId]) + ->first(); + + if ($authCode) { + // 期限切れもチェック + $now = new DateTime(); + if ($authCode->expires_at < $now) { + return true; + } + return $authCode->revoked; + } + + return true; // 見つからない場合は無効扱い + } + + /** + * 認可コードを保存(OAuth2Controller から呼び出される) + * + * @param array $data + * @return void + */ + public function storeAuthorizationCode(array $data): void + { + // データベースに保存 + $authCode = $this->authCodesTable->newEntity([ + 'code' => $data['code'], + 'client_id' => $data['client_id'], + 'user_id' => $data['user_id'], + 'scopes' => is_array($data['scope'] ?? [])? + implode(' ', $data['scope']) : + ($data['scope'] ?? ''), + 'expires_at' => DateTime::createFromTimestamp($data['expires_at']), + 'redirect_uri' => $data['redirect_uri'], + 'revoked' => false + ]); + + if (!$this->authCodesTable->save($authCode)) { + throw new \RuntimeException('Failed to save authorization code to database'); + } + } + + /** + * 認可コードを取得 + * + * @param string $code + * @return array|null + */ + public function getAuthorizationCode(string $code): ?array + { + // データベースから取得 + $authCode = $this->authCodesTable->find() + ->where(['code' => $code]) + ->first(); + + if ($authCode) { + return [ + 'code' => $authCode->code, + 'client_id' => $authCode->client_id, + 'user_id' => $authCode->user_id, + 'scope' => $authCode->scopes, + 'scopes' => $authCode->getScopesArray(), + 'expires_at' => $authCode->expires_at->getTimestamp(), + 'redirect_uri' => $authCode->redirect_uri, + 'revoked' => $authCode->revoked + ]; + } + + return null; + } + + /** + * 期限切れの認可コードをクリーンアップ + * + * @return int 削除された件数 + */ + public function cleanExpiredCodes(): int + { + return $this->authCodesTable->cleanExpiredCodes(); + } +} diff --git a/plugins/bc-mcp/src/OAuth2/Repository/OAuth2ClientRepository.php b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2ClientRepository.php new file mode 100644 index 0000000000..2deadd2a01 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2ClientRepository.php @@ -0,0 +1,199 @@ +clientsTable = TableRegistry::getTableLocator()->get('BcMcp.Oauth2Clients'); + + // 初期化時にデフォルトクライアントが存在しない場合のみ追加 + // Dynamic Client Registration を有効にするためコメントアウト +// $this->ensureDefaultClientsExist(); + } + + /** + * デフォルトクライアントが存在することを確認し、なければ作成 + */ +// private function ensureDefaultClientsExist(): void +// { +// $defaultClient = $this->clientsTable->findByClientId('mcp-client'); +// if (!$defaultClient) { +// // JSON型マッピングにより配列で渡せば自動的にJSONとして保存される +// $clientData = [ +// 'client_id' => 'mcp-client', +// 'client_secret' => 'mcp-secret-key', +// 'name' => 'MCP Server Client', +// 'grants' => ['client_credentials'], +// 'scopes' => ['mcp:read', 'mcp:write'], +// 'is_confidential' => true, +// 'redirect_uris' => ['http://localhost'], +// ]; +// +// $client = $this->clientsTable->newEntity($clientData); +// $this->clientsTable->save($client); +// } +// } + + /** + * クライアントエンティティを取得 + * + * ClientRepositoryInterface::getClientEntity($clientIdentifier) に準拠。 + * ここではエンティティ取得のみを行い、認証やグラントの検証は validateClient() 側で行う。 + * + * @param string $clientIdentifier クライアントID + * @return ClientEntityInterface|null + */ + public function getClientEntity($clientIdentifier): ?ClientEntityInterface + { + $clientData = $this->clientsTable->findByClientId($clientIdentifier); + if (!$clientData) { + return null; + } + return $this->createClientEntity($clientData); + } + + /** + * クライアント認証 + * + * @param string $clientIdentifier クライアントID + * @param string|null $clientSecret クライアント秘密キー + * @param string|null $grantType グラントタイプ + * @return bool + */ + public function validateClient($clientIdentifier, $clientSecret, $grantType): bool + { + $clientData = $this->clientsTable->findByClientId($clientIdentifier); + + if (!$clientData) { + return false; + } + + // グラントタイプの検証 + if ($grantType !== null && !in_array($grantType, $clientData->grants)) { + return false; + } + + // 機密クライアントの場合、シークレットキーを検証 + if ($clientData->is_confidential) { + return !empty($clientSecret) && $clientSecret === $clientData->client_secret; + } + + // パブリッククライアントの場合は、シークレットが空であることを確認 + return empty($clientSecret); + } + + /** + * 新しいクライアントを登録(Dynamic Client Registration用) + * + * @param array $clientData クライアントデータ + * @return string 登録されたクライアントID + */ + public function registerClient(array $clientData): string + { + $client = $this->clientsTable->newEntity($clientData); + $savedClient = $this->clientsTable->saveOrFail($client); + + return $savedClient->client_id; + } + + /** + * クライアント情報を更新(Dynamic Client Registration用) + * + * @param string $clientId クライアントID + * @param array $updateData 更新データ + * @return bool 更新成功 + */ + public function updateClient(string $clientId, array $updateData): bool + { + $client = $this->clientsTable->findByClientId($clientId); + + if (!$client) { + return false; + } + + $client = $this->clientsTable->patchEntity($client, $updateData); + return (bool)$this->clientsTable->save($client); + } + + /** + * クライアントを削除(Dynamic Client Registration用) + * + * @param string $clientId クライアントID + * @return bool 削除成功 + */ + public function deleteClient(string $clientId): bool + { + $client = $this->clientsTable->findByClientId($clientId); + + if (!$client) { + return false; + } + + return (bool)$this->clientsTable->delete($client); + } + + /** + * クライアント情報を取得(Dynamic Client Registration用) + * + * @param string $clientId クライアントID + * @return array|null クライアント情報 + */ + public function getClientInfo(string $clientId): ?array + { + $client = $this->clientsTable->findByClientId($clientId); + + if (!$client) { + return null; + } + + return [ + 'client_id' => $client->client_id, + 'client_name' => $client->name, + 'redirect_uris' => $client->redirect_uris, + 'grant_types' => $client->grants, + 'scope' => implode(' ', $client->scopes), + 'client_id_issued_at' => $client->created? $client->created->getTimestamp() : null, + ]; + } + + /** + * OAuth2Clientエンティティを作成 + * + * @param \BcMcp\Model\Entity\Oauth2Client $clientData + * @return ClientEntityInterface + */ + private function createClientEntity(\BcMcp\Model\Entity\Oauth2Client $clientData): ClientEntityInterface + { + $client = new Client(); + $client->setIdentifier($clientData->client_id); + $client->setName($clientData->name); + $client->setRedirectUri($clientData->redirect_uris); + $client->setIsConfidential($clientData->is_confidential); + + return $client; + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Repository/OAuth2RefreshTokenRepository.php b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2RefreshTokenRepository.php new file mode 100644 index 0000000000..ab8847e7c6 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2RefreshTokenRepository.php @@ -0,0 +1,120 @@ +refreshTokensTable = TableRegistry::getTableLocator()->get('BcMcp.Oauth2RefreshTokens'); + } + + /** + * 新しいリフレッシュトークンエンティティを作成 + * + * @return RefreshTokenEntityInterface + */ + public function getNewRefreshToken(): RefreshTokenEntityInterface + { + return new OAuth2RefreshToken(); + } + + /** + * リフレッシュトークンを永続化 + * + * @param RefreshTokenEntityInterface $refreshTokenEntity + * @return void + */ + public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void + { + // データベースに保存 + $refreshToken = $this->refreshTokensTable->newEntity([ + 'token_id' => $refreshTokenEntity->getIdentifier(), + 'access_token_id' => $refreshTokenEntity->getAccessToken()->getIdentifier(), + 'expires_at' => DateTime::createFromInterface($refreshTokenEntity->getExpiryDateTime()), + 'revoked' => false + ]); + + if (!$this->refreshTokensTable->save($refreshToken)) { + throw new \RuntimeException('Failed to save refresh token to database'); + } + } + + /** + * リフレッシュトークンを無効化 + * + * @param string $tokenId + * @return void + */ + public function revokeRefreshToken($tokenId): void + { + // データベースで無効化 + $refreshToken = $this->refreshTokensTable->find() + ->where(['token_id' => $tokenId]) + ->first(); + + if ($refreshToken) { + $refreshToken->revoked = true; + $this->refreshTokensTable->save($refreshToken); + } + } + + /** + * リフレッシュトークンが無効化されているかチェック + * + * @param string $tokenId + * @return bool + */ + public function isRefreshTokenRevoked($tokenId): bool + { + // データベースから確認 + $refreshToken = $this->refreshTokensTable->find() + ->where(['token_id' => $tokenId]) + ->first(); + + if ($refreshToken) { + // 期限切れもチェック + $now = new DateTime(); + if ($refreshToken->expires_at < $now) { + return true; + } + return $refreshToken->revoked; + } + + return true; // 見つからない場合は無効扱い + } + + /** + * 期限切れのリフレッシュトークンをクリーンアップ + * + * @return int 削除された件数 + */ + public function cleanExpiredTokens(): int + { + return $this->refreshTokensTable->cleanExpiredTokens(); + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Repository/OAuth2ScopeRepository.php b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2ScopeRepository.php new file mode 100644 index 0000000000..77f35118c2 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2ScopeRepository.php @@ -0,0 +1,71 @@ +scopes = [ + 'mcp:read' => 'データの読み取り', + 'mcp:write' => 'データの書き込み', + ]; + } + + /** + * スコープエンティティを取得 + * + * @param string $identifier スコープ識別子 + * @return ScopeEntityInterface|null + */ + public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface + { + if (!isset($this->scopes[$identifier])) { + return null; + } + + return new Scope($identifier, $this->scopes[$identifier]); + } + + /** + * スコープを最終化 + * + * @param ScopeEntityInterface[] $scopes + * @param string $grantType + * @param ClientEntityInterface $clientEntity + * @param string|null $userIdentifier + * @return ScopeEntityInterface[] + */ + public function finalizeScopes( + array $scopes, + $grantType, + ClientEntityInterface $clientEntity, + $userIdentifier = null + ): array + { + // クライアントが要求したスコープをそのまま返す + return $scopes; + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Repository/OAuth2UserRepository.php b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2UserRepository.php new file mode 100644 index 0000000000..d4f5b2db91 --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Repository/OAuth2UserRepository.php @@ -0,0 +1,44 @@ + registration_access_token] + * + * DB に registration_access_token カラムが存在しない / 未保存な環境でも + * テストを通すためのフォールバック。DBに値があれば常にDBを優先する。 + * 本番運用ではDB保存が前提のため、将来的に削除可能。 + * + * @var array + */ + private static array $registrationTokenMap = []; + + /** + * OAuth2クライアントリポジトリ + * + * @var OAuth2ClientRepository + */ + private OAuth2ClientRepository $clientRepository; + + /** + * サポートされるグラントタイプ + * + * @var array + */ + private array $supportedGrantTypes = [ + 'authorization_code', + 'client_credentials', + 'refresh_token' + ]; + + /** + * サポートされるレスポンスタイプ + * + * @var array + */ + private array $supportedResponseTypes = [ + 'code' + ]; + + /** + * サポートされるトークンエンドポイント認証方法 + * + * @var array + */ + private array $supportedAuthMethods = [ + 'client_secret_basic', + 'client_secret_post', + 'none' + ]; + + /** + * サポートされるスコープ + * + * @var array + */ + private array $supportedScopes = [ + 'mcp:read', + 'mcp:write', + 'admin' + ]; + + /** + * コンストラクタ + * + * @param OAuth2ClientRepository $clientRepository + */ + public function __construct(OAuth2ClientRepository $clientRepository) + { + $this->clientRepository = $clientRepository; + } + + /** + * 動的クライアント登録 + * + * @param array $requestData リクエストデータ + * @param string $baseUrl ベースURL + * @return Oauth2Client + * @throws Exception + */ + public function registerClient(array $requestData, string $baseUrl): Oauth2Client + { + // リクエストデータの検証 + $this->validateRegistrationRequest($requestData); + + // クライアントIDとシークレットを生成 + $clientId = $this->generateClientId(); + $clientSecret = null; + $tokenEndpointAuthMethod = $requestData['token_endpoint_auth_method'] ?? 'client_secret_basic'; + + // 機密クライアントの場合はシークレットを生成 + if ($tokenEndpointAuthMethod !== 'none') { + $clientSecret = $this->generateClientSecret(); + } + + // 現在時刻を取得 + $issuedAt = time(); + $secretExpiresAt = 0; + + // 登録アクセストークンを生成 + $registrationAccessToken = $this->generateRegistrationAccessToken(); + $registrationClientUri = $baseUrl . '/bc-mcp/oauth2/register/' . $clientId; + + // 保存データを整形(テーブル定義に合わせる) + $clientData = [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'name' => $requestData['client_name'] ?? 'Dynamic Client', + 'redirect_uris' => $requestData['redirect_uris'] ?? [], + 'grants' => $requestData['grant_types'] ?? ['authorization_code'], + 'scopes' => $this->parseScopes($requestData['scope'] ?? ''), + 'is_confidential' => $tokenEndpointAuthMethod !== 'none', + 'registration_access_token' => $registrationAccessToken, + ]; + + // クライアントを保存(Repository経由) + $this->clientRepository->registerClient($clientData); + + // フォールバック用にもメモリへ保持 + self::$registrationTokenMap[$clientId] = $registrationAccessToken; + + // 保存したエンティティを取得して返す + /** @var \BcMcp\Model\Table\Oauth2ClientsTable $table */ + $table = TableRegistry::getTableLocator()->get('BcMcp.Oauth2Clients'); + /** @var Oauth2Client $saved */ + $saved = $table->findByClientId($clientId); + + // 発行時刻など、レスポンス用の一時情報をエンティティに保持 + $saved->set('registration_client_uri', $registrationClientUri); + $saved->set('token_endpoint_auth_method', $tokenEndpointAuthMethod); + $saved->set('client_id_issued_at', $issuedAt); + $saved->set('client_secret_expires_at', $secretExpiresAt); + $saved->set('registration_access_token', $registrationAccessToken); + if ($clientSecret) { + $saved->set('client_secret', $clientSecret); + } + if (isset($requestData['contacts'])) { + $saved->set('contacts', $requestData['contacts']); + } + if (isset($requestData['client_uri'])) { + $saved->set('client_uri', $requestData['client_uri']); + } + if (isset($requestData['logo_uri'])) { + $saved->set('logo_uri', $requestData['logo_uri']); + } + if (isset($requestData['tos_uri'])) { + $saved->set('tos_uri', $requestData['tos_uri']); + } + if (isset($requestData['policy_uri'])) { + $saved->set('policy_uri', $requestData['policy_uri']); + } + if (isset($requestData['software_id'])) { + $saved->set('software_id', $requestData['software_id']); + } + if (isset($requestData['software_version'])) { + $saved->set('software_version', $requestData['software_version']); + } + + return $saved; + } + + /** + * クライアント情報の取得 + * @param string $clientId + * @param string $registrationAccessToken + * @return Oauth2Client|null + */ + public function getClient(string $clientId, string $registrationAccessToken): ?Oauth2Client + { + /** @var \BcMcp\Model\Table\Oauth2ClientsTable $table */ + $table = TableRegistry::getTableLocator()->get('BcMcp.Oauth2Clients'); + /** @var Oauth2Client|null $client */ + $client = $table->findByClientId($clientId); + + if (!$client) { + return null; + } + + $storedToken = $client->registration_access_token ?? null; + if ($storedToken === null) { + $storedToken = self::$registrationTokenMap[$clientId] ?? null; + } + if ($storedToken !== $registrationAccessToken) { + return null; + } + + $siteUrl = rtrim(env('SITE_URL', 'https://localhost'), '/'); + $client->set('registration_client_uri', $siteUrl . '/bc-mcp/oauth2/register/' . $clientId); + $client->set('token_endpoint_auth_method', $client->is_confidential? 'client_secret_basic' : 'none'); + $client->set('client_id_issued_at', $client->created? $client->created->getTimestamp() : null); + $client->set('client_secret_expires_at', null); + + return $client; + } + + /** + * クライアント情報の更新 + * @param string $clientId + * @param string $registrationAccessToken + * @param array $requestData + * @return Oauth2Client|null + * @throws Exception + */ + public function updateClient(string $clientId, string $registrationAccessToken, array $requestData): ?Oauth2Client + { + /** @var \BcMcp\Model\Table\Oauth2ClientsTable $table */ + $table = TableRegistry::getTableLocator()->get('BcMcp.Oauth2Clients'); + /** @var Oauth2Client|null $client */ + $client = $table->findByClientId($clientId); + + if (!$client) { + return null; + } + + $storedToken = $client->registration_access_token ?? null; + if ($storedToken === null) { + $storedToken = self::$registrationTokenMap[$clientId] ?? null; + } + if ($storedToken !== $registrationAccessToken) { + return null; + } + + $this->validateRegistrationRequest($requestData); + + $update = []; + if (array_key_exists('client_name', $requestData)) { + $update['name'] = $requestData['client_name']; + } + if (array_key_exists('redirect_uris', $requestData)) { + $update['redirect_uris'] = $requestData['redirect_uris']; + } + if (array_key_exists('grant_types', $requestData)) { + $update['grants'] = $requestData['grant_types']; + } + if (array_key_exists('scope', $requestData)) { + $update['scopes'] = $this->parseScopes($requestData['scope']); + } + if (array_key_exists('token_endpoint_auth_method', $requestData)) { + $update['is_confidential'] = ($requestData['token_endpoint_auth_method'] !== 'none'); + } + + if ($update) { + $client = $table->patchEntity($client, $update); + $table->saveOrFail($client); + } + + $siteUrl = rtrim(env('SITE_URL', 'https://localhost'), '/'); + $client->set('registration_client_uri', $siteUrl . '/bc-mcp/oauth2/register/' . $clientId); + $client->set('token_endpoint_auth_method', $client->is_confidential? 'client_secret_basic' : 'none'); + $client->set('client_id_issued_at', $client->created? $client->created->getTimestamp() : null); + $client->set('client_secret_expires_at', null); + + return $client; + } + + /** + * クライアントの削除 + * @param string $clientId + * @param string $registrationAccessToken + * @return bool + */ + public function deleteClient(string $clientId, string $registrationAccessToken): bool + { + $client = $this->getClient($clientId, $registrationAccessToken); + if (!$client) { + return false; + } + return $this->clientRepository->deleteClient($clientId); + } + + /** + * 登録リクエストの検証 + * @param array $requestData + * @return void + * @throws Exception + */ + private function validateRegistrationRequest(array $requestData): void + { + if (isset($requestData['redirect_uris'])) { + if (!is_array($requestData['redirect_uris'])) { + throw new Exception('redirect_uris must be an array'); + } + foreach($requestData['redirect_uris'] as $uri) { + if (!filter_var($uri, FILTER_VALIDATE_URL)) { + throw new Exception('Invalid redirect_uri: ' . $uri); + } + } + } + + if (isset($requestData['grant_types'])) { + if (!is_array($requestData['grant_types'])) { + throw new Exception('grant_types must be an array'); + } + foreach($requestData['grant_types'] as $grantType) { + if (!in_array($grantType, $this->supportedGrantTypes)) { + throw new Exception('Unsupported grant_type: ' . $grantType); + } + } + } + + if (isset($requestData['response_types'])) { + if (!is_array($requestData['response_types'])) { + throw new Exception('response_types must be an array'); + } + foreach($requestData['response_types'] as $responseType) { + if (!in_array($responseType, $this->supportedResponseTypes)) { + throw new Exception('Unsupported response_type: ' . $responseType); + } + } + } + + if (isset($requestData['token_endpoint_auth_method'])) { + if (!in_array($requestData['token_endpoint_auth_method'], $this->supportedAuthMethods)) { + throw new Exception('Unsupported token_endpoint_auth_method: ' . $requestData['token_endpoint_auth_method']); + } + } + + if (isset($requestData['scope'])) { + $scopes = $this->parseScopes($requestData['scope']); + foreach($scopes as $scope) { + if (!in_array($scope, $this->supportedScopes)) { + throw new Exception('Unsupported scope: ' . $scope); + } + } + } + } + + /** + * スコープ文字列を配列に変換 + * @param string $scopeString + * @return array + */ + private function parseScopes(string $scopeString): array + { + if (empty($scopeString)) { + return []; + } + return array_filter(explode(' ', $scopeString)); + } + + /** + * クライアントIDを生成 + * @return string + * @throws \Random\RandomException + */ + private function generateClientId(): string + { + return 'client_' . bin2hex(random_bytes(16)); + } + + /** + * クライアントシークレットを生成 + * @return string + * @throws \Random\RandomException + */ + private function generateClientSecret(): string + { + return bin2hex(random_bytes(32)); + } + + /** + * 登録アクセストークンを生成 + * @return string + * @throws \Random\RandomException + */ + private function generateRegistrationAccessToken(): string + { + return 'reg_' . bin2hex(random_bytes(32)); + } + +} diff --git a/plugins/bc-mcp/src/OAuth2/Service/OAuth2Service.php b/plugins/bc-mcp/src/OAuth2/Service/OAuth2Service.php new file mode 100644 index 0000000000..38aaa76cbf --- /dev/null +++ b/plugins/bc-mcp/src/OAuth2/Service/OAuth2Service.php @@ -0,0 +1,234 @@ +generateKeyPair(); + } + } + + /** + * Get Authorization Server + * @return AuthorizationServer + */ + public function getAuthorizationServer(): AuthorizationServer + { + if ($this->authorizationServer === null) { + $this->authorizationServer = $this->createAuthorizationServer(); + } + return $this->authorizationServer; + } + + /** + * Get Resource Server + * @return ResourceServer + */ + public function getResourceServer(): ResourceServer + { + if ($this->resourceServer === null) { + $this->resourceServer = $this->createResourceServer(); + } + return $this->resourceServer; + } + + /** + * Create Authorization Server + * @return AuthorizationServer + * @throws \Exception + */ + private function createAuthorizationServer(): AuthorizationServer + { + $clientRepository = new OAuth2ClientRepository(); + $accessTokenRepository = OAuth2AccessTokenRepository::getInstance(); + $scopeRepository = new OAuth2ScopeRepository(); + + $authCodeRepository = new \BcMcp\OAuth2\Repository\OAuth2AuthCodeRepository(); + $refreshTokenRepository = new \BcMcp\OAuth2\Repository\OAuth2RefreshTokenRepository(); + $userRepository = new \BcMcp\OAuth2\Repository\OAuth2UserRepository(); + + $privateKey = $this->getPrivateKey(); + $encryptionKey = $this->getEncryptionKey(); + + $server = new AuthorizationServer( + $clientRepository, + $accessTokenRepository, + $scopeRepository, + $privateKey, + $encryptionKey + ); + + $clientCredentialsGrant = new ClientCredentialsGrant(); + $server->enableGrantType( + $clientCredentialsGrant, + new \DateInterval('PT1H') + ); + + $authCodeGrant = new \BcMcp\OAuth2\Grant\AuthCodeGrant( + $authCodeRepository, + $refreshTokenRepository, + new \DateInterval('PT10M') + ); + $authCodeGrant->setRefreshTokenTTL(new \DateInterval('P1M')); + $server->enableGrantType( + $authCodeGrant, + new \DateInterval('PT1H') + ); + + $refreshTokenGrant = new \League\OAuth2\Server\Grant\RefreshTokenGrant( + $refreshTokenRepository + ); + $refreshTokenGrant->setRefreshTokenTTL(new \DateInterval('P1M')); + $server->enableGrantType( + $refreshTokenGrant, + new \DateInterval('PT1H') + ); + + return $server; + } + + /** + * Create Resource Server + * @return ResourceServer + * @throws \Exception + */ + private function createResourceServer(): ResourceServer + { + $accessTokenRepository = OAuth2AccessTokenRepository::getInstance(); + $publicKey = $this->getPublicKey(); + return new ResourceServer( + $accessTokenRepository, + $publicKey + ); + } + + /** + * Get Private Key + * @return CryptKey + */ + private function getPrivateKey(): CryptKey + { + $keyPath = CONFIG . 'oauth2_private.key'; + if (!file_exists($keyPath)) { + $this->generateKeyPair(); + } + return new CryptKey($keyPath, null, false); + } + + /** + * Get Public Key + * @return CryptKey + */ + private function getPublicKey(): CryptKey + { + $keyPath = CONFIG . 'oauth2_public.key'; + if (!file_exists($keyPath)) { + $this->generateKeyPair(); + } + return new CryptKey($keyPath, null, false); + } + + /** + * Get Encryption Key + * @return string + */ + private function getEncryptionKey(): string + { + return env('OAUTH2_ENC_KEY', 'j6eyb4oPtNL0R8i9uU8PlQJ2WY1f8yRk5AVXb7OJd3s'); + } + + /** + * Generate RSA Key Pair + * @return void + * @throws \Exception + */ + private function generateKeyPair(): void + { + $privateKeyPath = CONFIG . 'oauth2_private.key'; + $publicKeyPath = CONFIG . 'oauth2_public.key'; + + $config = [ + 'digest_alg' => 'sha256', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + + $res = openssl_pkey_new($config); + openssl_pkey_export($res, $privKey); + + $pubKey = openssl_pkey_get_details($res); + $publicKey = $pubKey['key']; + + file_put_contents($privateKeyPath, $privKey); + file_put_contents($publicKeyPath, $publicKey); + } + + /** + * Validate Access Token + * @param string $token + * @return array|null + */ + public function validateAccessToken(string $token): ?array + { + try { + $resourceServer = $this->getResourceServer(); + $siteUrl = env('SITE_URL', 'https://localhost'); + $request = new \Nyholm\Psr7\ServerRequest( + 'GET', + $siteUrl, + ['Authorization' => 'Bearer ' . $token] + ); + $request = $resourceServer->validateAuthenticatedRequest($request); + return [ + 'client_id' => $request->getAttribute('oauth_client_id'), + 'user_id' => $request->getAttribute('oauth_user_id'), + 'scope' => $request->getAttribute('oauth_scopes', []) + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Store Authorization Code + * @param array $data + * @return void + */ + public function storeAuthorizationCode(array $data): void + { + $authCodeRepository = new \BcMcp\OAuth2\Repository\OAuth2AuthCodeRepository(); + $authCodeRepository->storeAuthorizationCode($data); + } + +} diff --git a/plugins/bc-mcp/src/View/Helper/OAuth2Helper.php b/plugins/bc-mcp/src/View/Helper/OAuth2Helper.php new file mode 100644 index 0000000000..aaeae83b52 --- /dev/null +++ b/plugins/bc-mcp/src/View/Helper/OAuth2Helper.php @@ -0,0 +1,32 @@ + 'データの読み取り', + 'write' => 'データの書き込み', + ]; + + return $descriptions[$scope] ?? $scope; + } + +} diff --git a/plugins/bc-mcp/templates/Admin/McpServerManager/index.php b/plugins/bc-mcp/templates/Admin/McpServerManager/index.php new file mode 100644 index 0000000000..64b9fa37e6 --- /dev/null +++ b/plugins/bc-mcp/templates/Admin/McpServerManager/index.php @@ -0,0 +1,164 @@ + + + + +
+
接続情報
+
+ +
+
+
AIエージェント設定用URL
+
+ + +
+
+ +
+
認可サーバーのメタデータ
+
+ +
+
+ +
+
保護リソースのメタデータ
+
+ +
+
+ +
+
対応プロトコルバージョン
+
+ +

最新の と、それ以前の世代(initialize 方式)の両方に対応しています。

+
+
+
+ +
+
+ + +
+
AIエージェントでの設定方法
+
+ +
+
+
手順1
+
+ AIエージェントの設定で、上記の「AIエージェント設定用URL」をMCPサーバーとして登録してください +
+
+ +
+
手順2
+
+ AIエージェントから「ブログ記事を追加して」などの指示でbaserCMSを操作できます +
+
+
+ +
+
+ + +
+
直近の接続状況
+
+ + + + + + + + + + + + + + + + + + + + + + + +
日時世代プロトコルバージョンクライアントメソッド
+ + + () + +
+

クライアントが新しい世代()へ切り替わったかどうかを、ここで確認できます。

+ +

まだ接続がありません。

+ + +
+
+ + +
+
利用可能なツール(件)
+
+ + + + + + + + + + + + + + + + + +
ツール名説明
+ +

利用可能なツールがありません。

+ + +
+
+ + + diff --git a/plugins/bc-mcp/templates/Admin/Oauth2/authorize.php b/plugins/bc-mcp/templates/Admin/Oauth2/authorize.php new file mode 100644 index 0000000000..de6c9a81f9 --- /dev/null +++ b/plugins/bc-mcp/templates/Admin/Oauth2/authorize.php @@ -0,0 +1,165 @@ +BcBaser->setTitle('BcMcp アプリケーション認可'); +?> + + +
+
+
+

BcMcp アプリケーション認可

+
+
+
+ getName()) ?> が、 に対して、以下の権限を要求しています。 +
+ +
+

要求されている権限

+
    + +
  • 基本的なアクセス権限
  • + + +
  • OAuth2->getScopeDescription($scopeItem)) ?>
  • + + +
+
+ + + + BcAdminForm->create(null, ['type' => 'post']) ?> + BcAdminForm->hidden('client_id', ['value' => $clientId]) ?> + BcAdminForm->hidden('redirect_uri', ['value' => $redirectUri]) ?> + BcAdminForm->hidden('scope', ['value' => $scope]) ?> + BcAdminForm->hidden('state', ['value' => $state]) ?> + +
+
+ BcAdminForm->button('拒否', [ + 'block' => true, + 'class' => 'bca-btn bca-actions__item', + 'data-bca-btn-type' => 'delete', + 'data-bca-btn-size' => 'lg', + 'data-bca-btn-color' => "danger", + 'type' => 'submit', + 'name' => 'action', + 'value' => 'deny' + ]) ?> + BcAdminForm->button('許可', [ + 'div' => false, + 'class' => 'button bca-btn bca-actions__item', + 'data-bca-btn-type' => 'save', + 'data-bca-btn-size' => 'lg', + 'data-bca-btn-width' => 'lg', + 'type' => 'submit', + 'name' => 'action', + 'value' => 'approve' + ]) ?> +
+
+ + BcAdminForm->end() ?> +
+
+
+ + diff --git a/plugins/bc-mcp/tests/Factory/Oauth2AuthCodeFactory.php b/plugins/bc-mcp/tests/Factory/Oauth2AuthCodeFactory.php new file mode 100644 index 0000000000..d95a393602 --- /dev/null +++ b/plugins/bc-mcp/tests/Factory/Oauth2AuthCodeFactory.php @@ -0,0 +1,48 @@ +setDefaultData(function(Generator $faker) { + return [ + 'code' => 'c5c91c0f3dc02fff203115be82914b9e221cf69ebe43e24e81a605ea42098909be111f29c754f2ce', + 'user_id' => 1, + 'client_id' => 'mcp-client', + 'redirect_uris' => '[]', + 'scopes' => '["mcp:read","mcp:write"]', + 'revoked' => false, + 'expires_at' => FrozenTime::now()->addMinutes(10), + 'created' => FrozenTime::now(), + 'modified' => FrozenTime::now() + ]; + }); + } + +} diff --git a/plugins/bc-mcp/tests/Factory/Oauth2ClientFactory.php b/plugins/bc-mcp/tests/Factory/Oauth2ClientFactory.php new file mode 100644 index 0000000000..e6965286af --- /dev/null +++ b/plugins/bc-mcp/tests/Factory/Oauth2ClientFactory.php @@ -0,0 +1,48 @@ +setDefaultData(function(Generator $faker) { + return [ + 'name' => 'Generated from Admin Panel', + 'client_id' => 'mcp-client', + 'client_secret' => 'mcp-secret-key', + 'redirect_uris' => ["http://localhost"], + 'grants' => ["authorization_code", "refresh_token"], + 'scopes' => ["mcp:read", "mcp:write"], + 'is_confidential' => false, + 'created' => FrozenTime::now(), + 'modified' => FrozenTime::now() + ]; + }); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Controller/Admin/McpServerManagerControllerTest.php b/plugins/bc-mcp/tests/TestCase/Controller/Admin/McpServerManagerControllerTest.php new file mode 100644 index 0000000000..abcdf73e1d --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Controller/Admin/McpServerManagerControllerTest.php @@ -0,0 +1,73 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Controller\Admin; + +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Controller\Admin\McpServerManagerController; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use Cake\Http\ServerRequest; + +/** + * McpServerManagerControllerTest + */ +class McpServerManagerControllerTest extends BcTestCase +{ + + use ScenarioAwareTrait; + + /** + * test getRegisteredTools が登録済みツールを名前と説明付きで返す + * + * 移植前は「利用可能な機能」がテンプレートに手書きされており実態とずれていた + */ + public function testGetRegisteredTools() + { + $controller = new McpServerManagerController(new ServerRequest()); + + $tools = $controller->getRegisteredTools(); + + $this->assertNotEmpty($tools); + $names = array_column($tools, 'name'); + $this->assertContains('addBlogPost', $names); + $this->assertContains('addCustomEntry', $names); + $this->assertContains('serverInfo', $names); + + // 名前だけでなく説明も表示するため、説明が空でない事を確認する + foreach($tools as $tool) { + $this->assertNotEmpty($tool['name']); + $this->assertNotEmpty($tool['description'], "ツール {$tool['name']} の説明が空です"); + } + } + + /** + * test 管理画面が表示される + */ + public function testIndex() + { + $this->loadFixtureScenario(InitAppScenario::class); + $this->loginAdmin($this->getRequest('/baser/admin/bc-mcp/mcp-server-manager')); + + $this->get('/baser/admin/bc-mcp/mcp-server-manager'); + + $this->assertResponseSuccess(); + // 接続情報と対応プロトコルバージョンが表示される + $this->assertResponseContains('/bc-mcp'); + $this->assertResponseContains('2026-07-28'); + // 登録済みツールが表示される + $this->assertResponseContains('addBlogPost'); + // 起動・停止の操作は無くなっている + $this->assertResponseNotContains('mcp_server_manager/start'); + $this->assertResponseNotContains('mcp_server_manager/stop'); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php b/plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php new file mode 100644 index 0000000000..b9ac71583e --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php @@ -0,0 +1,641 @@ +loadFixtureScenario(InitAppScenario::class); + // OAuth2設定をセットアップ + Configure::write('BcMcp.OAuth2.clients', [ + 'mcp-client' => [ + 'name' => 'MCP Server Client', + 'secret' => 'mcp-secret-key', + 'redirect_uris' => ['http://localhost'], + 'grants' => ['authorization_code'], + 'scopes' => ['read', 'write'] + ] + ]); + + Configure::write('BcMcp.OAuth2.scopes', [ + 'read' => 'データの読み取り', + 'write' => 'データの書き込み', + 'admin' => '管理者権限' + ]); + + Configure::write('OAuth2.accessTokenTTL', 'PT1H'); + + // テスト用のOAuth2キーペアが存在することを確認 + $privateKeyPath = CONFIG . 'oauth2_private.key'; + $publicKeyPath = CONFIG . 'oauth2_public.key'; + + if (!file_exists($privateKeyPath) || !file_exists($publicKeyPath)) { + $this->generateTestKeys($privateKeyPath, $publicKeyPath); + } + + // Admin配下のテスト用設定 + $this->configRequest([ + 'environment' => [ + 'HTTPS' => 'off' + ] + ]); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + McpContext::clear(); + parent::tearDown(); + } + + /** + * MCP エンドポイントへ Modern(2026-07-28)形式でリクエストを送る + * + * 2026-07-28 では MCP-Protocol-Version / Mcp-Method / Mcp-Name が必須ヘッダで、 + * リクエストごとの _meta でプロトコルバージョンとクライアント情報を伝える。 + * 常駐プロセスは不要で、プロキシが同一プロセス内で SDK を実行する。 + * + * @param string $accessToken アクセストークン + * @param array $mcpRequest MCP リクエスト + * @return void + */ + private function postMcp(string $accessToken, array $mcpRequest): void + { + $mcpRequest['params']['_meta'] = [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientInfo' => [ + 'name' => 'OAuth2IntegrationTest', + 'version' => '1.0.0', + ], + 'io.modelcontextprotocol/clientCapabilities' => [], + ]; + + $headers = [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => $mcpRequest['method'], + ]; + if (isset($mcpRequest['params']['name'])) { + $headers['Mcp-Name'] = $mcpRequest['params']['name']; + } + + $this->configRequest(['headers' => $headers]); + $this->post('/bc-mcp', json_encode($mcpRequest, JSON_UNESCAPED_UNICODE)); + } + + /** + * テスト用のRSAキーペアを生成 + */ + private function generateTestKeys(string $privateKeyPath, string $publicKeyPath): void + { + $config = [ + "digest_alg" => "sha256", + "private_key_bits" => 2048, + "private_key_type" => OPENSSL_KEYTYPE_RSA, + ]; + + $res = openssl_pkey_new($config); + openssl_pkey_export($res, $privKey); + + $pubKey = openssl_pkey_get_details($res); + $publicKey = $pubKey["key"]; + + file_put_contents($privateKeyPath, $privKey); + file_put_contents($publicKeyPath, $publicKey); + } + + /** + * Test authorize endpoint with authenticated user + * デフォルトクライアントの認証テスト(DCR前提とするため一旦廃止) + * @return void + */ +// public function testAuthorizeEndpointWithAuthenticatedUser(): void +// { +// $this->loginAdmin($this->getRequest()); +// +// // 認可リクエストのパラメータ +// $params = [ +// 'client_id' => 'mcp-client', +// 'client_secret' => 'mcp-secret-key', +// 'response_type' => 'code', +// 'redirect_uri' => 'http://localhost', +// 'scope' => 'mcp:read mcp:write', +// 'state' => 'test-state' +// ]; +// +// $this->get('/baser/admin/bc-mcp/oauth2/authorize?' . http_build_query($params)); +// +// // 認証済みユーザーなので認可画面が表示される +// $this->assertResponseOk(); +// } + + /** + * Test authorize endpoint without authentication + * + * @return void + */ + public function testAuthorizeEndpointWithoutAuthentication(): void + { + // 認証なしでauthorizeエンドポイントにアクセス + $this->get('/baser/admin/bc-mcp/oauth2/authorize'); + + // 認証が必要なため、リダイレクトが返される + $this->assertResponseCode(302); + } + + public function testIntegration(): void + { + // MCPサーバーの接続ポイントにGETリクエストを送信 + // 2026-07-28 では GET ストリームが廃止されているため 405 が返る + $this->get('/bc-mcp'); + $this->assertResponseCode(405); + + // oauth-protected-resource にリクエストを送信 + $this->get('/.well-known/oauth-protected-resource/bc-mcp'); + $metadata = json_decode((string)$this->_response->getBody(), true); + $this->assertTextContains('/bc-mcp', $metadata['resource']); + + // oauth-authorization-server にリクエストを送信 + $this->get('/.well-known/oauth-authorization-server/bc-mcp'); + $metadata = json_decode((string)$this->_response->getBody(), true); + $registrationEndpoint = $metadata['registration_endpoint']; + + // クライアント登録エンドポイントにPOSTリクエストを送信 + $this->post($registrationEndpoint, [ + 'client_name' => 'Test Client', + 'client_uri' => 'http://localhost', + 'redirect_uris' => ['http://localhost/callback'], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + 'scope' => 'mcp:read mcp:write' + ]); + $metadata = json_decode((string)$this->_response->getBody(), true); + $this->assertResponseCode(201); + $this->assertArrayHasKey('client_id', $metadata); + + // 認可リクエスト + $this->get('/bc-mcp/oauth2/authorize?' . http_build_query([ + 'client_id' => $metadata['client_id'], + 'client_secret' => $metadata['client_secret'], + 'response_type' => 'code', + 'redirect_uri' => $metadata['redirect_uris'][0] + ])); + $this->assertResponseCode(302); + + $this->loginAdmin($this->getRequest()); + $this->get('/bc-mcp/oauth2/authorize?' . http_build_query([ + 'client_id' => $metadata['client_id'], + 'client_secret' => $metadata['client_secret'], + 'response_type' => 'code', + 'redirect_uri' => $metadata['redirect_uris'][0] + ])); + $this->assertResponseCode(200); + + // 認可承認 + $this->post('/bc-mcp/oauth2/authorize?' . http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => $metadata['client_id'], + 'client_secret' => $metadata['client_secret'], + 'response_type' => 'code', + 'redirect_uri' => $metadata['redirect_uris'][0] + ]), ['action' => 'approve']); + $this->assertResponseCode(302); + $redirectUrl = $this->_response->getHeaderLine('Location'); + $this->assertStringContainsString('code=', $redirectUrl); + // 認可コードを取得 + $queryParams = []; + parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams); + + // RFC 9207: 認可レスポンスに iss が含まれ、メタデータの issuer と一致する + $this->assertArrayHasKey('iss', $queryParams); + $this->get('/.well-known/oauth-authorization-server/bc-mcp'); + $issuerMetadata = json_decode((string)$this->_response->getBody(), true); + $this->assertEquals($issuerMetadata['issuer'], $queryParams['iss']); + $this->assertTrue($issuerMetadata['authorization_response_iss_parameter_supported']); + $this->assertArrayHasKey('code', $queryParams); + $authCode = $queryParams['code']; + + // 認可コードを使用してアクセストークンを取得 + $this->post('/bc-mcp/oauth2/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'redirect_uri' => $metadata['redirect_uris'][0], + 'client_id' => $metadata['client_id'], + 'client_secret' => $metadata['client_secret'], + 'scope' => 'read write' + ]); + $this->assertResponseCode(200); + $tokenData = json_decode((string)$this->_response->getBody(), true); + $accessToken = $tokenData['access_token']; + $refreshToken = $tokenData['refresh_token']; + + // リフレッシュトークンが取得できていることを確認 + $this->assertArrayHasKey('refresh_token', $tokenData); + $this->assertNotEmpty($refreshToken); + + // アクセストークンを使用してMCPサーバーのツールリストを取得 + $requestConfig = [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ] + ]; + + // MCPプロキシ経由でtools/listを呼び出し + $mcpRequest = [ + 'jsonrpc' => '2.0', + 'id' => 'test-tools-list', + 'method' => 'tools/list' + ]; + $this->postMcp($accessToken, $mcpRequest); + $this->assertResponseCode(200); + $this->assertContentType('application/json'); + + $toolsResponse = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($toolsResponse, 'MCP tools list response should be valid JSON'); + $this->assertArrayHasKey('result', $toolsResponse); + $this->assertArrayHasKey('tools', $toolsResponse['result']); + $this->assertIsArray($toolsResponse['result']['tools']); + + // ツールリストの内、ブログ記事一覧の取得ツールを実行 + $tools = $toolsResponse['result']['tools']; + // ツールリストに getBlogPostsが含まれていることを確認 + $this->assertTrue(in_array('getBlogPosts', array_column($tools, 'name')), 'getBlogPosts tool should be available'); + + // ブログ記事一覧取得ツールを実行 + $blogRequest = [ + 'jsonrpc' => '2.0', + 'id' => 'test-blog-tool', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'getBlogPosts', + 'arguments' => [] + ] + ]; + $this->postMcp($accessToken, $blogRequest); + $this->assertResponseCode(200); + + $blogResponse = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($blogResponse); + $this->assertArrayHasKey('result', $blogResponse); + + // リフレッシュトークンを使用して新しいアクセストークンを取得 + $this->post('/bc-mcp/oauth2/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => $metadata['client_id'], + 'client_secret' => $metadata['client_secret'] + ]); + $this->assertResponseCode(200); + $newTokenData = json_decode((string)$this->_response->getBody(), true); + $newAccessToken = $newTokenData['access_token']; + + // 新しいアクセストークンが取得できていることを確認 + $this->assertArrayHasKey('access_token', $newTokenData); + $this->assertNotEmpty($newAccessToken); + $this->assertNotEquals($accessToken, $newAccessToken, 'New access token should be different from the original'); + + // getBlogPostツールを実行(IDが必要な場合はダミーIDを使用) + $blogPostRequest = [ + 'jsonrpc' => '2.0', + 'id' => 'test-blog-post-tool', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'getBlogPost', + 'arguments' => [ + 'id' => 1 // ダミーID + ] + ] + ]; + // リフレッシュで取得した新しいアクセストークンで呼び出す + $this->postMcp($newAccessToken, $blogPostRequest); + + // レスポンスコードが200または404(データが存在しない場合)であることを確認 + $this->assertTrue( + in_array($this->_response->getStatusCode(), [200, 404]), + 'getBlogPost should return 200 (success) or 404 (not found)' + ); + + if ($this->_response->getStatusCode() === 200) { + $blogPostResponse = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($blogPostResponse); + $this->assertArrayHasKey('result', $blogPostResponse); + } + } + + /** + * PKCE (Proof Key for Code Exchange) フローの統合テスト + * ChatGPTコネクタで使用されるPKCEフローをテスト + * + * @return void + */ + public function testIntegrationWithPKCE(): void + { + // Step 1: OAuth2メタデータの取得 + $this->get('/.well-known/oauth-authorization-server/bc-mcp'); + $metadata = json_decode((string)$this->_response->getBody(), true); + $this->assertResponseOk(); + $this->assertArrayHasKey('registration_endpoint', $metadata); + $this->assertArrayHasKey('code_challenge_methods_supported', $metadata); + $this->assertContains('S256', $metadata['code_challenge_methods_supported']); + + // Step 2: 動的クライアント登録 + $registrationEndpoint = $metadata['registration_endpoint']; + $this->post($registrationEndpoint, [ + 'client_name' => 'ChatGPT Connector Test', + 'client_uri' => 'https://chatgpt.com', + 'redirect_uris' => ['https://chatgpt.com/connector_platform_oauth_redirect'], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + 'token_endpoint_auth_method' => 'none', // PKCEではclient_secretは不要 + 'scope' => 'mcp:read mcp:write' + ]); + $this->assertResponseCode(201); + $clientData = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('client_id', $clientData); + $clientId = $clientData['client_id']; + $redirectUri = $clientData['redirect_uris'][0]; + + // Step 3: PKCE パラメータの生成 + $codeVerifier = $this->generateCodeVerifier(); + $codeChallenge = $this->generateCodeChallenge($codeVerifier); + $state = bin2hex(random_bytes(16)); + + // Step 4: 認可リクエスト(PKCEパラメータ付き) + $authParams = [ + 'client_id' => $clientId, + 'response_type' => 'code', + 'redirect_uri' => $redirectUri, + 'state' => $state, + 'scope' => 'mcp:read mcp:write', + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256' + ]; + + // 未認証でのアクセス + $this->get('/bc-mcp/oauth2/authorize?' . http_build_query($authParams)); + $this->assertResponseCode(302); // ログイン画面へリダイレクト + + // 管理者でログイン + $this->loginAdmin($this->getRequest()); + $this->get('/bc-mcp/oauth2/authorize?' . http_build_query($authParams)); + $this->assertResponseOk(); // 認可画面が表示される + + // Step 5: 認可承認(PKCEパラメータが保存される) + $this->post('/bc-mcp/oauth2/authorize?' . http_build_query($authParams), [ + 'action' => 'approve', + 'scope' => 'mcp:read mcp:write' + ]); + $this->assertResponseCode(302); + + // リダイレクトURLから認可コードを取得 + $redirectUrl = $this->_response->getHeaderLine('Location'); + $this->assertStringContainsString('code=', $redirectUrl); + $this->assertStringContainsString('state=' . $state, $redirectUrl); + + $queryParams = []; + parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams); + $this->assertArrayHasKey('code', $queryParams); + $this->assertEquals($state, $queryParams['state']); + $authCode = $queryParams['code']; + + // Step 6: アクセストークン交換(PKCE検証) + $tokenParams = [ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'redirect_uri' => $redirectUri, + 'client_id' => $clientId, + 'code_verifier' => $codeVerifier // client_secretの代わりにcode_verifierを使用 + ]; + + $this->post('/bc-mcp/oauth2/token', $tokenParams); + $this->assertResponseOk(); + + $tokenData = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('access_token', $tokenData); + $this->assertArrayHasKey('token_type', $tokenData); + $this->assertEquals('Bearer', $tokenData['token_type']); + $accessToken = $tokenData['access_token']; + + // Step 7: 不正なcode_verifierでのテスト(失敗することを確認) + $invalidTokenParams = [ + 'grant_type' => 'authorization_code', + 'code' => $authCode, // 同じ認可コードを再利用(実際は無効化されているはず) + 'redirect_uri' => $redirectUri, + 'client_id' => $clientId, + 'code_verifier' => 'invalid_verifier' + ]; + + $this->post('/bc-mcp/oauth2/token', $invalidTokenParams); + $this->assertResponseError(); // 400番台のエラーが返されることを確認 + + // Step 8: アクセストークンを使用してMCPサーバーにアクセス + $requestConfig = [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ] + ]; + + // MCPプロキシ経由でtools/listを呼び出し + $mcpRequest = [ + 'jsonrpc' => '2.0', + 'id' => 'pkce-test-tools-list', + 'method' => 'tools/list' + ]; + + $this->postMcp($accessToken, $mcpRequest); + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $toolsResponse = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($toolsResponse); + $this->assertArrayHasKey('result', $toolsResponse); + $this->assertArrayHasKey('tools', $toolsResponse['result']); + $this->assertIsArray($toolsResponse['result']['tools']); + + // Step 9: ツール実行テスト + $tools = $toolsResponse['result']['tools']; + if (!empty($tools)) { + $firstTool = $tools[0]; + $toolRequest = [ + 'jsonrpc' => '2.0', + 'id' => 'pkce-test-tool-call', + 'method' => 'tools/call', + 'params' => [ + 'name' => $firstTool['name'], + 'arguments' => [] + ] + ]; + + $this->postMcp($accessToken, $toolRequest); + // ツールによってはパラメータが必要な場合があるので、200または400を許可 + $this->assertTrue( + in_array($this->_response->getStatusCode(), [200, 400]), + 'Tool call should return 200 (success) or 400 (missing parameters)' + ); + } + } + + /** + * PKCEのcode_verifierを生成 + * RFC 7636 に準拠した43-128文字のランダム文字列 + * + * @return string + */ + private function generateCodeVerifier(): string + { + $length = 43; // 最小文字数 + $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + $verifier = ''; + + for($i = 0; $i < $length; $i++) { + $verifier .= $characters[random_int(0, strlen($characters) - 1)]; + } + + return $verifier; + } + + /** + * PKCEのcode_challengeを生成 + * code_verifierのSHA256ハッシュをBase64URL エンコード + * + * @param string $codeVerifier + * @return string + */ + private function generateCodeChallenge(string $codeVerifier): string + { + $hash = hash('sha256', $codeVerifier, true); + return rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); + } + + /** + * PKCEセキュリティテスト - 不正なcode_verifierでの失敗を確認 + * + * @return void + */ + public function testPKCESecurityFailure(): void + { + // クライアント登録 + $this->get('/.well-known/oauth-authorization-server/bc-mcp'); + $metadata = json_decode((string)$this->_response->getBody(), true); + $registrationEndpoint = $metadata['registration_endpoint']; + + $this->post($registrationEndpoint, [ + 'client_name' => 'PKCE Security Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['authorization_code'], + 'response_types' => ['code'], + 'token_endpoint_auth_method' => 'none', + 'scope' => 'mcp:read' + ]); + $clientData = json_decode((string)$this->_response->getBody(), true); + $clientId = $clientData['client_id']; + $redirectUri = $clientData['redirect_uris'][0]; + + // PKCE パラメータ生成 + $codeVerifier = $this->generateCodeVerifier(); + $codeChallenge = $this->generateCodeChallenge($codeVerifier); + + // 認可フロー + $this->loginAdmin($this->getRequest()); + $authParams = [ + 'client_id' => $clientId, + 'response_type' => 'code', + 'redirect_uri' => $redirectUri, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256' + ]; + + $this->post('/bc-mcp/oauth2/authorize?' . http_build_query($authParams), [ + 'action' => 'approve' + ]); + + $redirectUrl = $this->_response->getHeaderLine('Location'); + $queryParams = []; + parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams); + $authCode = $queryParams['code']; + + // 正しいcode_verifierでトークン交換(成功するはず) + $this->post('/bc-mcp/oauth2/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'redirect_uri' => $redirectUri, + 'client_id' => $clientId, + 'code_verifier' => $codeVerifier + ]); + $this->assertResponseOk(); + $tokenData = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('access_token', $tokenData); + + // 新しい認可コードを取得(同じ認可コードは再利用できないため) + $codeVerifier2 = $this->generateCodeVerifier(); + $codeChallenge2 = $this->generateCodeChallenge($codeVerifier2); + $authParams2 = [ + 'client_id' => $clientId, + 'response_type' => 'code', + 'redirect_uri' => $redirectUri, + 'code_challenge' => $codeChallenge2, + 'code_challenge_method' => 'S256' + ]; + + $this->post('/bc-mcp/oauth2/authorize?' . http_build_query($authParams2), [ + 'action' => 'approve' + ]); + + $redirectUrl2 = $this->_response->getHeaderLine('Location'); + $queryParams2 = []; + parse_str(parse_url($redirectUrl2, PHP_URL_QUERY), $queryParams2); + $authCode2 = $queryParams2['code']; + + // 間違ったcode_verifierでトークン交換(失敗するはず) + $wrongVerifier = $this->generateCodeVerifier(); // 別のverifierを生成 + $this->post('/bc-mcp/oauth2/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authCode2, + 'redirect_uri' => $redirectUri, + 'client_id' => $clientId, + 'code_verifier' => $wrongVerifier + ]); + + // PKCE検証失敗でエラーが返されることを確認 + $this->assertResponseError(); + $errorResponse = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('error', $errorResponse); + $this->assertEquals('invalid_grant', $errorResponse['error']); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php b/plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php new file mode 100644 index 0000000000..a450c0178a --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Controller/McpProxyControllerTest.php @@ -0,0 +1,177 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Controller; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Controller\McpProxyController; +use Cake\Core\Configure; +use Cake\Http\ServerRequest; + +/** + * McpProxyControllerTest + */ +class McpProxyControllerTest extends BcTestCase +{ + + /** + * test toMcpMessage が MCP の必須ヘッダを引き継ぐ + * + * 2026-07-28 では MCP-Protocol-Version / Mcp-Method / Mcp-Name が必須ヘッダで、 + * SDK がヘッダとボディの一致を検証する + */ + public function testToMcpMessageCarriesRequiredHeaders() + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'HTTP_MCP_PROTOCOL_VERSION' => '2026-07-28', + 'HTTP_MCP_METHOD' => 'tools/call', + 'HTTP_MCP_NAME' => 'addBlogPost', + 'HTTP_AUTHORIZATION' => 'Bearer secret-token', + ], + ]); + $controller = new McpProxyController($request); + + $message = $controller->toMcpMessage(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call']); + + $this->assertEquals('2026-07-28', $message->getHeader('MCP-Protocol-Version')); + $this->assertEquals('tools/call', $message->getHeader('Mcp-Method')); + $this->assertEquals('addBlogPost', $message->getHeader('Mcp-Name')); + // 認証はプロキシで完結しているため SDK へ渡さない + $this->assertNull($message->getHeader('Authorization')); + $this->assertEquals('POST', $message->getMethod()); + } + + /** + * test toMcpMessage は存在しないヘッダを引き継がない + */ + public function testToMcpMessageOmitsAbsentHeaders() + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'HTTP_MCP_METHOD' => 'tools/list', + ], + ]); + $controller = new McpProxyController($request); + + $message = $controller->toMcpMessage(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + + $this->assertEquals('tools/list', $message->getHeader('Mcp-Method')); + $this->assertNull($message->getHeader('Mcp-Name')); + } + + /** + * test toMcpMessage はボディを改変しない + * + * 2026-07-28 ではヘッダとボディの一致が検証されるため、 + * ログインユーザーの注入などでボディを書き換えてはならない + */ + public function testToMcpMessageKeepsBodyIntact() + { + $request = new ServerRequest(['environment' => ['REQUEST_METHOD' => 'POST']]); + $controller = new McpProxyController($request); + $mcpRequest = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'addBlogPost', 'arguments' => ['title' => 'テスト']], + ]; + + $message = $controller->toMcpMessage($mcpRequest); + + $this->assertEquals($mcpRequest, json_decode((string)$message->getBody(), true)); + } + + /** + * test GET と DELETE は 405 を返す + * + * 2026-07-28 では GET ストリームが廃止されている + */ + public function testGetReturnsMethodNotAllowed() + { + $this->get('/bc-mcp'); + $this->assertResponseCode(405); + + $this->delete('/bc-mcp'); + $this->assertResponseCode(405); + } + + /** + * test 許可オリジンの判定 + */ + public function testIsAllowedOrigin() + { + Configure::write('BcMcp.allowedOrigins', ['https://claude.ai']); + $controller = new McpProxyController(new ServerRequest()); + + $this->assertTrue($controller->isAllowedOrigin('https://claude.ai')); + $this->assertFalse($controller->isAllowedOrigin('https://evil.example.com')); + // 部分一致で通してはならない + $this->assertFalse($controller->isAllowedOrigin('https://claude.ai.evil.example.com')); + } + + /** + * test 設定が空の場合は自サイトのオリジンのみを許可する + */ + public function testIsAllowedOriginFallbackToSiteUrl() + { + Configure::write('BcMcp.allowedOrigins', []); + $controller = new McpProxyController(new ServerRequest()); + + $siteUrl = rtrim((string)env('SITE_URL', ''), '/'); + if ($siteUrl) { + $parts = parse_url($siteUrl); + $origin = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port'])? ':' . $parts['port'] : ''); + $this->assertTrue($controller->isAllowedOrigin($origin)); + } + $this->assertFalse($controller->isAllowedOrigin('https://evil.example.com')); + } + + /** + * test 許可されないオリジンからのリクエストは 403 になる + * + * Origin 検証は DNS リバインディング対策であり、認証より前に効かせる。 + * そのため認証エラーの 401 ではなく 403 が返る + */ + public function testDisallowedOriginReturnsForbidden() + { + Configure::write('BcMcp.allowedOrigins', ['https://claude.ai']); + + $this->configRequest([ + 'headers' => [ + 'Origin' => 'https://evil.example.com', + 'Content-Type' => 'application/json', + ] + ]); + $this->post('/bc-mcp', json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'])); + + $this->assertResponseCode(403); + } + + /** + * test Origin ヘッダが無いリクエストは検証対象外 + * + * サーバー間通信では Origin が送られないため通す(認証で弾かれる) + */ + public function testRequestWithoutOriginIsNotBlocked() + { + Configure::write('BcMcp.allowedOrigins', ['https://claude.ai']); + + $this->configRequest(['headers' => ['Content-Type' => 'application/json']]); + $this->post('/bc-mcp', json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'])); + + // Origin 検証では弾かれず、認証エラーになる + $this->assertResponseCode(401); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerDynamicClientRegistrationTest.php b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerDynamicClientRegistrationTest.php new file mode 100644 index 0000000000..8ee1765462 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerDynamicClientRegistrationTest.php @@ -0,0 +1,335 @@ +loadPlugins(['BcMcp']); + parent::setUp(); + + // CSRF保護を無効にする(CakePHP 5対応) + $this->enableCsrfToken(); + $this->enableSecurityToken(); + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + } + + /** + * Test dynamic client registration + * + * @return void + */ + public function testDynamicClientRegistration(): void + { + $requestData = [ + 'client_name' => 'Test Dynamic Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['authorization_code', 'client_credentials'], + 'scope' => 'mcp:read mcp:write', + 'token_endpoint_auth_method' => 'client_secret_basic', + 'contacts' => ['admin@example.com'], + 'client_uri' => 'https://example.com', + 'logo_uri' => 'https://example.com/logo.png' + ]; + + // JSONデータとして送信するための設定 + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + // JSONエンコードしたデータを直接送信 + $this->post('/bc-mcp/oauth2/register', json_encode($requestData)); + + $this->assertResponseCode(201); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + + // Check required RFC7591 fields + $this->assertArrayHasKey('client_id', $response); + $this->assertArrayHasKey('client_secret', $response); + $this->assertArrayHasKey('registration_access_token', $response); + $this->assertArrayHasKey('registration_client_uri', $response); + $this->assertArrayHasKey('client_id_issued_at', $response); + + // Check provided fields + $this->assertEquals('Test Dynamic Client', $response['client_name']); + $this->assertEquals(['https://example.com/callback'], $response['redirect_uris']); + $this->assertEquals(['authorization_code', 'client_credentials'], $response['grant_types']); + $this->assertEquals('mcp:read mcp:write', $response['scope']); + $this->assertEquals('client_secret_basic', $response['token_endpoint_auth_method']); + $this->assertEquals(['admin@example.com'], $response['contacts']); + $this->assertEquals('https://example.com', $response['client_uri']); + $this->assertEquals('https://example.com/logo.png', $response['logo_uri']); + } + + /** + * Test client configuration retrieval + * + * @return void + */ + public function testClientConfigurationRetrieval(): void + { + // First register a client + $requestData = [ + 'client_name' => 'Test Config Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'], + 'scope' => 'mcp:read' + ]; + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + $this->post('/bc-mcp/oauth2/register', json_encode($requestData)); + $this->assertResponseCode(201); + + $registrationResponse = json_decode((string)$this->_response->getBody(), true); + $clientId = $registrationResponse['client_id']; + $registrationToken = $registrationResponse['registration_access_token']; + + // Then retrieve client configuration + $this->configRequest([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $registrationToken, + 'Accept' => 'application/json' + ] + ]); + + $this->get('/bc-mcp/oauth2/register/' . $clientId); + $this->assertResponseCode(200); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertEquals('Test Config Client', $response['client_name']); + $this->assertEquals(['https://example.com/callback'], $response['redirect_uris']); + $this->assertEquals(['client_credentials'], $response['grant_types']); + $this->assertEquals('mcp:read', $response['scope']); + } + + /** + * Test client configuration update + * + * @return void + */ + public function testClientConfigurationUpdate(): void + { + // First register a client + $requestData = [ + 'client_name' => 'Test Update Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'], + 'scope' => 'mcp:read' + ]; + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + $this->post('/bc-mcp/oauth2/register', json_encode($requestData)); + $this->assertResponseCode(201); + + $registrationResponse = json_decode((string)$this->_response->getBody(), true); + $clientId = $registrationResponse['client_id']; + $registrationToken = $registrationResponse['registration_access_token']; + + // Update client configuration + $updateData = [ + 'client_name' => 'Updated Client Name', + 'redirect_uris' => ['https://updated.com/callback'], + 'scope' => 'mcp:read mcp:write' + ]; + + $this->configRequest([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $registrationToken, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + $this->put('/bc-mcp/oauth2/register/' . $clientId, json_encode($updateData)); + $this->assertResponseCode(200); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertEquals('Updated Client Name', $response['client_name']); + $this->assertEquals(['https://updated.com/callback'], $response['redirect_uris']); + $this->assertEquals('mcp:read mcp:write', $response['scope']); + } + + /** + * Test client deletion + * + * @return void + */ + public function testClientDeletion(): void + { + // First register a client + $requestData = [ + 'client_name' => 'Test Delete Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'] + ]; + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + $this->post('/bc-mcp/oauth2/register', json_encode($requestData)); + $this->assertResponseCode(201); + + $registrationResponse = json_decode((string)$this->_response->getBody(), true); + $clientId = $registrationResponse['client_id']; + $registrationToken = $registrationResponse['registration_access_token']; + + // Delete the client + $this->configRequest([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $registrationToken, + 'Accept' => 'application/json' + ] + ]); + + $this->delete('/bc-mcp/oauth2/register/' . $clientId); + $this->assertResponseCode(204); // No Content + + // Verify client is deleted by trying to retrieve it + $this->get('/bc-mcp/oauth2/register/' . $clientId); + $this->assertResponseCode(401); // Unauthorized (client not found) + } + + /** + * Test invalid client metadata + * + * @return void + */ + public function testInvalidClientMetadata(): void + { + $requestData = [ + 'client_name' => 'Invalid Client', + 'redirect_uris' => ['invalid-uri'], // Invalid URI + 'grant_types' => ['authorization_code'] + ]; + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + $this->post('/bc-mcp/oauth2/register', json_encode($requestData)); + $this->assertResponseCode(400); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertEquals('invalid_client_metadata', $response['error']); + $this->assertStringContainsString('Invalid redirect_uri', $response['error_description']); + } + + /** + * Test unsupported grant type + * + * @return void + */ + public function testUnsupportedGrantType(): void + { + $requestData = [ + 'client_name' => 'Unsupported Grant Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['unsupported_grant'] // Unsupported grant type + ]; + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + $this->post('/bc-mcp/oauth2/register', json_encode($requestData)); + $this->assertResponseCode(400); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertEquals('invalid_client_metadata', $response['error']); + $this->assertStringContainsString('Unsupported grant_type', $response['error_description']); + } + + /** + * Test invalid registration access token + * + * @return void + */ + public function testInvalidRegistrationAccessToken(): void + { + // Register a client first + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'] + ]; + + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ]); + + $this->post('/bc-mcp/oauth2/register', json_encode($requestData)); + $registrationResponse = json_decode((string)$this->_response->getBody(), true); + $clientId = $registrationResponse['client_id']; + + // Try to access with invalid token + $this->configRequest([ + 'headers' => [ + 'Authorization' => 'Bearer invalid_token', + 'Accept' => 'application/json' + ] + ]); + + $this->get('/bc-mcp/oauth2/register/' . $clientId); + $this->assertResponseCode(401); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertEquals('invalid_token', $response['error']); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerTest.php b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerTest.php new file mode 100644 index 0000000000..c58fde52fa --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Controller/OAuth2ControllerTest.php @@ -0,0 +1,381 @@ +loadPlugins(['BcMcp']); + parent::setUp(); + + // OAuth2設定をセットアップ + Configure::write('BcMcp.OAuth2.clients', [ + 'mcp-client' => [ + 'name' => 'MCP Server Client', + 'secret' => 'mcp-secret-key', + 'redirect_uris' => ['http://localhost'], + 'grants' => ['client_credentials'], + 'scopes' => ['mcp:read', 'mcp:write'] + ] + ]); + + Configure::write('BcMcp.OAuth2.scopes', [ + 'mcp:read' => 'データの読み取り', + 'mcp:write' => 'データの書き込み' + ]); + + Configure::write('OAuth2.accessTokenTTL', 'PT1H'); + + // テスト用のOAuth2キーペアが存在することを確認 + $privateKeyPath = CONFIG . 'oauth2_private.key'; + $publicKeyPath = CONFIG . 'oauth2_public.key'; + + if (!file_exists($privateKeyPath) || !file_exists($publicKeyPath)) { + $this->generateTestKeys($privateKeyPath, $publicKeyPath); + } + } + + /** + * テスト用のRSAキーペアを生成 + */ + private function generateTestKeys(string $privateKeyPath, string $publicKeyPath): void + { + $config = [ + "digest_alg" => "sha256", + "private_key_bits" => 2048, + "private_key_type" => OPENSSL_KEYTYPE_RSA, + ]; + + $res = openssl_pkey_new($config); + openssl_pkey_export($res, $privKey); + + $pubKey = openssl_pkey_get_details($res); + $publicKey = $pubKey["key"]; + + file_put_contents($privateKeyPath, $privKey); + file_put_contents($publicKeyPath, $publicKey); + } + + /** + * Test token endpoint with valid client credentials (no auth required) + * + * @return void + */ + public function testTokenEndpointWithValidCredentials(): void + { + Oauth2ClientFactory::make([ + 'is_confidential' => true + ])->persist(); + $this->loadFixtureScenario(InitAppScenario::class); + + $this->loginAdmin($this->getRequest()); + $this->post('/bc-mcp/oauth2/authorize?' . http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => 'mcp-client', + 'client_secret' => 'mcp-secret-key', + 'response_type' => 'code', + 'redirect_uri' => 'http://localhost', + 'scope' => 'mcp:read mcp:write', + ]), ['action' => 'approve']); + $redirectUrl = $this->_response->getHeaderLine('Location'); + $queryParams = []; + parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams); + $authCode = $queryParams['code']; + + // 認証なしでtokenエンドポイントをテスト + $this->post('/bc-mcp/oauth2/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => 'mcp-client', + 'redirect_uri' => 'http://localhost', + 'client_secret' => 'mcp-secret-key', + 'scope' => 'mcp:read mcp:write', + 'code' => $authCode + ]); + + $this->assertResponseOk(); + $this->assertResponseCode(200); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($response, 'Response should be valid JSON'); + $this->assertArrayHasKey('access_token', $response); + $this->assertArrayHasKey('token_type', $response); + $this->assertArrayHasKey('expires_in', $response); + $this->assertEquals('Bearer', $response['token_type']); + } + + /** + * Test authorization server metadata endpoint (no auth required) + * + * @return void + */ + public function testAuthorizationServerMetadata(): void + { + $this->get('/.well-known/oauth-authorization-server/bc-mcp'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('issuer', $response); + $this->assertArrayHasKey('token_endpoint', $response); + $this->assertArrayHasKey('authorization_endpoint', $response); + } + + /** + * Test protected resource metadata endpoint (no auth required) + * + * @return void + */ + public function testProtectedResourceMetadata(): void + { + $this->get('/.well-known/oauth-protected-resource/bc-mcp'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('resource', $response); + $this->assertArrayHasKey('authorization_servers', $response); + } + + /** + * Test client registration endpoint (no auth required) + * + * @return void + */ + public function testClientRegistration(): void + { + $this->post('/bc-mcp/oauth2/register', [ + 'client_name' => 'Test Client', + 'client_uri' => 'http://localhost', + 'redirect_uris' => ['http://localhost/callback'], + 'grant_types' => ['client_credentials'], + 'response_types' => ['code'], + 'scope' => 'mcp:read mcp:write' + ]); + + $this->assertResponseCode(201); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('client_id', $response); + $this->assertArrayHasKey('client_secret', $response); + } + + /** + * JWKSエンドポイントのテスト + */ + public function testJwks(): void + { + $this->get('/bc-mcp/oauth2/jwks'); + $this->assertResponseOk(); + $this->assertContentType('application/json'); + $body = json_decode((string)$this->_response->getBody(), true); + $this->assertArrayHasKey('keys', $body); + $this->assertNotEmpty($body['keys']); + $key = $body['keys'][0]; + $this->assertEquals('RSA', $key['kty']); + $this->assertEquals('RS256', $key['alg']); + $this->assertEquals('sig', $key['use']); + $this->assertArrayHasKey('n', $key); + $this->assertArrayHasKey('e', $key); + } + + /** + * Test verify endpoint with valid token + * + * @return void + */ + public function testVerifyWithValidToken(): void + { + Oauth2ClientFactory::make([ + 'is_confidential' => true + ])->persist(); + $this->loadFixtureScenario(InitAppScenario::class); + + $this->loginAdmin($this->getRequest()); + $this->post('/bc-mcp/oauth2/authorize?' . http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => 'mcp-client', + 'client_secret' => 'mcp-secret-key', + 'response_type' => 'code', + 'redirect_uri' => 'http://localhost', + 'scope' => 'mcp:read mcp:write', + ]), ['action' => 'approve']); + $redirectUrl = $this->_response->getHeaderLine('Location'); + $queryParams = []; + parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $queryParams); + $authCode = $queryParams['code']; + + // まず有効なトークンを取得 + $this->post('/bc-mcp/oauth2/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => 'mcp-client', + 'redirect_uri' => 'http://localhost', + 'client_secret' => 'mcp-secret-key', + 'scope' => 'mcp:read mcp:write', + 'code' => $authCode + ]); + + $this->assertResponseOk(); + $tokenResponse = json_decode((string)$this->_response->getBody(), true); + $accessToken = $tokenResponse['access_token']; + + // 取得したトークンでverifyエンドポイントをテスト + $this->configRequest([ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken] + ]); + $this->get('/bc-mcp/oauth2/verify'); + + $this->assertResponseOk(); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($response, 'Response should be valid JSON'); + $this->assertArrayHasKey('valid', $response); + $this->assertTrue($response['valid']); + $this->assertArrayHasKey('client_id', $response); + + // client_idが期待される形式かどうかをチェック(URLまたは元のclient_id) + $this->assertNotEmpty($response['client_id']); + + $this->assertArrayHasKey('scope', $response); + $this->assertStringContainsString('mcp:read', $response['scope']); + $this->assertStringContainsString('mcp:write', $response['scope']); + } + + /** + * Test verify endpoint with missing token + * + * @return void + */ + public function testVerifyWithMissingToken(): void + { + $this->get('/bc-mcp/oauth2/verify'); + + $this->assertResponseCode(401); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($response, 'Response should be valid JSON'); + $this->assertArrayHasKey('error', $response); + $this->assertEquals('invalid_token', $response['error']); + $this->assertArrayHasKey('error_description', $response); + $this->assertEquals('The access token is missing or invalid.', $response['error_description']); + } + + /** + * Test verify endpoint with invalid token format + * + * @return void + */ + public function testVerifyWithInvalidTokenFormat(): void + { + $this->configRequest([ + 'headers' => ['Authorization' => 'InvalidFormat token123'] + ]); + $this->get('/bc-mcp/oauth2/verify'); + + $this->assertResponseCode(401); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($response, 'Response should be valid JSON'); + $this->assertArrayHasKey('error', $response); + $this->assertEquals('invalid_token', $response['error']); + $this->assertArrayHasKey('error_description', $response); + $this->assertEquals('The access token is missing or invalid.', $response['error_description']); + } + + /** + * Test verify endpoint with invalid token + * + * @return void + */ + public function testVerifyWithInvalidToken(): void + { + $this->configRequest([ + 'headers' => ['Authorization' => 'Bearer invalid_token_string'] + ]); + $this->get('/bc-mcp/oauth2/verify'); + + $this->assertResponseCode(401); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($response, 'Response should be valid JSON'); + $this->assertArrayHasKey('error', $response); + $this->assertEquals('invalid_token', $response['error']); + $this->assertArrayHasKey('error_description', $response); + $this->assertEquals('The access token is invalid or expired.', $response['error_description']); + } + + /** + * Test verify endpoint with empty Authorization header + * + * @return void + */ + public function testVerifyWithEmptyAuthorizationHeader(): void + { + $this->configRequest([ + 'headers' => ['Authorization' => ''] + ]); + $this->get('/bc-mcp/oauth2/verify'); + + $this->assertResponseCode(401); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($response, 'Response should be valid JSON'); + $this->assertArrayHasKey('error', $response); + $this->assertEquals('invalid_token', $response['error']); + $this->assertArrayHasKey('error_description', $response); + $this->assertEquals('The access token is missing or invalid.', $response['error_description']); + } + + /** + * Test verify endpoint with Bearer but no token + * + * @return void + */ + public function testVerifyWithBearerButNoToken(): void + { + $this->configRequest([ + 'headers' => ['Authorization' => 'Bearer '] + ]); + $this->get('/bc-mcp/oauth2/verify'); + + $this->assertResponseCode(401); + $this->assertContentType('application/json'); + + $response = json_decode((string)$this->_response->getBody(), true); + $this->assertNotNull($response, 'Response should be valid JSON'); + $this->assertArrayHasKey('error', $response); + $this->assertEquals('invalid_token', $response['error']); + $this->assertArrayHasKey('error_description', $response); + $this->assertEquals('The access token is invalid or expired.', $response['error_description']); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php b/plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php new file mode 100644 index 0000000000..3d23563221 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Lib/OAuth2UtilTest.php @@ -0,0 +1,108 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Lib; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Lib\OAuth2Util; +use Cake\Http\ServerRequest; + +/** + * OAuth2UtilTest + */ +class OAuth2UtilTest extends BcTestCase +{ + + /** + * test getIssuer が認可サーバーの issuer 識別子を返す + * + * RFC 8414 のメタデータで公開する issuer と同一の値でなければならない + */ + public function testGetIssuer() + { + // baserCMS は TRUST_PROXY が有効な場合 https 検出器を静的に差し替え、 + // HTTPS ではなく X-Forwarded 系を参照する。どちらの環境でも https と + // 判定されるよう両方を渡す + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'example.com', + 'HTTPS' => 'on', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ], + ]); + + $this->assertEquals('https://example.com/bc-mcp', OAuth2Util::getIssuer($request)); + } + + /** + * test getIssuer は HTTP でもスキームを正しく扱う + */ + public function testGetIssuerWithHttp() + { + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'localhost:8080', + 'HTTPS' => 'off', + 'HTTP_X_FORWARDED_PROTO' => 'http', + ], + ]); + + $this->assertEquals('http://localhost:8080/bc-mcp', OAuth2Util::getIssuer($request)); + } + + /** + * test addIssuerToUrl が iss クエリを付与する + * + * RFC 9207。認可レスポンスに issuer を含める事で mix-up 攻撃を防ぐ + */ + public function testAddIssuerToUrl() + { + $result = OAuth2Util::addIssuerToUrl( + 'https://claude.ai/callback?code=abc&state=xyz', + 'https://example.com/bc-mcp' + ); + + parse_str((string)parse_url($result, PHP_URL_QUERY), $query); + $this->assertEquals('https://example.com/bc-mcp', $query['iss']); + // 既存のクエリは保持される + $this->assertEquals('abc', $query['code']); + $this->assertEquals('xyz', $query['state']); + } + + /** + * test addIssuerToUrl はクエリが無い URL にも付与できる + */ + public function testAddIssuerToUrlWithoutQuery() + { + $result = OAuth2Util::addIssuerToUrl( + 'https://claude.ai/callback', + 'https://example.com/bc-mcp' + ); + + parse_str((string)parse_url($result, PHP_URL_QUERY), $query); + $this->assertEquals('https://example.com/bc-mcp', $query['iss']); + } + + /** + * test addIssuerToUrl はフラグメントを壊さない + */ + public function testAddIssuerToUrlWithFragment() + { + $result = OAuth2Util::addIssuerToUrl( + 'https://claude.ai/callback#code=abc', + 'https://example.com/bc-mcp' + ); + + $this->assertStringContainsString('iss=', $result); + $this->assertStringEndsWith('#code=abc', $result); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php new file mode 100644 index 0000000000..6831e5fe41 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php @@ -0,0 +1,284 @@ +BaseMcpTool = new TestBaseMcpTool(); + } + + /** + * テスト内で mcp_uploads ディレクトリに作成したファイルのパス + * + * 設定した場合のみ tearDown() で確実に削除する + */ + protected ?string $chunkUploadFile = null; + + /** + * テスト内で作成した mcp_uploads ディレクトリのパス + */ + protected ?string $chunkUploadDir = null; + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->BaseMcpTool); + if ($this->chunkUploadFile && file_exists($this->chunkUploadFile)) { + unlink($this->chunkUploadFile); + } + if ($this->chunkUploadDir && is_dir($this->chunkUploadDir) && count(scandir($this->chunkUploadDir)) === 2) { + rmdir($this->chunkUploadDir); + } + parent::tearDown(); + } + + /** + * test processFileUpload with base64 data + */ + public function testProcessFileUploadWithBase64() + { + // 小さなPNG画像のbase64データ(1x1ピクセルの透明PNG) + $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg=='; + + $result = $this->execPrivateMethod($this->BaseMcpTool, 'processFileUpload', [$base64Data]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('image/png', $result['type']); + $this->assertEquals('png', $result['ext']); + + // クリーンアップ + if (file_exists($result['tmp_name'])) { + unlink($result['tmp_name']); + } + } + + /** + * test processFileUpload with URL + */ + public function testProcessFileUploadWithUrl() + { + $url = 'https://basercms.net/img/basercms_logo.png'; + $result = $this->execPrivateMethod($this->BaseMcpTool, 'processFileUpload', [$url]); + + // URLの場合はそのまま返される + $this->assertArrayHasKey('tmp_name', $result); + } + + /** + * test processFileUpload rejects a filename pointing at an existing chunk upload file + * + * チャンクアップロードを廃止したため、TMP/mcp_uploads/ に実在するファイルを + * 指しても、それをアップロードとして扱ってはならない。 + * processChunkFile() が残っている間は配列(アップロード情報)が返り、 + * このテストは FAIL する。processChunkFile() 削除後は false が返り PASS する。 + */ + public function testProcessFileUploadRejectsExistingChunkFile() + { + $this->chunkUploadDir = TMP . 'mcp_uploads' . DS; + if (!is_dir($this->chunkUploadDir)) { + mkdir($this->chunkUploadDir, 0755, true); + } + $filename = 'test_chunk_upload.jpg'; + $this->chunkUploadFile = $this->chunkUploadDir . $filename; + file_put_contents($this->chunkUploadFile, 'dummy image data'); + + $result = $this->execPrivateMethod($this->BaseMcpTool, 'processFileUpload', [$filename]); + + $this->assertFalse($result); + } + + /** + * test getMimeTypeFromExtension + */ + public function testGetMimeTypeFromExtension() + { + $testCases = [ + 'jpg' => 'image/jpeg', + 'png' => 'image/png', + 'pdf' => 'application/pdf', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'mp3' => 'audio/mpeg', + 'unknown' => 'application/octet-stream' + ]; + + foreach($testCases as $extension => $expectedMimeType) { + $result = $this->execPrivateMethod($this->BaseMcpTool, 'getMimeTypeFromExtension', [$extension]); + $this->assertEquals($expectedMimeType, $result, "Extension: {$extension}"); + } + } + + /** + * test getExtensionFromMimeType + */ + public function testGetExtensionFromMimeType() + { + $testCases = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'application/pdf' => 'pdf', + 'text/plain' => 'txt', + 'application/unknown' => 'bin' + ]; + + foreach($testCases as $mimeType => $expectedExtension) { + $result = $this->execPrivateMethod($this->BaseMcpTool, 'getExtensionFromMimeType', [$mimeType]); + $this->assertEquals($expectedExtension, $result, "MIME Type: {$mimeType}"); + } + } + + /** + * test isAllowedExtension + */ + public function testIsAllowedExtension() + { + $allowedExtensions = ['jpg', 'png', 'pdf', 'docx']; + $disallowedExtensions = ['exe', 'bat', 'sh']; + + foreach($allowedExtensions as $extension) { + $result = $this->execPrivateMethod($this->BaseMcpTool, 'isAllowedExtension', [$extension]); + $this->assertTrue($result, "Extension should be allowed: {$extension}"); + } + + foreach($disallowedExtensions as $extension) { + $result = $this->execPrivateMethod($this->BaseMcpTool, 'isAllowedExtension', [$extension]); + $this->assertFalse($result, "Extension should not be allowed: {$extension}"); + } + } + + /** + * test processImageUpload + */ + public function testProcessImageUpload() + { + // 画像のbase64データ + $imageBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg=='; + + $result = $this->execPrivateMethod($this->BaseMcpTool, 'processImageUpload', [$imageBase64]); + + $this->assertIsArray($result); + $this->assertEquals('image/png', $result['type']); + + // クリーンアップ + if (file_exists($result['tmp_name'])) { + unlink($result['tmp_name']); + } + } + + /** + * test processImageUpload with non-image file should throw exception + */ + public function testProcessImageUploadWithNonImageFile() + { + // PDFのbase64データ(非画像ファイル) + $pdfBase64 = 'data:application/pdf;base64,JVBERi0xLjQK'; + + try { + $this->execPrivateMethod($this->BaseMcpTool, 'processImageUpload', [$pdfBase64]); + $this->fail('例外が投げられるべきです'); + } catch (\Exception $e) { + $this->assertStringContainsString('画像ファイルではありません', $e->getMessage()); + } + } + + /** + * test isFileUploadable method + */ + public function testIsFileUploadable() + { + // Base64データ + $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg=='; + $this->assertTrue($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$base64Data])); + + // URL + $url = 'https://example.com/image.jpg'; + $this->assertTrue($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$url])); + + // 通常の文字列 + $text = 'ただのテキスト'; + $this->assertFalse($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$text])); + + // 配列 + $array = ['test' => 'value']; + $this->assertTrue($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$array])); + + // 拡張子付きの素のファイル名(チャンクアップロードは廃止したため、対応しない) + // + // processFileUpload() は data: URI と http(s) URL しか受け付けないため、 + // isFileUploadable() がここで true を返すと、拡張子付きの通常の文字列値 + // (例: サンプルテキストのファイル名相当の値)がファイルと誤判定され、 + // その後 processFileUpload() が false を返して静かに失敗する + $bareFilename = 'photo.jpg'; + $this->assertFalse($this->execPrivateMethod($this->BaseMcpTool, 'isFileUploadable', [$bareFilename])); + } + + /** + * test executeWithErrorHandling + * + * \Exception だけではなく \Error も捕捉し、トレースを返す事を確認する + * (MCPサーバー側で丸められると発生箇所を追跡できなくなるため) + */ + public function testExecuteWithErrorHandling() + { + // \Exception を捕捉できる事を確認 + $result = $this->execPrivateMethod($this->BaseMcpTool, 'executeWithErrorHandling', [ + function() { + throw new \Exception('例外が発生しました'); + } + ]); + $this->assertEquals('例外が発生しました', $result['content']); + $this->assertArrayHasKey('trace', $result); + + // \Error を捕捉できる事を確認 + $result = $this->execPrivateMethod($this->BaseMcpTool, 'executeWithErrorHandling', [ + function() { + $request = null; + return $request->getParam('prefix'); + } + ]); + $this->assertStringContainsString('getParam() on null', $result['content']); + $this->assertArrayHasKey('trace', $result); + } +} + +/** + * テスト用のBaseMcpToolクラス + */ +class TestBaseMcpTool extends BaseMcpTool +{ + + /** + * ツールは登録しない(共通処理のテストが目的のため) + * + * @param \Mcp\Server\McpServer $server SDK のサーバー + * @return \Mcp\Server\McpServer + */ + public function registerTools(\Mcp\Server\McpServer $server): \Mcp\Server\McpServer + { + return $server; + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php new file mode 100644 index 0000000000..ab5af7f1b8 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/PagesToolTest.php @@ -0,0 +1,271 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BaserCore; + +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\Test\Scenario\RootContentScenario; +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\BaserCore\PagesTool; +use BcMcp\Mcp\McpContext; +use BcMcp\Test\TestSuite\McpTestTrait; +use Cake\ORM\TableRegistry; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; + +/** + * PagesToolTest + */ +class PagesToolTest extends BcTestCase +{ + + use ScenarioAwareTrait; + use McpTestTrait; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + // 固定ページの保存は afterSave で検索インデックスなど複数のテーブルへ + // 書き込むため、前のテストのデータが残ると一意制約に衝突する。 + // シナリオ読み込みの前に明示的に空にする。 + foreach([ + 'sites', 'users', 'user_groups', 'users_user_groups', + 'contents', 'content_folders', 'pages', 'search_indexes', 'dblogs', + ] as $table) { + $this->truncateTable($table); + } + $this->loadFixtureScenario(InitAppScenario::class); + // 親フォルダを省略した場合の配置先となるサイトルートを用意する + $this->loadFixtureScenario(RootContentScenario::class, 1, 1, null, null, '/'); + // ファクトリで作成したノードは lft / rght が整合しないため、 + // TreeBehavior が辿れるようツリーを再構築する + TableRegistry::getTableLocator()->get('BaserCore.Contents')->recover(); + McpContext::setLoginUserId(1); + } + + /** + * Tear down + */ + public function tearDown(): void + { + McpContext::clear(); + parent::tearDown(); + } + + /** + * test addPage で固定ページが登録できる + * + * 固定ページは pages テーブルと contents テーブルの複合構造であり、 + * 本文は pages.contents、タイトルや URL はコンテンツ情報に保存される + */ + public function testAddPage() + { + [$result, $isError] = $this->callMcpTool('addPage', [ + 'title' => '会社概要', + 'name' => 'about', + 'content' => '

会社概要のページです。

', + 'status' => 1, + ]); + + $this->assertFalse($isError, 'ツールの実行に失敗しました。' . (is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE))); + $this->assertArrayHasKey('id', $result, json_encode($result, JSON_UNESCAPED_UNICODE)); + // 本文は pages.contents に保存される + $this->assertEquals('

会社概要のページです。

', $result['contents']); + // タイトルと URL はコンテンツ情報に保存される + $this->assertEquals('会社概要', $result['content']['title']); + $this->assertEquals('about', $result['content']['name']); + // plugin と type はツール側で補われる + $this->assertEquals('BaserCore', $result['content']['plugin']); + $this->assertEquals('Page', $result['content']['type']); + $this->assertTrue((bool)$result['content']['self_status']); + } + + /** + * test editPage で固定ページが編集できる + */ + public function testEditPage() + { + [$added] = $this->callMcpTool('addPage', [ + 'title' => '編集前', + 'name' => 'before-edit', + 'content' => '

編集前の本文

', + ]); + + [$result, $isError] = $this->callMcpTool('editPage', [ + 'id' => $added['id'], + 'title' => '編集後', + 'content' => '

編集後の本文

', + ]); + + $this->assertFalse($isError, 'ツールの実行に失敗しました。' . (is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE))); + $this->assertEquals('編集後', $result['content']['title']); + $this->assertEquals('

編集後の本文

', $result['contents']); + // 指定しなかった項目は変更されない + $this->assertEquals('before-edit', $result['content']['name']); + } + + /** + * test getPages と getPage で固定ページを取得できる + */ + public function testGetPages() + { + [$added] = $this->callMcpTool('addPage', [ + 'title' => '取得テスト', + 'name' => 'get-test', + 'content' => '

取得テストの本文

', + ]); + + [$list, $listError] = $this->callMcpTool('getPages', ['limit' => 10]); + $this->assertFalse($listError, is_string($list)? $list : json_encode($list, JSON_UNESCAPED_UNICODE)); + // 他の一覧系ツールと同じ data / pagination 形式で返る + $this->assertNotEmpty($list['data']); + $this->assertEquals(1, $list['pagination']['page']); + $this->assertEquals(10, $list['pagination']['limit']); + $this->assertEquals(count($list['data']), $list['pagination']['count']); + + [$single, $singleError] = $this->callMcpTool('getPage', ['id' => $added['id']]); + $this->assertFalse($singleError, is_string($single)? $single : json_encode($single, JSON_UNESCAPED_UNICODE)); + $this->assertEquals('取得テスト', $single['content']['title']); + $this->assertEquals('

取得テストの本文

', $single['contents']); + } + + /** + * test getPages はキーワードで本文を検索できる + */ + public function testGetPagesWithKeyword() + { + $this->callMcpTool('addPage', [ + 'title' => 'キーワード対象', + 'name' => 'keyword-target', + 'content' => '

特別な検索語を含む本文

', + ]); + $this->callMcpTool('addPage', [ + 'title' => 'キーワード対象外', + 'name' => 'keyword-other', + 'content' => '

関係のない本文

', + ]); + + [$list, $isError] = $this->callMcpTool('getPages', ['keyword' => '特別な検索語']); + + $this->assertFalse($isError, is_string($list)? $list : json_encode($list, JSON_UNESCAPED_UNICODE)); + $this->assertCount(1, $list['data']); + $this->assertEquals('キーワード対象', $list['data'][0]['content']['title']); + } + + /** + * test deletePage で固定ページが削除できる + * + * PagesService::delete() は完全削除であり、pages と contents の + * レコードがいずれも消える(ゴミ箱にも残らない) + */ + public function testDeletePage() + { + [$added] = $this->callMcpTool('addPage', [ + 'title' => '削除対象', + 'name' => 'to-be-deleted', + 'content' => '

削除対象の本文

', + ]); + + [$result, $isError] = $this->callMcpTool('deletePage', ['id' => $added['id']]); + $this->assertFalse($isError, is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertEquals('削除対象', $result['title']); + + // pages のレコードが消えている + $this->assertEquals(0, TableRegistry::getTableLocator()->get('BaserCore.Pages') + ->find()->where(['Pages.id' => $added['id']])->count()); + // 紐づく contents のレコードも消えている(ゴミ箱にも残らない) + $this->assertEquals(0, TableRegistry::getTableLocator()->get('BaserCore.Contents') + ->find() + ->where(['Contents.entity_id' => $added['id'], 'Contents.type' => 'Page']) + ->applyOptions(['withDeleted']) + ->count()); + // 削除済みのため取得できない。 + // BaseMcpTool::executeWithErrorHandling() が例外を戻り値へ包むため、 + // MCP レベルの isError にはならず content にエラーメッセージが入る + [$notFound] = $this->callMcpTool('getPage', ['id' => $added['id']]); + $this->assertStringContainsString( + 'Record not found', + $notFound['content'] ?? '', + '削除したページが取得できてしまいました。' . json_encode($notFound, JSON_UNESCAPED_UNICODE) + ); + } + + /** + * test siteId を省略した場合はメインサイトに作成される + * + * 固定ページは Content が必須で、Content にはサイトの指定が必須である。 + * 省略時は ID の決め打ちではなくメインサイトを解決する + */ + public function testAddPageResolvesMainSite() + { + $mainSiteId = TableRegistry::getTableLocator()->get('BaserCore.Sites')->getRootMain()->id; + + [$result, $isError] = $this->callMcpTool('addPage', [ + 'title' => 'サイト解決テスト', + 'name' => 'main-site-resolution', + 'content' => '

本文

', + ]); + + $this->assertFalse($isError, is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertEquals($mainSiteId, $result['content']['site_id']); + // 親フォルダはそのサイトのルートになる + $this->assertNotEmpty($result['content']['parent_id']); + } + + /** + * test siteId を明示した場合はそのサイトに作成される + */ + public function testAddPageWithExplicitSiteId() + { + [$result, $isError] = $this->callMcpTool('addPage', [ + 'title' => 'サイト指定テスト', + 'name' => 'explicit-site', + 'content' => '

本文

', + 'siteId' => 1, + ]); + + $this->assertFalse($isError, is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertEquals(1, $result['content']['site_id']); + } + + /** + * test 権限チェック用のURL + */ + public function testGetPermissionUrl() + { + $this->assertEquals( + ['POST' => '/baser-core/pages/add.json'], + PagesTool::getPermissionUrl('addPage') + ); + $this->assertEquals( + ['POST' => '/baser-core/pages/edit/3.json'], + PagesTool::getPermissionUrl('editPage', ['id' => 3]) + ); + $this->assertEquals( + ['POST' => '/baser-core/pages/delete/3.json'], + PagesTool::getPermissionUrl('deletePage', ['id' => 3]) + ); + $this->assertEquals( + ['GET' => '/baser-core/pages/index.json'], + PagesTool::getPermissionUrl('getPages') + ); + $this->assertEquals( + ['GET' => '/baser-core/pages/view/3.json'], + PagesTool::getPermissionUrl('getPage', ['id' => 3]) + ); + // id が無い編集・削除・取得は権限チェックの対象にできない + $this->assertFalse(PagesTool::getPermissionUrl('editPage')); + $this->assertFalse(PagesTool::getPermissionUrl('unknownAction')); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogCategoriesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogCategoriesToolTest.php new file mode 100644 index 0000000000..7307da80ef --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogCategoriesToolTest.php @@ -0,0 +1,518 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcBlog; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\BcBlog\BlogCategoriesTool; +use BcBlog\Test\Factory\BlogCategoryFactory; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; + +/** + * BcMcp\Mcp\BcBlog\BlogCategoriesTool Test Case + * + * @uses \BcMcp\Mcp\BcBlog\BlogCategoriesTool + */ +class BlogCategoriesToolTest extends BcTestCase +{ + use ScenarioAwareTrait; + + /** + * Test subject + * + * @var \BcMcp\Mcp\BcBlog\BlogCategoriesTool + */ + protected $BlogCategoriesTool; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + $this->BlogCategoriesTool = new BlogCategoriesTool(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->BlogCategoriesTool); + parent::tearDown(); + } + + /** + * Test addBlogCategory method - 基本テスト + * + * @return void + */ + public function testAddBlogCategoryBasic() + { + $title = 'テストカテゴリ'; + $blogContentId = 1; + + $result = $this->BlogCategoriesTool->addBlogCategory( + title: $title, + blogContentId: $blogContentId + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + } + + /** + * Test getBlogCategories method - 基本テスト + * + * @return void + */ + public function testGetBlogCategoriesBasic() + { + // テストデータを作成 + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => 'テストカテゴリ1', + 'name' => 'test-category-1', + 'status' => 1 + ])->persist(); + + $result = $this->BlogCategoriesTool->getBlogCategories(1); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + } + + /** + * Test getBlogCategory method - IDによる取得 + * + * @return void + */ + public function testGetBlogCategoryById() + { + // テストデータを作成 + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => 'テストカテゴリ', + 'name' => 'test-category', + 'status' => 1 + ])->persist(); + + $result = $this->BlogCategoriesTool->getBlogCategory(1); + + $this->assertIsArray($result); + // IDが存在する場合は成功を想定 + $this->assertEquals(1, $result['id']); + } + + /** + * Test editBlogCategory method - 編集機能 + * + * @return void + */ + public function testEditBlogCategory() + { + // テストデータを作成 + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => 'テストカテゴリ', + 'name' => 'test-category', + 'status' => 1 + ])->persist(); + + $newTitle = '編集テストカテゴリ'; + + $result = $this->BlogCategoriesTool->editBlogCategory( + id: 1, + title: $newTitle + ); + + $this->assertIsArray($result); + $this->assertEquals($newTitle, $result['title']); + } + + /** + * Test deleteBlogCategory method - 削除機能 + * + * @return void + */ + public function testDeleteBlogCategory() + { + // テストデータを作成 + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => 'テストカテゴリ', + 'name' => 'test-category', + 'status' => 1 + ])->persist(); + + $result = $this->BlogCategoriesTool->deleteBlogCategory(1); + + $this->assertIsArray($result); + $this->assertArrayHasKey('message', $result); + } + + /** + * Test addBlogCategory method - エラーテスト(空のタイトル) + * + * @return void + */ + public function testAddBlogCategoryWithEmptyTitle() + { + $result = $this->BlogCategoriesTool->addBlogCategory(''); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertEquals('titleは必須です', $result['content']); + } + + /** + * Test getBlogCategory method - 存在しないIDのテスト + * + * @return void + */ + public function testGetBlogCategoryNotFound() + { + $nonExistentId = 999999; + + $result = $this->BlogCategoriesTool->getBlogCategory($nonExistentId); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertEquals('Record not found in table `blog_categories`.', $result['content']); + } + + /** + * Test getBlogCategories method - ページネーションテスト(limit指定) + * + * @return void + */ + public function testGetBlogCategoriesWithLimit() + { + // 複数のテストデータを作成 + for($i = 1; $i <= 5; $i++) { + BlogCategoryFactory::make([ + 'id' => $i, + 'blog_content_id' => 1, + 'title' => "テストカテゴリ{$i}", + 'name' => "test-category-{$i}", + 'status' => 1 + ])->persist(); + } + + // limit=3で取得 + $result = $this->BlogCategoriesTool->getBlogCategories( + blogContentId: 1, + limit: 3 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + + // ページネーション情報の確認 + $this->assertEquals(1, $result['pagination']['page']); + $this->assertEquals(3, $result['pagination']['limit']); + $this->assertEquals(3, $result['pagination']['count']); // 実際に返された件数 + $this->assertEquals(5, $result['pagination']['total']); // 総件数 + } + + /** + * Test getBlogCategories method - ページネーションテスト(page指定) + * + * @return void + */ + public function testGetBlogCategoriesWithPage() + { + // 複数のテストデータを作成 + for($i = 1; $i <= 10; $i++) { + BlogCategoryFactory::make([ + 'id' => $i, + 'blog_content_id' => 1, + 'title' => "テストカテゴリ{$i}", + 'name' => "test-category-{$i}", + 'status' => 1 + ])->persist(); + } + + // page=2, limit=3で取得 + $result = $this->BlogCategoriesTool->getBlogCategories( + blogContentId: 1, + limit: 3, + page: 2 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + + // ページネーション情報の確認 + $this->assertEquals(2, $result['pagination']['page']); + $this->assertEquals(3, $result['pagination']['limit']); + $this->assertEquals(3, $result['pagination']['count']); // 実際に返された件数 + $this->assertEquals(10, $result['pagination']['total']); // 総件数 + } + + /** + * Test getBlogCategories method - ページネーションテスト(limit未指定) + * + * @return void + */ + public function testGetBlogCategoriesWithoutLimit() + { + // 複数のテストデータを作成 + for($i = 1; $i <= 5; $i++) { + BlogCategoryFactory::make([ + 'id' => $i, + 'blog_content_id' => 1, + 'title' => "テストカテゴリ{$i}", + 'name' => "test-category-{$i}", + 'status' => 1 + ])->persist(); + } + + // limitを指定せずに取得 + $result = $this->BlogCategoriesTool->getBlogCategories( + blogContentId: 1, + page: 1 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + + // ページネーション情報の確認 + $this->assertEquals(1, $result['pagination']['page']); + $this->assertNull($result['pagination']['limit']); + $this->assertEquals(5, $result['pagination']['count']); + $this->assertEquals(5, $result['pagination']['total']); // 総件数 + } + + /** + * Test getBlogCategories method - ページネーションテスト(空のページ) + * + * @return void + */ + public function testGetBlogCategoriesEmptyPage() + { + // 5件のテストデータを作成 + for($i = 1; $i <= 5; $i++) { + BlogCategoryFactory::make([ + 'id' => $i, + 'blog_content_id' => 1, + 'title' => "テストカテゴリ{$i}", + 'name' => "test-category-{$i}", + 'status' => 1 + ])->persist(); + } + + // 存在しないページ(page=10, limit=3)で取得 + $result = $this->BlogCategoriesTool->getBlogCategories( + blogContentId: 1, + limit: 3, + page: 10 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + + // ページネーション情報の確認 + $this->assertEquals(10, $result['pagination']['page']); + $this->assertEquals(3, $result['pagination']['limit']); + $this->assertEquals(0, $result['pagination']['count']); // 実際に返された件数 + $this->assertEquals(5, $result['pagination']['total']); // 総件数 + } + + /** + * Test getBlogCategories method - 公開状態フィルタテスト + * + * @return void + */ + public function testGetBlogCategoriesWithPublishStatus() + { + // BlogContentScenarioを使用してBlogContentとContentを作成 + $this->loadFixtureScenario(\BcBlog\Test\Scenario\BlogContentScenario::class, 1, 1, 1, 'blog', '/blog/', 'ブログ'); + + // 2つのカテゴリを作成(1つは公開、1つは非公開) + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => '公開カテゴリ', + 'name' => 'public-category', + 'status' => 1 // 公開 + ])->persist(); + + BlogCategoryFactory::make([ + 'id' => 2, + 'blog_content_id' => 1, + 'title' => '非公開カテゴリ', + 'name' => 'private-category', + 'status' => 0 // 非公開 + ])->persist(); + + // status=1を指定すると'publish'に変換される + $result = $this->BlogCategoriesTool->getBlogCategories( + blogContentId: 1, + status: 'publish' + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + + // 結果の確認:公開されているカテゴリのみが取得される + // paginationキーを除外してカテゴリデータを取得 + $categories = array_values(array_filter($result, function($key) { + return $key !== 'pagination'; + }, ARRAY_FILTER_USE_KEY)); + + $this->assertCount(1, $categories); // 公開されているカテゴリのみ1件 + $this->assertEquals('公開カテゴリ', $categories[0]['title']); + $this->assertEquals(1, $categories[0]['status']); + + // ページネーション情報の確認 + $this->assertEquals(1, $result['pagination']['count']); // 実際に返された件数 + $this->assertEquals(1, $result['pagination']['total']); // 公開状態の総件数 + } + + /** + * Test getBlogCategories method - 全ての状態のカテゴリ取得テスト + * + * @return void + */ + public function testGetBlogCategoriesWithAllStatus() + { + // 2つのカテゴリを作成(1つは公開、1つは非公開) + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => '公開カテゴリ', + 'name' => 'public-category', + 'status' => 1 // 公開 + ])->persist(); + + BlogCategoryFactory::make([ + 'id' => 2, + 'blog_content_id' => 1, + 'title' => '非公開カテゴリ', + 'name' => 'private-category', + 'status' => 0 // 非公開 + ])->persist(); + + // 全ての状態のカテゴリを取得(status指定なし) + $result = $this->BlogCategoriesTool->getBlogCategories( + blogContentId: 1 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + + // 結果の確認 + $categories = array_values(array_filter($result, function($key) { + return $key !== 'pagination'; + }, ARRAY_FILTER_USE_KEY)); + $this->assertCount(2, $categories); // 公開・非公開両方取得される + + // ページネーション情報の確認 + $this->assertEquals(2, $result['pagination']['count']); // 実際に返された件数 + $this->assertEquals(2, $result['pagination']['total']); // 全件数 + } + + /** + * Test getBlogCategories method - status=0(非公開)は対応しないテスト + * + * @return void + */ + public function testGetBlogCategoriesWithUnpublishStatusNotSupported() + { + // 2つのカテゴリを作成(1つは公開、1つは非公開) + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => '公開カテゴリ', + 'name' => 'public-category', + 'status' => 1 // 公開 + ])->persist(); + + BlogCategoryFactory::make([ + 'id' => 2, + 'blog_content_id' => 1, + 'title' => '非公開カテゴリ', + 'name' => 'private-category', + 'status' => 0 // 非公開 + ])->persist(); + + // status=0を指定(対応しないため、全てのカテゴリが取得される) + $result = $this->BlogCategoriesTool->getBlogCategories( + blogContentId: 1, + status: null + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + + // 結果の確認:status=0は対応しないため、全てのカテゴリが取得される + $categories = array_values(array_filter($result, function($key) { + return $key !== 'pagination'; + }, ARRAY_FILTER_USE_KEY)); + $this->assertCount(2, $categories); // 公開・非公開両方取得される + + // ページネーション情報の確認 + $this->assertEquals(2, $result['pagination']['count']); // 実際に返された件数 + $this->assertEquals(2, $result['pagination']['total']); // 全件数 + } + + /** + * testGetBlogCategoriesWithTitle + * + * @return void + */ + public function testGetBlogCategoriesWithTitle() + { + // 2つのカテゴリを作成(1つは公開、1つは非公開) + BlogCategoryFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'title' => 'カテゴリ1', + 'name' => 'public-category', + 'status' => 1 + ])->persist(); + + BlogCategoryFactory::make([ + 'id' => 2, + 'blog_content_id' => 1, + 'title' => 'カテゴリ2', + 'name' => 'private-category', + 'status' => 1 + ])->persist(); + + $result = $this->BlogCategoriesTool->getBlogCategories( + title: 'カテゴリ' + ); + $categories = array_values(array_filter($result, function($key) { + return $key !== 'pagination'; + }, ARRAY_FILTER_USE_KEY)); + $this->assertCount(2, $categories); + + $result = $this->BlogCategoriesTool->getBlogCategories( + title: '1' + ); + $categories = array_values(array_filter($result, function($key) { + return $key !== 'pagination'; + }, ARRAY_FILTER_USE_KEY)); + $this->assertCount(1, $categories); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogContentsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogContentsToolTest.php new file mode 100644 index 0000000000..caab5ce5ac --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogContentsToolTest.php @@ -0,0 +1,335 @@ + + * Copyright (c) NPO baserCMS Users Community + * + * @copyright Copyright (c) NPO baserCMS Users Community + * @link https://basercms.net baserCMS Project + * @since 5.0.0 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcBlog; + +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\Test\Scenario\SmallSetContentsScenario; +use BaserCore\Utility\BcUtil; +use BcBlog\Test\Scenario\BlogContentScenario; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use BcMcp\Mcp\BcBlog\BlogContentsTool; +use BaserCore\TestSuite\BcTestCase; +use Cake\ORM\TableRegistry; + +/** + * BlogContentsToolTest + */ +class BlogContentsToolTest extends BcTestCase +{ + use ScenarioAwareTrait; + + /** + * @var BlogContentsTool + */ + public $BlogContentsTool; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $this->BlogContentsTool = new BlogContentsTool(); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->BlogContentsTool); + parent::tearDown(); + } + + /** + * Test instantiation + */ + public function testInstantiation() + { + $this->assertInstanceOf(BlogContentsTool::class, $this->BlogContentsTool); + $this->assertTrue(method_exists($this->BlogContentsTool, 'addBlogContent')); + $this->assertTrue(method_exists($this->BlogContentsTool, 'getBlogContents')); + } + + /** + * test addBlogContent + */ + public function testAddBlogContent() + { + $this->loadFixtureScenario(InitAppScenario::class); + $this->loadFixtureScenario(SmallSetContentsScenario::class); + $result = $this->BlogContentsTool->addBlogContent( + 'test-blog', + 'テストブログ', + 1, // siteId + 1, // parentId + 'テストブログの説明' // description + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + } + + /** + * test getBlogContents + */ + public function testGetBlogContents() + { + $this->loadFixtureScenario(BlogContentScenario::class, + 1, // id + 1, + null, + 'test-blog', + 'test-blog-url', + ); + $result = $this->BlogContentsTool->getBlogContents(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('data', $result); + $this->assertCount(1, $result['data']); + } + + /** + * test getBlogContent + */ + public function testGetBlogContent() + { + $this->loadFixtureScenario(BlogContentScenario::class, + 1, // id + 1, + null, + 'test-blog', + 'test-blog-url', + ); + $result = $this->BlogContentsTool->getBlogContent(1); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + } + + /** + * test editBlogContent + */ + public function testEditBlogContent() + { + $this->loadFixtureScenario(BlogContentScenario::class, + 1, // id + 1, + null, + 'test-blog', + 'test-blog-url', + ); + // BlogContentScenario は parentId に null を渡しても `?? 1` で parent_id=1 となり、 + // id=1 と一致して「自分自身を親にできない」(TreeBehavior, CakePHP 5.2) になるため、 + // 正当なルート(parent_id=null)へ補正してツリーを再構築する。 + $contentsTable = TableRegistry::getTableLocator()->get('BaserCore.Contents'); + $contentsTable->updateAll(['parent_id' => null], ['id' => 1]); + $contentsTable->recover(); + + $result = $this->BlogContentsTool->editBlogContent( + 1, + 'updated-blog', + '更新されたブログ', + 1, + null, + '更新されたブログの説明' + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + } + + /** + * test deleteBlogContent + */ + public function testDeleteBlogContent() + { + // テストではID=1のブログコンテンツが存在することを前提とする + $result = $this->BlogContentsTool->deleteBlogContent(1); + + $this->assertIsArray($result); + // 削除結果のチェック(成功またはエラーのいずれか) + if (isset($result['message'])) { + // 成功の場合 + $this->assertEquals('ブログコンテンツを削除しました', $result['message']); + } else { + // エラーの場合 + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getBlogContentsWithSearch + */ + public function testGetBlogContentsWithSearch() + { + $result = $this->BlogContentsTool->getBlogContents('test'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('data', $result); + } + + /** + * test getBlogContentWithInvalidId + */ + public function testGetBlogContentWithInvalidId() + { + $result = $this->BlogContentsTool->getBlogContent(99999); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertIsString($result['content']); // エラーメッセージ + } + + /** + * test editBlogContentWithInvalidId + */ + public function testEditBlogContentWithInvalidId() + { + $this->loadFixtureScenario(BlogContentScenario::class, + 1, // id + 1, + null, + 'test-blog', + 'test-blog-url', + ); + $result = $this->BlogContentsTool->editBlogContent( + 99999, + 'test-blog', + 'テストブログ', + 1, + null, + 'テストブログの説明' + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertIsString($result['content']); // エラーメッセージ + } + + /** + * test addBlogContentWithEyeCatchSize + */ + public function testAddBlogContentWithEyeCatchSize() + { + $this->loadFixtureScenario(InitAppScenario::class); + $this->loadFixtureScenario(SmallSetContentsScenario::class); + + $result = $this->BlogContentsTool->addBlogContent( + name: 'eyecatch-test-blog', + title: 'アイキャッチテストブログ', + description: 'アイキャッチサイズのテスト', + eyeCatchSizeThumbWidth: 300, + eyeCatchSizeThumbHeight: 200, + eyeCatchSizeMobileThumbWidth: 150, + eyeCatchSizeMobileThumbHeight: 100 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + + // アイキャッチサイズの設定を確認 + $blogContent = $result; + $this->assertArrayHasKey('eye_catch_size', $blogContent); + + // eye_catch_sizeがbase64エンコードされたシリアライズ形式の場合は、デコードしてアンシリアライズする + $eyeCatchSize = $blogContent['eye_catch_size']; + if (is_string($eyeCatchSize)) { + // base64デコードしてからアンシリアライズ + $eyeCatchSize = BcUtil::unserialize($eyeCatchSize); + } + + // 実際のキー名で確認(thumb_width等) + $this->assertEquals(300, $eyeCatchSize['thumb_width']); + $this->assertEquals(200, $eyeCatchSize['thumb_height']); + $this->assertEquals(150, $eyeCatchSize['mobile_thumb_width']); + $this->assertEquals(100, $eyeCatchSize['mobile_thumb_height']); + } + + /** + * test editBlogContentWithEyeCatchSize + */ + public function testEditBlogContentWithEyeCatchSize() + { + $this->loadFixtureScenario(BlogContentScenario::class, + 1, // id + 1, + null, + 'test-blog', + 'test-blog-url', + ); + + $result = $this->BlogContentsTool->editBlogContent( + id: 1, + eyeCatchSizeThumbWidth: 400, + eyeCatchSizeThumbHeight: 300, + eyeCatchSizeMobileThumbWidth: 200, + eyeCatchSizeMobileThumbHeight: 150 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + + // アイキャッチサイズの更新を確認 + $blogContent = $result; + $this->assertArrayHasKey('eye_catch_size', $blogContent); + + // eye_catch_sizeが文字列の場合は、アンシリアライズする + $eyeCatchSize = $blogContent['eye_catch_size']; + if (is_string($eyeCatchSize)) { + $eyeCatchSize = BcUtil::unserialize($eyeCatchSize); + } + + $this->assertEquals(400, $eyeCatchSize['thumb_width']); + $this->assertEquals(300, $eyeCatchSize['thumb_height']); + $this->assertEquals(200, $eyeCatchSize['mobile_thumb_width']); + $this->assertEquals(150, $eyeCatchSize['mobile_thumb_height']); + } + + /** + * test addBlogContentWithDefaultEyeCatchSize + */ + public function testAddBlogContentWithDefaultEyeCatchSize() + { + $this->loadFixtureScenario(InitAppScenario::class); + $this->loadFixtureScenario(SmallSetContentsScenario::class); + + // アイキャッチサイズを指定せずにブログコンテンツを作成 + $result = $this->BlogContentsTool->addBlogContent( + 'default-eyecatch-blog', + 'デフォルトアイキャッチブログ', + 1, // siteId + 1, // parentId + 'デフォルトアイキャッチサイズのテスト' // description + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + + // デフォルトのアイキャッチサイズが設定されることを確認 + $blogContent = $result; + $this->assertArrayHasKey('eye_catch_size', $blogContent); + + // eye_catch_sizeが文字列の場合は、アンシリアライズする + $eyeCatchSize = $blogContent['eye_catch_size']; + if (is_string($eyeCatchSize)) { + $eyeCatchSize = BcUtil::unserialize($eyeCatchSize); + } + + $this->assertArrayHasKey('thumb_width', $eyeCatchSize); + $this->assertArrayHasKey('thumb_height', $eyeCatchSize); + $this->assertArrayHasKey('mobile_thumb_width', $eyeCatchSize); + $this->assertArrayHasKey('mobile_thumb_height', $eyeCatchSize); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php new file mode 100644 index 0000000000..29d16d3f51 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php @@ -0,0 +1,889 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcBlog; + +use BaserCore\Test\Factory\ContentFactory; +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\TestSuite\BcTestCase; +use BaserCore\Utility\BcFolder; +use BcBlog\Test\Factory\BlogCategoryFactory; +use BcBlog\Test\Factory\BlogContentFactory; +use BcBlog\Test\Factory\BlogPostFactory; +use BcBlog\Test\Scenario\BlogContentScenario; +use BcBlog\Test\Scenario\BlogPostsAdminServiceScenario; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use BcMcp\Mcp\BcBlog\BlogPostsTool; + +/** + * BlogPostsToolTest + */ +class BlogPostsToolTest extends BcTestCase +{ + use ScenarioAwareTrait; + + /** + * Test subject + * + * @var \BcMcp\Mcp\BcBlog\BlogPostsTool + */ + protected $BlogPostsTool; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $this->BlogPostsTool = new BlogPostsTool(); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->BlogPostsTool); + parent::tearDown(); + } + + /** + * test BlogPostsTool instantiation + */ + public function testInstantiation() + { + $this->assertInstanceOf(BlogPostsTool::class, $this->BlogPostsTool); + } + + /** + * test addBlogPost + */ + public function testAddBlogPost() + { + // テストデータが無い環境でも、メソッドが存在することを確認 + $this->assertTrue(method_exists($this->BlogPostsTool, 'addBlogPost')); + + // エラーの場合でも結果が配列で返されることを確認 + $result = $this->BlogPostsTool->addBlogPost( + 'テストブログ記事', + 'これはテスト用のブログ記事です。', + 'news', + null, + 'test@example.com' + ); + + $this->assertIsArray($result); + // ブログ記事が追加されたかどうかの確認 + // エラーが発生した場合はcontentキーにエラーメッセージが含まれる + if (isset($result['content']) && is_string($result['content'])) { + // エラーケース + $this->assertIsString($result['content']); + } else { + // 成功ケース + $this->assertArrayHasKey('id', $result); + } + } + + /** + * test getBlogPosts + */ + public function testGetBlogPosts() + { + BlogPostFactory::make([ + 'id' => 1, + ])->persist(); + $result = $this->BlogPostsTool->getBlogPosts(1); + + $this->assertArrayHasKey('pagination', $result); + $this->assertArrayHasKey('data', $result); + $this->assertIsArray($result['data']); + } + + /** + * test getBlogPosts with keyword search + */ + public function testGetBlogPostsWithKeyword() + { + // テスト用のブログ記事を作成 + BlogPostFactory::make([ + 'id' => 1, + 'title' => 'テストブログ記事', + 'detail' => 'これはテスト用の詳細です。', + 'content' => 'テスト用の概要内容です。', + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => '2023-01-01 00:00:00' + ])->persist(); + + BlogPostFactory::make([ + 'id' => 2, + 'title' => '別の記事', + 'detail' => '別の内容です。', + 'content' => '別の概要です。', + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => '2023-01-02 00:00:00' + ])->persist(); + + BlogPostFactory::make([ + 'id' => 3, + 'title' => 'サンプル記事', + 'detail' => 'テストという単語が含まれる詳細です。', + 'content' => 'サンプル概要です。', + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => '2023-01-03 00:00:00' + ])->persist(); + + // キーワード検索のテスト("テスト"で検索) + $result = $this->BlogPostsTool->getBlogPosts(1, 'テスト'); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('pagination', $result); + + // キーワードに一致する記事が取得されることを確認 + // "テスト"という単語がタイトルまたは詳細に含まれる記事が検索される + $this->assertGreaterThan(0, count($result['data'])); + + // 検索結果の構造を確認 + if (count($result['data']) > 0) { + $firstPost = $result['data'][0]; + $this->assertArrayHasKey('id', $firstPost); + $this->assertArrayHasKey('title', $firstPost); + $this->assertArrayHasKey('detail', $firstPost); + } + } + + /** + * test getBlogPosts with keyword search no results + */ + public function testGetBlogPostsWithKeywordNoResults() + { + // テスト用のブログ記事を作成 + BlogPostFactory::make([ + 'id' => 1, + 'title' => 'サンプル記事', + 'detail' => 'サンプルの詳細です。', + 'content' => 'サンプル概要です。', + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => '2023-01-01 00:00:00' + ])->persist(); + + // 存在しないキーワードで検索 + $result = $this->BlogPostsTool->getBlogPosts(1, '存在しないキーワード'); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('pagination', $result); + + // 検索結果が0件であることを確認 + $this->assertEquals(0, count($result['data'])); + } + + /** + * test getBlogPosts with empty keyword + */ + public function testGetBlogPostsWithEmptyKeyword() + { + // テスト用のブログ記事を作成 + BlogPostFactory::make([ + 'id' => 1, + 'title' => 'テスト記事', + 'detail' => 'テストの詳細です。', + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => '2023-01-01 00:00:00' + ])->persist(); + + // 空のキーワードで検索(すべての記事が取得される) + $result = $this->BlogPostsTool->getBlogPosts(1, ''); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('pagination', $result); + $this->assertArrayHasKey('data', $result); + + // 記事が取得されることを確認 + $this->assertGreaterThan(0, count($result['data'])); + } + + /** + * test getBlogPosts with limit parameter + */ + public function testGetBlogPostsWithLimit() + { + // 5つのテスト記事を作成 + for($i = 1; $i <= 5; $i++) { + BlogPostFactory::make([ + 'id' => $i, + 'title' => "テスト記事 {$i}", + 'detail' => "テスト記事 {$i} の詳細です。", + 'content' => "テスト記事 {$i} の概要です。", + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => "2023-01-0{$i} 00:00:00" + ])->persist(); + } + + // limit = 3 でテスト + $result = $this->BlogPostsTool->getBlogPosts(1, null, null, 3, 1); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('pagination', $result); + + // limitが正しく適用されていることを確認 + $this->assertLessThanOrEqual(3, count($result['data'])); + $this->assertEquals(3, $result['pagination']['limit']); + } + + /** + * test getBlogPosts with page parameter + */ + public function testGetBlogPostsWithPage() + { + // 10個のテスト記事を作成 + for($i = 1; $i <= 10; $i++) { + BlogPostFactory::make([ + 'id' => $i, + 'title' => "ページテスト記事 {$i}", + 'detail' => "ページテスト記事 {$i} の詳細です。", + 'content' => "ページテスト記事 {$i} の概要です。", + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => sprintf("2023-01-%02d 00:00:00", $i) + ])->persist(); + } + + // 1ページ目(limit=3) + $result1 = $this->BlogPostsTool->getBlogPosts(1, null, null, 3, 1); + + $this->assertArrayHasKey('data', $result1); + $this->assertArrayHasKey('pagination', $result1); + $this->assertArrayHasKey('data', $result1); + $this->assertArrayHasKey('pagination', $result1); + + $this->assertEquals(1, $result1['pagination']['page']); + $this->assertEquals(3, $result1['pagination']['limit']); + $this->assertLessThanOrEqual(3, count($result1['data'])); + + // 2ページ目(limit=3) + $result2 = $this->BlogPostsTool->getBlogPosts(1, null, null, 3, 2); + + $this->assertArrayHasKey('data', $result2); + $this->assertArrayHasKey('pagination', $result2); + $this->assertArrayHasKey('data', $result2); + $this->assertArrayHasKey('pagination', $result2); + + $this->assertEquals(2, $result2['pagination']['page']); + $this->assertEquals(3, $result2['pagination']['limit']); + $this->assertLessThanOrEqual(3, count($result2['data'])); + + // 1ページ目と2ページ目で異なる記事が取得されることを確認 + if (count($result1['data']) > 0 && count($result2['data']) > 0) { + $firstPageIds = array_column($result1['data'], 'id'); + $secondPageIds = array_column($result2['data'], 'id'); + + // 1ページ目と2ページ目のIDに重複がないことを確認 + $intersection = array_intersect($firstPageIds, $secondPageIds); + $this->assertEmpty($intersection, '1ページ目と2ページ目で同じ記事が重複して取得されています'); + } + } + + /** + * test getBlogPosts with limit and page combination + */ + public function testGetBlogPostsWithLimitAndPage() + { + // 8個のテスト記事を作成 + for($i = 1; $i <= 8; $i++) { + BlogPostFactory::make([ + 'id' => $i, + 'title' => "組み合わせテスト記事 {$i}", + 'detail' => "組み合わせテスト記事 {$i} の詳細です。", + 'content' => "組み合わせテスト記事 {$i} の概要です。", + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => sprintf("2023-01-%02d 00:00:00", $i) + ])->persist(); + } + + // limit=2, page=3 のテスト(5〜6番目の記事が取得される想定) + $result = $this->BlogPostsTool->getBlogPosts(1, null, null, 2, 3); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('pagination', $result); + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('pagination', $result); + + // パラメータが正しく設定されていることを確認 + $this->assertEquals(3, $result['pagination']['page']); + $this->assertEquals(2, $result['pagination']['limit']); + $this->assertLessThanOrEqual(2, count($result['data'])); + } + + /** + * test getBlogPosts with invalid page number + */ + public function testGetBlogPostsWithInvalidPageNumber() + { + // 2個のテスト記事を作成 + BlogPostFactory::make([ + 'id' => 1, + 'title' => '無効ページテスト記事1', + 'detail' => '無効ページテスト記事1の詳細です。', + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => '2023-01-01 00:00:00' + ])->persist(); + + BlogPostFactory::make([ + 'id' => 2, + 'title' => '無効ページテスト記事2', + 'detail' => '無効ページテスト記事2の詳細です。', + 'blog_content_id' => 1, + 'status' => 1, + 'posted' => '2023-01-02 00:00:00' + ])->persist(); + + // 存在しないページ番号(page=10)でテスト + $result = $this->BlogPostsTool->getBlogPosts(1, null, null, 10, 10); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('pagination', $result); + $this->assertArrayHasKey('data', $result); + + // 存在しないページの場合、データが空であることを確認 + $this->assertEquals(0, count($result['data'])); + } + + /** + * test getBlogPost + */ + public function testGetBlogPost() + { + $this->loadFixtureScenario(BlogPostsAdminServiceScenario::class); + $result = $this->BlogPostsTool->getBlogPost(1); + + $this->assertArrayHasKey('id', $result); + $this->assertEquals(1, $result['id']); + } + + /** + * test editBlogPost + */ + public function testEditBlogPost() + { + $this->loadFixtureScenario(BlogPostsAdminServiceScenario::class); + $result = $this->BlogPostsTool->editBlogPost( + 1, + '更新されたタイトル', + '更新された詳細', + null, + null, + null, + null + ); + + $this->assertArrayHasKey('title', $result); + $this->assertEquals('更新されたタイトル', $result['title']); + $this->assertEquals('更新された詳細', $result['detail']); + } + + /** + * test deleteBlogPost + */ + public function testDeleteBlogPost() + { + $this->loadFixtureScenario(BlogPostsAdminServiceScenario::class); + $result = $this->BlogPostsTool->deleteBlogPost(1); + + $this->assertArrayHasKey('message', $result); + } + + /** + * test getBlogCategoryId + */ + public function testGetBlogCategoryId() + { + BlogCategoryFactory::make([ + 'name' => 'プログラム', + 'blog_content_id' => 1, + ])->persist(); + $categoryId = $this->execPrivateMethod($this->BlogPostsTool, 'getBlogCategoryId', ['プログラム', 1]); + + $this->assertIsInt($categoryId); + $this->assertGreaterThan(0, $categoryId); + } + + /** + * test getBlogContentId + */ + public function testGetBlogContentId() + { + $this->loadFixtureScenario(BlogContentScenario::class, + 1, // id + 1, + null, + 'test-blog', + 'test-blog-url', + ); + $contentId = $this->execPrivateMethod($this->BlogPostsTool, 'getBlogContentId', ['test-blog']); + + $this->assertIsInt($contentId); + $this->assertGreaterThan(0, $contentId); + } + + /** + * test processFileUpload with base64 data + */ + public function testProcessFileUploadWithBase64() + { + // 小さなPNG画像のbase64データ(2x2ピクセルの赤いPNG) + $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVQIHWP8//8/AzYwOjr6PxQAAP//DyGg5r8AAAAASUVORK5CYII='; + + $result = $this->execPrivateMethod($this->BlogPostsTool, 'processFileUpload', [$base64Data]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('type', $result); + $this->assertArrayHasKey('tmp_name', $result); + $this->assertArrayHasKey('error', $result); + $this->assertArrayHasKey('size', $result); + $this->assertArrayHasKey('ext', $result); + + $this->assertEquals('image/png', $result['type']); + $this->assertEquals('png', $result['ext']); + $this->assertEquals(UPLOAD_ERR_OK, $result['error']); + + // 一時ファイルがちゃんと作成されているかチェック + $this->assertTrue(file_exists($result['tmp_name'])); + + // クリーンアップ + if (file_exists($result['tmp_name'])) { + unlink($result['tmp_name']); + } + } + + /** + * test processFileUpload with URL + */ + public function testProcessFileUploadWithUrl() + { + $url = 'https://example.com/image.jpg'; + + $result = $this->execPrivateMethod($this->BlogPostsTool, 'processFileUpload', [$url]); + + // URLの場合はダウンロードに失敗してfalseが返される(example.comは存在しない画像) + $this->assertFalse($result); + } + + /** + * test processFileUpload with invalid base64 data + */ + public function testProcessFileUploadWithInvalidBase64() + { + // より確実に無効になるbase64データ + $invalidBase64 = 'invalid_format_data'; + $result = $this->execPrivateMethod($this->BlogPostsTool, 'processFileUpload', [$invalidBase64]); + // 無効なフォーマットの場合はfalseが返される + $this->assertFalse($result); + } + + /** + * test processUrlFile with invalid URL + */ + public function testProcessUrlFileWithInvalidUrl() + { + $invalidUrl = 'not_a_url'; + + try { + $this->execPrivateMethod($this->BlogPostsTool, 'processUrlFile', [$invalidUrl]); + $this->fail('例外が投げられるべきです'); + } catch (\Exception $e) { + $this->assertStringContainsString('不正なURL形式です', $e->getMessage()); + } + } + + /** + * test processUrlFile with non-HTTP URL + */ + public function testProcessUrlFileWithNonHttpUrl() + { + $ftpUrl = 'ftp://example.com/file.jpg'; + + try { + $this->execPrivateMethod($this->BlogPostsTool, 'processUrlFile', [$ftpUrl]); + $this->fail('例外が投げられるべきです'); + } catch (\Exception $e) { + $this->assertStringContainsString('HTTPまたはHTTPSのURLのみサポートされています', $e->getMessage()); + } + } + + /** + * test processBase64File with invalid base64 format + */ + public function testProcessBase64FileWithInvalidFormat() + { + // 正しくないdata:URLフォーマット + $invalidBase64 = 'data:image/png;base64,not_valid_base64!!!'; + + try { + $this->execPrivateMethod($this->BlogPostsTool, 'processBase64File', [$invalidBase64]); + $this->fail('例外が投げられるべきです'); + } catch (\Exception $e) { + $this->assertStringContainsString('base64デコードに失敗しました', $e->getMessage()); + } + } + + /** + * test addBlogPost with base64 eyeCatch + */ + public function testAddBlogPostWithBase64EyeCatch() + { + $this->loadFixtureScenario(InitAppScenario::class); + ContentFactory::make([ + 'name' => 'news', + 'type' => 'BlogContent', + 'plugin' => 'BcBlog', + 'site_id' => 1, + 'entity_id' => 1000, + ])->persist(); + // BlogContentのテストデータを作成 + BlogContentFactory::make([ + 'id' => 1000, + 'description' => 'ニュースブログ', + 'template' => 'default', + 'list_count' => 10, + 'list_direction' => 'DESC', + 'feed_count' => 10, + 'tag_use' => false, + 'comment_use' => false, + 'comment_approve' => false, + 'widget_area' => null, + 'eye_catch_size_thumb_width' => 150, + 'eye_catch_size_thumb_height' => 150, + 'eye_catch_size_mobile_thumb_width' => 100, + 'eye_catch_size_mobile_thumb_height' => 100, + 'use_content' => true, + ])->persist(); + + // 2x2ピクセルの小さなPNG画像のbase64データ(テスト済み) + $base64Data = $this->eyeCatchBase64(); + + $result = $this->BlogPostsTool->addBlogPost( + 'テストブログ記事(アイキャッチ付き)', + 'これはアイキャッチ画像付きのテスト記事です。', + 'news', // blogContent + null, // name + 'これは概要です。', // content + null, // category + null, // email + 0, // status + '2025/01/01 00:00:00', // posted + null, // publishBegin + null, // publishEnd + $base64Data, // eyeCatch, + 1 + ); + + $this->assertIsArray($result); + + // エラーが発生しないことを明確にテスト + + // 成功時のレスポンス内容をテスト + $this->assertArrayHasKey('title', $result); + $this->assertEquals('テストブログ記事(アイキャッチ付き)', $result['title']); + $filePath = WWW_ROOT . 'files' . DS . 'blog' . DS . '1000' . DS . 'blog_posts' . DS . $result['eye_catch']; + $this->assertFileExists($filePath); + (new BcFolder())->delete(WWW_ROOT . 'files' . DS . 'blog' . DS . '1000'); + } + + public function testAddBlogPostWithUrlEyeCatch() + { + $this->loadFixtureScenario(InitAppScenario::class); + ContentFactory::make([ + 'name' => 'news', + 'type' => 'BlogContent', + 'plugin' => 'BcBlog', + 'site_id' => 1, + 'entity_id' => 1000, + ])->persist(); + // BlogContentのテストデータを作成 + BlogContentFactory::make([ + 'id' => 1000, + 'description' => 'ニュースブログ', + 'template' => 'default', + 'list_count' => 10, + 'list_direction' => 'DESC', + 'feed_count' => 10, + 'tag_use' => false, + 'comment_use' => false, + 'comment_approve' => false, + 'widget_area' => null, + 'eye_catch_size_thumb_width' => 150, + 'eye_catch_size_thumb_height' => 150, + 'eye_catch_size_mobile_thumb_width' => 100, + 'eye_catch_size_mobile_thumb_height' => 100, + 'use_content' => true, + ])->persist(); + + $result = $this->BlogPostsTool->addBlogPost( + 'テストブログ記事(アイキャッチ付き)', + 'これはアイキャッチ画像付きのテスト記事です。', + 'news', // blogContent + null, // name + 'これは概要です。', // content + null, // category + null, // email + 0, // status + null, // posted + null, // publishBegin + null, // publishEnd + 'https://basercms.net/img/basercms_logo.png', // eyeCatch, + 1 + ); + + $this->assertIsArray($result); + + // エラーが発生しないことを明確にテスト + + // 成功時のレスポンス内容をテスト + $this->assertArrayHasKey('title', $result); + $this->assertEquals('テストブログ記事(アイキャッチ付き)', $result['title']); + $this->assertTrue(isset($result['eye_catch'])); + $filePath = WWW_ROOT . 'files' . DS . 'blog' . DS . '1000' . DS . 'blog_posts' . DS . $result['eye_catch']; + $this->assertFileExists($filePath); + (new BcFolder())->delete(WWW_ROOT . 'files' . DS . 'blog' . DS . '1000'); + } + + /** + * テスト用のアイキャッチ画像(PNG)を data: URI で返す + * + * @return string + */ + private function eyeCatchBase64(): string + { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAbkAAABQCAYAAACEaAvWAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAEalJREFUeNrsnTF24zgShmG1gwkm0GbKhj5B0yeQfIK2T9BStpnlE9g+ge0TWJ1tZvsEpk9g9QlanSkb7XuT7xIEaFEySRTIAglS//+e+r2Z6REhAFVfFQgUjkTbGokw/nOo/2kj1mIpIAiCIIhBRy1ALYj/vIw/k/gTFvwtCbof8ec5ht4KwwRBEAT5DTkFt0cNNxst4s8tYAdBEAT5CbmRmMd/3tX4hk38mcWge8aQQRAEQf5AbpRkb1Omb5OgW2DYIAiCIIq+dAhwUufizxjM/4gIQwdBEAS1Bzl+wKWaxKALYtC9YPggCIKg5iHnDnCpQoAOgiAIMon/nZx7wGW1EGsxs2yfPJN3Hn++CXWEIdj7G6v4E8WfN7z/gyAIAuTaApwd6BTcrnX7hsTvlrs6H+LPffyMDaYLBEHQoUKuHcDRQDdKMrdHC7iJnOzuAtVYIAiCuiWed3LtAk6q+B2dOqMn2/dHje+XcPx3/Izf8TMAOgiCoIPJ5NoHXCq5nHi6UxnFTdv8Oqs3Eq+iuIpMFLf1DNMcgqBD1XEN5yqzmydhX6bLFeDOGgCcSLLCUfwnNqVAEAR5r0ENwL0yAm6jP3UAt2wwu5Sgm2L6QBAE9Q1yW8CFrFmY+mwq/b/NAg6ggyAI6iXkXAFOQkqBygZ0bQIOoIMgCOoV5FwCLhUddD4ADqCDIAjqBeT4Abf6BCk66HwCHEAHQRDUacjxA07C6bT0YHUx6HwEHEAHQRDUSci5AdwZqUTWZ9D5DDiADoIgqFOQaxNwn0G3cgw4zrqUAB0EQZAnOvYWcLugO9lrHzfgzvRvfWQEHQ6MQxAEeQc5nwCXD2B+wCmQLhMwAXQQBEG90QCA2znCIIE0Y+xfLF1CEAR5AblDBxxAB0EQ1DtllyvvDh5wWdDxL12u4u+NMOWsxz7UY7Js4dmB+HxzPI8OaS6oMcze5bjaKabu5lkux+6mQrv2L21+FupGk03DY3GXaYMM6K+ctWFrP/vj34Si1MaOdGMmOosD4HafPWUEnTTsEwcTibNQtmqn+sg+etOTZdOQAcrLbcf694Ql80t+XuJ2PTM/Xz73u35+4PS3rpkuLFZOfaL7bbg3FzaZ/vqZOFaXY6kceTqGYckY3lYCRbEjlc/8Jpq6EaXK2OXbKa+vrOZL5bNPGP21HI9LPSaBaE8fc+yI2VH2B3BuQMd/Fx0/5PL67VlPmpUDw5OOca4NY1ihbQ/x577WnFOG+SiavDaqDuS2MLmssPrynPQZZyap+u9at2lo44BqBiTXoo2rvmzHrtyHyMDjogHASRu7K/ivi7gNM4Zn3Ogx8UEfc2yQiQQBuPwJvRDqHR3H77oU3dNQj8OvZExGjMsOyvB+acMYVmzbtW7bvEYm9C78uBeR6jDftdOs8nrhPFm1GSWfsGZbhnr565fYLoG5/v3ymU9COA/uOHVZOh4jx79ju1RapGktu1Zj8uoR4HY00MszAJwZdFWuAtpXqKPeriqF3TmDo3rVkSWHYxwm36Uc99CiHaF2lkPve37bZ49My0CTBJb1g4N5g30QaqCed8xuTMGEazjMCXO8Dmi9DjgGDI3jA5wy5HevALcFne1VQC4mky+Z3ZNemqjjqFz0Q+q4Q9Jc6w7gXGabdzqotM0mX0WT71xGOgPtwnjttntCmreusjk1zykrSGHF778RfBsWnUGuTgN5Aef6Kh8/QNflTG43+rR3jk1kToEQpKW4O9EdwLkGypQ8ltv3S8OG++Cpc4CzC2pdZXNzYr+NK4xLIDxdoszqGICrALpRArqqznos+iPpHN9Im2mazZzSbPM0d34q45x637vN9tlUV+iZGWDz2HAfBMJu57cc70io3aQ+iGrvKpvj3RBEzeJsYJwVZdl4JdRmp/823O8RB+SuDg5wu6CLhP/vBtIt5DayPdMiN6MsCf1tG4mv9Oct4ywCi4wm0A75ooZxyjH+3eL4Vcleor0+sMkAi0G3tVPbQHiTGcO8NprnF60P5Hfeenj+0AYe15Z9w5XFpWNsC9lvhv++EC7P4TWQyT0lGU0dmHQRcKrdjzUAt2pwfCV8zir8vlBst6gPSXNBlJwBVJsbqMYuDeOhcAxV2y6Jmdh5srz2OdM0G+eatepNlTl2w9Jn23Nk1B2sss/yjotQYZMe61jUPnKilkYnhOddeHm43v49G182Z5fFZYEcWf794qCjbRvSGojq75cUoEaVX1i6KCN20hDgpjW+4bfwXesEjjcaXPekrKloI4p5+3J2/E4TwzBdqKuM55SYpd7l7LgMS4OQ9gEXEB2Uuc8UaDYWEf3FJzgpZ00J6iLdnhumM5XXRJuPPLWk0MFvdpPFKX1l/O0/fBmEgRC1oFANdL7XyXQHOFGzv5uG3Sb+XAlaLc/Lgu37FGNbWGfg201AS8Icneb8O5+N85qtz+yKGcwKgEFxvItk1YCrYMDIWDGj2Woh1VTl/Xv9nZbVsjhTZmarlS+DICH3UvM77EB32IATQnSwZiGtaPVnmNCMLdKZSJW7BjdE0HXnEP62mkmZnkl9Zg+4Rc53UIpFuFje/U5o78bz0SzqN1O762ZzVbI4ZcMjNr/8zZdBGAjBUv+PBjoA7rkDhlkGOtPS5b5jMpV5Uu9T6mab5u8ILA6wf225p019tiJl1hyAo8FGtufKUT8U6baVgt12PqNsA9eDIdOpns3RAssZUzZXNgZTRmDWhJxaXlg4Bx0Al07uLuvWEIXuV3QZG7+PY/zUHL41/K0x0Tjdl1mqFwHfNpLB0WDjJqMy9/99B2xlUrp6YZ6vVbM5Uxa3MCQ2NkHe0sCD96TsW7v29HGfnMl5cYDO36t8mgHcc+evV1l/7JyjGvfEkMUtGFt3b9Eu0zg8JTtCR60cPp6UZk2mM4nKofAAznzVkKvro8IerIZ8LfVfqt95szlaFpcGSSuGTI7y/nqumfA/5s/fuozf3FQqcZCJhLmWHIpAdyV4Nl10EXAbIfzYTsugBcm4Rx93ejXjrNYftyVQHOcLYQ7LoOxvJmN8Il2cq4x1WHklYFsZhCODE8J8xs7VJp2yPnjpiJ1MSgKDTSa54MzmzFncdmPQsnDMqfV1VYDTVuCeXiulCoSr4vFBWSaXvnNZMDZgF3T0TQJ9BNxZZ9/FfZ7YK0MEGhKicak3B637SYBIapyrBo3xXKhD878M0XlAmP9lgKNWR7kiXvlkiupdObi/SrNH31WeAS/3fC5PNkfN4mi2YpPNcd3QUlfSX7/nBZODPQc2cwC6CRPougy47hwboGnlyXfYOt1gzzibVqBt4q5CBlN8m7gd4GQ0z/NOy93ye9DCM5vI4vLgwpXN2WRxJlsZW8yBleApXM/FnMf9OqyDnEa7AN20JugAuP5Bru2MVBp5WxsY5gUFkcMKc9YWcDMBudaYHIhxZHP2WZww+N/Q0paoZ1aby+oyzBkUNJoTdELTtSroADjIFeiumOe5rSHOa85ZAM5PhQZ/ZgKQbTZnm8UJw+aT0HrTlapEdKpXSHwIgj/e0R2XNHqWFGvlc/yPuvjr4qOTt9X8QwDOSVYVOHpmIPoiNc83osnLP7POa5Q4oE0myr8ujdjTv9tvwL2JoiW/bB/4KAWHsNBW89oufeIoGfegNJvLW6qtlsVl/WvxM6ucoU73dmzr334V/LdoBEQfJPt0dkxwAG2BDoCj6XfpZHDjFCjLGaZobiJ8qf4iM7pRsmvvWjR7qW1a15O6s1k5nsPO4ELhd9WgiWUWlwXRo8FhRyxZ3G4wUXQOcizqFApRPtCdH9xel1VWRF6ultweExorQfdb8BUOLQLdU2aCAHA8mZzQk3jB2Gemq3je9LiudIBUJHno+cajjC5KnIgynomOFOtGoUNCQDDNQM40d8b6iifqjQBdBdyyE8FRMRyK9LM0A7LN5uplcaZ+9vq2bw3um7gP7kX5auDkmPiF8stWgu/CxM+gk4BQ0AkBODaHIPVd8L53+m7RnqgkslXVUdaebWLhqwCUzrOJKL8XbvjhvFTAtypxdFPdnxQH1OUMbtmZ4MgukzPB2TabM2Vxt6X2tU6Cuiq/wyd7TZOk9wK7GQ8svkwaPqfRPH4606CM0i3gRolT6c8mk/XHxZSiNALkXSIQREM2Hdy9E33XmlTCKSQ6QkpmqCCxJta3HHl4Q3r5Wcyw7TJRFmNpF5Da7LQ0Z3HSJ9yT5kp5gNYN0BUXJggGll/WBOjcAk6ltv0A3FamtXMumNwZIsf9Kiamdp1bFE7uskyZ4dAiMKA4rTOCLUx11vDoJejK546fwdHIWJKN4ttMAdElMYt7ID7PtDTcFRUGhwPrr2oCdG4B161byGky1ZMMC85l2fTfXJiL9b7kROQRYfxD0WfZBG7rxLlXDfRo77JVfz82boN2+mGYzzcejvSkcha3618jQ2AYMmVxUmWVT8YdsrIhH+S6CLp+Ay5dsjTBZFoZdApwpui5qHjwLWFyvnZmaaRa/wXMQUsdwE21LbQTbPLN6WsPwVxt04m9zZh21j5YBFbd3Xyyq2+8kOsS6PoOOLphpKB7t6iHFyTFhWnLQ7cF8yQiADgF3V1Llf9dy9R/+3Pp3jKbMwNOvYu+E+U7M33L6G4JPsOnpcsyu4rI32K2GdMdjfeWzyp+ThdWWVQbi+btclDry30HHT/gVsLXSib0MlWhBspr8j4sDyrq30tn+EsI0juzyFDwl1rEdS62FcXPK2RA/hmfChJMfbjcG8sNMWjJOr1pbn+NPpb2fgnagXd/lo9pc3pOKHzdxFhPrMaYJ2itm8VR2jbx3MbOC1YmPjLoI6YHTQXf8QLlFNc1t3F39ZLW9n5zpP8/20xK9sep8SgA/xyporNPkasCwHWLbUrLIeX1GQWQRWM5rDgPtkcPTH2zFkeezWk5B+U7zbdkXjZZzLl8SV8u5Z9U+M5XS8hIWzyx9lHlu83lZrILz/zcRM+H74R58a9jloeqg4xCuDpHB8BRx0GeGZkJejUMjohtRjrrpubIWAgvd/K1qQdDBhxUmMdVx9K/s3V2czrQ2epc+wFXbcqD+5gxi8tmcxOruVTNR/00rPyUwebVY9tKSuYNGAd+IXxYujxUwG3HId1C3kR7Z3o3ILVt3IW/u67yZV6eOxi7C7h25nRVTSpCpOx3R4L+Ls/uXRwdwkFHXxvI/kgqCQ2YJ2O7oDt0wDXrFKotKStHei+gDyM0ZjLuQXfvfXUUn0FnLnUX1fh26ru5h8p+yrysO+mgfc3S/hg4mIztgA6Ay3MKJ4K/zt9KqHdwixptk879wvPI3DXg6BuYtqC7d9COmR6PQ57TLrM4U6bEkc1tGOZGWRvHHbOvnRWmgaPJ2CzoALhi57hOnCPXHU+3GnBLhrY9a4d1aFndSlTZoavG8krDjmMsF7WDlX7MaQ59LR3v+n7ElM09MDyjD+fllnlzeuBwMroA3Q0AV3Es1O6uWYWocqWN7CQp1M3ZL1vHfaKfsRL91YYlSFCFnNOxjCq0YaHHcuZdcezm5nSTmRxHQCjH+bnEPjkCxfLNJ36fX13p7C3Xto6dT0TeXZeyysFfmS3OAJx94LHQL5LTCw2DAmf4U6jtw8sG2iUn6Y1QV2cEgu+qm7zflWcgkaNflvbj0mqDjt1YDjNjGZa0Iaqwpd5l37joh4nuAxcXdeZLPXdVEqC9MD0pTRjO98bngslfRYaxDnKAvWlxfqwy87rURx01NBGmgveMlJzYVwAcBEEHpVHmVuy15wGIJzpqcHC4QbdhjNYAOAiCoB7qS2NP+icGyZ/JDeNcV6v8AcBBEARBfmRy24xO1U4UXrzIBOAgCIIAuV6CDoCDIAgC5HoJOgAOgiAIkOsl6AA4CIIgQK6XoAPgIAiCALlegg6AgyAIAuR6CToADoIg6AA18KYl7q7SeAbgIAiCkMn1MaNbeH9PFgRBEHQAmdxuRncq6hf+vAXgIAiCDltfvGzVP2ITf37oMmCmW3f3JeEoK3P/B8MLQRB02DrqRCtHSb3Lbxp4YQHY5JUWz52+IwuCIAhi1f8FGACAMsToDJhC1gAAAABJRU5ErkJggg=='; + } + + /** + * eye_catch を明示的に設定したブログ記事(ID:1)を用意する + * + * BlogPostsAdminServiceScenario と同等の前提データを、eye_catch 付きで + * 直接構築する(同シナリオはeye_catchを指定できないため)。 + * + * @param string $eyeCatch 既存のアイキャッチファイル名 + */ + private function makeBlogPostWithEyeCatch(string $eyeCatch): void + { + ContentFactory::make([ + 'id' => 100, + 'url' => '/index', + 'site_id' => 1, + 'status' => true, + 'entity_id' => 1, + 'plugin' => 'BcBlog', + 'type' => 'BlogContent', + 'lft' => '1', + 'rght' => '2', + 'publish_begin' => '2020-01-27 12:00:00', + 'publish_end' => '9000-01-27 12:00:00' + ])->persist(); + BlogPostFactory::make([ + 'id' => 1, + 'blog_content_id' => 1, + 'no' => 1, + 'status' => true, + 'eye_catch' => $eyeCatch + ])->persist(); + BlogContentFactory::make(['id' => 1])->persist(); + } + + /** + * editBlogPost に不正な形式(素のファイル名)の eyeCatch を渡した場合、 + * 既存のアイキャッチ画像を黙って削除せず、エラー応答を返すことを確認するテスト + * + * 変更前は isFileUploadable() が拡張子付き文字列も許容していたが、 + * チャンクアップロード廃止に伴い false を返すようになった結果、 + * else 分岐(空文字列=削除)に落ちて既存画像が消えてしまう回帰があった。 + */ + public function testEditBlogPostWithInvalidEyeCatchDoesNotDeleteExisting() + { + $this->makeBlogPostWithEyeCatch('existing.jpg'); + + $result = $this->BlogPostsTool->editBlogPost( + 1, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 'photo.jpg' // eyeCatch(素のファイル名。アップロード可能な形式ではない) + ); + + // エラー応答であることを確認(成功レスポンスにはtitleキーが含まれる) + $this->assertArrayHasKey('content', $result); + $this->assertArrayNotHasKey('title', $result); + + // 既存のアイキャッチが削除されずに残っていることをDB側で確認 + $entity = $this->BlogPostsTool->getBlogPost(1); + $this->assertEquals('existing.jpg', $entity['eye_catch']); + } + + /** + * editBlogPost に空文字列の eyeCatch を渡した場合は、 + * 従来どおりアイキャッチが削除されることを確認するテスト + */ + public function testEditBlogPostWithEmptyEyeCatchRemovesExisting() + { + $this->makeBlogPostWithEyeCatch('existing.jpg'); + + $result = $this->BlogPostsTool->editBlogPost( + 1, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + '' // eyeCatch(空文字列) + ); + + $this->assertArrayHasKey('title', $result); + $this->assertNull($result['eye_catch']); + } + + /** + * editBlogPost に正当な data: URI を渡した場合、アイキャッチが差し替わることを + * 確認するテスト + * + * eyeCatch の分岐は「未指定=変更しない」「空文字列=削除」「不正値=エラー」 + * 「アップロード失敗=エラー」と成功の5経路がある。異常系にテストを足した際、 + * 主経路である成功だけが未検証だったため、条件を書き換えて正当な値まで + * 弾いてしまっても気づけない状態だった。 + */ + public function testEditBlogPostWithValidEyeCatchReplacesExisting() + { + $this->makeBlogPostWithEyeCatch('existing.jpg'); + + $result = $this->BlogPostsTool->editBlogPost( + 1, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + $this->eyeCatchBase64() // eyeCatch(正当な data: URI) + ); + + // 成功レスポンスであることを確認(エラー応答には title が含まれない) + $this->assertArrayHasKey('title', $result); + + // 元のファイル名から差し替わっていることを確認 + $this->assertNotEmpty($result['eye_catch']); + $this->assertNotEquals('existing.jpg', $result['eye_catch']); + + // 実ファイルが生成されていることを確認 + $filePath = WWW_ROOT . 'files' . DS . 'blog' . DS . '1' . DS . 'blog_posts' . DS . $result['eye_catch']; + $this->assertFileExists($filePath); + (new BcFolder())->delete(WWW_ROOT . 'files' . DS . 'blog' . DS . '1'); + } + + /** + * editBlogPost に、形式は正しいがダウンロードに失敗するURLを渡した場合、 + * 無言で「編集成功」にはならず、エラー応答が返ることを確認するテスト + * + * processFileUpload() が false を返すケース(到達不能なURL等)で、 + * $data['eye_catch'] が未設定のまま処理が継続していた回帰の再発防止。 + */ + public function testEditBlogPostWithUnreachableEyeCatchUrlReturnsError() + { + $this->makeBlogPostWithEyeCatch('existing.jpg'); + + $result = $this->BlogPostsTool->editBlogPost( + 1, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 'https://example.com/image.jpg' // 到達不能(ダウンロード失敗)なURL + ); + + $this->assertArrayHasKey('content', $result); + $this->assertArrayNotHasKey('title', $result); + + // 既存のアイキャッチも変更されていないことを確認 + $entity = $this->BlogPostsTool->getBlogPost(1); + $this->assertEquals('existing.jpg', $entity['eye_catch']); + } + + /** + * addBlogPost に、形式は正しいがダウンロードに失敗するURLを渡した場合、 + * 無言で「登録成功」にはならず、エラー応答が返ることを確認するテスト + */ + public function testAddBlogPostWithUnreachableEyeCatchUrlReturnsError() + { + $this->loadFixtureScenario(InitAppScenario::class); + ContentFactory::make([ + 'name' => 'news', + 'type' => 'BlogContent', + 'plugin' => 'BcBlog', + 'site_id' => 1, + 'entity_id' => 1000, + ])->persist(); + BlogContentFactory::make(['id' => 1000, 'name' => 'news'])->persist(); + + $result = $this->BlogPostsTool->addBlogPost( + 'アイキャッチURLダウンロード失敗テスト', + '詳細', + 'news', + null, + null, + null, + null, + 0, + null, + null, + null, + 'https://example.com/image.jpg', // 到達不能(ダウンロード失敗)なURL + 1 + ); + + $this->assertArrayHasKey('content', $result); + $this->assertArrayNotHasKey('title', $result); + $this->assertStringContainsString('アイキャッチ画像', $result['content']); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogTagsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogTagsToolTest.php new file mode 100644 index 0000000000..6a0b7e56a6 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogTagsToolTest.php @@ -0,0 +1,196 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcBlog; + +use BaserCore\TestSuite\BcTestCase; +use BcBlog\Test\Scenario\BlogTagsScenario; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use BcMcp\Mcp\BcBlog\BlogTagsTool; + +/** + * BlogTagsToolTest + */ +class BlogTagsToolTest extends BcTestCase +{ + + use ScenarioAwareTrait; + + /** + * @var BlogTagsTool + */ + public $BlogTagsTool; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $this->BlogTagsTool = new BlogTagsTool(); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->BlogTagsTool); + parent::tearDown(); + } + + /** + * test addBlogTag + */ + public function testAddBlogTag() + { + + $result = $this->BlogTagsTool->addBlogTag('テストタグ'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + $this->assertEquals('テストタグ', $result['name']); + } + + /** + * test getBlogTags + */ + public function testGetBlogTags() + { + $this->loadFixtureScenario(BlogTagsScenario::class); + $result = $this->BlogTagsTool->getBlogTags(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + $this->assertArrayHasKey('data', $result); + $this->assertIsArray($result['data']); + } + + /** + * test getBlogTag + */ + public function testGetBlogTag() + { + $this->loadFixtureScenario(BlogTagsScenario::class); + $result = $this->BlogTagsTool->getBlogTag(1); + + $this->assertIsArray($result); + $this->assertEquals(1, $result['id']); + } + + /** + * test editBlogTag + */ + public function testEditBlogTag() + { + $this->loadFixtureScenario(BlogTagsScenario::class); + $result = $this->BlogTagsTool->editBlogTag(1, '更新されたタグ'); + + $this->assertIsArray($result); + $this->assertEquals('更新されたタグ', $result['name']); + } + + /** + * test deleteBlogTag + */ + public function testDeleteBlogTag() + { + $this->loadFixtureScenario(BlogTagsScenario::class); + $result = $this->BlogTagsTool->deleteBlogTag(1); + + $this->assertIsArray($result); + $this->assertArrayHasKey('message', $result); + $this->assertEquals('ブログタグを削除しました', $result['message']); + } + + /** + * test getBlogTags with search parameters + */ + public function testGetBlogTagsWithSearch() + { + $this->loadFixtureScenario(BlogTagsScenario::class); + $result = $this->BlogTagsTool->getBlogTags( + name: 'tag1' + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + $this->assertEquals(1, $result['pagination']['page']); + $this->assertEquals(10, $result['pagination']['limit']); + } + + /** + * test getBlogTags with limit parameter + */ + public function testGetBlogTagsWithLimit() + { + $this->loadFixtureScenario(BlogTagsScenario::class); + $result = $this->BlogTagsTool->getBlogTags(null, 2, 1); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + $this->assertEquals(1, $result['pagination']['page']); + $this->assertEquals(2, $result['pagination']['limit']); + $this->assertArrayHasKey('data', $result); + $this->assertLessThanOrEqual(2, count($result['data'])); + } + + /** + * test getBlogTags with page parameter + */ + public function testGetBlogTagsWithPage() + { + $this->loadFixtureScenario(BlogTagsScenario::class); + $result = $this->BlogTagsTool->getBlogTags(null, 2, 2); + + $this->assertIsArray($result); + $this->assertArrayHasKey('pagination', $result); + $this->assertEquals(2, $result['pagination']['page']); + $this->assertEquals(2, $result['pagination']['limit']); + } + + /** + * test getBlogTag with invalid ID + */ + public function testGetBlogTagWithInvalidId() + { + $result = $this->BlogTagsTool->getBlogTag(999); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertEquals('Record not found in table `blog_tags`.', $result['content']); + } + + /** + * test editBlogTag with invalid ID + */ + public function testEditBlogTagWithInvalidId() + { + $result = $this->BlogTagsTool->editBlogTag(999, 'Test Tag'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertEquals('Record not found in table `blog_tags`.', $result['content']); + } + + /** + * test deleteBlogTag with invalid ID + */ + public function testDeleteBlogTagWithInvalidId() + { + $result = $this->BlogTagsTool->deleteBlogTag(999); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertEquals('Record not found in table `blog_tags`.', $result['content']); + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomContentsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomContentsToolTest.php new file mode 100644 index 0000000000..832463632e --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomContentsToolTest.php @@ -0,0 +1,195 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcCustomContent; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\BcCustomContent\CustomContentsTool; + +/** + * CustomContentsToolTest + */ +class CustomContentsToolTest extends BcTestCase +{ + /** + * @var CustomContentsTool + */ + public $CustomContentsTool; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $this->CustomContentsTool = new CustomContentsTool(); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->CustomContentsTool); + parent::tearDown(); + } + + /** + * Test instantiation + */ + public function testInstantiation() + { + $this->assertInstanceOf(CustomContentsTool::class, $this->CustomContentsTool); + $this->assertTrue(method_exists($this->CustomContentsTool, 'addCustomContent')); + $this->assertTrue(method_exists($this->CustomContentsTool, 'getCustomContents')); + } + + /** + * test addCustomContent + */ + public function testAddCustomContent() + { + $result = $this->CustomContentsTool->addCustomContent( + name: 'test-content', + title: 'テストカスタムコンテンツ', + customTableId: 1, + description: 'テスト用のカスタムコンテンツです', + authorId: 1, + status: true, + listOrder: 'id', + ); + + $this->assertIsArray($result); + // エラーの場合はcontentキーにエラーメッセージが文字列として含まれる + if (isset($result['content']) && is_string($result['content'])) { + $this->assertIsString($result['content']); + } else { + // 成功の場合は直接データがアクセス可能 + $this->assertArrayHasKey('id', $result); + } + } + + /** + * test getCustomContents + */ + public function testGetCustomContents() + { + $result = $this->CustomContentsTool->getCustomContents( + status: 'publish', + limit: 10, + ); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getCustomContent + */ + public function testGetCustomContent() + { + $result = $this->CustomContentsTool->getCustomContent(1); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test editCustomContent + */ + public function testEditCustomContent() + { + $result = $this->CustomContentsTool->editCustomContent( + id: 1, + name: 'updated-name', + title: '更新されたタイトル', + description: '更新された説明', + template: 'custom', + listCount: 20, + listDirection: 'ASC', + listOrder: 'name', + status: true + ); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test deleteCustomContent + */ + public function testDeleteCustomContent() + { + $result = $this->CustomContentsTool->deleteCustomContent(1); + + $this->assertIsArray($result); + if (isset($result['success'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getCustomContents with search parameters + */ + public function testGetCustomContentsWithSearch() + { + $result = $this->CustomContentsTool->getCustomContents( + status: 'publish', + limit: 5 + ); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getCustomContent with invalid ID + */ + public function testGetCustomContentWithInvalidId() + { + $result = $this->CustomContentsTool->getCustomContent(999); + + $this->assertIsArray($result); + if (isset($result['error'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test editCustomContent with invalid ID + */ + public function testEditCustomContentWithInvalidId() + { + $result = $this->CustomContentsTool->editCustomContent(999, 'test', 'Test Title'); + + $this->assertIsArray($result); + if (isset($result['error'])) { + $this->assertArrayHasKey('content', $result); + } + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php new file mode 100644 index 0000000000..8ccb2df823 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php @@ -0,0 +1,729 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcCustomContent; + +use BaserCore\TestSuite\BcTestCase; +use BaserCore\Utility\BcContainerTrait; +use BcCustomContent\Service\CustomEntriesService; +use BcCustomContent\Service\CustomEntriesServiceInterface; +use BcCustomContent\Service\CustomTablesService; +use BcCustomContent\Test\Factory\CustomFieldFactory; +use BcMcp\Mcp\BcCustomContent\CustomEntriesTool; +use BaserCore\Service\BcDatabaseServiceInterface; +use BcCustomContent\Test\Factory\CustomTableFactory; +use BcCustomContent\Test\Scenario\CustomFieldsScenario; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use BcCustomContent\Service\CustomTablesServiceInterface; +use BcCustomContent\Test\Scenario\CustomContentsScenario; +use Mcp\Server\McpServer; + +/** + * BcMcp\Mcp\BcCustomContent\CustomEntriesTool Test Case + * + * @uses \BcMcp\Mcp\BcCustomContent\CustomEntriesTool + */ +class CustomEntriesToolTest extends BcTestCase +{ + use ScenarioAwareTrait; + use BcContainerTrait; + + /** + * Test subject + * + * @var \BcMcp\Mcp\BcCustomContent\CustomEntriesTool + */ + protected $CustomEntriesTool; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + $this->CustomEntriesTool = new CustomEntriesTool(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->CustomEntriesTool); + parent::tearDown(); + } + + /** + * Test addCustomEntry method - 基本テスト + * CustomTablesに依存するため、適切なセットアップが必要 + * + * @return void + */ + public function testAddCustomEntryBasic() + { + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + // CustomFieldsScenarioを読み込み + $this->loadFixtureScenario(CustomFieldsScenario::class); + + $customTableId = 1; + $title = 'テストカスタムエントリー'; + + // カスタムテーブルを作成 + $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact', + 'title' => 'お問い合わせタイトル', + 'display_field' => 'お問い合わせ' + ]); + + $result = $this->CustomEntriesTool->addCustomEntry( + customTableId: $customTableId, + title: $title, + name: 'test_entry', + status: true, + creatorId: 1 + ); + + $this->assertIsArray($result); + if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('title', $result); + $this->assertEquals($title, $result['title']); + $this->assertEquals($customTableId, $result['custom_table_id']); + } + + // テーブルをクリーンアップ + $dataBaseService->dropTable('custom_entry_1_contact'); + } + + /** + * Test addCustomEntry method - ファイルアップロード付きテスト + * + * @return void + */ + public function testAddCustomEntryWithFileUpload() + { + // Base64画像データ(1x1ピクセルの透明PNG) + $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + $this->loadFixtureScenario(CustomFieldsScenario::class); + + $customTableId = 1; + $title = 'ファイルアップロード付きエントリー'; + $customFields = [ + 'image_field' => $base64Image, + 'text_field' => 'テキスト値' + ]; + + // カスタムテーブルを作成 + $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact_with_files', + 'title' => 'ファイル付きお問い合わせ', + 'display_field' => 'お問い合わせ' + ]); + + $result = $this->CustomEntriesTool->addCustomEntry( + customTableId: $customTableId, + title: $title, + customFields: $customFields + ); + + $this->assertIsArray($result); + if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('title', $result); + $this->assertEquals($title, $result['title']); + // ファイルアップロードが処理されていることを確認 + $this->assertNotEquals($base64Image, $result['image_field'] ?? ''); + $this->assertEquals('テキスト値', $result['text_field'] ?? ''); + } else { + // エラーケースでもレスポンス構造をテスト + } + + // テーブルをクリーンアップ + $dataBaseService->dropTable('custom_entry_1_contact_with_files'); + } + + /** + * Test addCustomEntry method - 外部画像URL指定テスト + * + * @return void + */ + public function testAddCustomEntryWithImageUrl() + { + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + /** @var CustomTablesService $customTablesService */ + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + CustomFieldFactory::make([ + 'id' => 1, + 'title' => 'ファイル', + 'name' => 'image_field', + 'type' => 'BcCcFile', + ])->persist(); + + $customTableId = 1; + $title = '外部画像URL付きエントリー'; + // GitHubのアバター画像(確実にアクセス可能) + $imageUrl = 'https://github.com/github.png'; + $customFields = [ + 'image_field' => $imageUrl + ]; + + // カスタムテーブルを作成 + $customTable = $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact_with_image_url', + 'title' => '画像URL付きお問い合わせ', + 'display_field' => 'お問い合わせ' + ]); + $customTablesService->update($customTable, [ + 'id' => $customTable->id, + 'custom_links' => [ + 'new-2' => [ + 'custom_field_id' => 1, + 'title' => 'ファイル', + 'name' => 'image_field', + 'type' => 'BcCcFile', + 'status' => true, + ] + ] + ]); + + /** @var CustomEntriesService $customEntriesService */ + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + $customEntriesService->setup(1); + + $result = $this->CustomEntriesTool->addCustomEntry( + customTableId: $customTableId, + title: $title, + customFields: $customFields, + status: true + ); + + $this->assertIsArray($result); + // 登録が成功したことを確認 + $this->assertArrayHasKey('title', $result); + $this->assertEquals($title, $result['title']); + // 外部画像URLが正しく保存されていることを確認(保存先は現在年月のディレクトリ) + $this->assertEquals(date('Y/m') . '/00000001_image_field.png', $result['image_field'] ?? ''); + $this->assertTrue($result['status'] ?? false); + + // テーブルをクリーンアップ + $dataBaseService->dropTable('custom_entry_1_contact_with_image_url'); + } + + /** + * Test addCustomEntry method - BcCcFile型フィールドに素のファイル名を渡した場合のエラーテスト + * + * 変更前は isFileUploadable() が拡張子付き文字列も許容していたため、 + * ファイルアップロード形式でない値は InvalidArgumentException で明示的に + * 弾かれていた。チャンクアップロード廃止後に isFileUploadable() が + * false を返すようになった結果、processCustomFields() の else 分岐 + * (通常の値)に落ちて、存在しないファイル名がそのままDBに書き込まれる + * 回帰があった。 + * + * @return void + */ + public function testAddCustomEntryWithPlainFileNameForBcCcFileFieldReturnsError() + { + CustomFieldFactory::make([ + 'id' => 1, + 'title' => 'ファイル', + 'name' => 'image_field', + 'type' => 'BcCcFile', + ])->persist(); + + /** @var CustomTablesService $customTablesService */ + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + $customTableId = 1; + $customTable = $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact_with_invalid_file', + 'title' => '不正ファイル名お問い合わせ', + 'display_field' => 'お問い合わせ' + ]); + $customTablesService->update($customTable, [ + 'id' => $customTable->id, + 'custom_links' => [ + 'new-2' => [ + 'custom_field_id' => 1, + 'title' => 'ファイル', + 'name' => 'image_field', + 'type' => 'BcCcFile', + 'status' => true, + ] + ] + ]); + + $result = $this->CustomEntriesTool->addCustomEntry( + customTableId: $customTableId, + title: '不正ファイル名エントリー', + customFields: ['image_field' => 'photo.jpg'] // アップロード可能な形式ではない素のファイル名 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + $this->assertArrayNotHasKey('title', $result); + + // テーブルをクリーンアップ + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + $dataBaseService->dropTable('custom_entry_1_contact_with_invalid_file'); + } + + /** + * Test addCustomEntry method - カスタムフィールド付きテスト + * + * @return void + */ + public function testAddCustomEntryWithCustomFields() + { + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + $this->loadFixtureScenario(CustomFieldsScenario::class); + + $customTableId = 1; + $title = 'カスタムフィールド付きエントリー'; + $customFields = [ + 'custom_field1' => 'カスタム値1', + 'custom_field2' => 'カスタム値2' + ]; + + // カスタムテーブルを作成 + $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact', + 'title' => 'お問い合わせタイトル', + 'display_field' => 'お問い合わせ' + ]); + + $result = $this->CustomEntriesTool->addCustomEntry( + customTableId: $customTableId, + title: $title, + customFields: $customFields + ); + + $this->assertIsArray($result); + if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('title', $result); + $this->assertEquals($title, $result['title']); + } + + // テーブルをクリーンアップ + $dataBaseService->dropTable('custom_entry_1_contact'); + } + + /** + * Test addCustomEntry method - エラーテスト(空のタイトル) + * + * @return void + */ + public function testAddCustomEntryWithEmptyTitle() + { + $result = $this->CustomEntriesTool->addCustomEntry( + customTableId: 1, + title: '' + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + } + + /** + * Test getCustomEntries method - 基本的な一覧取得テスト + * + * @return void + */ + public function testGetCustomEntriesBasic() + { + // テストデータを作成 + CustomTableFactory::make([ + 'id' => 1, + 'name' => 'test_table', + 'display_name' => 'テストテーブル', + 'status' => 1 + ])->persist(); + + $this->loadFixtureScenario(CustomContentsScenario::class); + + $result = $this->CustomEntriesTool->getCustomEntries( + customTableId: 1, + limit: 10, + page: 1 + ); + + $this->assertIsArray($result); + if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('content', $result); + $this->assertArrayHasKey('pagination', $result); + $this->assertEquals(10, $result['pagination']['limit']); + $this->assertEquals(1, $result['pagination']['page']); + } else { + // エラーケースでもレスポンス構造をテスト + $this->assertArrayHasKey('content', $result); + } + } + + /** + * Test getCustomEntries method - ステータスフィルタリングテスト + * + * @return void + */ + public function testGetCustomEntriesWithStatusFilter() + { + // テストデータを作成 + CustomTableFactory::make([ + 'id' => 1, + 'name' => 'test_table', + 'display_name' => 'テストテーブル', + 'status' => 1 + ])->persist(); + + $this->loadFixtureScenario(CustomContentsScenario::class); + + $result = $this->CustomEntriesTool->getCustomEntries( + customTableId: 1, + status: 'publish', + limit: 5 + ); + + $this->assertIsArray($result); + if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('content', $result); + $this->assertEquals(5, $result['pagination']['limit']); + } else { + // エラーケースでもレスポンス構造をテスト + $this->assertArrayHasKey('content', $result); + } + } + + /** + * Test getCustomEntries method - キーワード絞り込みテスト + * + * 他の一覧ツールと同じく keyword で指定できる。 + * 対象はタイトルとスラッグ(CustomEntriesService の title 条件)。 + * + * @return void + */ + public function testGetCustomEntriesWithKeyword() + { + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + $this->loadFixtureScenario(CustomFieldsScenario::class); + + $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact', + 'title' => 'お問い合わせタイトル', + 'display_field' => 'お問い合わせ' + ]); + + $this->CustomEntriesTool->addCustomEntry( + customTableId: 1, + title: '検索対象のエントリー', + name: 'keyword_target', + status: true, + creatorId: 1 + ); + $this->CustomEntriesTool->addCustomEntry( + customTableId: 1, + title: '関係のないエントリー', + name: 'keyword_other', + status: true, + creatorId: 1 + ); + + $result = $this->CustomEntriesTool->getCustomEntries( + customTableId: 1, + keyword: '検索対象' + ); + + $this->assertIsArray($result); + $this->assertCount(1, $result['results'], json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertEquals('検索対象のエントリー', $result['results'][0]['title']); + + $dataBaseService->dropTable('custom_entry_1_contact'); + } + + /** + * Test getCustomEntry method - IDによる単一取得テスト + * + * @return void + */ + public function testGetCustomEntryById() + { + $result = $this->CustomEntriesTool->getCustomEntry( + customTableId: 1, + id: 1 + ); + + $this->assertIsArray($result); + // 存在しないエントリーの場合はエラーが返される + if (isset($result['error']) && $result['error']) { + $this->assertArrayHasKey('content', $result); + } else if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('content', $result); + $this->assertEquals(1, $result['id']); + } + } + + /** + * Test getCustomEntry method - 存在しないIDのテスト + * + * @return void + */ + public function testGetCustomEntryNotFound() + { + $nonExistentId = 999999; + + $result = $this->CustomEntriesTool->getCustomEntry( + customTableId: 1, + id: $nonExistentId + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + } + + /** + * Test editCustomEntry method - 基本的な編集テスト + * + * @return void + */ + public function testEditCustomEntryBasic() + { + $newTitle = '編集されたタイトル'; + $newStatus = true; + + $result = $this->CustomEntriesTool->editCustomEntry( + customTableId: 1, + id: 1, + title: $newTitle, + status: $newStatus + ); + + $this->assertIsArray($result); + // 存在しないエントリーの場合はエラーが返される + if (isset($result['error']) && $result['error']) { + $this->assertArrayHasKey('content', $result); + } else if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('content', $result); + $this->assertEquals($newTitle, $result['title']); + } + } + + /** + * Test editCustomEntry method - カスタムフィールド編集テスト + * + * @return void + */ + public function testEditCustomEntryWithCustomFields() + { + $customFields = [ + 'custom_field1' => '更新されたカスタム値1', + 'custom_field2' => '更新されたカスタム値2' + ]; + + $result = $this->CustomEntriesTool->editCustomEntry( + customTableId: 1, + id: 1, + customFields: $customFields + ); + + $this->assertIsArray($result); + // 存在しないエントリーの場合はエラーが返される + if (isset($result['error']) && $result['error']) { + $this->assertArrayHasKey('content', $result); + } else if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * Test editCustomEntry method - 存在しないエントリーの編集テスト + * + * @return void + */ + public function testEditCustomEntryNotFound() + { + $nonExistentId = 999999; + + $result = $this->CustomEntriesTool->editCustomEntry( + customTableId: 1, + id: $nonExistentId, + title: '新しいタイトル' + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + } + + /** + * Test deleteCustomEntry method - 削除機能テスト + * + * @return void + */ + public function testDeleteCustomEntryBasic() + { + $result = $this->CustomEntriesTool->deleteCustomEntry( + customTableId: 1, + id: 1 + ); + + $this->assertIsArray($result); + // 削除処理は存在しないエントリーでもエラーハンドリングされる + if (isset($result['error']) && $result['error']) { + $this->assertArrayHasKey('content', $result); + } else if (isset($result['success']) && $result['success']) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * Test deleteCustomEntry method - 存在しないエントリーの削除テスト + * + * @return void + */ + public function testDeleteCustomEntryNotFound() + { + $nonExistentId = 999999; + + $result = $this->CustomEntriesTool->deleteCustomEntry( + customTableId: 1, + id: $nonExistentId + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + } + + /** + * Test registerTools method - サーバーへのツール登録テスト + * + * @return void + */ + public function testRegisterTools() + { + $server = new McpServer('test'); + + $result = $this->CustomEntriesTool->registerTools($server); + + // メソッドチェーンのため同じインスタンスが返る + // 登録されたツール名の検証は tools/list 経由の McpServerTest が担う + $this->assertSame($server, $result); + } + + /** + * Test processCustomFields method - ファイル処理テスト + * + * @return void + */ + public function testProcessCustomFields() + { + // Base64画像データ(1x1ピクセルの透明PNG) + $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + $customFields = [ + 'text_field' => 'テキスト値', + 'number_field' => 123, + 'image_field' => $base64Image, + 'array_field' => ['値1', '値2'] + ]; + + // フィールド型のマップ。いずれも BcCcFile ではない + $fieldTypes = [ + 'text_field' => 'BcCcText', + 'number_field' => 'BcCcText', + 'image_field' => 'BcCcText', + 'array_field' => 'BcCcText', + ]; + + // リフレクションを使ってプライベートメソッドをテスト + $reflection = new \ReflectionClass($this->CustomEntriesTool); + $method = $reflection->getMethod('processCustomFields'); + + $result = $method->invoke($this->CustomEntriesTool, $customFields, $fieldTypes); + + $this->assertIsArray($result); + $this->assertEquals('テキスト値', $result['text_field']); + $this->assertEquals(123, $result['number_field']); + // フィールドタイプがBcCcFileでない場合、ファイルアップロード処理は行われない + $this->assertEquals($base64Image, $result['image_field']); // そのまま残る + $this->assertEquals(['値1', '値2'], $result['array_field']); + } + + /** + * test buildFieldTypeMap method + * + * フィールド型は CustomEntriesService::setup() が読み込んだ links から引く。 + * フィールドごとに DB を引き直すと N+1 になるため、読み込み済みのものを + * 参照していることを確認する。 + */ + public function testBuildFieldTypeMap() + { + $dataBaseService = $this->getService(BcDatabaseServiceInterface::class); + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + + $this->loadFixtureScenario(CustomFieldsScenario::class); + + $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact', + 'title' => 'お問い合わせタイトル', + 'display_field' => 'お問い合わせ' + ]); + + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + $customEntriesService->setup(1); + + $reflection = new \ReflectionClass($this->CustomEntriesTool); + $method = $reflection->getMethod('buildFieldTypeMap'); + $result = $method->invoke($this->CustomEntriesTool, $customEntriesService); + + $this->assertIsArray($result); + + // links に載っているフィールドはすべてマップに含まれる + $this->assertNotEmpty($customEntriesService->CustomEntries->links, 'links が読み込まれていません'); + foreach($customEntriesService->CustomEntries->links as $link) { + $this->assertArrayHasKey($link->name, $result); + $this->assertSame($link->custom_field->type ?? null, $result[$link->name]); + } + + $dataBaseService->dropTable('custom_entry_1_contact'); + } + + /** + * test buildFieldTypeMap method - setup 前は空を返す + * + * links が未読み込みでも例外にならず、結果として全フィールドが + * 「BcCcFile ではない」=通常の値として扱われる。 + */ + public function testBuildFieldTypeMapWithoutSetup() + { + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + + $reflection = new \ReflectionClass($this->CustomEntriesTool); + $method = $reflection->getMethod('buildFieldTypeMap'); + + $this->assertSame([], $method->invoke($this->CustomEntriesTool, $customEntriesService)); + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomFieldsToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomFieldsToolTest.php new file mode 100644 index 0000000000..50534ceb03 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomFieldsToolTest.php @@ -0,0 +1,214 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcCustomContent; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\BcCustomContent\CustomFieldsTool; + +/** + * CustomFieldsToolTest + */ +class CustomFieldsToolTest extends BcTestCase +{ + /** + * @var CustomFieldsTool + */ + public $CustomFieldsTool; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $this->CustomFieldsTool = new CustomFieldsTool(); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->CustomFieldsTool); + parent::tearDown(); + } + + /** + * Test instantiation + */ + public function testInstantiation() + { + $this->assertInstanceOf(CustomFieldsTool::class, $this->CustomFieldsTool); + $this->assertTrue(method_exists($this->CustomFieldsTool, 'addCustomField')); + $this->assertTrue(method_exists($this->CustomFieldsTool, 'getCustomFields')); + } + + /** + * test addCustomField + */ + public function testAddCustomField() + { + $result = $this->CustomFieldsTool->addCustomField( + name: 'test_field', + title: 'テストフィールド', + type: 'text' + ); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getCustomFields + */ + public function testGetCustomFields() + { + $result = $this->CustomFieldsTool->getCustomFields(); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getCustomField + */ + public function testGetCustomField() + { + $result = $this->CustomFieldsTool->getCustomField(1); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test editCustomField + */ + public function testEditCustomField() + { + $result = $this->CustomFieldsTool->editCustomField( + 1, + 'updated_field', + '更新されたフィールド', + 'textarea' + ); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test deleteCustomField + */ + public function testDeleteCustomField() + { + $result = $this->CustomFieldsTool->deleteCustomField(1); + + $this->assertIsArray($result); + if (isset($result['success'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getCustomFields with search parameters + */ + public function testGetCustomFieldsWithSearch() + { + $result = $this->CustomFieldsTool->getCustomFields( + name: 'test', + title: 'text', + status: 1 + ); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test getCustomField with invalid ID + */ + public function testGetCustomFieldWithInvalidId() + { + $result = $this->CustomFieldsTool->getCustomField(999); + + $this->assertIsArray($result); + if (isset($result['error'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test editCustomField with invalid ID + */ + public function testEditCustomFieldWithInvalidId() + { + $result = $this->CustomFieldsTool->editCustomField(999, 'test', 'Test Field', 'text'); + + $this->assertIsArray($result); + if (isset($result['error'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test deleteCustomField with invalid ID + */ + public function testDeleteCustomFieldWithInvalidId() + { + $result = $this->CustomFieldsTool->deleteCustomField(999); + + $this->assertIsArray($result); + if (isset($result['error'])) { + $this->assertArrayHasKey('content', $result); + } + } + + /** + * test addCustomField with minimal parameters + */ + public function testAddCustomFieldWithMinimalParameters() + { + $result = $this->CustomFieldsTool->addCustomField( + 'minimal_field', + 'ミニマルフィールド', + 'text' + ); + + $this->assertIsArray($result); + if (isset($result['success'])) { + } + if (isset($result['content'])) { + $this->assertArrayHasKey('content', $result); + } + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomLinksToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomLinksToolTest.php new file mode 100644 index 0000000000..22c6dabfac --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomLinksToolTest.php @@ -0,0 +1,244 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcCustomContent; + +use BaserCore\TestSuite\BcTestCase; +use BaserCore\Utility\BcContainerTrait; +use BcMcp\Mcp\BcCustomContent\CustomLinksTool; +use BaserCore\Service\BcDatabaseServiceInterface; +use BcCustomContent\Test\Factory\CustomLinkFactory; +use BcCustomContent\Test\Factory\CustomTableFactory; +use BcCustomContent\Test\Scenario\CustomFieldsScenario; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use BcCustomContent\Service\CustomTablesServiceInterface; +use BcCustomContent\Test\Scenario\CustomContentsScenario; + +/** + * BcMcp\Mcp\BcCustomContent\CustomLinksTool Test Case + * + * @uses \BcMcp\Mcp\BcCustomContent\CustomLinksTool + */ +class CustomLinksToolTest extends BcTestCase +{ + use ScenarioAwareTrait; + use BcContainerTrait; + + /** + * Test subject + * + * @var \BcMcp\Mcp\BcCustomContent\CustomLinksTool + */ + protected $CustomLinksTool; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + $this->CustomLinksTool = new CustomLinksTool(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->CustomLinksTool); + parent::tearDown(); + } + + /** + * Test instantiation + */ + public function testInstantiation() + { + $this->assertInstanceOf(CustomLinksTool::class, $this->CustomLinksTool); + $this->assertTrue(method_exists($this->CustomLinksTool, 'addCustomLink')); + $this->assertTrue(method_exists($this->CustomLinksTool, 'getCustomLink')); + $this->assertTrue(method_exists($this->CustomLinksTool, 'getCustomLinks')); + } + + /** + * Test addCustomLink method - 基本テスト (簡略版) + * 複雑な依存関係のため、メソッドの存在のみをテスト + * + * @return void + */ + public function testAddCustomLinkBasic() + { + // メソッドが存在することを確認 + $this->assertTrue(method_exists($this->CustomLinksTool, 'addCustomLink')); + + // メソッドのパラメータ数を確認 + $reflection = new \ReflectionMethod($this->CustomLinksTool, 'addCustomLink'); + $this->assertGreaterThanOrEqual(4, $reflection->getNumberOfParameters()); + + // 必須パラメータが正しく定義されていることを確認 + $parameters = $reflection->getParameters(); + $this->assertEquals('name', $parameters[0]->getName()); + $this->assertEquals('title', $parameters[1]->getName()); + $this->assertEquals('customTableId', $parameters[2]->getName()); + $this->assertEquals('customFieldId', $parameters[3]->getName()); + } + + /** + * Test getCustomLink method - IDによる取得 + * + * @return void + */ + public function testGetCustomLink() + { + // テストデータを作成 + CustomLinkFactory::make([ + 'id' => 1, + 'custom_table_id' => 1, + 'custom_field_id' => 1, + 'name' => 'test_link', // ハイフンをアンダースコアに変更 + 'title' => 'テストリンク', + 'status' => 1 + ])->persist(); + + $result = $this->CustomLinksTool->getCustomLink(1); + + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + $this->assertEquals(1, $result['id']); + } + + /** + * Test editCustomLink method - 編集機能 + * + * @return void + */ + public function testEditCustomLink() + { + // テストデータを作成 + CustomLinkFactory::make([ + 'id' => 1, + 'custom_table_id' => 1, + 'custom_field_id' => 1, + 'name' => 'test_link', + 'title' => 'テストリンク', + 'status' => 1 + ])->persist(); + + $newTitle = '編集テストリンク'; + + $result = $this->CustomLinksTool->editCustomLink( + id: 1, + title: $newTitle + ); + + $this->assertIsArray($result); + // エラーでない場合はタイトルが更新されたことを確認 + if (!isset($result['content']) || !is_string($result['content'])) { + $this->assertEquals($newTitle, $result['title']); + } + } + + /** + * Test deleteCustomLink method - 削除機能 + * + * @return void + */ + public function testDeleteCustomLink() + { + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + $databaseService = $this->getService(BcDatabaseServiceInterface::class); + // テストデータを作成 + CustomLinkFactory::make([ + 'id' => 1, + 'custom_table_id' => 1, + 'custom_field_id' => 1, + 'name' => 'test_link', + 'title' => 'テストリンク', + 'status' => 1 + ])->persist(); + $customTablesService->create([ + 'type' => 'contact', + 'name' => 'contact', + 'title' => 'お問い合わせタイトル', + 'display_field' => 'お問い合わせ' + ]); + $databaseService->addColumn('custom_entry_1_contact', 'test_link', 'text'); + $result = $this->CustomLinksTool->deleteCustomLink(1); + $this->assertArrayHasKey('message', $result); + $databaseService->dropTable('custom_entry_1_contact'); + } + + /** + * Test addCustomLink method - エラーテスト(空の名前) + * + * @return void + */ + public function testAddCustomLinkWithEmptyName() + { + $result = $this->CustomLinksTool->addCustomLink( + name: '', + title: 'テストタイトル', + customTableId: 1, + customFieldId: 1 + ); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + } + + /** + * Test getCustomLink method - 存在しないIDのテスト + * + * @return void + */ + public function testGetCustomLinkNotFound() + { + $nonExistentId = 999999; + + $result = $this->CustomLinksTool->getCustomLink($nonExistentId); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + } + + /** + * Test getCustomLinks method - フィルタリングテスト + * + * @return void + */ + public function testGetCustomLinks() + { + CustomTableFactory::make([ + 'id' => 1, + 'name' => 'test_table', + 'display_name' => 'テストテーブル', + 'status' => 1 + ])->persist(); + $this->loadFixtureScenario(CustomContentsScenario::class); + $this->loadFixtureScenario(CustomFieldsScenario::class); + + // ステータス1でフィルタリング + $result = $this->CustomLinksTool->getCustomLinks( + customTableId: 1, + status: 'publish', + limit: 10 + ); + + $this->assertIsArray($result); + $this->assertCount(2, $result['results']); + $this->assertArrayHasKey('pagination', $result); + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomTablesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomTablesToolTest.php new file mode 100644 index 0000000000..b730919fcd --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomTablesToolTest.php @@ -0,0 +1,196 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @since 5.0.7 + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp\BcCustomContent; + +use BaserCore\TestSuite\BcTestCase; +use BcCustomContent\Service\CustomEntriesServiceInterface; +use BcCustomContent\Service\CustomTablesServiceInterface; +use BcCustomContent\Test\Factory\CustomFieldFactory; +use BcCustomContent\Test\Scenario\CustomTablesScenario; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use BcMcp\Mcp\BcCustomContent\CustomTablesTool; + +/** + * CustomTablesToolTest + */ +class CustomTablesToolTest extends BcTestCase +{ + + use ScenarioAwareTrait; + + /** + * @var CustomTablesTool + */ + public $CustomTablesTool; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $this->CustomTablesTool = new CustomTablesTool(); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->CustomTablesTool); + parent::tearDown(); + } + + /** + * test addCustomTable + */ + public function testAddCustomTable() + { + CustomFieldFactory::make([ + 'name' => 'field1' + ])->persist(); + CustomFieldFactory::make([ + 'name' => 'field2' + ])->persist(); + $result = $this->CustomTablesTool->addCustomTable( + name: 'test_table', + title: 'テストテーブル', + customFieldNames: ['field1', 'field2'] + ); + + $this->assertArrayHasKey('title', $result); + $this->assertEquals('test_table', $result['name']); + $this->assertEquals('テストテーブル', $result['title']); + } + + /** + * test getCustomTables + */ + public function testGetCustomTables() + { + $this->loadFixtureScenario(CustomTablesScenario::class); + $result = $this->CustomTablesTool->getCustomTables(); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + } + + /** + * test getCustomTable + */ + public function testGetCustomTable() + { + $this->loadFixtureScenario(CustomTablesScenario::class); + $result = $this->CustomTablesTool->getCustomTable(2); + + $this->assertArrayHasKey('title', $result); + $this->assertEquals(2, $result['id']); + } + + /** + * test editCustomTable + */ + public function testEditCustomTable() + { + $this->loadFixtureScenario(CustomTablesScenario::class); + $result = $this->CustomTablesTool->editCustomTable( + id: 2, + name: 'updated_table', + title: '更新されたテーブル', + customFieldNames: ['field3', 'field4'] + ); + + $this->assertArrayHasKey('title', $result); + $this->assertEquals('updated_table', $result['name']); + $this->assertEquals('更新されたテーブル', $result['title']); + } + + /** + * test deleteCustomTable + */ + public function testDeleteCustomTable() + { + $customEntriesService = $this->getService(CustomEntriesServiceInterface::class); + $customTablesService = $this->getService(CustomTablesServiceInterface::class); + $customTablesService->create([ + 'name' => 'test_table', + 'title' => 'テストテーブル', + 'type' => 'default' + ]); + $customEntriesService->setup(1); + $result = $this->CustomTablesTool->deleteCustomTable(1); + + $this->assertArrayHasKey('message', $result); + $this->assertEquals('カスタムテーブルを削除しました', $result['message']); + } + + /** + * test getCustomTables with search parameters + */ + public function testGetCustomTablesWithSearch() + { + $this->loadFixtureScenario(CustomTablesScenario::class); + $result = $this->CustomTablesTool->getCustomTables(2, 1, 'default', 10, 1); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + } + + /** + * test getCustomTable with invalid ID + */ + public function testGetCustomTableWithInvalidId() + { + $result = $this->CustomTablesTool->getCustomTable(999); + + $this->assertArrayHasKey('content', $result); + $this->assertEquals('Record not found in table `custom_tables`.', $result['content']); + } + + /** + * test editCustomTable with invalid ID + */ + public function testEditCustomTableWithInvalidId() + { + $result = $this->CustomTablesTool->editCustomTable(999, 'test', 'Test Table'); + + $this->assertArrayHasKey('content', $result); + $this->assertEquals('Record not found in table `custom_tables`.', $result['content']); + } + + /** + * test deleteCustomTable with invalid ID + */ + public function testDeleteCustomTableWithInvalidId() + { + $result = $this->CustomTablesTool->deleteCustomTable(999); + + $this->assertArrayHasKey('content', $result); + $this->assertEquals('Record not found in table `custom_tables`.', $result['content']); + } + + /** + * test addCustomTable without customFieldNames + */ + public function testAddCustomTableWithoutCustomFieldNames() + { + $result = $this->CustomTablesTool->addCustomTable( + name: 'simple_table', + title: 'シンプルテーブル' + ); + + $this->assertArrayHasKey('title', $result); + $this->assertEquals('simple_table', $result['name']); + $this->assertEquals('シンプルテーブル', $result['title']); + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/DualEraTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/DualEraTest.php new file mode 100644 index 0000000000..716cab8acf --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/DualEraTest.php @@ -0,0 +1,194 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Test\TestSuite\McpTestTrait; + +/** + * DualEraTest + * + * Modern(2026-07-28)と Legacy(initialize 方式)の両世代が同一サーバーで + * 動作することを検証する。本移植の受け入れテストにあたる。 + */ +class DualEraTest extends BcTestCase +{ + + use McpTestTrait; + + /** + * test server/discover が対応バージョンと capabilities を返す + * + * 2026-07-28 ではサーバーの実装が MUST とされている + */ + public function testServerDiscover() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'server/discover', + 'params' => ['_meta' => $this->modernMeta()], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'server/discover', + ]); + + $this->assertArrayNotHasKey('error', $response, json_encode($response, JSON_UNESCAPED_UNICODE)); + + // 対応するプロトコルバージョンを列挙する + $this->assertContains('2026-07-28', $response['result']['supportedVersions']); + $this->assertArrayHasKey('capabilities', $response['result']); + // tools を提供している事が申告される + $this->assertArrayHasKey('tools', $response['result']['capabilities']); + + // サーバーの識別情報は _meta の io.modelcontextprotocol/serverInfo に入る(仕様どおり) + $serverInfo = $response['result']['_meta']['io.modelcontextprotocol/serverInfo'] ?? null; + $this->assertNotNull($serverInfo, json_encode($response['result'], JSON_UNESCAPED_UNICODE)); + $this->assertEquals('baserCMS MCP Server', $serverInfo['name']); + + // 一覧結果にはキャッシュヒントが付与される + $this->assertArrayHasKey('ttlMs', $response['result']); + $this->assertArrayHasKey('cacheScope', $response['result']); + } + + /** + * test Modern で tools/call が実行できる + */ + public function testModernToolsCall() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'serverInfo', + 'arguments' => [], + '_meta' => $this->modernMeta(), + ], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => 'serverInfo', + ]); + + $this->assertArrayNotHasKey('error', $response, json_encode($response, JSON_UNESCAPED_UNICODE)); + // 2026-07-28 の必須項目 + $this->assertEquals('complete', $response['result']['resultType']); + $this->assertNotEmpty($response['result']['content']); + } + + /** + * test Legacy の initialize がセッションを払い出し、tools/call まで通る + * + * Legacy 世代はセッションを必要とするため、initialize で払い出された + * Mcp-Session-Id を以降のリクエストに付けるのが正規のフローになる + */ + public function testLegacyFlow() + { + $init = $this->callMcpRaw([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => [], + 'clientInfo' => ['name' => 'LegacyTestClient', 'version' => '1.0.0'], + ], + ], ['MCP-Protocol-Version' => '2025-06-18', 'Mcp-Method' => 'initialize']); + + $this->assertEquals(200, $init->getStatusCode()); + $initResult = json_decode((string)$init->getBody(), true); + $this->assertArrayHasKey('protocolVersion', $initResult['result']); + $this->assertArrayHasKey('capabilities', $initResult['result']); + + // セッションIDが払い出される + $sessionId = $init->getHeader('Mcp-Session-Id'); + $this->assertNotEmpty($sessionId, 'Legacy 世代では Mcp-Session-Id が払い出される'); + + // 払い出されたセッションIDで tools/list が通る + $list = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], ['Mcp-Session-Id' => $sessionId]); + $this->assertArrayNotHasKey('error', $list, json_encode($list, JSON_UNESCAPED_UNICODE)); + $this->assertContains('addBlogPost', array_column($list['result']['tools'], 'name')); + + // 同じセッションで tools/call も通る + $call = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => ['name' => 'serverInfo', 'arguments' => []], + ], ['Mcp-Session-Id' => $sessionId]); + $this->assertArrayNotHasKey('error', $call, json_encode($call, JSON_UNESCAPED_UNICODE)); + $this->assertNotEmpty($call['result']['content']); + } + + /** + * test Legacy はセッションID無しでは拒否される + */ + public function testLegacyRequiresSession() + { + $response = $this->callMcpRaw([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ]); + + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * test 未対応バージョンは UnsupportedProtocolVersionError になる + */ + public function testUnsupportedProtocolVersion() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => $this->modernMeta('1900-01-01')], + ], [ + 'MCP-Protocol-Version' => '1900-01-01', + 'Mcp-Method' => 'tools/list', + ]); + + $this->assertEquals(-32022, $response['error']['code']); + $this->assertArrayHasKey('supported', $response['error']['data']); + } + + /** + * test ヘッダとボディの不一致は HeaderMismatch になる + */ + public function testHeaderMismatch() + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'serverInfo', + 'arguments' => [], + '_meta' => $this->modernMeta(), + ], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/call', + // ボディの params.name と一致しない + 'Mcp-Name' => 'getBlogPosts', + ]); + + $this->assertEquals(-32020, $response['error']['code']); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php new file mode 100644 index 0000000000..c6ff97aabe --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/McpContextTest.php @@ -0,0 +1,46 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\McpContext; + +/** + * McpContextTest + */ +class McpContextTest extends BcTestCase +{ + + /** + * Tear down + */ + public function tearDown(): void + { + McpContext::clear(); + parent::tearDown(); + } + + /** + * test ログインユーザーIDの設定と取得 + */ + public function testSetAndGetLoginUserId() + { + $this->assertNull(McpContext::getLoginUserId()); + + McpContext::setLoginUserId(5); + $this->assertEquals(5, McpContext::getLoginUserId()); + + McpContext::clear(); + $this->assertNull(McpContext::getLoginUserId()); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/McpLoggerTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/McpLoggerTest.php new file mode 100644 index 0000000000..c4d2d32b52 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/McpLoggerTest.php @@ -0,0 +1,81 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\McpLogger; + +/** + * McpLoggerTest + */ +class McpLoggerTest extends BcTestCase +{ + + /** + * ログファイルのパス + * @var string + */ + private string $logFile; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $this->logFile = TMP . 'bc_mcp_logger_test.log'; + if (file_exists($this->logFile)) unlink($this->logFile); + } + + /** + * Tear down + */ + public function tearDown(): void + { + if (file_exists($this->logFile)) unlink($this->logFile); + parent::tearDown(); + } + + /** + * test log + * + * 例外のトレースまで記録される事を確認する + */ + public function testLog() + { + $logger = new McpLogger($this->logFile); + $logger->error('Tool execution failed.', [ + 'tool' => 'addBlogPost', + 'exception' => new \Exception('Call to a member function getParam() on null') + ]); + + $log = file_get_contents($this->logFile); + $this->assertStringContainsString('Tool execution failed.', $log); + $this->assertStringContainsString('(tool: addBlogPost)', $log); + $this->assertStringContainsString('Call to a member function getParam() on null', $log); + // トレースが記録されている事を確認 + $this->assertStringContainsString('#0 ', $log); + } + + /** + * test log with unrecorded level + * + * 記録対象外のログレベルは記録されない事を確認する + */ + public function testLogWithUnrecordedLevel() + { + $logger = new McpLogger($this->logFile); + $logger->debug('デバッグメッセージ'); + $this->assertFalse(file_exists($this->logFile)); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php new file mode 100644 index 0000000000..4c9dd130c2 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerTest.php @@ -0,0 +1,205 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\McpServer; +use BcMcp\Test\TestSuite\McpTestTrait; + +/** + * McpServerTest + */ +class McpServerTest extends BcTestCase +{ + + use McpTestTrait; + + /** + * 接頭辞ごとに期待する注釈 + * + * @var array + */ + private const EXPECTED = [ + 'get' => ['readOnlyHint' => true, 'openWorldHint' => false], + 'add' => ['readOnlyHint' => false, 'destructiveHint' => false, 'idempotentHint' => false, 'openWorldHint' => false], + 'edit' => ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => true, 'openWorldHint' => false], + 'delete' => ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => true, 'openWorldHint' => false], + ]; + + /** + * tools/list を実行する + * + * @return array + */ + private function listTools(): array + { + return $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['_meta' => $this->modernMeta()], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/list', + ]); + } + + /** + * test tools/list に全プラグインのツールが並ぶ + */ + public function testToolsListContainsAllTools() + { + $response = $this->listTools(); + + $this->assertArrayNotHasKey('error', $response, json_encode($response, JSON_UNESCAPED_UNICODE)); + $names = array_column($response['result']['tools'], 'name'); + + // BcBlog + $this->assertContains('addBlogPost', $names); + $this->assertContains('getBlogContents', $names); + $this->assertContains('addBlogCategory', $names); + $this->assertContains('addBlogTag', $names); + // BcCustomContent + $this->assertContains('addCustomTable', $names); + $this->assertContains('addCustomContent', $names); + $this->assertContains('addCustomField', $names); + $this->assertContains('addCustomEntry', $names); + $this->assertContains('addCustomLink', $names); + // BaserCore(固定ページ) + $this->assertContains('getPages', $names); + $this->assertContains('getPage', $names); + $this->assertContains('addPage', $names); + $this->assertContains('editPage', $names); + $this->assertContains('deletePage', $names); + // BaserCore + $this->assertContains('serverInfo', $names); + } + + /** + * test tools/list の結果にキャッシュヒントが付与される + * + * 2026-07-28 では ttlMs / cacheScope が必須項目であり、SDK が付与する + */ + public function testToolsListHasCacheHints() + { + $response = $this->listTools(); + + $this->assertArrayHasKey('ttlMs', $response['result']); + $this->assertArrayHasKey('cacheScope', $response['result']); + } + + /** + * test 全 result に resultType が付与される + */ + public function testResultTypeIsComplete() + { + $response = $this->listTools(); + + $this->assertEquals('complete', $response['result']['resultType']); + } + + /** + * test loginUserId が inputSchema に公開されていない + * + * 公開すると AI クライアントが他ユーザーの ID を指定できてしまう + */ + public function testLoginUserIdIsNotExposed() + { + $response = $this->listTools(); + + foreach($response['result']['tools'] as $tool) { + $properties = $tool['inputSchema']['properties'] ?? []; + $this->assertArrayNotHasKey( + 'loginUserId', + $properties, + "ツール {$tool['name']} の inputSchema に loginUserId が公開されています" + ); + } + } + + /** + * test serverInfo が提供するトランスポートは HTTP のみ + * + * 認証と権限を通らない stdio 経路は提供しない + */ + public function testServerInfoReportsHttpOnly() + { + $result = (new McpServer())->serverInfo(); + + $this->assertEquals(['http'], $result['available_transports']); + } + + /** + * test 全ツールが接頭辞に応じた注釈を宣言している + * + * 注釈は接頭辞ごとに手で指定する方針のため、ツールを追加したときの + * 付け忘れを検出する仕組みが要る。個別ツールごとではなく tools/list を + * 走査して全数を確認する。 + * + * 付け忘れ・値の取り違えに加え、余分な項目も検出する。 + */ + public function testAllToolsDeclareAnnotations() + { + $response = $this->listTools(); + $tools = $response['result']['tools'] ?? []; + $this->assertNotEmpty($tools, 'ツール一覧を取得できませんでした'); + + foreach($tools as $tool) { + $name = $tool['name']; + + // serverInfo は接頭辞を持たないが読み取り専用 + $prefix = ($name === 'serverInfo')? 'get' : null; + foreach(array_keys(self::EXPECTED) as $candidate) { + if (str_starts_with($name, $candidate)) { + $prefix = $candidate; + break; + } + } + + $this->assertNotNull($prefix, "ツール {$name} の接頭辞が想定外です。注釈の割り当てを決めてください。"); + $this->assertArrayHasKey('annotations', $tool, "ツール {$name} に注釈がありません"); + + // 期待する項目だけを個別に見ると、余分な項目(読み取り専用ツールに + // destructiveHint が付いている等)を見逃す。配列全体を比較する。 + // title は任意項目のため比較から除く。 + $actual = $tool['annotations']; + unset($actual['title']); + ksort($actual); + $expected = self::EXPECTED[$prefix]; + ksort($expected); + + $this->assertSame( + $expected, + $actual, + sprintf( + 'ツール %s の注釈が想定と異なります。期待: %s / 実際: %s', + $name, + json_encode($expected), + json_encode($actual) + ) + ); + } + } + + /** + * test 公開しているツールの数が変わっていない + * + * スコープ整理でツールを増減させていないことの確認 + */ + public function testToolCount() + { + $response = $this->listTools(); + + $this->assertCount(51, $response['result']['tools']); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php new file mode 100644 index 0000000000..6cd8a45c60 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php @@ -0,0 +1,125 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\Test\Scenario\InitAppScenario; +use BaserCore\TestSuite\BcTestCase; +use BcBlog\Test\Scenario\BlogContentScenario; +use BcMcp\Mcp\McpContext; +use BcMcp\Test\TestSuite\McpTestTrait; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; + +/** + * McpServerToolCallTest + * + * MCPサーバーを別プロセスで起動する事なく、JSON-RPC の tools/call と同じ経路 + * (スキーマ検証 → 引数マッピング → ツール実行)をプロセス内で実行するテスト + */ +class McpServerToolCallTest extends BcTestCase +{ + + use ScenarioAwareTrait; + use McpTestTrait; + + /** + * Tear down + */ + public function tearDown(): void + { + McpContext::clear(); + parent::tearDown(); + } + + /** + * test tools/call addBlogPost + * + * 本番環境にて `Call to a member function getParam() on null` が発生した + * リクエストと同じ引数で、ブログ記事が登録できる事を確認する + */ + public function testCallToolAddBlogPost() + { + $this->loadFixtureScenario(InitAppScenario::class); + $this->loadFixtureScenario(BlogContentScenario::class, + 1, // id + 1, // siteId + null, // parentId + 'news', // name + '/news/' // url + ); + + // 認証済みの操作者はコンテキストから渡す(リクエストボディは改変しない) + McpContext::setLoginUserId(1); + + [$result, $isError] = $this->callMcpTool('addBlogPost', [ + 'title' => 'BcMcpについて', + 'name' => 'about-bcmcp', + 'status' => 0, + 'content' => '

BcMcpは、baserCMSを外部のAIエージェントから直接操作できるようにするMCP(Model Context Protocol)サーバーです。ブログ記事やカテゴリ、タグの管理はもちろん、カスタムテーブル・カスタムコンテンツ・カスタムエントリー・カスタムリンクといったbaserCMSの柔軟な拡張機能まで、AIアシスタント経由で読み書きできます。

', + 'detail' => $this->getDetail(), + ]); + + // ツール実行時に例外が発生していない事を確認 + $this->assertFalse($isError, 'ツールの実行に失敗しました。' . (is_string($result)? $result : json_encode($result, JSON_UNESCAPED_UNICODE))); + // ブログ記事が登録されている事を確認 + $this->assertArrayHasKey('id', $result, 'ブログ記事の登録に失敗しました。' . json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertEquals('BcMcpについて', $result['title']); + $this->assertEquals('about-bcmcp', $result['name']); + $this->assertEquals(1, $result['blog_content_id']); + // McpContext 経由でログインユーザーが反映されている事を確認 + $this->assertEquals(1, $result['user_id']); + $this->assertFalse($result['status']); + } + + /** + * 本番環境で送信された記事詳細を取得する + * + * @return string + */ + private function getDetail(): string + { + return <<BcMcpとは +

BcMcpは、baserCMSをAIエージェントから直接操作するためのMCP(Model Context Protocol)サーバーです。MCPは、AIアシスタントと外部システムを標準化された方法でつなぐプロトコルであり、BcMcpはこの仕組みを使ってbaserCMSのAPIをAIエージェント向けに公開しています。

+

これにより、ChatやAIエージェントとの対話の中で「ブログ記事を書いて」「このカテゴリを追加して」といった指示を出すだけで、baserCMSサイトの更新が完結するようになります。

+ +

BcMcpでできること

+

BcMcpは、baserCMSが持つ主要な機能をひととおりカバーしています。

+
    +
  • ブログ管理:ブログコンテンツの作成・取得、記事の追加・編集・削除、カテゴリの管理、タグの管理
  • +
  • カスタムコンテンツ管理:カスタムテーブルと紐づくカスタムコンテンツの作成・編集・削除
  • +
  • カスタムエントリー管理:カスタムテーブルに登録されたデータ(エントリー)の一覧取得・追加・編集・削除
  • +
  • カスタムフィールド管理:カスタムエントリーの入力項目定義の作成・編集・削除
  • +
  • カスタムテーブル管理:カスタムフィールドを組み合わせたテーブル自体の作成・編集・削除
  • +
  • カスタムリンク管理:サイト内の任意のリンク項目の作成・編集・削除
  • +
+

つまり、記事の投稿だけでなく、baserCMSの汎用データベース機能(カスタムテーブル)を使った独自コンテンツの管理まで、AIエージェント経由でひととおり行えるようになっています。

+ +

なぜBcMcpを作ったのか

+

baserCMSは2010年の誕生以来、オープンソースのCMSとして進化を続けてきました。近年のAIエージェントの普及を受けて、baserCMSを「人が管理画面を操作するCMS」から一歩進めて、「AIエージェントが自律的に運用できるCMS(Agentic CMS)」として位置づけ直す取り組みの一環がBcMcpです。

+

管理画面にログインして手作業で更新する代わりに、AIエージェントに指示を出すだけでサイト更新が完結する。BcMcpはそのための土台となるインターフェースです。

+ +

活用イメージ

+

ClaudeのようなAIアシスタントにBcMcpを接続すると、たとえば次のようなことが会話ベースで行えるようになります。

+
    +
  • 新しいブログ記事の下書きを作ってもらい、そのまま下書き状態でサイトに登録する
  • +
  • 既存記事の内容を要約・修正してもらい、そのまま更新する
  • +
  • お知らせやFAQなど、カスタムテーブルで管理しているデータをまとめて追加・更新する
  • +
  • サイト内の各種リンク項目を整理・更新する
  • +
+

これにより、コンテンツ更新のたびに管理画面を開いて手作業を行う必要がなくなり、AIとの対話の延長でサイト運用が進むようになります。

+ +

まとめ

+

BcMcpは、baserCMSをAIエージェントから直接操作できるようにするMCPサーバーです。ブログ管理からカスタムテーブルを使った独自コンテンツ管理まで幅広くカバーしており、baserCMSをAIネイティブに運用していくための重要なピースとなっています。

+EOF; + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php new file mode 100644 index 0000000000..9cdd3206ef --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/NegotiationLoggerTest.php @@ -0,0 +1,187 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestCase\Mcp; + +use BaserCore\TestSuite\BcTestCase; +use BcMcp\Mcp\NegotiationLogger; + +/** + * NegotiationLoggerTest + */ +class NegotiationLoggerTest extends BcTestCase +{ + + /** + * test Modern リクエストを Modern と判定する + */ + public function testDescribeModern() + { + $result = NegotiationLogger::describe([ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'addBlogPost', + '_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientInfo' => ['name' => 'claude-ai', 'version' => '2.0.0'], + ], + ], + ], '2026-07-28'); + + $this->assertEquals('modern', $result['era']); + $this->assertEquals('2026-07-28', $result['protocolVersion']); + $this->assertEquals('claude-ai', $result['clientName']); + $this->assertEquals('2.0.0', $result['clientVersion']); + $this->assertEquals('tools/call', $result['method']); + } + + /** + * test Legacy の initialize を Legacy と判定する + */ + public function testDescribeLegacy() + { + $result = NegotiationLogger::describe([ + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'clientInfo' => ['name' => 'legacy-client', 'version' => '1.0.0'], + ], + ], ''); + + $this->assertEquals('legacy', $result['era']); + $this->assertEquals('2025-06-18', $result['protocolVersion']); + $this->assertEquals('legacy-client', $result['clientName']); + } + + /** + * test _meta を持たないリクエストはヘッダのバージョンを使う + */ + public function testDescribeLegacyWithHeaderOnly() + { + $result = NegotiationLogger::describe([ + 'method' => 'tools/list', + 'params' => [], + ], '2025-03-26'); + + $this->assertEquals('legacy', $result['era']); + $this->assertEquals('2025-03-26', $result['protocolVersion']); + $this->assertEquals('', $result['clientName']); + } + + /** + * test 引数の中身は記録対象に含まれない + * + * 機密情報がログに混入するのを防ぐ + */ + public function testDescribeOmitsArguments() + { + $result = NegotiationLogger::describe([ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'addBlogPost', + 'arguments' => ['title' => '秘密の記事'], + '_meta' => ['io.modelcontextprotocol/protocolVersion' => '2026-07-28'], + ], + ], '2026-07-28'); + + $this->assertStringNotContainsString('秘密の記事', json_encode($result, JSON_UNESCAPED_UNICODE)); + $this->assertArrayNotHasKey('arguments', $result); + } + + /** + * test readRecent が記録した接続状況を新しい順に読み出す + * + * 管理画面の「直近の接続状況」で使う + */ + public function testReadRecent() + { + $logFile = TMP . 'test_mcp_negotiation.log'; + if (file_exists($logFile)) { + unlink($logFile); + } + file_put_contents($logFile, implode("\n", [ + '2026-08-12 10:00:00 info: MCP negotiation: era=legacy protocolVersion=2025-06-18 client=old-client/1.0.0 method=initialize', + '2026-08-12 11:00:00 info: MCP negotiation: era=modern protocolVersion=2026-07-28 client=claude-ai/2.0.0 method=tools/call', + ]) . "\n"); + + $recent = NegotiationLogger::readRecent(10, $logFile); + + // 新しい順に返る + $this->assertCount(2, $recent); + $this->assertEquals('modern', $recent[0]['era']); + $this->assertEquals('2026-07-28', $recent[0]['protocolVersion']); + $this->assertEquals('claude-ai', $recent[0]['clientName']); + $this->assertEquals('2.0.0', $recent[0]['clientVersion']); + $this->assertEquals('tools/call', $recent[0]['method']); + $this->assertEquals('2026-08-12 11:00:00', $recent[0]['loggedAt']); + $this->assertEquals('legacy', $recent[1]['era']); + + unlink($logFile); + } + + /** + * test readRecent は件数を制限する + */ + public function testReadRecentLimit() + { + $logFile = TMP . 'test_mcp_negotiation_limit.log'; + $lines = []; + for($i = 0; $i < 5; $i++) { + $lines[] = sprintf( + '2026-08-12 1%d:00:00 info: MCP negotiation: era=modern protocolVersion=2026-07-28 client=claude-ai/2.0.0 method=tools/list', + $i + ); + } + file_put_contents($logFile, implode("\n", $lines) . "\n"); + + $this->assertCount(2, NegotiationLogger::readRecent(2, $logFile)); + + unlink($logFile); + } + + /** + * test readRecent はログが無い場合に空配列を返す + */ + public function testReadRecentWithoutLog() + { + $this->assertSame([], NegotiationLogger::readRecent(10, TMP . 'not_exists_mcp.log')); + } + + /** + * test log で記録した内容が readRecent で読み出せる + */ + public function testLogAndReadRecent() + { + $logFile = LOGS . 'mcp.log'; + $before = is_file($logFile) ? (string)file_get_contents($logFile) : ''; + + NegotiationLogger::log([ + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => '2026-07-28', + 'io.modelcontextprotocol/clientInfo' => ['name' => 'log-test-client', 'version' => '3.0.0'], + ], + ], + ], '2026-07-28'); + + $recent = NegotiationLogger::readRecent(1); + + $this->assertNotEmpty($recent); + $this->assertEquals('modern', $recent[0]['era']); + $this->assertEquals('log-test-client', $recent[0]['clientName']); + $this->assertNotEmpty($recent[0]['loggedAt']); + + // テストで追記した分を元に戻す + file_put_contents($logFile, $before); + } + +} diff --git a/plugins/bc-mcp/tests/TestCase/Service/OAuth2ClientRegistrationServiceTest.php b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ClientRegistrationServiceTest.php new file mode 100644 index 0000000000..9bf5381dca --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ClientRegistrationServiceTest.php @@ -0,0 +1,267 @@ +service = new OAuth2ClientRegistrationService($clientRepository); + } + + /** + * tearDown method + * + * @return void + */ + protected function tearDown(): void + { + unset($this->service); + parent::tearDown(); + } + + /** + * Test registerClient method + * + * @return void + */ + public function testRegisterClient(): void + { + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['authorization_code'], + 'scope' => 'mcp:read mcp:write', + 'token_endpoint_auth_method' => 'client_secret_basic', + 'contacts' => ['admin@example.com'] + ]; + + $baseUrl = 'https://localhost'; + $client = $this->service->registerClient($requestData, $baseUrl); + + $this->assertNotNull($client); + $this->assertEquals('Test Client', $client->getName()); + $this->assertEquals(['https://example.com/callback'], $client->getRedirectUri()); + $this->assertEquals(['authorization_code'], $client->getGrants()); + $this->assertEquals(['mcp:read', 'mcp:write'], $client->getScopes()); + $this->assertEquals('client_secret_basic', $client->getTokenEndpointAuthMethod()); + $this->assertEquals(['admin@example.com'], $client->getContacts()); + $this->assertNotNull($client->getRegistrationAccessToken()); + $this->assertNotNull($client->getRegistrationClientUri()); + $this->assertNotNull($client->getClientIdIssuedAt()); + } + + /** + * Test registerClient with invalid redirect URI + * + * @return void + */ + public function testRegisterClientWithInvalidRedirectUri(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid redirect_uri: invalid-uri'); + + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['invalid-uri'], + 'grant_types' => ['authorization_code'] + ]; + + $this->service->registerClient($requestData, 'https://localhost'); + } + + /** + * Test registerClient with unsupported grant type + * + * @return void + */ + public function testRegisterClientWithUnsupportedGrantType(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported grant_type: unsupported_grant'); + + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['unsupported_grant'] + ]; + + $this->service->registerClient($requestData, 'https://localhost'); + } + + /** + * Test getClient method + * + * @return void + */ + public function testGetClient(): void + { + // First register a client + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'] + ]; + + $client = $this->service->registerClient($requestData, 'https://localhost'); + $clientId = $client->getIdentifier(); + $registrationToken = $client->getRegistrationAccessToken(); + + // Then retrieve it + $retrievedClient = $this->service->getClient($clientId, $registrationToken); + + $this->assertNotNull($retrievedClient); + $this->assertEquals($clientId, $retrievedClient->getIdentifier()); + $this->assertEquals('Test Client', $retrievedClient->getName()); + } + + /** + * Test getClient with invalid token + * + * @return void + */ + public function testGetClientWithInvalidToken(): void + { + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'] + ]; + + $client = $this->service->registerClient($requestData, 'https://localhost'); + $clientId = $client->getIdentifier(); + + // Try to retrieve with invalid token + $retrievedClient = $this->service->getClient($clientId, 'invalid_token'); + $this->assertNull($retrievedClient); + } + + /** + * Test updateClient method + * + * @return void + */ + public function testUpdateClient(): void + { + // First register a client + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'] + ]; + + $client = $this->service->registerClient($requestData, 'https://localhost'); + $clientId = $client->getIdentifier(); + $registrationToken = $client->getRegistrationAccessToken(); + + // Update the client + $updateData = [ + 'client_name' => 'Updated Client', + 'redirect_uris' => ['https://updated.com/callback'], + 'scope' => 'mcp:read' + ]; + + $updatedClient = $this->service->updateClient($clientId, $registrationToken, $updateData); + + $this->assertNotNull($updatedClient); + $this->assertEquals('Updated Client', $updatedClient->getName()); + $this->assertEquals(['https://updated.com/callback'], $updatedClient->getRedirectUri()); + $this->assertEquals(['mcp:read'], $updatedClient->getScopes()); + } + + /** + * Test deleteClient method + * + * @return void + */ + public function testDeleteClient(): void + { + // First register a client + $requestData = [ + 'client_name' => 'Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['client_credentials'] + ]; + + $client = $this->service->registerClient($requestData, 'https://localhost'); + $clientId = $client->getIdentifier(); + $registrationToken = $client->getRegistrationAccessToken(); + + // Delete the client + $result = $this->service->deleteClient($clientId, $registrationToken); + $this->assertTrue($result); + + // Verify it's deleted + $retrievedClient = $this->service->getClient($clientId, $registrationToken); + $this->assertNull($retrievedClient); + } + + /** + * Test RFC7591 compliance response + * + * @return void + */ + public function testRfc7591ComplianceResponse(): void + { + $requestData = [ + 'client_name' => 'RFC7591 Test Client', + 'redirect_uris' => ['https://example.com/callback'], + 'grant_types' => ['authorization_code', 'client_credentials'], + 'scope' => 'mcp:read mcp:write', + 'token_endpoint_auth_method' => 'client_secret_post', + 'contacts' => ['admin@example.com', 'support@example.com'], + 'client_uri' => 'https://example.com', + 'logo_uri' => 'https://example.com/logo.png', + 'tos_uri' => 'https://example.com/tos', + 'policy_uri' => 'https://example.com/policy', + 'software_id' => 'test-software-123', + 'software_version' => '1.0.0' + ]; + + $client = $this->service->registerClient($requestData, 'https://localhost'); + $response = $client->toRegistrationResponse(); + + // Check required fields + $this->assertArrayHasKey('client_id', $response); + $this->assertArrayHasKey('client_secret', $response); + $this->assertArrayHasKey('registration_access_token', $response); + $this->assertArrayHasKey('registration_client_uri', $response); + $this->assertArrayHasKey('client_id_issued_at', $response); + + // Check optional fields + $this->assertEquals('RFC7591 Test Client', $response['client_name']); + $this->assertEquals(['https://example.com/callback'], $response['redirect_uris']); + $this->assertEquals(['authorization_code', 'client_credentials'], $response['grant_types']); + $this->assertEquals('mcp:read mcp:write', $response['scope']); + $this->assertEquals('client_secret_post', $response['token_endpoint_auth_method']); + $this->assertEquals(['admin@example.com', 'support@example.com'], $response['contacts']); + $this->assertEquals('https://example.com', $response['client_uri']); + $this->assertEquals('https://example.com/logo.png', $response['logo_uri']); + $this->assertEquals('https://example.com/tos', $response['tos_uri']); + $this->assertEquals('https://example.com/policy', $response['policy_uri']); + $this->assertEquals('test-software-123', $response['software_id']); + $this->assertEquals('1.0.0', $response['software_version']); + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Service/OAuth2ServiceTest.php b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ServiceTest.php new file mode 100644 index 0000000000..9771ea7836 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Service/OAuth2ServiceTest.php @@ -0,0 +1,80 @@ + [ + 'name' => 'Test Client', + 'secret' => null, + 'redirect_uris' => ['http://localhost'], + 'grants' => ['client_credentials'], + 'scopes' => ['read', 'write'] + ] + ]); + + Configure::write('BcMcp.OAuth2.scopes', [ + 'read' => 'データの読み取り', + 'write' => 'データの書き込み' + ]); + + $this->oauth2Service = new OAuth2Service(); + } + + /** + * Test OAuth2 authorization server creation + * + * @return void + */ + public function testAuthorizationServerCreation(): void + { + $server = $this->oauth2Service->getAuthorizationServer(); + $this->assertInstanceOf(\League\OAuth2\Server\AuthorizationServer::class, $server); + } + + /** + * Test OAuth2 resource server creation + * + * @return void + */ + public function testResourceServerCreation(): void + { + $server = $this->oauth2Service->getResourceServer(); + $this->assertInstanceOf(\League\OAuth2\Server\ResourceServer::class, $server); + } + + /** + * Test access token validation with invalid token + * + * @return void + */ + public function testValidateAccessTokenWithInvalidToken(): void + { + $result = $this->oauth2Service->validateAccessToken('invalid-token'); + $this->assertNull($result); + } +} diff --git a/plugins/bc-mcp/tests/TestSuite/McpTestTrait.php b/plugins/bc-mcp/tests/TestSuite/McpTestTrait.php new file mode 100644 index 0000000000..7831141a2a --- /dev/null +++ b/plugins/bc-mcp/tests/TestSuite/McpTestTrait.php @@ -0,0 +1,108 @@ + + * Copyright (c) NPO baser foundation + * + * @copyright Copyright (c) NPO baser foundation + * @link https://basercms.net baserCMS Project + * @license https://basercms.net/license/index.html MIT License + */ + +namespace BcMcp\Test\TestSuite; + +use BcMcp\Mcp\McpRequestHandler; +use Mcp\Server\Transport\Http\HttpMessage; + +/** + * MCP サーバーをプロセス内で実行するテスト用ヘルパ + * + * 本番と同じ McpRequestHandler を経由するため、テストが実装の実経路を検証する。 + * Modern(2026-07-28)と Legacy(initialize 方式)のどちらのリクエストも実行できる。 + */ +trait McpTestTrait +{ + + /** + * Modern リクエストの _meta を取得する + * + * @param string $protocolVersion プロトコルバージョン + * @return array + */ + protected function modernMeta(string $protocolVersion = '2026-07-28'): array + { + return [ + 'io.modelcontextprotocol/protocolVersion' => $protocolVersion, + 'io.modelcontextprotocol/clientInfo' => [ + 'name' => 'BcMcpTestClient', + 'version' => '1.0.0', + ], + 'io.modelcontextprotocol/clientCapabilities' => [], + ]; + } + + /** + * JSON-RPC リクエストをプロセス内で実行する + * + * @param array $request JSON-RPC リクエスト + * @param array $headers HTTP ヘッダ(Modern の必須ヘッダを渡す) + * @return array デコード済みのレスポンス + */ + protected function callMcp(array $request, array $headers = []): array + { + $response = $this->callMcpRaw($request, $headers); + return json_decode((string)$response->getBody(), true) ?? []; + } + + /** + * JSON-RPC リクエストを実行して HttpMessage を得る + * + * ステータスコードやヘッダを検証したい場合に使う。 + * + * @param array $request JSON-RPC リクエスト + * @param array $headers HTTP ヘッダ + * @return \Mcp\Server\Transport\Http\HttpMessage + */ + protected function callMcpRaw(array $request, array $headers = []): HttpMessage + { + $message = new HttpMessage(json_encode($request, JSON_UNESCAPED_UNICODE)); + $message->setMethod('POST'); + $message->setUri('/bc-mcp'); + $message->setHeader('Content-Type', 'application/json'); + $message->setHeader('Accept', 'application/json, text/event-stream'); + foreach($headers as $name => $value) { + $message->setHeader($name, $value); + } + return (new McpRequestHandler())->handle($message); + } + + /** + * tools/call を実行する + * + * @param string $name ツール名 + * @param array $arguments 引数 + * @return array [デコード済みの戻り値, エラーかどうか] + */ + protected function callMcpTool(string $name, array $arguments): array + { + $response = $this->callMcp([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + '_meta' => $this->modernMeta(), + ], + ], [ + 'MCP-Protocol-Version' => '2026-07-28', + 'Mcp-Method' => 'tools/call', + 'Mcp-Name' => $name, + ]); + + $text = $response['result']['content'][0]['text'] ?? ''; + $isError = $response['result']['isError'] ?? isset($response['error']); + return [json_decode($text, true) ?? $text, (bool)$isError]; + } + +} diff --git a/plugins/bc-mcp/webroot/.gitkeep b/plugins/bc-mcp/webroot/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 874a27b87f..ae0121c3ce 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -108,6 +108,7 @@ ['plugin' => 'BcCustomContent'], ['plugin' => 'BcFavorite'], ['plugin' => 'BcMail'], + ['plugin' => 'BcMcp'], ['plugin' => 'BcSearchIndex'], ['plugin' => 'BcSeo'], ['plugin' => 'BcThemeConfig'],