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..ef6ca23dd1 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", "mobiledetect/mobiledetectlib": "~3.74.4", - "psr/http-message": "^1.0", - "robmorgan/phinx": "0.16.10" + "nyholm/psr7": "~1.8.2", + "php-mcp/server": "~3.3.0", + "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..4b61779e8a 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": "af852178e38b4fc1395469df963be781", "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", @@ -640,6 +707,53 @@ ], "time": "2024-02-05T11:35:39+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "ezyang/htmlpurifier", "version": "v4.19.0", @@ -701,6 +815,62 @@ }, "time": "2025-10-17T16:34:55+00:00" }, + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, { "name": "firebase/php-jwt", "version": "v7.0.5", @@ -977,6 +1147,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", @@ -1060,35 +1367,35 @@ "time": "2025-05-20T12:55:37+00:00" }, { - "name": "m1/env", - "version": "2.2.0", + "name": "league/event", + "version": "2.3.0", "source": { "type": "git", - "url": "https://github.com/m1/Env.git", - "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3" + "url": "https://github.com/thephpleague/event.git", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/m1/Env/zipball/5c296e3e13450a207e12b343f3af1d7ab569f6f3", - "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3", + "url": "https://api.github.com/repos/thephpleague/event/zipball/062ebb450efbe9a09bc2478e89b7c933875b0935", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "4.*", - "scrutinizer/ocular": "~1.1", - "squizlabs/php_codesniffer": "^2.3" - }, - "suggest": { - "josegonzalez/dotenv": "For loading of .env", - "m1/vars": "For loading of configs" + "henrikbjorn/phpspec-code-coverage": "~1.0.1", + "phpspec/phpspec": "^2.2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + } + }, "autoload": { "psr-4": { - "M1\\Env\\": "src" + "League\\Event\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1097,60 +1404,62 @@ ], "authors": [ { - "name": "Miles Croxford", - "email": "hello@milescroxford.com", - "homepage": "http://milescroxford.com", - "role": "Developer" + "name": "Frank de Jonge", + "email": "info@frenky.net" } ], - "description": "Env is a lightweight library bringing .env file parser compatibility to PHP. In short - it enables you to read .env files with PHP.", - "homepage": "https://github.com/m1/Env", + "description": "Event package", "keywords": [ - ".env", - "config", - "dotenv", - "env", - "loader", - "m1", - "parser", - "support" + "emitter", + "event", + "listener" ], "support": { - "issues": "https://github.com/m1/Env/issues", - "source": "https://github.com/m1/Env/tree/2.2.0" + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/2.3.0" }, - "time": "2020-02-19T09:02:13+00:00" + "time": "2025-03-14T19:51:10+00:00" }, { - "name": "mobiledetect/mobiledetectlib", - "version": "3.74.4", + "name": "league/oauth2-server", + "version": "8.5.5", "source": { "type": "git", - "url": "https://github.com/serbanghita/Mobile-Detect.git", - "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a" + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/e72098eba91e5f16278b17d42ca193ae71e4ae0a", - "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/cc8778350f905667e796b3c2364a9d3bd7a73518", + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518", "shasum": "" }, "require": { - "php": ">=7.4" + "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": { - "friendsofphp/php-cs-fixer": "^3.14", - "phpunit/phpunit": "^9.6", - "squizlabs/php_codesniffer": "^3.7" + "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": { - "Detection\\": "src/" - }, - "classmap": [ - "src/MobileDetect.php" - ] + "League\\OAuth2\\Server\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1158,59 +1467,92 @@ ], "authors": [ { - "name": "Serban Ghita", - "email": "serbanghita@gmail.com", - "homepage": "https://mobiledetect.net", + "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": "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": "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": [ - "detect mobile devices", - "mobile", - "mobile detect", - "mobile detector", - "php mobile detect" + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" ], "support": { - "issues": "https://github.com/serbanghita/Mobile-Detect/issues", - "source": "https://github.com/serbanghita/Mobile-Detect/tree/3.74.4" + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/8.5.5" }, "funding": [ { - "url": "https://github.com/serbanghita", + "url": "https://github.com/sephster", "type": "github" } ], - "time": "2026-04-15T08:43:14+00:00" + "time": "2024-12-20T23:06:10+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "league/uri", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "php": ">=8.0.0" + "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": "1.0.x-dev" + "dev-master": "7.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Cache\\": "src/" + "League\\Uri\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1219,42 +1561,87 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "Common interface for caching libraries", + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", "keywords": [ - "cache", - "psr", - "psr-6" + "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": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "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" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" }, { - "name": "psr/clock", - "version": "1.0.0", + "name": "league/uri-interfaces", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0" + "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": { - "Psr\\Clock\\": "src/" + "League\\Uri\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1263,51 +1650,76 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" ], "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" + "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" }, - "time": "2022-11-25T14:36:26+00:00" + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "m1/env", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/m1/Env.git", + "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/m1/Env/zipball/5c296e3e13450a207e12b343f3af1d7ab569f6f3", + "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=5.3.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "phpunit/phpunit": "4.*", + "scrutinizer/ocular": "~1.1", + "squizlabs/php_codesniffer": "^2.3" + }, + "suggest": { + "josegonzalez/dotenv": "For loading of .env", + "m1/vars": "For loading of configs" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "M1\\Env\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1316,53 +1728,60 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Miles Croxford", + "email": "hello@milescroxford.com", + "homepage": "http://milescroxford.com", + "role": "Developer" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "Env is a lightweight library bringing .env file parser compatibility to PHP. In short - it enables you to read .env files with PHP.", + "homepage": "https://github.com/m1/Env", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + ".env", + "config", + "dotenv", + "env", + "loader", + "m1", + "parser", + "support" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/m1/Env/issues", + "source": "https://github.com/m1/Env/tree/2.2.0" }, - "time": "2021-11-05T16:47:00+00:00" + "time": "2020-02-19T09:02:13+00:00" }, { - "name": "psr/http-client", - "version": "1.0.3", + "name": "mobiledetect/mobiledetectlib", + "version": "3.74.4", "source": { "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + "url": "https://github.com/serbanghita/Mobile-Detect.git", + "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/e72098eba91e5f16278b17d42ca193ae71e4ae0a", + "reference": "e72098eba91e5f16278b17d42ca193ae71e4ae0a", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=7.4" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.7" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Http\\Client\\": "src/" - } + "Detection\\": "src/" + }, + "classmap": [ + "src/MobileDetect.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1370,50 +1789,73 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Serban Ghita", + "email": "serbanghita@gmail.com", + "homepage": "https://mobiledetect.net", + "role": "Developer" } ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", + "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": [ - "http", - "http-client", - "psr", - "psr-18" + "detect mobile devices", + "mobile", + "mobile detect", + "mobile detector", + "php mobile detect" ], "support": { - "source": "https://github.com/php-fig/http-client" + "issues": "https://github.com/serbanghita/Mobile-Detect/issues", + "source": "https://github.com/serbanghita/Mobile-Detect/tree/3.74.4" }, - "time": "2023-09-23T14:17:50+00:00" + "funding": [ + { + "url": "https://github.com/serbanghita", + "type": "github" + } + ], + "time": "2026-04-15T08:43:14+00:00" }, { - "name": "psr/http-factory", - "version": "1.1.0", + "name": "nyholm/psr7", + "version": "1.8.2", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", "shasum": "" }, "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" + "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.0.x-dev" + "dev-master": "1.8-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "Nyholm\\Psr7\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1422,163 +1864,418 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" } ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", "keywords": [ - "factory", - "http", - "message", - "psr", "psr-17", - "psr-7", - "request", - "response" + "psr-7" ], "support": { - "source": "https://github.com/php-fig/http-factory" + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" }, - "time": "2024-04-15T12:06:14+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": "psr/http-message", - "version": "1.1", + "name": "opis/json-schema", + "version": "2.6.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "Opis\\JsonSchema\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "Apache-2.0" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" + "json", + "json-schema", + "schema", + "validation", + "validator" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/1.1" + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" }, - "time": "2023-04-04T09:50:52+00:00" + "time": "2025-10-17T12:46:48+00:00" }, { - "name": "psr/http-server-handler", - "version": "1.0.2", + "name": "opis/string", + "version": "2.1.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-server-handler.git", - "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", - "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", "shasum": "" }, "require": { - "php": ">=7.0", - "psr/http-message": "^1.0 || ^2.0" + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Server\\": "src/" + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "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": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" } ], - "description": "Common interface for HTTP server-side request handler", + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", "keywords": [ - "handler", - "http", - "http-interop", - "psr", - "psr-15", - "psr-7", - "request", - "response", - "server" + "csprng", + "polyfill", + "pseudorandom", + "random" ], "support": { - "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" }, - "time": "2023-04-10T20:06:20+00:00" + "time": "2020-10-15T08:29:30+00:00" }, { - "name": "psr/http-server-middleware", + "name": "php-mcp/schema", "version": "1.0.2", "source": { "type": "git", - "url": "https://github.com/php-fig/http-server-middleware.git", - "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + "url": "https://github.com/php-mcp/schema.git", + "reference": "18f9f09b1564dd222f59674249eb4aa43615afe7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", - "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "url": "https://api.github.com/repos/php-mcp/schema/zipball/18f9f09b1564dd222f59674249eb4aa43615afe7", + "reference": "18f9f09b1564dd222f59674249eb4aa43615afe7", "shasum": "" }, "require": { - "php": ">=7.0", - "psr/http-message": "^1.0 || ^2.0", - "psr/http-server-handler": "^1.0" + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpMcp\\Schema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "description": "PHP Data Transfer Objects (DTOs) and Enums for the Model Context Protocol (MCP) schema.", + "support": { + "issues": "https://github.com/php-mcp/schema/issues", + "source": "https://github.com/php-mcp/schema/tree/1.0.2" + }, + "time": "2025-07-25T12:45:32+00:00" + }, + { + "name": "php-mcp/server", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-mcp/server.git", + "reference": "37b40d5e91f0600442677ddd226e5a22d5661ee1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-mcp/server/zipball/37b40d5e91f0600442677ddd226e5a22d5661ee1", + "reference": "37b40d5e91f0600442677ddd226e5a22d5661ee1", + "shasum": "" + }, + "require": { + "opis/json-schema": "^2.4", + "php": ">=8.1", + "php-mcp/schema": "^1.0", + "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", + "psr/container": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "react/event-loop": "^1.5", + "react/http": "^1.11", + "react/promise": "^3.0", + "react/stream": "^1.4", + "symfony/finder": "^6.4 || ^7.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.75", + "mockery/mockery": "^1.6", + "pestphp/pest": "^2.36.0|^3.5.0", + "react/async": "^4.0", + "react/child-process": "^0.6.6", + "symfony/var-dumper": "^6.4.11|^7.1.5" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using StdioServerTransport with StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpMcp\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "description": "PHP SDK for building Model Context Protocol (MCP) servers - Create MCP tools, resources, and prompts", + "keywords": [ + "Model Context Protocol", + "mcp", + "php", + "php mcp", + "php mcp prompts", + "php mcp resources", + "php mcp sdk", + "php mcp server", + "php mcp tools", + "php model context protocol", + "server" + ], + "support": { + "issues": "https://github.com/php-mcp/server/issues", + "source": "https://github.com/php-mcp/server/tree/3.3.0" + }, + "time": "2025-07-12T22:19:39+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-2.x": "2.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Server\\": "src/" + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1587,53 +2284,1134 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" } ], - "description": "Common interface for HTTP server-side middleware", + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", "keywords": [ - "http", - "http-interop", - "middleware", - "psr", - "psr-15", - "psr-7", - "request", - "response" + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" ], "support": { - "issues": "https://github.com/php-fig/http-server-middleware/issues", - "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" }, - "time": "2023-04-11T06:14:47+00:00" + "time": "2020-06-27T09:03:43+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.7", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", "shasum": "" }, "require": { - "php": ">=8.0.0" + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" + }, + "time": "2026-03-18T20:47:46+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/http", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/http.git", + "reference": "8db02de41dcca82037367f67a2d4be365b1c4db9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/http/zipball/8db02de41dcca82037367f67a2d4be365b1c4db9", + "reference": "8db02de41dcca82037367f67a2d4be365b1c4db9", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "fig/http-message-util": "^1.1", + "php": ">=5.3.0", + "psr/http-message": "^1.0", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.3 || ^1.2.1", + "react/socket": "^1.16", + "react/stream": "^1.4" + }, + "require-dev": { + "clue/http-proxy-react": "^1.8", + "clue/reactphp-ssh-proxy": "^1.4", + "clue/socks-react": "^1.4", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.2 || ^3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Http\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven, streaming HTTP client and server implementation for ReactPHP", + "keywords": [ + "async", + "client", + "event-driven", + "http", + "http client", + "http server", + "https", + "psr-7", + "reactphp", + "server", + "streaming" + ], + "support": { + "issues": "https://github.com/reactphp/http/issues", + "source": "https://github.com/reactphp/http/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-11-20T15:24:08+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "autoload": { "psr-4": { - "Psr\\Log\\": "src" + "React\\Socket\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1642,48 +3420,73 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", "keywords": [ - "log", - "psr", - "psr-3" + "Connection", + "Socket", + "async", + "reactphp", + "stream" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" }, { - "name": "psr/simple-cache", - "version": "3.0.0", + "name": "react/stream", + "version": "v1.4.0", "source": { "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", "shasum": "" }, "require": { - "php": ">=8.0.0" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\SimpleCache\\": "src/" + "React\\Stream\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1692,22 +3495,48 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Common interfaces for simple caching", + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" }, - "time": "2021-10-29T13:26:27+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" }, { "name": "robmorgan/phinx", @@ -1850,10 +3679,179 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/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-21T09:46:53+00:00" + }, + { + "name": "symfony/console", + "version": "v6.4.43", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d", + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "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": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/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-26T14:44:19+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "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": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.43" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1873,56 +3871,34 @@ "type": "tidelift" } ], - "time": "2026-07-21T09:46:53+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/console", + "name": "symfony/filesystem", "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "3b643aa587acbc42f967a429af088a56ed8f046d" + "url": "https://github.com/symfony/filesystem.git", + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d", - "reference": "3b643aa587acbc42f967a429af088a56ed8f046d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" - }, - "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/process": "^5.4|^6.4|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" + "Symfony\\Component\\Filesystem\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1942,16 +3918,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Eases the creation of beautiful and testable command line interfaces", + "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", - "keywords": [ - "cli", - "command-line", - "console", - "terminal" - ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.43" + "source": "https://github.com/symfony/filesystem/tree/v6.4.43" }, "funding": [ { @@ -1971,38 +3941,35 @@ "type": "tidelift" } ], - "time": "2026-07-26T14:44:19+00:00" + "time": "2026-06-27T10:13:35+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "name": "symfony/finder", + "version": "v6.4.42", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "url": "https://github.com/symfony/finder.git", + "reference": "0b73dac42493acbadbba644207a715b254e9b029" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", + "reference": "0b73dac42493acbadbba644207a715b254e9b029", "shasum": "" }, "require": { "php": ">=8.1" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" }, + "type": "library", "autoload": { - "files": [ - "function.php" + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2011,18 +3978,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/finder/tree/v6.4.42" }, "funding": [ { @@ -2042,34 +4009,45 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-06-26T15:18:24+00:00" }, { - "name": "symfony/filesystem", + "name": "symfony/http-foundation", "version": "v6.4.43", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" + "url": "https://github.com/symfony/http-foundation.git", + "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ea0c801ec34e9017a8c9363e55c8ef5f1717216e", + "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" + "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": { - "symfony/process": "^5.4|^6.4|^7.0" + "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\\Filesystem\\": "" + "Symfony\\Component\\HttpFoundation\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2089,10 +4067,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.43" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.43" }, "funding": [ { @@ -2112,7 +4090,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T10:13:35+00:00" + "time": "2026-07-29T06:55:26+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2414,20 +4392,193 @@ }, { "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "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-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": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", + "description": "PSR HTTP message bridge", + "homepage": "http://symfony.com", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "http", + "http-message", + "psr-17", + "psr-7" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "issues": "https://github.com/symfony/psr-http-message-bridge/issues", + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v2.3.1" }, "funding": [ { @@ -2438,16 +4589,12 @@ "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-05-27T06:59:30+00:00" + "time": "2023-07-26T11:53:26+00:00" }, { "name": "symfony/service-contracts", @@ -2624,6 +4771,64 @@ } ], "time": "2026-07-28T07:28:15+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^7.2 || ^8.0" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.12.1" + }, + "time": "2025-10-29T15:56:20+00:00" } ], "packages-dev": [ @@ -3886,20 +6091,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 +6139,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", @@ -4119,53 +6324,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phpstan/phpdoc-parser", - "version": "2.3.3", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" - }, - "time": "2026-07-08T07:01:06+00:00" - }, { "name": "phpunit/php-code-coverage", "version": "10.1.16", @@ -4580,79 +6738,6 @@ ], "time": "2026-07-06T14:50:35+00:00" }, - { - "name": "react/promise", - "version": "v3.3.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", - "shasum": "" - }, - "require": { - "php": ">=7.1.0" - }, - "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], - "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2025-08-19T18:57:03+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -5423,16 +7508,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 +7560,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 +7580,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T07:50:56+00:00" + "time": "2026-08-11T05:27:39+00:00" }, { "name": "sebastian/type", @@ -5672,16 +7757,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 +7799,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 +7866,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 +7887,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 +7915,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 +7927,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 +7953,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,75 +8011,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-10T16:43:36+00:00" - }, - { - "name": "symfony/finder", - "version": "v6.4.42", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "0b73dac42493acbadbba644207a715b254e9b029" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", - "reference": "0b73dac42493acbadbba644207a715b254e9b029", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "symfony/filesystem": "^6.0|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.42" - }, - "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-06-26T15:18:24+00:00" + "time": "2026-08-06T02:45:27+00:00" }, { "name": "symfony/polyfill-php73", @@ -6754,7 +8776,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -6762,6 +8784,7 @@ "ext-gd": "*", "ext-json": "*", "ext-mbstring": "*", + "ext-openssl": "*", "ext-pdo": "*", "ext-sqlite3": "*", "ext-zip": "*" @@ -6772,5 +8795,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.9.0" } 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/bootstrap.php b/plugins/baser-core/config/bootstrap.php index 659992c35a..ccb68a218a 100644 --- a/plugins/baser-core/config/bootstrap.php +++ b/plugins/baser-core/config/bootstrap.php @@ -39,6 +39,21 @@ } } +/** + * CLI で別プロセスとして起動するコマンドに、任意の DB 接続を default として使わせる切替。 + * 環境変数 BC_CONNECTION に接続名(例: test)を指定すると、その接続を default にエイリアスする。 + * プラグインのロード(BcUtil::getEnablePlugins)はこの後段で行われるため、ここで切り替えておくと + * 子プロセスでも親プロセスと同じ接続の plugins テーブルを参照してプラグインを読み込める。 + * 主にユニットテストが起動する常駐プロセス(MCP サーバー等)のための仕組み。 + */ +if (BcUtil::isConsole()) { + $bcConnection = (string)env('BC_CONNECTION', ''); + if ($bcConnection !== '' && $bcConnection !== 'default' && ConnectionManager::getConfig($bcConnection)) { + ConnectionManager::alias($bcConnection, 'default'); + } + unset($bcConnection); +} + /** * キャッシュ設定 * ユニットテスト時に重複して設定するとエラーとなるため判定を入れている 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..94a15940ca --- /dev/null +++ b/plugins/bc-mcp/README.md @@ -0,0 +1,211 @@ +# 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サーバー管理」よりMCPサーバーを起動します。 + +## クライアント連携 + +### 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クライアントで使用できます。 + +## 利用可能なツール + +### ブログ関連 + +- `getBlogPosts`: ブログ記事一覧を取得 +- `getBlogPost`: 単一のブログ記事を取得 +- `addBlogPost`: ブログ記事を追加 +- `editBlogPost`: ブログ記事を編集 +- `deleteBlogPost`: ブログ記事を削除 + +### カスタムコンテンツ関連 + +- `getCustomContents`: カスタムコンテンツ一覧を取得 +- `getCustomContent`: 単一のカスタムコンテンツを取得 +- `addCustomContent`: カスタムコンテンツを追加 +- `editCustomContent`: カスタムコンテンツを編集 +- `deleteCustomContent`: カスタムコンテンツを削除 +- `getCustomContentEntries`: カスタムコンテンツのエントリー一覧を取得 +- `getCustomContentEntry`: 単一のカスタムエントリーを取得 +- `addCustomEntry`: カスタムエントリーを追加 +- `editCustomEntry`: カスタムエントリーを編集 +- `deleteCustomEntry`: カスタムエントリーを削除 +- `getCustomFields`: カスタムフィールド情報を取得 +- `getCustomField`: 単一のカスタムフィールド情報を取得 +- `addCustomField`: カスタムフィールドを追加 +- `editCustomField`: カスタムフィールドを編集 +- `deleteCustomField`: カスタムフィールドを削除 +- `getCustomTables`: カスタムテーブル情報を取得 +- `getCustomTable`: 単一のカスタムテーブル情報を取得 +- `addCustomTable`: カスタムテーブルを追加 +- `editCustomTable`: カスタムテーブルを編集 +- `deleteCustomTable`: カスタムテーブルを削除 + +### システム情報 + +- `serverInfo`: サーバー情報を取得 + +## 使用例 + +### ブログ記事の追加 + +``` +「News」というブログにタイトル「AIの未来について」というタイトルで記事を作成して +``` + +### カスタムコンテンツ・カスタムエントリーの追加 + +``` +カスタムコンテンツを使って、「家具紹介」のコンテンツを作って +「家具紹介」に「カジュアルデスク」というタイトルでエントリーを追加して +``` + +## 権限について +設定時、連携を許可する際にログインしたユーザーの権限として動作します。 +また、権限については、Admin Web APIの権限に準じます。 +システム管理グループのユーザーは特に気にする必要はありませんが、それ以外のグループのユーザーで利用する場合は、`管理画面 > ユーザー管理 > ユーザーグループ > 対象グループ > 編集` より、Admin Web API を有効化します。 +その上で、アクセスルールグループより、権限設定を調整してください。 + + +## ファイルアップロードについて +ブログのアイキャッチなどのファイルについて、現在は、ローカルよりアップロードする事はできず、ネット上に公開されたURLからのみ送信可能です。 +これは、現在の、HTTP方式のMCPサーバーの制約によるものです。 + +### 制約事項 +- multipart/form-dataに対応しておらず、JSONで送信するため base64エンコード行う必要があり、生成AI側のメッセージ送信のトークン制限に引っかかってしまい処理が中断される +- 約30KB以下でチャンク分割送信を行うにしても送信回数が多くなりすぎ現実的ではない + +### 現状の対応方法 +現状としてはSTDIO方式のアップロードツールで、BcMcpが参照可能な領域にアップロードして、そのURLを送信するしかありません。 + +### 将来的な対応予定 +将来的には、MPCの仕様として multipart/form-data に対応する予定との事ですので、その際にBcMcpも対応する予定です。 + +## 技術的な仕組み + +### HTTPプロキシベースの接続 + +BcMcpプラグインは以下の仕組みでクラアントと連携します: + +1. **クライアント** → HTTPリクエスト → **baserCMS(/bc-mcp)** +2. **MCPProxyController** → JSON-RPC変換 → **内部MCPサーバー** +3. **内部MCPサーバー** → baserCMS操作 → **レスポンス** +4. **MCPProxyController** → HTTPレスポンス → **クライアント** + +## トラブルシューティング + +### よくある問題 + +1. **MCPサーバーが起動しない** + - PHP 8.1以上がインストールされているか確認 + - Composerの依存関係がインストールされているか確認 + - ログファイルにエラーメッセージがないか確認 + +2. **ツールが正常に動作しない** + - baserCMSのデータベースに接続できているか確認 + - 必要なプラグイン(BcBlog、BcCustomContent)が有効になっているか確認 + +3. **認可画面が表示されない** + - baserCMSを古いバージョンからアップデートした場合、`/.htaccess` が正しく設定されていない可能性があります。次のように変更をお願いします。 +```bash +# 変更前 +RewriteRule ^(\.well-known/.*)$ $1 [L] +# 変更後 +RewriteRule ^(\.well-known/.*)$ webroot/$1 [L] +``` + +### MCPサーバーのログの確認 + +```bash +# MCPサーバーのログを確認 +tail -f tmp/logs/mcp_server.log +``` + +## 開発への貢献 +[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..3bccabe074 --- /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", + "php-mcp/server": "^3.3", + "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' => [ + /** + * 利用可能な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..e04f0ab377 --- /dev/null +++ b/plugins/bc-mcp/src/BcMcpPlugin.php @@ -0,0 +1,140 @@ +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 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; + } + + /** + * 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']); + $routes->get('/mcp-server-manager/configure', ['controller' => 'McpServerManager', 'action' => 'configure']); + $routes->post('/mcp-server-manager/configure', ['controller' => 'McpServerManager', 'action' => 'configure']); + $routes->post('/mcp-server-manager/start', ['controller' => 'McpServerManager', 'action' => 'start']); + $routes->post('/mcp-server-manager/stop', ['controller' => 'McpServerManager', 'action' => 'stop']); + $routes->post('/mcp-server-manager/restart', ['controller' => 'McpServerManager', 'action' => 'restart']); + }); + }); + + parent::routes($routes); + } + +} diff --git a/plugins/bc-mcp/src/Command/McpServerCommand.php b/plugins/bc-mcp/src/Command/McpServerCommand.php new file mode 100644 index 0000000000..9c3d4bbded --- /dev/null +++ b/plugins/bc-mcp/src/Command/McpServerCommand.php @@ -0,0 +1,117 @@ +setDescription('baserCMS MCP サーバーを起動します') + ->addOption('transport', [ + 'short' => 't', + 'help' => 'トランスポートタイプ (stdio, sse)', + 'default' => 'stdio' + ]) + ->addOption('host', [ + 'help' => 'SSEモード時のホスト名', + 'default' => '127.0.0.1' + ]) + ->addOption('port', [ + 'short' => 'p', + 'help' => 'SSEモード時のポート番号', + 'default' => '3000' + ]) + ->addOption('config', [ + 'short' => 'c', + 'help' => '設定ファイルのパス', + 'default' => null + ]) + ->addOption('connection', [ + 'help' => 'サーバーが使用する DB 接続名。default 以外を指定すると default にエイリアスする(主にテストで test 接続を使う用途)。' + . 'プラグインのロード自体は bootstrap で環境変数 BC_CONNECTION により切り替わる。', + 'default' => 'default' + ]); + + return $parser; + } + + /** + * コマンドの実行 + * + * @param \Cake\Console\Arguments $args + * @param \Cake\Console\ConsoleIo $io + * @return int|null|void + */ + public function execute(Arguments $args, ConsoleIo $io) + { + $io->out('baserCMS MCP サーバーを起動しています...'); + + $transport = $args->getOption('transport'); + $host = $args->getOption('host'); + $port = (int)$args->getOption('port'); + $configPath = $args->getOption('config'); + $connection = (string)$args->getOption('connection'); + + // default 以外の接続が指定された場合は default にエイリアスし、サーバーの全 DB 操作を + // その接続へ向ける。bootstrap 時の env(BC_MCP_CONNECTION) と同じ切替を冪等に行う保険。 + if ($connection !== '' && $connection !== 'default' && \Cake\Datasource\ConnectionManager::getConfig($connection)) { + \Cake\Datasource\ConnectionManager::alias($connection, 'default'); + $io->out("DB 接続: {$connection}(default にエイリアス)"); + } + + try { + // MCPサーバーのインスタンス作成 + $server = new McpServer(); + + // 設定ファイルがある場合は読み込み + if ($configPath && file_exists($configPath)) { + $config = require $configPath; + $server->setConfig($config); + } + + $io->out("Transport: {$transport}"); + + if ($transport === 'stdio') { + $io->out('STDIO モードで起動中...'); + $io->out('クライアントからの接続を待機しています...'); + + // STDIOモードで実行 + $server->runStdio(); + } elseif ($transport === 'sse') { + $io->out("SSE モードで起動中... (http://{$host}:{$port})"); + + // SSEモードで実行 + $server->runSse($host, $port); + } else { + $io->error("サポートされていないトランスポートタイプ: {$transport}"); + return self::CODE_ERROR; + } + + } catch (\Exception $e) { + $io->error('MCPサーバーの起動中にエラーが発生しました:'); + $io->error($e->getMessage()); + $io->error($e->getTraceAsString()); + return self::CODE_ERROR; + } + + return self::CODE_SUCCESS; + } +} 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..782ce67a90 --- /dev/null +++ b/plugins/bc-mcp/src/Controller/Admin/McpServerManagerController.php @@ -0,0 +1,148 @@ +set('title', 'MCPサーバー管理'); + $this->mcpServerManager = new McpServerManger(); + } + + /** + * MCPサーバー管理画面 + */ + public function index() + { + $status = $this->mcpServerManager->getServerStatus(); + $config = $this->mcpServerManager->getServerConfig(); + + $this->set(compact('status', 'config')); + } + + /** + * MCPサーバー起動 + */ + public function start() + { + $this->request->allowMethod(['post']); + + try { + if ($this->mcpServerManager->isServerRunning()) { + $this->BcMessage->setError('MCPサーバーは既に起動しています'); + return $this->redirect(['action' => 'index']); + } + + $config = $this->mcpServerManager->getServerConfig(); + $result = $this->mcpServerManager->startMcpServer($config); + + if ($result['success']) { + $this->BcMessage->setSuccess('MCPサーバーを起動しました'); + } else { + $this->BcMessage->setError('MCPサーバーの起動に失敗しました: ' . $result['message']); + } + + } catch (\Exception $e) { + $this->BcMessage->setError('MCPサーバーの起動中にエラーが発生しました: ' . $e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * MCPサーバー停止 + */ + public function stop() + { + $this->request->allowMethod(['post']); + + try { + $result = $this->mcpServerManager->stopMcpServer(); + + if ($result['success']) { + $this->BcMessage->setSuccess('MCPサーバーを停止しました'); + } else { + $this->BcMessage->setError('MCPサーバーの停止に失敗しました: ' . $result['message']); + } + + } catch (\Exception $e) { + $this->BcMessage->setError('MCPサーバーの停止中にエラーが発生しました: ' . $e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * MCPサーバー再起動 + */ + public function restart() + { + $this->request->allowMethod(['post']); + + try { + // 停止 + if ($this->mcpServerManager->isServerRunning()) { + $this->mcpServerManager->stopMcpServer(); + sleep(2); // 少し待機 + } + + // 起動 + $config = $this->mcpServerManager->getServerConfig(); + $result = $this->mcpServerManager->startMcpServer($config); + + if ($result['success']) { + $this->BcMessage->setSuccess('MCPサーバーを再起動しました'); + } else { + $this->BcMessage->setError('MCPサーバーの再起動に失敗しました: ' . $result['message']); + } + + } catch (\Exception $e) { + $this->BcMessage->setError('MCPサーバーの再起動中にエラーが発生しました: ' . $e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * 設定画面 + */ + public function configure() + { + if ($this->request->is(['post', 'put'])) { + $data = $this->request->getData(); + + try { + $this->mcpServerManager->saveServerConfig($data); + $this->BcMessage->setSuccess('設定を保存しました'); + return $this->redirect(['action' => 'index']); + + } catch (\Exception $e) { + $this->BcMessage->setError('設定の保存に失敗しました: ' . $e->getMessage()); + } + } + + $config = $this->mcpServerManager->getServerConfig(); + $this->set(compact('config')); + } + +} 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..5de29c741e --- /dev/null +++ b/plugins/bc-mcp/src/Controller/Admin/Oauth2Controller.php @@ -0,0 +1,199 @@ +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); + + return $server->completeAuthorizationRequest($authRequest, $this->response); + } elseif ($action === 'deny') { + // アクセス拒否 + $params = [ + 'error' => 'access_denied', + 'error_description' => 'The user denied the request' + ]; + if ($state) { + $params['state'] = $state; + } + + $redirectUrl = $redirectUri . '?' . http_build_query($params); + 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..0523acf5a5 --- /dev/null +++ b/plugins/bc-mcp/src/Controller/McpProxyController.php @@ -0,0 +1,348 @@ +FormProtection->setConfig('validate', false); + // OAuth2サービスを初期化 + $this->oauth2Service = new OAuth2Service(); + $this->mcpServerManager = new McpServerManger(); + + // 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'); + } + + /** + * MCPプロトコルバージョンの取得 + * @return string + */ + private function getProtocolVersion(): string + { + $protocolVersion = $this->request->getHeaderLine('MCP-Protocol-Version'); + if (!empty($protocolVersion)) { + return $protocolVersion; + } + $requestBody = (string)$this->request->getBody(); + $mcpRequest = json_decode($requestBody, true); + + if (isset($mcpRequest['params']['protocolVersion'])) { + return $mcpRequest['params']['protocolVersion']; + } + return '2025-06-18'; + } + + /** + * リクエスト処理前の認証チェック + */ + public function beforeFilter(EventInterface $event): void + { + parent::beforeFilter($event); + + $method = $this->request->getMethod(); + + // OPTIONS は認証不要 + if ($method === 'OPTIONS') { + 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)); + } + + /** + * MCPサーバーへのプロキシ処理 + * /mcp へのアクセスを内部MCPサーバーに転送 + * OPTIONSリクエストも含めて全てここで処理 + */ + public function index() + { + $protocolVersion = $this->getProtocolVersion(); + $this->response = $this->response->withHeader('MCP-Protocol-Version', $protocolVersion); + + // OPTIONSリクエストの場合はCORSレスポンスを返す + if ($this->request->getMethod() === 'OPTIONS') { + return $this->_handleOptionsRequest(); + } + + // POST以外のメソッドは許可しない + if ($this->request->getMethod() === 'GET') { + return $this->response + ->withStatus(200) + ->withHeader('Content-Type', 'application/json') + ->withStringBody(json_encode([ + 'jsonrpc' => '2.0', + 'name' => 'bc-mcp', + 'version' => '1.0.0', + 'authenticated' => true + ])); + } + + try { + // MCPサーバーの設定を取得 + $config = $this->mcpServerManager->getServerConfig(); + + // MCPサーバーが起動しているかチェック + if (!$this->mcpServerManager->isServerRunning()) { + throw new ServiceUnavailableException( + 'MCPサーバーが起動していません。管理画面からMCPサーバーを起動してください。' + ); + } + + // CakePHPのリクエストオブジェクトからJSONボディを取得 + $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'); + } + + $mcpRequest['params']['arguments']['loginUserId'] = $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.' + ] + ])); + } + + // SSEクライアントとしてMCPサーバーに接続してリクエストを処理 + $response = $this->sendMcpRequest($config, $mcpRequest); + + $this->response = $this->response + ->withHeader('Content-Type', 'application/json') + ->withHeader('Access-Control-Allow-Credentials', 'true') + ->withStringBody(json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + if ($this->request->getData('method') === 'notifications/initialized') { + $this->response = $this->response->withStatus(202); + } + } 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' => 'MCPサーバーとの通信に失敗しました: ' . $e->getMessage() + ] + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } 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 | JSON_UNESCAPED_SLASHES)); + } + + return $this->response; + } + + /** + * 権限チェック + * @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($mcpRequest['params']['arguments']['loginUserId']); + 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'] + ); + } + + /** + * StreamableHttpServerTransport用のMCPリクエスト送信 + * 直接JSONエンドポイントとして通信(SSE初期化不要) + */ + private function sendMcpRequest(array $config, array $mcpRequest): array + { + // StreamableHttpServerTransportの場合はルートパス(/)を使用 + $jsonUrl = "http://127.0.0.1:{$config['port']}/"; + + try { + $client = new Client(['timeout' => 10]); + $response = $client->post($jsonUrl, json_encode($mcpRequest), [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ] + ]); + + $responseData = json_decode($response->getBody()->getContents(), true); + + if (!$responseData) { + return [ + "jsonrpc" => "2.0", + "result" => [] + ]; + } + + // MCP Inspector対応:プロトコルバージョンとcapabilitiesを調整 + if (isset($responseData['result']) && isset($mcpRequest['method']) && $mcpRequest['method'] === 'initialize') { + // capabilitiesにツールの存在を示す(実際のツールリストはtools/listで取得) + $responseData['result']['capabilities'] = [ + 'tools' => ['listChanged' => true], // 空オブジェクトでツール機能があることを示す + 'resources' => ['listChanged' => true], + 'prompts' => ['listChanged' => true] + ]; + $responseData['result']['protocolVersion'] = '2025-06-18'; + } + return $responseData; + + } catch (\Exception $e) { + throw new \Exception('MCPサーバーとの通信に失敗しました: ' . $e->getMessage()); + } + } + + /** + * 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..31b7467e6e --- /dev/null +++ b/plugins/bc-mcp/src/Controller/Oauth2Controller.php @@ -0,0 +1,555 @@ +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' => $baseUrl . '/bc-mcp', + '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', + + '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..163e12b6ca --- /dev/null +++ b/plugins/bc-mcp/src/Lib/OAuth2Util.php @@ -0,0 +1,81 @@ +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..a52676bd43 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BaseMcpTool.php @@ -0,0 +1,664 @@ +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); + } + } + + /** + * 値がファイルアップロード可能な形式かどうかを判定 + * + * @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; + } + + // チャンクファイル名の場合(拡張子があるファイル名) + if (!empty($value) && preg_match('/\.[a-zA-Z0-9]{2,4}$/', $value)) { + return true; + } + + return false; + } + + /** + * ファイルアップロード処理 + * + * @param string $fileData ファイルパス、URL、または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); + } + + if (!empty($fileData)) { + return $this->processChunkFile($fileData); + } + + throw new \Exception('不正なファイルデータ形式です: ' . $fileData); + + } catch (\Exception $e) { + // エラーログを出力 + if (!BcUtil::isTest()) { + error_log($fieldName . 'の処理に失敗しました: ' . $e->getMessage()); + } + return false; + } + } + + /** + * チャンクファイルを処理 + * + * @param string $fileData チャンクファイル名 + * @return array アップロード情報の配列 + * @throws \Exception + */ + public function processChunkFile(string $fileData): array + { + $filePath = TMP . 'mcp_uploads' . DS . $fileData; + if (!file_exists($filePath)) { + throw new \Exception('チャンクファイルが存在しません'); + } + + // ファイル情報を取得 + $fileSize = filesize($filePath); + $fileName = basename($fileData); + + // ファイル拡張子を取得 + $pathInfo = pathinfo($fileName); + $extension = strtolower($pathInfo['extension'] ?? ''); + + // 許可された拡張子かチェック + if (!$this->isAllowedExtension($extension)) { + throw new \Exception('サポートされていないファイル形式です: ' . $extension); + } + + // MIMEタイプを取得 + $mimeType = $this->getMimeTypeFromExtension($extension); + + // アップロード情報として返す + return [ + 'name' => $fileName, + 'type' => $mimeType, + 'tmp_name' => $filePath, + 'error' => UPLOAD_ERR_OK, + 'size' => $fileSize, + 'ext' => $extension + ]; + } + + /** + * 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、または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..4e4baed4c4 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BaserCore/BaserCoreServer.php @@ -0,0 +1,27 @@ + ツールクラス名の配列 + */ + public static function getToolClasses(): array + { + return [ + // SearchIndexesTool::class, // ChatGPTで動作しないため一旦、停止 + // FileUploadTool::class // AI側のメッセージ制限によりチャンクによるアップロードを実装したが、それでも、現実的でなかったため、一旦、停止 + ]; + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BaserCore/FileUploadTool.php b/plugins/bc-mcp/src/Mcp/BaserCore/FileUploadTool.php new file mode 100644 index 0000000000..028789da16 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BaserCore/FileUploadTool.php @@ -0,0 +1,128 @@ +uploadDir)) { + mkdir($this->uploadDir, 0755, true); + } + } + + /** + * 検索インデックス用のツールを ServerBuilder に追加 + */ + public function addToolsToBuilder(ServerBuilder $builder): ServerBuilder + { + return $builder + ->withTool( + handler: [self::class, 'sendFileChunk'], + name: 'sendFileChunk', + description: 'ファイルをチャンク分割して送信します。大きなファイルを小さな部分に分けて段階的にアップロードするために使用します。分割したチャンクは30KB以下にしてください。', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'fileId' => ['type' => 'string', 'description' => 'ファイルを一意に識別するID(必須)'], + 'chunkIndex' => ['type' => 'number', 'description' => '現在のチャンクのインデックス番号(0から開始)(必須)'], + 'totalChunks' => ['type' => 'number', 'description' => 'ファイル全体のチャンク総数(必須)'], + 'chunkData' => ['type' => 'string', 'description' => 'base64エンコードされたチャンクデータ(30KB以下)(必須)'], + 'filename' => ['type' => 'string', 'description' => 'ファイル名(拡張子含む)(必須)'], + ], + 'required' => ['fileId', 'chunkIndex', 'totalChunks', 'chunkData', 'filename'] + ] + ); + } + + /** + * ファイルチャンクを受信して保存 + * チャンクが全て揃ったら結合して最終ファイルを生成 + * @param string $fileId + * @param int $chunkIndex + * @param int $totalChunks + * @param string $chunkData + * @param string $filename + * @return array + */ + public function sendFileChunk(string $fileId, int $chunkIndex, int $totalChunks, string $chunkData, string $filename): array + { + return $this->executeWithErrorHandling(function() use ($fileId, $chunkIndex, $totalChunks, $chunkData, $filename) { + if (empty($fileId) || $chunkIndex < 0 || $totalChunks <= 0 || empty($chunkData) || empty($filename)) { + throw new \InvalidArgumentException('Invalid parameters.'); + } + + // チャンクファイルとして保存 + $chunkFile = $this->uploadDir . $fileId . '.part' . $chunkIndex; + file_put_contents($chunkFile, base64_decode($chunkData)); + + // 全チャンク受信完了チェック + if ($this->allChunksReceived($fileId, $totalChunks)) { + return $this->createSuccessResponse($this->mergeChunks($fileId, $totalChunks, $filename)); + } + return $this->createSuccessResponse(['status' => 'chunk_received', 'progress' => $chunkIndex + 1]); + }); + } + + /** + * チャンクを結合して最終ファイルを生成 + * @param $fileId + * @param $totalChunks + * @param $filename + * @return string[] + */ + private function mergeChunks($fileId, $totalChunks, $filename) + { + $finalFile = $this->uploadDir . $filename; + $handle = fopen($finalFile, 'wb'); + + for($i = 0; $i < $totalChunks; $i++) { + $chunkFile = $this->uploadDir . $fileId . '.part' . $i; + if (file_exists($chunkFile)) { + $chunkData = file_get_contents($chunkFile); + fwrite($handle, $chunkData); + unlink($chunkFile); // チャンクファイル削除 + } + } + + fclose($handle); + return ['status' => 'complete', 'file' => $finalFile]; + } + + /** + * 全チャンク受信完了チェック + * @param $fileId + * @param $totalChunks + * @return bool + */ + private function allChunksReceived($fileId, $totalChunks) + { + // 方法1: ファイル存在チェックによる確認 + for($i = 0; $i < $totalChunks; $i++) { + $chunkFile = $this->uploadDir . $fileId . '.part' . $i; + if (!file_exists($chunkFile)) { + return false; // 欠損チャンクがある + } + } + return true; // 全チャンク揃っている + } + +} diff --git a/plugins/bc-mcp/src/Mcp/BaserCore/SearchIndexesTool.php b/plugins/bc-mcp/src/Mcp/BaserCore/SearchIndexesTool.php new file mode 100644 index 0000000000..958c7dfbed --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BaserCore/SearchIndexesTool.php @@ -0,0 +1,132 @@ +searchIndexesService = $this->getService(SearchIndexesServiceInterface::class); + Configure::write('App.fullBaseUrl', preg_replace('/\/$/', '', env('SITE_URL', 'https://localhost/'))); + } + + /** + * 検索インデックス用のツールを ServerBuilder に追加 + */ + public function addToolsToBuilder(ServerBuilder $builder): ServerBuilder + { + return $builder + ->withTool( + handler: [self::class, 'search'], + name: 'search', + description: 'クエリ文字列でサイトを検索します。', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string', 'description' => '検索クエリ'] + ], + 'required' => ['query'] + ] + )->withTool( + handler: [self::class, 'fetch'], + name: 'fetch', + description: '識別子を指定してデータを取得します。', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'description' => '識別子(必須)'] + ], + 'required' => ['id'] + ] + ); + } + + /** + * IDを指定して検索インデックスのデータを取得 + * @param string $id + * @return array + */ + public function fetch(string $id): array + { + return $this->executeWithErrorHandling(function() use ($id) { + $entity = $this->searchIndexesService->get((int)$id, [ + 'status' => 'publish', + 'site_id' => null + ]); + + if ($entity) { + $result = [ + 'type' => 'resource', + 'resource' => [ + 'url' => Router::url($entity->url, true), + 'text' => $entity->detail, + 'mineType' => 'text/html', + ] + ]; + return $this->createSuccessResponse($result); + } else { + return $this->createErrorResponse('指定されたIDの検索インデックスが見つかりません'); + } + }); + } + + /** + * クエリ文字列で検索インデックスを検索 + * @param string $query + * @return array + */ + public function search(string $query): array + { + return $this->executeWithErrorHandling(function() use ($query) { + $entities = $this->searchIndexesService->getIndex([ + 'status' => 'publish', + 'keyword' => $query, + 'site_id' => null, + 'op' => 'or' + ]); + + $results = []; + foreach($entities as $entity) { + $results[] = ResourceLinkContent::make( + name: (string)$entity->id, + uri: Router::url($entity->url, true), + title: $entity->title, + description: mb_substr($entity->detail, 0, 200, 'UTF-8'), + ); + } + + return $this->createSuccessResponse($results); + }); + } + +} 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..c92e605c2e --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogCategoriesTool.php @@ -0,0 +1,363 @@ +withTool( + handler: [self::class, 'addBlogCategory'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getBlogCategories'], + 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ページ目)'], + ] + ] + ) + ->withTool( + handler: [self::class, 'getBlogCategory'], + name: 'getBlogCategory', + description: '指定されたIDのブログカテゴリを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カテゴリID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'editBlogCategory'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'deleteBlogCategory'], + 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..875bbd9c48 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogContentsTool.php @@ -0,0 +1,450 @@ +withTool( + handler: [self::class, 'addBlogContent'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'editBlogContent'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getBlogContents'], + 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ページ目)'], + ] + ] + ) + ->withTool( + handler: [self::class, 'getBlogContent'], + name: 'getBlogContent', + description: 'baserCMSは複数のブログを持つことができます。一つ一つのブログをブログコンテンツと呼び、指定されたIDのブログコンテンツを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログコンテンツID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'deleteBlogContent'], + 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..519142b768 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogPostsTool.php @@ -0,0 +1,488 @@ +withTool( + handler: [self::class, 'getBlogPosts'], + name: 'getBlogPosts', + description: 'ブログ記事の一覧を取得します', + 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( + handler: [self::class, 'getBlogPost'], + name: 'getBlogPost', + description: '指定されたIDのブログ記事を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '記事ID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'addBlogPost'], + name: 'addBlogPost', + description: 'ブログ記事を追加します', + 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'] + ] + ) + ->withTool( + handler: [self::class, 'editBlogPost'], + name: 'editBlogPost', + description: 'ブログ記事を編集します', + 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'] + ] + ) + ->withTool( + handler: [self::class, 'deleteBlogPost'], + name: 'deleteBlogPost', + 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 '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 + { + 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); + } + } 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 + { + 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 (!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 { + // 空文字列の場合は削除 + $data['eye_catch'] = null; + } + } + + $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 + { + 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..e198ebd788 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcBlog/BlogTagsTool.php @@ -0,0 +1,248 @@ +withTool( + handler: [self::class, 'addBlogTag'], + name: 'addBlogTag', + description: 'ブログタグを追加します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'タグ名(必須)'] + ], + 'required' => ['name'] + ] + ) + ->withTool( + handler: [self::class, 'getBlogTags'], + name: 'getBlogTags', + description: 'ブログタグの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'タグ名での検索'], + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は10件)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + ] + ] + ) + ->withTool( + handler: [self::class, 'getBlogTag'], + name: 'getBlogTag', + description: '指定されたIDのブログタグを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログタグID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'editBlogTag'], + name: 'editBlogTag', + description: '指定されたIDのブログタグを編集します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ブログタグID(必須)'], + 'name' => ['type' => 'string', 'description' => 'タグ名(必須)'] + ], + 'required' => ['id', 'name'] + ] + ) + ->withTool( + handler: [self::class, 'deleteBlogTag'], + 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..c3cb022bfe --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomContentsTool.php @@ -0,0 +1,386 @@ +withTool( + handler: [self::class, 'addCustomContent'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getCustomContents'], + name: 'getCustomContents', + description: 'カスタムテーブルと紐づくカスタムコンテンツの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'limit' => ['type' => 'number', 'description' => '取得件数(省略時は制限なし)'], + 'page' => ['type' => 'number', 'description' => 'ページ番号(省略時は1ページ目)'], + 'status' => ['type' => 'number', 'description' => '公開ステータス(null: 非公開, publish: 公開)'] + ] + ] + ) + ->withTool( + handler: [self::class, 'getCustomContent'], + name: 'getCustomContent', + description: 'カスタムテーブルと紐づくカスタムコンテンツをIDを指定して取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムコンテンツID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'editCustomContent'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'deleteCustomContent'], + 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..3115b41163 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomEntriesTool.php @@ -0,0 +1,463 @@ +withTool( + handler: [self::class, 'addCustomEntry'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'editCustomEntry'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getCustomEntries'], + name: 'getCustomEntries', + description: 'カスタムエントリーの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'limit' => ['type' => 'number', 'default' => 20, 'description' => '取得件数(デフォルト: 20)'], + 'page' => ['type' => 'number', 'default' => 1, 'description' => 'ページ番号(デフォルト: 1)'], + 'status' => ['type' => 'number', 'description' => 'ステータス(null: 非公開, publish: 公開)'] + ], + 'required' => ['customTableId'] + ] + ) + ->withTool( + handler: [self::class, 'getCustomEntry'], + name: 'getCustomEntry', + description: '指定されたIDのカスタムエントリーを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'customTableId' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'], + 'id' => ['type' => 'number', 'description' => 'カスタムエントリーID(必須)'] + ], + 'required' => ['customTableId', 'id'] + ] + ) + ->withTool( + handler: [self::class, 'deleteCustomEntry'], + 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, $customTableId); + $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, $customTableId); + $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, int $customTableId): array + { + $processedFields = []; + + foreach($customFields as $fieldName => $value) { + if (is_array($value)) { + // 配列の場合、json形式またはファイルアップロードの可能性をチェック + $processedFields[$fieldName] = $value; + } elseif ($this->isFileUpload($value, $customTableId, $fieldName)) { + // ファイルアップロードデータの処理(フィールドタイプもチェック) + $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})"); + } + } else { + // 通常の値 + $processedFields[$fieldName] = $value; + } + } + + return $processedFields; + } + + /** + * カスタムフィールドのタイプを取得 + * + * @param int $customTableId カスタムテーブルID + * @param string $fieldName フィールド名 + * @return string|null フィールドタイプ(BcCcFileなど)、見つからない場合はnull + */ + protected function getCustomFieldType(int $customTableId, string $fieldName): ?string + { + try { + $customLinksTable = \Cake\ORM\TableRegistry::getTableLocator()->get('BcCustomContent.CustomLinks'); + + $customLink = $customLinksTable->find() + ->contain(['CustomFields']) + ->where([ + 'CustomLinks.custom_table_id' => $customTableId, + 'CustomLinks.name' => $fieldName + ]) + ->first(); + + if ($customLink && $customLink->custom_field) { + return $customLink->custom_field->type; + } + + return null; + } catch (\Exception $e) { + // エラーログを出力 + error_log('カスタムフィールドタイプの取得に失敗: ' . $e->getMessage()); + return null; + } + } + + /** + * カスタムフィールドがファイルアップロードフィールドかどうかを判定 + * + * @param int $customTableId カスタムテーブルID + * @param string $fieldName フィールド名 + * @return bool BcCcFileフィールドの場合true + */ + protected function isFileUploadField(int $customTableId, string $fieldName): bool + { + $fieldType = $this->getCustomFieldType($customTableId, $fieldName); + return $fieldType === 'BcCcFile'; + } + + /** + * ファイルアップロードデータかどうかを判定(カスタムエントリー用) + * + * @param mixed $value 判定対象の値 + * @param int $customTableId カスタムテーブルID + * @param string $fieldName フィールド名 + * @return bool ファイルアップロードデータの場合true + */ + protected function isFileUpload($value, int $customTableId, string $fieldName): bool + { + // フィールドタイプがBcCcFileでない場合は対象外 + if (!$this->isFileUploadField($customTableId, $fieldName)) { + return false; + } + + // 値の形式チェック + return $this->isFileUploadable($value); + } + + /** + * カスタムエントリー一覧を取得 + */ + public function getCustomEntries( + int $customTableId, + ?string $title = null, + ?int $creatorId = null, + ?string $published = null, + ?int $limit = 20, + ?int $page = 1, + ?string $status = null + ): array + { + return $this->executeWithErrorHandling(function() use ($customTableId, $title, $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; + if (!is_null($title)) $conditions['title'] = $title; + 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..323380a67a --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomFieldsTool.php @@ -0,0 +1,394 @@ + '郵便番号', + '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` として指定', + ]; + + /** + * カスタムフィールド関連のツールを ServerBuilder に追加 + */ + public function addToolsToBuilder(ServerBuilder $builder): ServerBuilder + { + $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 $builder + ->withTool( + handler: [self::class, 'addCustomField'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'editCustomField'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getCustomFields'], + 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: 有効)'] + ] + ] + ) + ->withTool( + handler: [self::class, 'getCustomField'], + name: 'getCustomField', + description: 'カスタムエントリーの入力欄を定義する、カスタムフィールドをIDを指定して取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムフィールドID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'deleteCustomField'], + 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..4645be4ac2 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomLinksTool.php @@ -0,0 +1,390 @@ +withTool( + handler: [self::class, 'addCustomLink'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getCustomLinks'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getCustomLink'], + name: 'getCustomLink', + description: '指定されたIDのカスタムリンクを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムリンクID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'editCustomLink'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'deleteCustomLink'], + 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..bdfbb2a22b --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/BcCustomContent/CustomTablesTool.php @@ -0,0 +1,339 @@ +withTool( + handler: [self::class, 'addCustomTable'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'editCustomTable'], + 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'] + ] + ) + ->withTool( + handler: [self::class, 'getCustomTables'], + name: 'getCustomTables', + description: 'カスタムテーブルの一覧を取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string', 'description' => 'テーブルタイプ'] + ] + ] + ) + ->withTool( + handler: [self::class, 'getCustomTable'], + name: 'getCustomTable', + description: '指定されたIDのカスタムテーブルを取得します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'カスタムテーブルID(必須)'] + ], + 'required' => ['id'] + ] + ) + ->withTool( + handler: [self::class, 'deleteCustomTable'], + 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/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/McpServer.php b/plugins/bc-mcp/src/Mcp/McpServer.php new file mode 100644 index 0000000000..a59c913bc0 --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/McpServer.php @@ -0,0 +1,181 @@ +buildServer(); + } + + /** + * サーバーのビルド + */ + private function buildServer(): void + { + $builder = new ServerBuilder(); + + // サーバー名の設定 + $serverName = 'baserCMS MCP Server'; + $serverVersion = '1.0.0'; + + $builder = $builder + ->withServerInfo($serverName, $serverVersion) + ->withLogger(new McpLogger(LOGS . 'bc_mcp_error.log')) + ->withCapabilities(new ServerCapabilities( + tools: true, + resources: false, + prompts: false + )); + + $availableServers = Configure::read('BcMcp.availableServers',); + foreach($availableServers as $serverClass) { + $this->registerToolsFromServer($serverClass::getToolClasses(), $builder); + } + + // サーバー情報ツールを追加 + $builder = $builder->withTool( + handler: [self::class, 'serverInfo'], + name: 'serverInfo', + description: 'サーバーのバージョンや環境情報を返します', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => 'ID'], + ] + ] + ); + + $this->server = $builder->build(); + } + + /** + * ツールクラス配列からツールを登録 + * + * @param array $toolClasses ツールクラス名の配列 + * @param ServerBuilder $builder サーバービルダー + * @return void + */ + private function registerToolsFromServer(array $toolClasses, ServerBuilder &$builder): void + { + foreach($toolClasses as $toolClass) { + $toolInstance = new $toolClass(); + $builder = $toolInstance->addToolsToBuilder($builder); + } + } + + /** + * リソースクラス配列からリソースを登録 + * + * @param array $resourceClasses リソースクラス名の配列 + * @param ServerBuilder $builder サーバービルダー + * @return void + */ + private function registerResourcesFromServer(array $resourceClasses, ServerBuilder &$builder): void + { + foreach($resourceClasses as $resourceClass) { + $resourceInstance = new $resourceClass(); + $builder = $resourceInstance->addResourcesToBuilder($builder); + } + } + + /** + * MCPサーバーの実体を取得する + * + * @return Server + */ + public function getServer(): Server + { + return $this->server; + } + + /** + * 標準入力からサーバーを起動 + */ + public function runStdio(): void + { + $transport = new StdioServerTransport(); + $this->server->listen($transport); + } + + /** + * SSEでサーバーを起動 + * + * @param string $host ホスト名 + * @param int $port ポート番号 + */ + public function runSse(string $host, int $port): void + { + $transport = new StreamableHttpServerTransport( + host: $host, + port: $port, + mcpPath: '', // 明示的にパスを指定 + enableJsonResponse: true, + stateless: true + ); + $this->server->listen($transport); + } + + /** + * サーバー情報を取得 + */ + public function serverInfo(array $arguments = []): array + { + try { + $info = [ + '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', 'sse'] + ]; + + return [ + 'isError' => false, + 'content' => $info + ]; + } catch (\Exception $e) { + return [ + 'isError' => true, + 'content' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]; + } + } + + /** + * 設定を適用 + */ + public function setConfig(array $config): void + { + // 将来的な設定対応のためのメソッド + } +} diff --git a/plugins/bc-mcp/src/Mcp/McpServerManger.php b/plugins/bc-mcp/src/Mcp/McpServerManger.php new file mode 100644 index 0000000000..563e74d28a --- /dev/null +++ b/plugins/bc-mcp/src/Mcp/McpServerManger.php @@ -0,0 +1,219 @@ +getPidFilePath(); + $logFile = $this->getLogFilePath(); + $cakeCommand = ROOT . DS . 'bin' . DS . 'cake'; + + // 任意の DB 接続を指定された場合は、子プロセスのプラグインロード(bootstrap)に効く + // 環境変数 BC_CONNECTION と、起動後の DB 操作に効く --connection オプションを併用する。 + $envPrefix = ''; + $connectionOption = ''; + if (!empty($config['connection']) && $config['connection'] !== 'default') { + $envPrefix = 'BC_CONNECTION=' . escapeshellarg($config['connection']) . ' '; + $connectionOption = ' --connection=' . escapeshellarg($config['connection']); + } + + // バックグラウンドでMCPサーバーを起動。 + // nohup で SIGHUP から切り離しつつ、stdin/stdout/stderr をすべて親から切り離す + // (stdin は /dev/null、stdout/stderr はログファイルへ)。これをしないと SSE 常駐プロセスが + // 親プロセス(CI のステップ等)の継承 fd を掴んだままになり、出力パイプが EOF にならず + // ジョブが次ステップへ進めずハングする。$! は nohup 実行なので php プロセスの PID と一致する。 + $command = sprintf( + 'cd %s && %snohup %s bc_mcp.server --transport=sse --host=%s --port=%s%s < /dev/null > %s 2>&1 & echo $! > %s', + ROOT, + $envPrefix, + $cakeCommand, + escapeshellarg($config['host']), + escapeshellarg($config['port']), + $connectionOption, + escapeshellarg($logFile), + escapeshellarg($pidFile) + ); + + shell_exec($command); + + // 起動確認(最大10秒待機) + $attempts = 0; + while($attempts < 20 && !$this->isServerRunning()) { + usleep(500000); // 0.5秒待機 + $attempts++; + } + + if ($this->isServerRunning()) { + return ['success' => true, 'message' => 'MCPサーバーが正常に起動しました']; + } else { + $logContent = file_exists($logFile)? file_get_contents($logFile) : 'ログファイルが見つかりません'; + return ['success' => false, 'message' => 'サーバーの起動を確認できませんでした。ログ: ' . $logContent]; + } + + } catch (\Exception $e) { + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + /** + * MCPサーバーを停止 + */ + public function stopMcpServer(): array + { + try { + $pidFile = $this->getPidFilePath(); + + if (!file_exists($pidFile)) { + return ['success' => false, 'message' => 'PIDファイルが見つかりません(サーバーが起動していない可能性があります)']; + } + + $pid = trim(file_get_contents($pidFile)); + + if (!$pid || !$this->isProcessRunning($pid)) { + unlink($pidFile); + return ['success' => false, 'message' => 'MCPサーバーのプロセスが見つかりません']; + } + + // プロセスを停止 + shell_exec("kill {$pid} 2>&1"); + + // 停止確認 + sleep(1); + if (!$this->isProcessRunning($pid)) { + unlink($pidFile); + return ['success' => true, 'message' => 'MCPサーバーを正常に停止しました']; + } else { + // 強制終了 + shell_exec("kill -9 {$pid} 2>&1"); + unlink($pidFile); + return ['success' => true, 'message' => 'MCPサーバーを強制終了しました']; + } + + } catch (\Exception $e) { + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + /** + * サーバーの状態を取得 + */ + public function getServerStatus(): array + { + $pidFile = $this->getPidFilePath(); + $config = $this->getServerConfig(); + + $isRunning = $this->isServerRunning(); + $pid = file_exists($pidFile)? trim(file_get_contents($pidFile)) : null; + $request = Router::getRequest(); + $protocol = ($request->is('https'))? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + + return [ + 'running' => $isRunning, + 'pid' => $pid, + 'proxy_url' => "{$protocol}://{$host}/bc-mcp", + 'internal_url' => "http://{$config['host']}:{$config['port']}", + 'config' => $config + ]; + } + + /** + * MCPサーバーが起動しているかチェック + */ + public function isServerRunning(): bool + { + $pidFile = $this->getPidFilePath(); + + if (!file_exists($pidFile)) { + return false; + } + + $pid = trim(file_get_contents($pidFile)); + return $pid && $this->isProcessRunning($pid); + } + + /** + * プロセスが実行中かチェック + */ + public function isProcessRunning(string $pid): bool + { + $result = shell_exec("ps -p {$pid} 2>/dev/null"); + return !empty($result) && strpos($result, $pid) !== false; + } + + /** + * サーバー設定を取得 + */ + public function getServerConfig(): array + { + $configFile = $this->getConfigFilePath(); + + $defaultConfig = [ + 'host' => '127.0.0.1', + 'port' => '3000' + ]; + + if (file_exists($configFile)) { + $savedConfig = json_decode(file_get_contents($configFile), true); + return array_merge($defaultConfig, $savedConfig?: []); + } + + return $defaultConfig; + } + + /** + * サーバー設定を保存 + */ + public function saveServerConfig(array $config): void + { + $configFile = $this->getConfigFilePath(); + $configDir = dirname($configFile); + + if (!is_dir($configDir)) { + mkdir($configDir, 0755, true); + } + + $jsonConfig = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + file_put_contents($configFile, $jsonConfig); + } + + /** + * PIDファイルのパスを取得 + */ + private function getPidFilePath(): string + { + return TMP . 'bc_mcp_server.pid'; + } + + /** + * ログファイルのパスを取得 + */ + private function getLogFilePath(): string + { + return LOGS . 'bc_mcp_server.log'; + } + + /** + * 設定ファイルのパスを取得 + */ + private function getConfigFilePath(): string + { + return CONFIG . 'bc_mcp_server.json'; + } + +} 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/Schema/Content/ResourceLinkContent.php b/plugins/bc-mcp/src/Schema/Content/ResourceLinkContent.php new file mode 100644 index 0000000000..ae03fc7c30 --- /dev/null +++ b/plugins/bc-mcp/src/Schema/Content/ResourceLinkContent.php @@ -0,0 +1,68 @@ + $this->type, + 'uri' => $this->uri, + 'name' => $this->name, + 'title' => $this->title, + ]; + + if ($this->description !== null) { + $result['description'] = $this->description; + } + + return $result; + } + + /** + * JSON形式で出力 + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} 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/configure.php b/plugins/bc-mcp/templates/Admin/McpServerManager/configure.php new file mode 100644 index 0000000000..73489afe6a --- /dev/null +++ b/plugins/bc-mcp/templates/Admin/McpServerManager/configure.php @@ -0,0 +1,77 @@ +BcAdmin->setTitle('MCPサーバー設定'); +?> + + +BcAdminForm->create(null, ['novalidate' => true]) ?> + +
+
+
+ ホスト +
+
+ BcAdminForm->control('host', [ + 'type' => 'text', + 'value' => $config['host'], + 'help' => '通常は変更不要です(127.0.0.1)' + ]) ?> +
+
+ +
+
+ ポート +
+
+ BcAdminForm->control('port', [ + 'type' => 'number', + 'value' => $config['port'], + 'min' => 1024, + 'max' => 65535, + 'help' => '内部通信用ポート(デフォルト: 3000)' + ]) ?> +
+
+ +
+ + +
+
+ BcAdminForm->submit('保存', [ + 'div' => false, + 'class' => 'btn-red bca-btn bca-loading', + 'data-bca-btn-type' => 'save', + 'data-bca-btn-size' => 'lg', + 'data-bca-btn-width' => 'lg', + 'id' => 'BtnSave' + ]) ?> +
+
+ Html->link('キャンセル', ['action' => 'index'], [ + 'class' => 'bca-btn', + 'data-bca-btn-type' => 'cancel', + 'data-bca-btn-size' => 'lg', + 'data-bca-btn-width' => 'lg' + ]) ?> +
+ + + BcAdminForm->end() ?> + + + 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..b678723fbb --- /dev/null +++ b/plugins/bc-mcp/templates/Admin/McpServerManager/index.php @@ -0,0 +1,168 @@ + + + + +
+
MCPサーバー状態
+
+ +
+
+
状態
+
+ + 稼働中 + + (PID: ) + + + 停止中 + +
+
+ +
+
AIエージェント設定用URL
+
+ + +
+
+ +
+
内部URL
+
+ +
+
+
+ +
+
+ + +
+
サーバー操作
+
+ +
+ + BcAdminForm->postLink( + '停止', + ['action' => 'stop'], + [ + 'class' => 'bca-btn bca-btn--danger', + 'confirm' => 'MCPサーバーを停止しますか?' + ] + ) ?> + + BcAdminForm->postLink( + '再起動', + ['action' => 'restart'], + [ + 'class' => 'bca-btn bca-btn--warning', + 'confirm' => 'MCPサーバーを再起動しますか?' + ] + ) ?> + + BcAdminForm->postLink( + '起動', + ['action' => 'start'], + [ + 'class' => 'bca-btn bca-btn--success' + ] + ) ?> + + +Html->link( +// '設定', +// ['action' => 'configure'], +// ['class' => 'bca-btn bca-btn--default'] +// ) ?> +
+ +
+
+ + +
+
AIエージェントでの設定方法
+
+ +
+
+
手順1
+
+ 上記の「起動」ボタンでMCPサーバーを起動してください +
+
+ +
+
手順2
+
+ AIエージェントの設定ファイルで上記AIエージェント設定用URLを設定してください +
+
+ +
+
手順3
+
+ 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/Command/McpServerCommandTest.php b/plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php new file mode 100644 index 0000000000..c01df76e99 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Command/McpServerCommandTest.php @@ -0,0 +1,71 @@ + + * 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\Command; + +use BaserCore\TestSuite\BcTestCase; +use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use BcMcp\Command\McpServerCommand; + +/** + * BcMcp\Command\McpServerCommand Test Case + * + * @uses \BcMcp\Command\McpServerCommand + */ +class McpServerCommandTest extends BcTestCase +{ + use ConsoleIntegrationTestTrait; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + } + + /** + * Test buildOptionParser method + * + * @return void + */ + public function testBuildOptionParser() + { + $command = new McpServerCommand(); + $parser = $command->getOptionParser(); + + $options = $parser->options(); + $this->assertArrayHasKey('transport', $options); + $this->assertArrayHasKey('host', $options); + $this->assertArrayHasKey('port', $options); + $this->assertArrayHasKey('config', $options); + + $this->assertEquals('stdio', $options['transport']->defaultValue()); + $this->assertEquals('127.0.0.1', $options['host']->defaultValue()); + $this->assertEquals('3000', $options['port']->defaultValue()); + } + + /** + * Test execute method help + * + * @return void + */ + public function testExecuteHelp() + { + $command = new McpServerCommand(); + $parser = $command->getOptionParser(); + + $this->assertStringContainsString('baserCMS MCP サーバーを起動します', $parser->getDescription()); + } +} 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..c25604a9b7 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Controller/Admin/OAuth2ControllerTest.php @@ -0,0 +1,683 @@ +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 + * + * 統合テストで起動した MCP サーバー(SSE 常駐プロセス)を確実に停止する。 + * 停止しないと CI 上で孤児プロセスがステップの fd を掴んだまま残り、 + * ジョブが次のステップへ進めず無限待機する(GHA で 8.1 がハングした原因)。 + * + * @return void + */ + public function tearDown(): void + { + $mcpServerManager = new McpServerManger(); + if ($mcpServerManager->isServerRunning()) { + $mcpServerManager->stopMcpServer(); + } + parent::tearDown(); + } + + /** + * MCPプロキシ経由の統合テスト用に、実際の MCP サーバー(SSE)を用意する。 + * 起動していなければ起動し、プロキシが接続する 127.0.0.1:{port} へ実際に到達できるまで待つ。 + * 到達できない場合はスキップせず明示的に失敗させる(サーバー起動の不具合を隠さない)。 + * + * @return void + */ + private function requireMcpServer(): void + { + // サーバー子プロセスの `bin/cake bc_mcp.server` は default 接続の DB から + // 有効プラグイン(plugins テーブル status=true)を読んでロードする。BcMcp は + // defaultInstallCorePlugins に含めない方針のため `bin/cake install` では有効化されず、 + // console に bc_mcp.server コマンドが登録されない(CI で起動失敗→500 の原因)。 + // そこでテスト内で BcMcp を有効化しておく(既にあればスキップ)。 + $pluginsTable = TableRegistry::getTableLocator()->get('BaserCore.Plugins'); + if (!$pluginsTable->exists(['name' => 'BcMcp'])) { + PluginFactory::make([ + 'name' => 'BcMcp', + 'title' => 'baserCMS MCP Server', + 'status' => true, + 'db_init' => true, + 'priority' => 100, + ])->persist(); + } + + $mcpServerManager = new McpServerManger(); + $config = $mcpServerManager->getServerConfig(); + // 子プロセス(bin/cake bc_mcp.server)は別プロセスのため、テストが PluginFactory で + // 有効化した BcMcp を default 接続では見られない。test 接続(両プロセスで共有)を使わせ、 + // 子プロセスの bootstrap でも BcMcp がロードされる(=コマンドが登録される)ようにする。 + $config['connection'] = 'test'; + if (!$mcpServerManager->isServerRunning()) { + $mcpServerManager->startMcpServer($config); + } + // プロセス存在だけでなく、プロキシが叩く 127.0.0.1:{port} へ実際に接続できるまで待つ。 + // (プロセス起動直後はポート bind が間に合わず接続拒否 → 500 になることがあるため) + $host = $config['host'] ?? '127.0.0.1'; + $port = (int)($config['port'] ?? 3000); + $deadline = microtime(true) + 15.0; + $reachable = false; + while (microtime(true) < $deadline) { + $conn = @fsockopen($host, $port, $errno, $errstr, 1); + if ($conn) { + fclose($conn); + $reachable = true; + break; + } + usleep(300000); // 0.3秒 + } + if (!$reachable) { + // 失敗時はサーバーの起動ログを添えて原因を可視化する(CI で bin/cake bc_mcp.server が + // 起動できない理由=コマンド未登録・DB未接続・ポート競合等がここに出る)。 + $logFile = LOGS . 'bc_mcp_server.log'; + $serverLog = is_file($logFile) ? (string)file_get_contents($logFile) : '(bc_mcp_server.log が存在しません=サーバープロセスがログを出す前に失敗した可能性)'; + $this->fail(sprintf( + "MCP サーバー(SSE / %s:%d)へ接続できませんでした。\n===== bc_mcp_server.log(末尾3000字) =====\n%s", + $host, + $port, + mb_substr($serverLog, -3000) + )); + } + } + + /** + * テスト用の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 + { + $this->requireMcpServer(); + // MPCサーバーの接続ポイントにGETリクエストを送信 + $this->get('/bc-mcp'); + $this->assertResponseCode(401); + + // 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); + $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->configRequest($requestConfig); + $this->post('/bc-mcp', json_encode($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->configRequest($requestConfig); + $this->post('/bc-mcp', json_encode($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ツールを実行 + $newRequestConfig = [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $newAccessToken, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ] + ]; + + // getBlogPostツールを実行(IDが必要な場合はダミーIDを使用) + $blogPostRequest = [ + 'jsonrpc' => '2.0', + 'id' => 'test-blog-post-tool', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'getBlogPost', + 'arguments' => [ + 'id' => 1 // ダミーID + ] + ] + ]; + $this->configRequest($newRequestConfig); + $this->post('/bc-mcp', json_encode($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 + { + $this->requireMcpServer(); + // 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->configRequest($requestConfig); + $this->post('/bc-mcp', json_encode($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->configRequest($requestConfig); + $this->post('/bc-mcp', json_encode($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/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/Mcp/BaseMcpToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php new file mode 100644 index 0000000000..ad2116f581 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaseMcpToolTest.php @@ -0,0 +1,223 @@ +BaseMcpTool = new TestBaseMcpTool(); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->BaseMcpTool); + 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 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])); + } + + /** + * 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 +{ + // テスト用のため空実装 +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php new file mode 100644 index 0000000000..1472a01d61 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/FileUploadToolTest.php @@ -0,0 +1,359 @@ +fileUploadTool = new FileUploadTool(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + // テスト用ファイルをクリーンアップ + $this->cleanupTestFiles(); + unset($this->fileUploadTool); + parent::tearDown(); + } + + /** + * テスト用ファイルをクリーンアップ + */ + private function cleanupTestFiles(): void + { + $uploadDir = TMP . 'mcp_uploads/'; + if (is_dir($uploadDir)) { + $files = glob($uploadDir . '*'); + foreach($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + } + + /** + * 単一チャンクファイルのアップロードテスト + */ + public function testSendSingleChunk() + { + $fileId = 'test_file_' . uniqid(); + $filename = 'test.txt'; + $content = 'Hello, World!'; + $chunkData = base64_encode($content); + + $result = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, $chunkData, $filename); + + $this->assertEquals('complete', $result['status']); + $this->assertArrayHasKey('file', $result); + + // ファイルが正しく作成されているかチェック + $finalFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($finalFile)); + $this->assertEquals($content, file_get_contents($finalFile)); + } + + /** + * 複数チャンクファイルのアップロードテスト + */ + public function testSendMultipleChunks() + { + $fileId = 'test_multi_' . uniqid(); + $filename = 'test_multi.txt'; + $content1 = 'Hello, '; + $content2 = 'World!'; + $totalChunks = 2; + + // 最初のチャンクを送信 + $result1 = $this->fileUploadTool->sendFileChunk($fileId, 0, $totalChunks, base64_encode($content1), $filename); + + $this->assertEquals('chunk_received', $result1['status']); + $this->assertEquals(1, $result1['progress']); + + // 2番目のチャンクを送信 + $result2 = $this->fileUploadTool->sendFileChunk($fileId, 1, $totalChunks, base64_encode($content2), $filename); + + $this->assertEquals('complete', $result2['status']); + $this->assertArrayHasKey('file', $result2); + + // マージされたファイルが正しく作成されているかチェック + $finalFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($finalFile)); + $this->assertEquals($content1 . $content2, file_get_contents($finalFile)); + + // チャンクファイルが削除されているかチェック + $this->assertFalse(file_exists(TMP . 'mcp_uploads/' . $fileId . '.part0')); + $this->assertFalse(file_exists(TMP . 'mcp_uploads/' . $fileId . '.part1')); + } + + /** + * チャンク順序が異なる場合のテスト + */ + public function testSendChunksOutOfOrder() + { + $fileId = 'test_order_' . uniqid(); + $filename = 'test_order.txt'; + $content1 = 'Hello, '; + $content2 = 'World!'; + $totalChunks = 2; + + // 2番目のチャンクを先に送信 + $result1 = $this->fileUploadTool->sendFileChunk($fileId, 1, $totalChunks, base64_encode($content2), $filename); + + $this->assertEquals('chunk_received', $result1['status']); + $this->assertEquals(2, $result1['progress']); + + // 1番目のチャンクを後で送信 + $result2 = $this->fileUploadTool->sendFileChunk($fileId, 0, $totalChunks, base64_encode($content1), $filename); + + $this->assertEquals('complete', $result2['status']); + + // マージされたファイルが正しい順序で作成されているかチェック + $finalFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($finalFile)); + $this->assertEquals($content1 . $content2, file_get_contents($finalFile)); + } + + /** + * 大きなファイルの分割アップロードテスト + */ + public function testLargeFileUpload() + { + $fileId = 'test_large_' . uniqid(); + $filename = 'test_large.txt'; + $chunkSize = 1024; // 1KB chunks + $totalSize = 3000; // 3KB total + $content = str_repeat('A', $totalSize); + + $chunks = str_split($content, $chunkSize); + $totalChunks = count($chunks); + + // 各チャンクを順番に送信 + for($i = 0; $i < $totalChunks - 1; $i++) { + $result = $this->fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename); + + $this->assertEquals('chunk_received', $result['status']); + $this->assertEquals($i + 1, $result['progress']); + } + + // 最後のチャンクを送信 + $lastIndex = $totalChunks - 1; + $result = $this->fileUploadTool->sendFileChunk($fileId, $lastIndex, $totalChunks, base64_encode($chunks[$lastIndex]), $filename); + + $this->assertEquals('complete', $result['status']); + + // マージされたファイルが正しく作成されているかチェック + $finalFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($finalFile)); + $this->assertEquals($totalSize, filesize($finalFile)); + $this->assertEquals($content, file_get_contents($finalFile)); + } + + /** + * 不正なbase64データのテスト + */ + public function testInvalidBase64Data() + { + $fileId = 'test_invalid_' . uniqid(); + $filename = 'test_invalid.txt'; + $invalidBase64 = 'invalid-base64-data!@#$%'; + + $result = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, $invalidBase64, $filename); + + // base64_decodeはfalseを返すが、空文字列として処理される + $this->assertEquals('complete', $result['status']); + + $finalFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($finalFile)); + // 不正なbase64は空文字列またはガベージデータになる + $this->assertTrue(filesize($finalFile) >= 0); + } + + /** + * アップロードディレクトリが作成されることをテスト + */ + public function testUploadDirectoryCreation() + { + $uploadDir = TMP . 'mcp_uploads/'; + + // ディレクトリが存在することを確認 + $this->assertTrue(is_dir($uploadDir)); + $this->assertTrue(is_writable($uploadDir)); + } + + /** + * 同じファイルIDで複数回アップロードした場合のテスト + */ + public function testDuplicateFileId() + { + $fileId = 'test_duplicate_' . uniqid(); + $filename = 'test_duplicate.txt'; + $content1 = 'First upload'; + $content2 = 'Second upload'; + + // 最初のアップロード + $result1 = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, base64_encode($content1), $filename); + $this->assertEquals('complete', $result1['status']); + + // 同じファイルIDで2回目のアップロード(上書きされる) + $result2 = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, base64_encode($content2), $filename); + $this->assertEquals('complete', $result2['status']); + + // 最後にアップロードされたファイルの内容を確認 + $finalFile = TMP . 'mcp_uploads/' . $filename; + $this->assertEquals($content2, file_get_contents($finalFile)); + } + + /** + * 実際の画像ファイル(basercms.png)を使ったアップロードテスト + */ + public function testUploadRealImageFile() + { + $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png'; + + // ファイルが存在することを確認 + $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません'); + + $imageContent = file_get_contents($imagePath); + $fileId = 'test_image_' . uniqid(); + $filename = 'basercms.png'; + + // 画像ファイルを単一チャンクでアップロード + $result = $this->fileUploadTool->sendFileChunk($fileId, 0, 1, base64_encode($imageContent), $filename); + + $this->assertEquals('complete', $result['status']); + $this->assertArrayHasKey('file', $result); + + // アップロードされたファイルが元のファイルと同じであることを確認 + $uploadedFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($uploadedFile)); + $this->assertEquals(filesize($imagePath), filesize($uploadedFile)); + $this->assertEquals($imageContent, file_get_contents($uploadedFile)); + + // ファイルがPNG画像として有効か確認(オプション) + $imageInfo = getimagesize($uploadedFile); + $this->assertNotFalse($imageInfo, 'アップロードされたファイルが有効な画像ではありません'); + $this->assertEquals(IMAGETYPE_PNG, $imageInfo[2], 'アップロードされたファイルがPNG形式ではありません'); + } + + /** + * 画像ファイルを複数チャンクに分割してアップロードするテスト + */ + public function testUploadImageFileInChunks() + { + $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png'; + + // ファイルが存在することを確認 + $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません'); + + $imageContent = file_get_contents($imagePath); + $fileId = 'test_image_chunks_' . uniqid(); + $filename = 'basercms_chunked.png'; + + // 画像を1024バイトずつのチャンクに分割 + $chunkSize = 1024; + $chunks = str_split($imageContent, $chunkSize); + $totalChunks = count($chunks); + + $this->assertGreaterThan(1, $totalChunks, '画像ファイルが小さすぎてチャンク分割できません'); + + // 各チャンクを順番に送信(最後のチャンク以外) + for($i = 0; $i < $totalChunks - 1; $i++) { + $result = $this->fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename); + + $this->assertEquals('chunk_received', $result['status']); + $this->assertEquals($i + 1, $result['progress']); + } + + // 最後のチャンクを送信 + $lastIndex = $totalChunks - 1; + $result = $this->fileUploadTool->sendFileChunk($fileId, $lastIndex, $totalChunks, base64_encode($chunks[$lastIndex]), $filename); + + $this->assertEquals('complete', $result['status']); + + // マージされたファイルが元のファイルと同じであることを確認 + $uploadedFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($uploadedFile)); + $this->assertEquals(filesize($imagePath), filesize($uploadedFile)); + $this->assertEquals($imageContent, file_get_contents($uploadedFile)); + + // ファイルがPNG画像として有効か確認 + $imageInfo = getimagesize($uploadedFile); + $this->assertNotFalse($imageInfo, 'マージされたファイルが有効な画像ではありません'); + $this->assertEquals(IMAGETYPE_PNG, $imageInfo[2], 'マージされたファイルがPNG形式ではありません'); + + // チャンクファイルが削除されているかチェック + for($i = 0; $i < $totalChunks; $i++) { + $chunkFile = TMP . 'mcp_uploads/' . $fileId . '.part' . $i; + $this->assertFalse(file_exists($chunkFile), "チャンクファイル {$chunkFile} が削除されていません"); + } + } + + /** + * 大きな画像ファイルのMD5ハッシュチェックテスト + */ + public function testImageFileIntegrityWithMd5() + { + $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png'; + + // ファイルが存在することを確認 + $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません'); + + $imageContent = file_get_contents($imagePath); + $originalMd5 = md5($imageContent); + $fileId = 'test_image_md5_' . uniqid(); + $filename = 'basercms_md5.png'; + + // 画像を512バイトずつの小さなチャンクに分割(より細かい分割) + $chunkSize = 512; + $chunks = str_split($imageContent, $chunkSize); + $totalChunks = count($chunks); + + // 各チャンクを順番に送信 + for($i = 0; $i < $totalChunks; $i++) { + $result = $this->fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename); + + + if ($i < $totalChunks - 1) { + $this->assertEquals('chunk_received', $result['status']); + } else { + $this->assertEquals('complete', $result['status']); + } + } + + // アップロードされたファイルのMD5ハッシュを確認 + $uploadedFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($uploadedFile)); + + $uploadedMd5 = md5_file($uploadedFile); + $this->assertEquals($originalMd5, $uploadedMd5, 'アップロードされたファイルのMD5ハッシュが元のファイルと一致しません'); + + // ファイルサイズも確認 + $this->assertEquals(filesize($imagePath), filesize($uploadedFile), 'ファイルサイズが一致しません'); + } +} diff --git a/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php new file mode 100644 index 0000000000..5fcfa44800 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BaserCore/SearchIndexesToolTest.php @@ -0,0 +1,94 @@ +searchIndexesTool = new SearchIndexesTool(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + unset($this->searchIndexesTool); + parent::tearDown(); + } + + public function testFetch() + { + // テストデータを作成 + SearchIndexFactory::make([[ + 'id' => 1, + 'title' => 'テストタイトル1', + 'detail' => 'テスト詳細1', + 'url' => '/test-url-1', + 'status' => 0, + ], [ + 'id' => 2, + 'title' => 'テストタイトル2', + 'detail' => 'テスト詳細2', + 'url' => '/test-url-2', + 'status' => 1, + ]])->persist(); + + // status=0(非公開)のデータはstatus='publish'フィルターで除外される + $result = $this->searchIndexesTool->fetch("1"); + $this->assertEquals('Record not found in table `search_indexes`.', $result['content']); + + // status=1(公開)のデータは取得できる + $result = $this->searchIndexesTool->fetch("2"); + $this->assertArrayHasKey('type', $result); + $this->assertEquals('resource', $result['type']); + $this->assertArrayHasKey('resource', $result); + } + + public function testSearch() + { + // テストデータを作成 + SearchIndexFactory::make([[ + 'id' => 1, + 'title' => 'テストタイトル1', + 'detail' => 'テスト詳細1', + 'url' => '/test-url-1', + 'status' => 0, + ], [ + 'id' => 2, + 'title' => 'テストタイトル2', + 'detail' => 'テスト詳細2', + 'url' => '/test-url-2', + 'status' => 1, + ]])->persist(); + + $result = $this->searchIndexesTool->search("詳細"); + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertInstanceOf('BcMcp\Schema\Content\ResourceLinkContent', $result[0]); + } + +} 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..1f56a8eb40 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcBlog/BlogPostsToolTest.php @@ -0,0 +1,757 @@ + + * 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 = '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=='; + + $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'); + } + + /** + * basercms.png をチャンク分割して送信し、その画像を使ってブログ記事を追加するテスト + */ + public function testAddBlogPostWithChunkedImageUpload() + { + // 初期設定とファクトリー設定 + $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(); + + // basercms.pngファイルを読み込み + $imagePath = WWW_ROOT . 'img' . DS . 'basercms.png'; + $this->assertTrue(file_exists($imagePath), 'basercms.png が存在しません'); + + $imageContent = file_get_contents($imagePath); + $fileId = 'test_blog_image_' . uniqid(); + $filename = 'basercms_blog.png'; + + // FileUploadToolのインスタンスを作成 + $fileUploadTool = new \BcMcp\Mcp\BaserCore\FileUploadTool(); + + // 画像を1024バイトずつのチャンクに分割 + $chunkSize = 1024; + $chunks = str_split($imageContent, $chunkSize); + $totalChunks = count($chunks); + + $this->assertGreaterThan(1, $totalChunks, '画像ファイルが小さすぎてチャンク分割できません'); + + // 各チャンクを順番に送信(最後のチャンク以外) + for($i = 0; $i < $totalChunks - 1; $i++) { + $result = $fileUploadTool->sendFileChunk($fileId, $i, $totalChunks, base64_encode($chunks[$i]), $filename); + + $this->assertEquals('chunk_received', $result['status']); + $this->assertEquals($i + 1, $result['progress']); + } + + // 最後のチャンクを送信 + $lastIndex = $totalChunks - 1; + $result = $fileUploadTool->sendFileChunk($fileId, $lastIndex, $totalChunks, base64_encode($chunks[$lastIndex]), $filename); + + $this->assertEquals('complete', $result['status']); + + // アップロードされたファイルが正しく作成されていることを確認 + $uploadedFile = TMP . 'mcp_uploads/' . $filename; + $this->assertTrue(file_exists($uploadedFile), 'アップロードされたファイルが見つかりません'); + $this->assertEquals($imageContent, file_get_contents($uploadedFile), 'アップロードされたファイルの内容が一致しません'); + + // アップロードしたファイル名を使ってブログ記事を作成 + $blogResult = $this->BlogPostsTool->addBlogPost( + 'チャンク分割画像付きブログ記事', // title + '

チャンク分割でアップロードした画像を使用したテスト記事です。

', // detail + 'news', // blogContent + null, // name + 'チャンク分割画像のテスト概要', // content + null, // category + null, // email + 1, // status (公開) + null, // posted + null, // publishBegin + null, // publishEnd + $filename, // eyeCatch (アップロードしたファイル名) + 1 // loginUserId + ); + + $this->assertNotEmpty($blogResult['eye_catch'], 'アイキャッチ画像が設定されていません'); + + // アップロードされた画像ファイルが正しい場所に配置されていることを確認 + $blogImagePath = WWW_ROOT . 'files' . DS . 'blog' . DS . '1000' . DS . 'blog_posts' . DS . $blogResult['eye_catch']; + $this->assertTrue(file_exists($blogImagePath), 'ブログ用のアイキャッチ画像ファイルが見つかりません'); + + // アイキャッチ画像がPNG形式として有効か確認 + $imageInfo = getimagesize($blogImagePath); + $this->assertNotFalse($imageInfo, 'アイキャッチ画像が有効な画像ではありません'); + $this->assertEquals(IMAGETYPE_PNG, $imageInfo[2], 'アイキャッチ画像がPNG形式ではありません'); + + // テスト後のクリーンアップ + if (file_exists($uploadedFile)) { + unlink($uploadedFile); + } + if (file_exists($blogImagePath)) { + (new BcFolder())->delete(WWW_ROOT . 'files' . DS . 'blog' . DS . '1'); + } + } + +} 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..9122429dc4 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/BcCustomContent/CustomEntriesToolTest.php @@ -0,0 +1,611 @@ + + * 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 PhpMcp\Server\ServerBuilder; + +/** + * 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 - カスタムフィールド付きテスト + * + * @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 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 addToolsToBuilder method - ServerBuilderへのツール追加テスト + * + * @return void + */ + public function testAddToolsToBuilder() + { + // ServerBuilderがfinalクラスのため、実際のインスタンスを使用 + $serverBuilder = new ServerBuilder(); + + $result = $this->CustomEntriesTool->addToolsToBuilder($serverBuilder); + + $this->assertInstanceOf(ServerBuilder::class, $result); + // ServerBuilderが返されることを確認(チェーンメソッドパターン) + $this->assertSame($serverBuilder, $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'] + ]; + + // リフレクションを使ってプライベートメソッドをテスト + $reflection = new \ReflectionClass($this->CustomEntriesTool); + $method = $reflection->getMethod('processCustomFields'); + + $result = $method->invoke($this->CustomEntriesTool, $customFields, 1); // customTableId = 1 を追加 + + $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 getCustomFieldType method + */ + public function testGetCustomFieldType() + { + $customTableId = 1; + $fieldName = 'test_field'; + + // リフレクションを使ってプライベートメソッドをテスト + $reflection = new \ReflectionClass($this->CustomEntriesTool); + $method = $reflection->getMethod('getCustomFieldType'); + + // フィールドタイプが取得できない場合はnullを返す + $result = $method->invoke($this->CustomEntriesTool, $customTableId, $fieldName); + $this->assertNull($result); + } + + /** + * test isFileUploadField method + */ + public function testIsFileUploadField() + { + $customTableId = 1; + $fieldName = 'test_field'; + + // リフレクションを使ってプライベートメソッドをテスト + $reflection = new \ReflectionClass($this->CustomEntriesTool); + $method = $reflection->getMethod('isFileUploadField'); + + // フィールドタイプが取得できない場合はfalseを返す + $result = $method->invoke($this->CustomEntriesTool, $customTableId, $fieldName); + $this->assertFalse($result); + } + + /** + * test isFileUpload method + */ + public function testIsFileUpload() + { + $customTableId = 1; + $fieldName = 'test_field'; + $base64Data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jAuoqQAAAABJRU5ErkJggg=='; + + // リフレクションを使ってプライベートメソッドをテスト + $reflection = new \ReflectionClass($this->CustomEntriesTool); + $method = $reflection->getMethod('isFileUpload'); + + // フィールドタイプが取得できない場合、ファイルアップロード形式でもfalseを返す + $result = $method->invoke($this->CustomEntriesTool, $base64Data, $customTableId, $fieldName); + $this->assertFalse($result); + } +} 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/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/McpServerToolCallTest.php b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php new file mode 100644 index 0000000000..f0d90d0341 --- /dev/null +++ b/plugins/bc-mcp/tests/TestCase/Mcp/McpServerToolCallTest.php @@ -0,0 +1,159 @@ + + * 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\McpServer; +use CakephpFixtureFactories\Scenario\ScenarioAwareTrait; +use PhpMcp\Schema\Request\CallToolRequest; +use PhpMcp\Server\Dispatcher; +use PhpMcp\Server\Session\SubscriptionManager; + +/** + * McpServerToolCallTest + * + * MCPサーバーを別プロセスで起動する事なく、JSON-RPC の tools/call と同じ経路 + * (スキーマ検証 → 引数マッピング → ツール実行)をプロセス内で実行するテスト + */ +class McpServerToolCallTest extends BcTestCase +{ + + use ScenarioAwareTrait; + + /** + * @var Dispatcher + */ + protected $dispatcher; + + /** + * Set up + */ + public function setUp(): void + { + parent::setUp(); + $server = (new McpServer())->getServer(); + $configuration = $server->getConfiguration(); + $this->dispatcher = new Dispatcher( + $configuration, + $server->getRegistry(), + new SubscriptionManager($configuration->logger) + ); + } + + /** + * Tear down + */ + public function tearDown(): void + { + unset($this->dispatcher); + parent::tearDown(); + } + + /** + * tools/call を実行する + * + * @param string $name ツール名 + * @param array $arguments 引数 + * @return array [デコード済みの戻り値, エラーかどうか] + */ + private function callTool(string $name, array $arguments): array + { + $result = $this->dispatcher->handleToolCall( + new CallToolRequest('test-' . $name, $name, $arguments) + ); + $text = $result->content[0]->text ?? ''; + return [json_decode($text, true) ?? $text, $result->isError]; + } + + /** + * 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 + ); + + [$result, $isError] = $this->callTool('addBlogPost', [ + 'title' => 'BcMcpについて', + 'name' => 'about-bcmcp', + 'status' => 0, + 'content' => '

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

', + 'detail' => $this->getDetail(), + 'loginUserId' => 1, + ]); + + // ツール実行時に例外が発生していない事を確認 + $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']); + $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/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/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'],