From fb7c3839a538a6655228756b23826f64568b1bc1 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 29 Sep 2026 13:59:11 +0200 Subject: [PATCH 1/4] fix(models): match User, UserRole and Permissions allowlists to Evo 3.x evo.model.list|get selected columns that no longer exist in Evolution CMS 3.x (users.isfrontend/createdon/blocked..., user_roles.frames/home/rank/locked, permissions.description) and failed with "Unknown column". The allowlists now list only real columns; block state and timestamps stay available through UserAttribute. A new unit test checks every allowlisted field against the column listing of a stock Evo 3.5 install, so a projection can no longer point at a missing column. Co-Authored-By: Claude Opus 5.5 --- .ci/governance-lock.json | 4 +-- SPEC.md | 6 ++-- composer.json | 2 ++ src/Support/ModelFieldPolicy.php | 6 ++-- tests/Fixtures/schema/evo-3.5-columns.json | 15 +++++++++ tests/Phase6/SecurityGuardsTest.php | 8 ++++- tests/Unit/ModelFieldPolicySchemaTest.php | 36 ++++++++++++++++++++++ 7 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/schema/evo-3.5-columns.json create mode 100644 tests/Unit/ModelFieldPolicySchemaTest.php diff --git a/.ci/governance-lock.json b/.ci/governance-lock.json index 1878243..b7d3a61 100644 --- a/.ci/governance-lock.json +++ b/.ci/governance-lock.json @@ -14,7 +14,7 @@ "spec_version": "1.0-contract", "runtime_status": "Gate C baseline validated in demo runtime; RC-1 hardening pending", "spec_public_contract_hash": "752772e83d65c41fe55bd6ddef3359a8ab04f0e3efe67d4901e79870d1967e7b", - "model_allowlists_hash": "2e27381b6120a53f4adb6da3649fcc5319530da4f6e41063d7b7b0700d600731", + "model_allowlists_hash": "4aed463f34f34da8231a6499bd58bcc11755d0bdb9bb69f86d9610b51e92b42d", "golden_fixtures_hash": "d015d5fdab8c8f2f5267cdf47046e6580c730edc7c50cc4515eb4dfd2265c147", - "updated_at": "2026-03-03T06:15:17+00:00" + "updated_at": "2026-09-29T11:20:48+00:00" } diff --git a/SPEC.md b/SPEC.md index a63cbf1..c41ad18 100644 --- a/SPEC.md +++ b/SPEC.md @@ -656,10 +656,10 @@ Default model field allowlists (MUST): - `SitePlugin`: `id`, `name`, `description`, `category`, `locked`, `disabled`, `createdon`, `editedon`. - `SiteModule`: `id`, `name`, `description`, `category`, `disabled`, `createdon`, `editedon`. - `Category`: `id`, `category`. -- `User`: `id`, `username`, `isfrontend`, `createdon`, `editedon`, `blocked`, `blockeduntil`, `blockedafter`. +- `User`: `id`, `username` (block state and timestamps live on `UserAttribute`). - `UserAttribute`: `id`, `internalKey`, `fullname`, `email`, `phone`, `mobilephone`, `blocked`, `blockeduntil`, `blockedafter`, `failedlogincount`, `logincount`, `lastlogin`. -- `UserRole`: `id`, `name`, `description`, `frames`, `home`, `rank`, `locked`. -- `Permissions`: `id`, `name`, `description`. +- `UserRole`: `id`, `name`, `description`. +- `Permissions`: `id`, `name`, `key`, `lang_key`, `group_id`, `disabled`. - `PermissionsGroups`: `id`, `name`. - `RolePermissions`: `id`, `role_id`, `permission`. diff --git a/composer.json b/composer.json index 722188b..0dd1919 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "test:unit:server-registry": "php tests/Unit/ServerRegistryTest.php", "test:unit:test-command": "php tests/Unit/eMcpTestCommandRequirementsTest.php", "test:unit:model-field-leakage": "php tests/Unit/ModelFieldPolicyLeakageTest.php", + "test:unit:model-field-schema": "php tests/Unit/ModelFieldPolicySchemaTest.php", "test:feature:dispatch-idempotency": "php tests/Feature/DispatchIdempotencyBehaviorTest.php", "test:feature:dispatch-async-failover": "php tests/Feature/DispatchAsyncFailoverBehaviorTest.php", "test:feature:dispatch-materialization": "php tests/Feature/DispatchMaterializationPolicyTest.php", @@ -75,6 +76,7 @@ "@test:unit:server-registry", "@test:unit:test-command", "@test:unit:model-field-leakage", + "@test:unit:model-field-schema", "@test:phase25", "@test:phase25:raw-tv", "@test:phase3", diff --git a/src/Support/ModelFieldPolicy.php b/src/Support/ModelFieldPolicy.php index 4a96b0d..e940136 100644 --- a/src/Support/ModelFieldPolicy.php +++ b/src/Support/ModelFieldPolicy.php @@ -17,10 +17,10 @@ final class ModelFieldPolicy 'SitePlugin' => ['id', 'name', 'description', 'category', 'locked', 'disabled', 'createdon', 'editedon'], 'SiteModule' => ['id', 'name', 'description', 'category', 'disabled', 'createdon', 'editedon'], 'Category' => ['id', 'category'], - 'User' => ['id', 'username', 'isfrontend', 'createdon', 'editedon', 'blocked', 'blockeduntil', 'blockedafter'], + 'User' => ['id', 'username'], 'UserAttribute' => ['id', 'internalKey', 'fullname', 'email', 'phone', 'mobilephone', 'blocked', 'blockeduntil', 'blockedafter', 'failedlogincount', 'logincount', 'lastlogin'], - 'UserRole' => ['id', 'name', 'description', 'frames', 'home', 'rank', 'locked'], - 'Permissions' => ['id', 'name', 'description'], + 'UserRole' => ['id', 'name', 'description'], + 'Permissions' => ['id', 'name', 'key', 'lang_key', 'group_id', 'disabled'], 'PermissionsGroups' => ['id', 'name'], 'RolePermissions' => ['id', 'role_id', 'permission'], ]; diff --git a/tests/Fixtures/schema/evo-3.5-columns.json b/tests/Fixtures/schema/evo-3.5-columns.json new file mode 100644 index 0000000..ba41db1 --- /dev/null +++ b/tests/Fixtures/schema/evo-3.5-columns.json @@ -0,0 +1,15 @@ +{ + "SiteTemplate": ["id", "templatename", "templatealias", "templatesource", "description", "editor_type", "category", "icon", "template_type", "content", "locked", "selectable", "createdon", "editedon", "templatefileextension"], + "SiteTmplvar": ["id", "type", "name", "caption", "description", "editor_type", "category", "locked", "elements", "rank", "display", "display_params", "default_text", "properties", "createdon", "editedon"], + "SiteTmplvarContentvalue": ["id", "tmplvarid", "contentid", "value"], + "SiteSnippet": ["id", "name", "description", "editor_type", "category", "cache_type", "snippet", "locked", "properties", "moduleguid", "createdon", "editedon", "disabled"], + "SitePlugin": ["id", "name", "description", "editor_type", "category", "cache_type", "plugincode", "locked", "properties", "disabled", "moduleguid", "createdon", "editedon"], + "SiteModule": ["id", "name", "description", "editor_type", "disabled", "category", "wrap", "locked", "icon", "enable_resource", "resourcefile", "createdon", "editedon", "guid", "enable_sharedparams", "properties", "modulecode"], + "Category": ["id", "category", "rank"], + "User": ["id", "username", "password", "cachepwd", "cachepwd_valid_to", "refresh_token", "access_token", "valid_to", "verified_key"], + "UserAttribute": ["id", "internalKey", "fullname", "first_name", "last_name", "middle_name", "role", "email", "phone", "mobilephone", "blocked", "blockeduntil", "blockedafter", "logincount", "lastlogin", "thislogin", "failedlogincount", "sessionid", "dob", "gender", "country", "street", "city", "state", "zip", "fax", "photo", "comment", "createdon", "editedon", "verified"], + "UserRole": ["id", "name", "description"], + "Permissions": ["id", "name", "key", "lang_key", "group_id", "disabled", "created_at", "updated_at"], + "PermissionsGroups": ["id", "name", "lang_key", "created_at", "updated_at"], + "RolePermissions": ["id", "permission", "role_id", "created_at", "updated_at"] +} diff --git a/tests/Phase6/SecurityGuardsTest.php b/tests/Phase6/SecurityGuardsTest.php index b5ba5fe..18e5433 100644 --- a/tests/Phase6/SecurityGuardsTest.php +++ b/tests/Phase6/SecurityGuardsTest.php @@ -62,11 +62,17 @@ function extractQuotedList(string $content, string $constantName): array assertTrue(!isset($userLookup[$field]), "Sensitive field leaked into User allowlist: {$field}"); } -$requiredSafeFields = ['id', 'username', 'blocked', 'createdon']; +$requiredSafeFields = ['id', 'username']; foreach ($requiredSafeFields as $field) { assertTrue(isset($userLookup[$field]), "Expected safe User field missing from allowlist: {$field}"); } +// Evo 3.x keeps block state and timestamps on user_attributes, not users. +$attributeFields = $allowlists['UserAttribute'] ?? []; +foreach (['blocked', 'internalKey'] as $field) { + assertTrue(in_array($field, $attributeFields, true), "Expected safe UserAttribute field missing from allowlist: {$field}"); +} + $scopesMiddleware = file_get_contents(__DIR__ . '/../../src/Middleware/EnsureMcpScopes.php'); assertTrue(is_string($scopesMiddleware), 'Unable to read EnsureMcpScopes.php'); assertTrue( diff --git a/tests/Unit/ModelFieldPolicySchemaTest.php b/tests/Unit/ModelFieldPolicySchemaTest.php new file mode 100644 index 0000000..9ddd401 --- /dev/null +++ b/tests/Unit/ModelFieldPolicySchemaTest.php @@ -0,0 +1,36 @@ + $fields) { + assertTrue(isset($schema[$model]), "Schema fixture has no columns for model [{$model}]"); + + $columns = array_fill_keys($schema[$model], true); + foreach ($fields as $field) { + assertTrue( + isset($columns[$field]), + "Allowlisted field [{$field}] does not exist in Evo 3.5 table for model [{$model}]" + ); + } +} + +echo "Model field schema checks passed.\n"; From 0c45f9c7a8b2fd48d5e2bace657ef70aa21aba52 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 29 Sep 2026 13:59:25 +0200 Subject: [PATCH 2/4] add(settings): auto-publish eMCP config and a manager settings page - The site copy of the settings (core/custom/config/cms/settings/eMCP.php) is created on first boot when missing: a plain `composer require` never runs vendor:publish, so write tools could not be enabled without shell access. - Tools -> MCP settings (`{manager_url}/emcp/settings`) shows the boolean switches (write tools, endpoints, scopes, rate limit, audit, streaming) as checkboxes. It needs Evo's `settings` permission on top of `emcp`. Values are rewritten token by token so the file keeps its comments; missing keys are inserted, a result that does not parse is refused. - After publishing or saving, Evo 3.5.9+'s configuration cache (env.php) is invalidated so the change applies on the next request. - Manager pages return a 403 response instead of calling abort(), which Evo does not define (was a 500), and follow manager_language instead of always rendering English. - Page and menu strings translated into all 22 locales. - docker: drop the configuration cache after installing the package, otherwise Evo 3.5.9 does not load the eMCP provider at all. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + DOCS.md | 4 +- DOCS.uk.md | 4 +- README.md | 4 + README.uk.md | 4 + composer.json | 2 + docker/entrypoint.sh | 3 + lang/az/global.php | 19 ++ lang/be/global.php | 19 ++ lang/bg/global.php | 19 ++ lang/cs/global.php | 19 ++ lang/da/global.php | 19 ++ lang/de/global.php | 19 ++ lang/en/global.php | 19 ++ lang/es/global.php | 19 ++ lang/fa/global.php | 19 ++ lang/fi/global.php | 19 ++ lang/fr/global.php | 19 ++ lang/he/global.php | 19 ++ lang/it/global.php | 19 ++ lang/ja/global.php | 19 ++ lang/nl/global.php | 19 ++ lang/nn/global.php | 19 ++ lang/pl/global.php | 19 ++ lang/pt/global.php | 19 ++ lang/sk/global.php | 19 ++ lang/sv/global.php | 19 ++ lang/uk/global.php | 19 ++ lang/zh/global.php | 19 ++ plugins/eMCPPlugin.php | 31 +++ src/Http/Controllers/Concerns/ManagerPage.php | 66 +++++ .../Controllers/SettingsPageController.php | 107 +++++++ src/Http/Controllers/TokensPageController.php | 62 ++--- src/Http/mgrRoutes.php | 10 + src/Support/CustomConfigPath.php | 32 +++ src/Support/SettingsCatalog.php | 51 ++++ src/Support/SettingsFile.php | 261 ++++++++++++++++++ src/eMCPServiceProvider.php | 34 ++- tests/Unit/SettingsFileTest.php | 136 +++++++++ views/manager/settings.blade.php | 70 +++++ views/manager/tokens.blade.php | 3 + 41 files changed, 1248 insertions(+), 58 deletions(-) create mode 100644 src/Http/Controllers/Concerns/ManagerPage.php create mode 100644 src/Http/Controllers/SettingsPageController.php create mode 100644 src/Support/CustomConfigPath.php create mode 100644 src/Support/SettingsCatalog.php create mode 100644 src/Support/SettingsFile.php create mode 100644 tests/Unit/SettingsFileTest.php create mode 100644 views/manager/settings.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e859c..2bbb67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Content read tools respect document groups (`use_udperms`) and `view_unpublished`; model catalog reads require the permission of the matching manager screen. - `evo.elements.list|get` and write tools `evo.write.content.update|create|publish`, `evo.write.elements.save`, `evo.write.cache.clear` (behind `security.enable_write_tools` and the `mcp:write` scope). - `docker/` compose setup with a preinstalled site and smoke test. +- `core/custom/config/cms/settings/eMCP.php` is published automatically on first boot when missing (plain `composer require` never runs `vendor:publish`). +- **Tools → MCP settings** manager page (`{manager_url}/emcp/settings`, `settings` permission): checkboxes for the boolean switches, saved into that file with its comments kept. +### Fixed +- `evo.model.list|get` for `User`, `UserRole` and `Permissions` selected columns that do not exist in Evolution CMS 3.x (`isfrontend`, `frames`, ...) and failed with an SQL error; allowlists now match the 3.x schema. All notable changes to this project will be documented in this file. diff --git a/DOCS.md b/DOCS.md index fc12baa..91f893c 100644 --- a/DOCS.md +++ b/DOCS.md @@ -93,7 +93,9 @@ php artisan migrate ``` ## 5) Publish Resources -Auto-publish may be enabled by installer flows, but explicit publish is recommended: +`core/custom/config/cms/settings/eMCP.php` is created automatically on the first request after install when it is missing, so `emcp-config` is only needed to restore the defaults. `emcp-mcp-config` changes the MCP server list, `emcp-stubs` scaffolds your own servers and tools. + +Site-wide switches (write tools, manager/API endpoints, token scopes, rate limit, audit log, streaming) are also available as checkboxes in **Tools → MCP settings** (`{manager_url}/emcp/settings`, requires the `settings` permission). The page edits the same file and keeps its comments. ```bash php artisan vendor:publish --provider="EvolutionCMS\\eMCP\\eMCPServiceProvider" --tag=emcp-config diff --git a/DOCS.uk.md b/DOCS.uk.md index bd73884..cf4eb14 100644 --- a/DOCS.uk.md +++ b/DOCS.uk.md @@ -93,7 +93,9 @@ php artisan migrate ``` ## 5) Publish ресурсів -Auto-publish може бути увімкнений інсталером, але краще явно виконати: +`core/custom/config/cms/settings/eMCP.php` створюється автоматично під час першого запиту після встановлення, якщо його немає, тому `emcp-config` потрібен лише щоб повернути значення за замовчуванням. `emcp-mcp-config` змінює список MCP-серверів, `emcp-stubs` дає шаблони для власних серверів і tools. + +Загальносайтові перемикачі (інструменти запису, endpoint менеджера/API, scopes токенів, rate limit, журнал аудиту, streaming) також доступні як чекбокси в **Інструменти → Налаштування MCP** (`{manager_url}/emcp/settings`, потрібен дозвіл `settings`). Сторінка редагує той самий файл і зберігає коментарі в ньому. ```bash php artisan vendor:publish --provider="EvolutionCMS\\eMCP\\eMCPServiceProvider" --tag=emcp-config diff --git a/README.md b/README.md index 49f38a6..626adc5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ php artisan migrate ## Publish Config and Stubs +`core/custom/config/cms/settings/eMCP.php` is created automatically on the first request after install when it is missing, so `emcp-config` is only needed to restore the defaults. `emcp-mcp-config` changes the MCP server list, `emcp-stubs` scaffolds your own servers and tools. + +Site-wide switches (write tools, manager/API endpoints, token scopes, rate limit, audit log, streaming) are also available as checkboxes in **Tools → MCP settings** (`{manager_url}/emcp/settings`, requires the `settings` permission). The page edits the same file and keeps its comments. + ```bash php artisan vendor:publish --provider="EvolutionCMS\\eMCP\\eMCPServiceProvider" --tag=emcp-config php artisan vendor:publish --provider="EvolutionCMS\\eMCP\\eMCPServiceProvider" --tag=emcp-mcp-config diff --git a/README.uk.md b/README.uk.md index 8ba62b1..fa0ba17 100644 --- a/README.uk.md +++ b/README.uk.md @@ -48,6 +48,10 @@ php artisan migrate ## Publish конфігів і stubs +`core/custom/config/cms/settings/eMCP.php` створюється автоматично під час першого запиту після встановлення, якщо його немає, тому `emcp-config` потрібен лише щоб повернути значення за замовчуванням. `emcp-mcp-config` змінює список MCP-серверів, `emcp-stubs` дає шаблони для власних серверів і tools. + +Загальносайтові перемикачі (інструменти запису, endpoint менеджера/API, scopes токенів, rate limit, журнал аудиту, streaming) також доступні як чекбокси в **Інструменти → Налаштування MCP** (`{manager_url}/emcp/settings`, потрібен дозвіл `settings`). Сторінка редагує той самий файл і зберігає коментарі в ньому. + ```bash php artisan vendor:publish --provider="EvolutionCMS\\eMCP\\eMCPServiceProvider" --tag=emcp-config php artisan vendor:publish --provider="EvolutionCMS\\eMCP\\eMCPServiceProvider" --tag=emcp-mcp-config diff --git a/composer.json b/composer.json index 0dd1919..5e0e617 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "test:unit:test-command": "php tests/Unit/eMcpTestCommandRequirementsTest.php", "test:unit:model-field-leakage": "php tests/Unit/ModelFieldPolicyLeakageTest.php", "test:unit:model-field-schema": "php tests/Unit/ModelFieldPolicySchemaTest.php", + "test:unit:settings-file": "php tests/Unit/SettingsFileTest.php", "test:feature:dispatch-idempotency": "php tests/Feature/DispatchIdempotencyBehaviorTest.php", "test:feature:dispatch-async-failover": "php tests/Feature/DispatchAsyncFailoverBehaviorTest.php", "test:feature:dispatch-materialization": "php tests/Feature/DispatchMaterializationPolicyTest.php", @@ -77,6 +78,7 @@ "@test:unit:test-command", "@test:unit:model-field-leakage", "@test:unit:model-field-schema", + "@test:unit:settings-file", "@test:phase25", "@test:phase25:raw-tv", "@test:phase3", diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bcc3e5a..45f0b1c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -41,6 +41,8 @@ if [ ! -f "$CORE/config/database/connections/default.php" ]; then JSON # System composer, not core/vendor/bin/composer: the latter would replace the very files it runs from. (cd "$CORE" && composer update --no-dev --no-interaction --optimize-autoloader --with-all-dependencies evolution-cms/emcp) + # Evo 3.5.9+ caches the configuration (providers included) during install; drop it so eMCP loads. + rm -f "$CORE/storage/cache/env.php" log "Publishing eMCP config ..." (cd "$CORE" \ @@ -83,6 +85,7 @@ cat < 'Server tapılmadı', 'invalid_payload' => 'Yanlış payload', ], + 'menu_settings' => 'MCP parametrləri', + 'settings' => [ + 'title' => 'MCP parametrləri', + 'intro' => 'Bu açarlar bütün sayta aiddir və :path faylında saxlanılır. Digər parametrlər (scope-lar, limitlər, icazə siyahıları) birbaşa həmin faylda redaktə olunur.', + 'save' => 'Parametrləri saxla', + 'saved' => 'Parametrlər saxlanıldı.', + 'error_write' => ':path saxlanıla bilmədi. Veb serverin bu fayla yaza bildiyini yoxlayın.', + 'fields' => [ + 'enable' => ['label' => 'eMCP-ni aktivləşdir', 'hint' => 'Bütün MCP endpoint-ləri üçün əsas açar.'], + 'mode_internal' => ['label' => 'Manager endpoint-i', 'hint' => 'Manager sessiyası üzərindən MCP.'], + 'mode_api' => ['label' => 'API endpoint-i', 'hint' => 'Xarici agentlər üçün tokenlə autentifikasiya olunan endpoint.'], + 'require_scopes' => ['label' => 'Token scope-larını tələb et', 'hint' => 'Hər çağırış tokenin scope-ları ilə əhatə olunmalıdır.'], + 'enable_write_tools' => ['label' => 'Yazma alətlərinə icazə ver', 'hint' => 'Agentlər sənədləri və elementləri yarada və dəyişə bilər (evo.write.*). Tokenə həmçinin mcp:write scope-u lazımdır.'], + 'self_service' => ['label' => 'Tokenlərin özünəxidmət səhifəsi', 'hint' => 'İstifadəçilər öz tokenlərini managerdə yaradır.'], + 'rate_limit' => ['label' => 'Sorğu limiti', 'hint' => 'Hər istifadəçi üçün dəqiqədəki sorğu sayını məhdudlaşdırır.'], + 'audit' => ['label' => 'Audit jurnalı', 'hint' => 'Hər MCP çağırışını audit jurnalına yazır.'], + 'stream' => ['label' => 'Axınlı cavablar', 'hint' => 'Axınlı (SSE) cavablara icazə verir.'], + ], + ], ]; diff --git a/lang/be/global.php b/lang/be/global.php index 9c401f1..8f7c4e7 100644 --- a/lang/be/global.php +++ b/lang/be/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Сервер не знойдзены', 'invalid_payload' => 'Некарэктны payload', ], + 'menu_settings' => 'Налады MCP', + 'settings' => [ + 'title' => 'Налады MCP', + 'intro' => 'Гэтыя пераключальнікі дзейнічаюць для ўсяго сайта і захоўваюцца ў :path. Астатнія параметры (scopes, ліміты, спісы дазволеных) рэдагуюцца непасрэдна ў гэтым файле.', + 'save' => 'Захаваць налады', + 'saved' => 'Налады захаваны.', + 'error_write' => 'Не ўдалося захаваць :path. Праверце, ці можа вэб-сервер запісваць у гэты файл.', + 'fields' => [ + 'enable' => ['label' => 'Уключыць eMCP', 'hint' => 'Галоўны выключальнік для ўсіх MCP endpoint.'], + 'mode_internal' => ['label' => 'Endpoint мэнэджара', 'hint' => 'MCP праз сесію мэнэджара.'], + 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint з аўтэнтыфікацыяй па токене для знешніх агентаў.'], + 'require_scopes' => ['label' => 'Патрабаваць scopes токена', 'hint' => 'Кожны выклік павінен пакрывацца scopes свайго токена.'], + 'enable_write_tools' => ['label' => 'Дазволіць інструменты запісу', 'hint' => 'Агенты могуць ствараць і змяняць дакументы і элементы (evo.write.*). Токену таксама патрэбны scope mcp:write.'], + 'self_service' => ['label' => 'Старонка самаабслугоўвання токенаў', 'hint' => 'Карыстальнікі самі ствараюць свае токены ў мэнэджары.'], + 'rate_limit' => ['label' => 'Абмежаванне частаты запытаў', 'hint' => 'Абмяжоўвае колькасць запытаў у хвіліну для кожнага карыстальніка.'], + 'audit' => ['label' => 'Журнал аўдыту', 'hint' => 'Запісваць кожны выклік MCP у журнал аўдыту.'], + 'stream' => ['label' => 'Струменевыя адказы', 'hint' => 'Дазволіць струменевыя (SSE) адказы.'], + ], + ], ]; diff --git a/lang/bg/global.php b/lang/bg/global.php index ce05781..7bbecc9 100644 --- a/lang/bg/global.php +++ b/lang/bg/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Сървърът не е намерен', 'invalid_payload' => 'Невалиден payload', ], + 'menu_settings' => 'MCP настройки', + 'settings' => [ + 'title' => 'MCP настройки', + 'intro' => 'Тези превключватели важат за целия сайт и се записват в :path. Останалите опции (scopes, лимити, списъци с разрешени) се редактират директно в този файл.', + 'save' => 'Запази настройките', + 'saved' => 'Настройките са запазени.', + 'error_write' => 'Неуспешно записване на :path. Проверете дали уеб сървърът има права за запис във файла.', + 'fields' => [ + 'enable' => ['label' => 'Включи eMCP', 'hint' => 'Главен превключвател за всички MCP endpoint-и.'], + 'mode_internal' => ['label' => 'Endpoint в мениджъра', 'hint' => 'MCP чрез сесията на мениджъра.'], + 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint с удостоверяване чрез токен за външни агенти.'], + 'require_scopes' => ['label' => 'Изисквай scopes на токена', 'hint' => 'Всяко извикване трябва да е покрито от scopes на своя токен.'], + 'enable_write_tools' => ['label' => 'Разреши инструменти за запис', 'hint' => 'Агентите могат да създават и променят документи и елементи (evo.write.*). Токенът трябва да има и scope mcp:write.'], + 'self_service' => ['label' => 'Страница за самообслужване с токени', 'hint' => 'Потребителите създават свои токени в мениджъра.'], + 'rate_limit' => ['label' => 'Ограничение на заявките', 'hint' => 'Ограничава броя заявки в минута за всеки потребител.'], + 'audit' => ['label' => 'Одитен журнал', 'hint' => 'Записва всяко MCP извикване в одитния журнал.'], + 'stream' => ['label' => 'Поточни отговори', 'hint' => 'Разрешава поточни (SSE) отговори.'], + ], + ], ]; diff --git a/lang/cs/global.php b/lang/cs/global.php index a572974..c054e1f 100644 --- a/lang/cs/global.php +++ b/lang/cs/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Server nenalezen', 'invalid_payload' => 'Neplatný payload', ], + 'menu_settings' => 'Nastavení MCP', + 'settings' => [ + 'title' => 'Nastavení MCP', + 'intro' => 'Tyto přepínače platí pro celý web a ukládají se do :path. Ostatní volby (scopes, limity, seznamy povolených) se upravují přímo v tomto souboru.', + 'save' => 'Uložit nastavení', + 'saved' => 'Nastavení uloženo.', + 'error_write' => 'Soubor :path se nepodařilo uložit. Zkontrolujte, zda do něj webový server může zapisovat.', + 'fields' => [ + 'enable' => ['label' => 'Zapnout eMCP', 'hint' => 'Hlavní vypínač všech MCP endpointů.'], + 'mode_internal' => ['label' => 'Endpoint v manažeru', 'hint' => 'MCP přes relaci manažera.'], + 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint s ověřením tokenem pro externí agenty.'], + 'require_scopes' => ['label' => 'Vyžadovat scopes tokenu', 'hint' => 'Každé volání musí být pokryto scopes svého tokenu.'], + 'enable_write_tools' => ['label' => 'Povolit zapisovací nástroje', 'hint' => 'Agenti mohou vytvářet a měnit dokumenty a elementy (evo.write.*). Token navíc potřebuje scope mcp:write.'], + 'self_service' => ['label' => 'Samoobslužná stránka tokenů', 'hint' => 'Uživatelé si vytvářejí vlastní tokeny v manažeru.'], + 'rate_limit' => ['label' => 'Omezení počtu požadavků', 'hint' => 'Omezí počet požadavků za minutu pro každého uživatele.'], + 'audit' => ['label' => 'Auditní log', 'hint' => 'Zapisovat každé volání MCP do auditního logu.'], + 'stream' => ['label' => 'Streamované odpovědi', 'hint' => 'Povolit streamované (SSE) odpovědi.'], + ], + ], ]; diff --git a/lang/da/global.php b/lang/da/global.php index bbaad59..54fbac6 100644 --- a/lang/da/global.php +++ b/lang/da/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Server ikke fundet', 'invalid_payload' => 'Ugyldig payload', ], + 'menu_settings' => 'MCP-indstillinger', + 'settings' => [ + 'title' => 'MCP-indstillinger', + 'intro' => 'Disse kontakter gælder for hele sitet og gemmes i :path. Øvrige indstillinger (scopes, grænser, tilladelseslister) redigeres direkte i filen.', + 'save' => 'Gem indstillinger', + 'saved' => 'Indstillingerne er gemt.', + 'error_write' => 'Kunne ikke gemme :path. Kontrollér, at webserveren kan skrive til filen.', + 'fields' => [ + 'enable' => ['label' => 'Aktivér eMCP', 'hint' => 'Hovedkontakt for alle MCP-endpoints.'], + 'mode_internal' => ['label' => 'Manager-endpoint', 'hint' => 'MCP via manager-sessionen.'], + 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Token-godkendt endpoint til eksterne agenter.'], + 'require_scopes' => ['label' => 'Kræv token-scopes', 'hint' => 'Hvert kald skal være dækket af tokenets scopes.'], + 'enable_write_tools' => ['label' => 'Tillad skriveværktøjer', 'hint' => 'Agenter må oprette og ændre dokumenter og elementer (evo.write.*). Tokenet skal også have scopet mcp:write.'], + 'self_service' => ['label' => 'Selvbetjeningsside for tokens', 'hint' => 'Brugere opretter selv deres tokens i manageren.'], + 'rate_limit' => ['label' => 'Begrænsning af forespørgsler', 'hint' => 'Begrænser antallet af forespørgsler pr. minut for hver bruger.'], + 'audit' => ['label' => 'Revisionslog', 'hint' => 'Registrér hvert MCP-kald i revisionsloggen.'], + 'stream' => ['label' => 'Streamede svar', 'hint' => 'Tillad streamede (SSE) svar.'], + ], + ], ]; diff --git a/lang/de/global.php b/lang/de/global.php index 7e89de7..c2b9d80 100644 --- a/lang/de/global.php +++ b/lang/de/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Server nicht gefunden', 'invalid_payload' => 'Ungültiger Payload', ], + 'menu_settings' => 'MCP-Einstellungen', + 'settings' => [ + 'title' => 'MCP-Einstellungen', + 'intro' => 'Diese Schalter gelten für die gesamte Website und werden in :path gespeichert. Weitere Optionen (Scopes, Limits, Allowlists) werden direkt in dieser Datei bearbeitet.', + 'save' => 'Einstellungen speichern', + 'saved' => 'Einstellungen gespeichert.', + 'error_write' => ':path konnte nicht gespeichert werden. Prüfen Sie, ob der Webserver in die Datei schreiben darf.', + 'fields' => [ + 'enable' => ['label' => 'eMCP aktivieren', 'hint' => 'Hauptschalter für alle MCP-Endpunkte.'], + 'mode_internal' => ['label' => 'Manager-Endpunkt', 'hint' => 'MCP über die Manager-Sitzung.'], + 'mode_api' => ['label' => 'API-Endpunkt', 'hint' => 'Per Token authentifizierter Endpunkt für externe Agenten.'], + 'require_scopes' => ['label' => 'Token-Scopes erzwingen', 'hint' => 'Jeder Aufruf muss von den Scopes seines Tokens abgedeckt sein.'], + 'enable_write_tools' => ['label' => 'Schreib-Tools erlauben', 'hint' => 'Agenten dürfen Dokumente und Elemente anlegen und ändern (evo.write.*). Das Token braucht zusätzlich den Scope mcp:write.'], + 'self_service' => ['label' => 'Self-Service-Seite für Tokens', 'hint' => 'Benutzer erstellen ihre eigenen Tokens im Manager.'], + 'rate_limit' => ['label' => 'Ratenbegrenzung', 'hint' => 'Begrenzt die Anfragen pro Minute für jeden Benutzer.'], + 'audit' => ['label' => 'Audit-Log', 'hint' => 'Jeden MCP-Aufruf im Audit-Log protokollieren.'], + 'stream' => ['label' => 'Gestreamte Antworten', 'hint' => 'Gestreamte (SSE) Antworten erlauben.'], + ], + ], ]; diff --git a/lang/en/global.php b/lang/en/global.php index 4eed9c1..2168396 100644 --- a/lang/en/global.php +++ b/lang/en/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Server not found', 'invalid_payload' => 'Invalid payload', ], + 'menu_settings' => 'MCP settings', + 'settings' => [ + 'title' => 'MCP settings', + 'intro' => 'These switches apply to the whole site and are saved to :path. Other options (scopes, limits, allowlists) are edited in that file directly.', + 'save' => 'Save settings', + 'saved' => 'Settings saved.', + 'error_write' => 'Could not save :path. Check that the web server can write to it.', + 'fields' => [ + 'enable' => ['label' => 'Enable eMCP', 'hint' => 'Master switch for all MCP endpoints.'], + 'mode_internal' => ['label' => 'Manager endpoint', 'hint' => 'MCP over the manager session.'], + 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Token-authenticated endpoint for external agents.'], + 'require_scopes' => ['label' => 'Require token scopes', 'hint' => 'Every call must be covered by the scopes of its token.'], + 'enable_write_tools' => ['label' => 'Allow write tools', 'hint' => 'Agents may create and change documents and elements (evo.write.*). A token also needs the mcp:write scope.'], + 'self_service' => ['label' => 'Token self-service page', 'hint' => 'Users create their own tokens in the manager.'], + 'rate_limit' => ['label' => 'Rate limiting', 'hint' => 'Limit the number of requests per minute for each user.'], + 'audit' => ['label' => 'Audit log', 'hint' => 'Record every MCP call in the audit log.'], + 'stream' => ['label' => 'Streaming responses', 'hint' => 'Allow streamed (SSE) responses.'], + ], + ], ]; diff --git a/lang/es/global.php b/lang/es/global.php index 6404116..d11df94 100644 --- a/lang/es/global.php +++ b/lang/es/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Servidor no encontrado', 'invalid_payload' => 'Payload no válido', ], + 'menu_settings' => 'Ajustes de MCP', + 'settings' => [ + 'title' => 'Ajustes de MCP', + 'intro' => 'Estos interruptores se aplican a todo el sitio y se guardan en :path. Las demás opciones (scopes, límites, listas permitidas) se editan directamente en ese archivo.', + 'save' => 'Guardar ajustes', + 'saved' => 'Ajustes guardados.', + 'error_write' => 'No se pudo guardar :path. Compruebe que el servidor web puede escribir en él.', + 'fields' => [ + 'enable' => ['label' => 'Activar eMCP', 'hint' => 'Interruptor general de todos los endpoints MCP.'], + 'mode_internal' => ['label' => 'Endpoint del manager', 'hint' => 'MCP a través de la sesión del manager.'], + 'mode_api' => ['label' => 'Endpoint de API', 'hint' => 'Endpoint autenticado por token para agentes externos.'], + 'require_scopes' => ['label' => 'Exigir scopes del token', 'hint' => 'Cada llamada debe estar cubierta por los scopes de su token.'], + 'enable_write_tools' => ['label' => 'Permitir herramientas de escritura', 'hint' => 'Los agentes pueden crear y modificar documentos y elementos (evo.write.*). El token también necesita el scope mcp:write.'], + 'self_service' => ['label' => 'Página de autoservicio de tokens', 'hint' => 'Los usuarios crean sus propios tokens en el manager.'], + 'rate_limit' => ['label' => 'Límite de peticiones', 'hint' => 'Limita las peticiones por minuto de cada usuario.'], + 'audit' => ['label' => 'Registro de auditoría', 'hint' => 'Registrar cada llamada MCP en el registro de auditoría.'], + 'stream' => ['label' => 'Respuestas en streaming', 'hint' => 'Permitir respuestas en streaming (SSE).'], + ], + ], ]; diff --git a/lang/fa/global.php b/lang/fa/global.php index 16c7cdb..d1a5b75 100644 --- a/lang/fa/global.php +++ b/lang/fa/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'سرور یافت نشد', 'invalid_payload' => 'بار داده نامعتبر', ], + 'menu_settings' => 'تنظیمات MCP', + 'settings' => [ + 'title' => 'تنظیمات MCP', + 'intro' => 'این کلیدها برای کل سایت اعمال می‌شوند و در :path ذخیره می‌شوند. گزینه‌های دیگر (scopeها، محدودیت‌ها، فهرست‌های مجاز) مستقیماً در همان فایل ویرایش می‌شوند.', + 'save' => 'ذخیره تنظیمات', + 'saved' => 'تنظیمات ذخیره شد.', + 'error_write' => 'ذخیره :path ممکن نشد. بررسی کنید که وب‌سرور اجازه نوشتن در آن را داشته باشد.', + 'fields' => [ + 'enable' => ['label' => 'فعال‌سازی eMCP', 'hint' => 'کلید اصلی همه endpointهای MCP.'], + 'mode_internal' => ['label' => 'endpoint مدیریت', 'hint' => 'MCP از طریق نشست مدیریت.'], + 'mode_api' => ['label' => 'endpoint API', 'hint' => 'endpoint با احراز هویت توکن برای عامل‌های خارجی.'], + 'require_scopes' => ['label' => 'الزام scopeهای توکن', 'hint' => 'هر فراخوانی باید در scopeهای توکن خود پوشش داده شود.'], + 'enable_write_tools' => ['label' => 'اجازه ابزارهای نوشتن', 'hint' => 'عامل‌ها می‌توانند اسناد و عناصر را ایجاد و ویرایش کنند (evo.write.*). توکن به scope mcp:write نیز نیاز دارد.'], + 'self_service' => ['label' => 'صفحه سلف‌سرویس توکن', 'hint' => 'کاربران توکن‌های خود را در بخش مدیریت می‌سازند.'], + 'rate_limit' => ['label' => 'محدودیت نرخ درخواست', 'hint' => 'تعداد درخواست‌ها در دقیقه را برای هر کاربر محدود می‌کند.'], + 'audit' => ['label' => 'گزارش حسابرسی', 'hint' => 'هر فراخوانی MCP در گزارش حسابرسی ثبت شود.'], + 'stream' => ['label' => 'پاسخ‌های جریانی', 'hint' => 'اجازه پاسخ‌های جریانی (SSE).'], + ], + ], ]; diff --git a/lang/fi/global.php b/lang/fi/global.php index 8d52073..68ec136 100644 --- a/lang/fi/global.php +++ b/lang/fi/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Palvelinta ei löytynyt', 'invalid_payload' => 'Virheellinen payload', ], + 'menu_settings' => 'MCP-asetukset', + 'settings' => [ + 'title' => 'MCP-asetukset', + 'intro' => 'Nämä kytkimet koskevat koko sivustoa ja tallennetaan tiedostoon :path. Muita asetuksia (scopet, rajat, sallittujen listat) muokataan suoraan tiedostossa.', + 'save' => 'Tallenna asetukset', + 'saved' => 'Asetukset tallennettu.', + 'error_write' => 'Tiedostoa :path ei voitu tallentaa. Tarkista, että verkkopalvelin voi kirjoittaa siihen.', + 'fields' => [ + 'enable' => ['label' => 'Ota eMCP käyttöön', 'hint' => 'Pääkytkin kaikille MCP-endpointeille.'], + 'mode_internal' => ['label' => 'Managerin endpoint', 'hint' => 'MCP managerin istunnon kautta.'], + 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Tokenilla todennettu endpoint ulkoisille agenteille.'], + 'require_scopes' => ['label' => 'Vaadi tokenin scopet', 'hint' => 'Jokaisen kutsun on kuuluttava tokenin scopeihin.'], + 'enable_write_tools' => ['label' => 'Salli kirjoitustyökalut', 'hint' => 'Agentit voivat luoda ja muuttaa dokumentteja ja elementtejä (evo.write.*). Token tarvitsee lisäksi scopen mcp:write.'], + 'self_service' => ['label' => 'Tokenien itsepalvelusivu', 'hint' => 'Käyttäjät luovat omat tokeninsa managerissa.'], + 'rate_limit' => ['label' => 'Pyyntörajoitus', 'hint' => 'Rajoittaa kunkin käyttäjän pyyntöjä minuutissa.'], + 'audit' => ['label' => 'Auditointiloki', 'hint' => 'Kirjaa jokainen MCP-kutsu auditointilokiin.'], + 'stream' => ['label' => 'Suoratoistetut vastaukset', 'hint' => 'Salli suoratoistetut (SSE) vastaukset.'], + ], + ], ]; diff --git a/lang/fr/global.php b/lang/fr/global.php index cc09be8..a2d5541 100644 --- a/lang/fr/global.php +++ b/lang/fr/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Serveur introuvable', 'invalid_payload' => 'Payload invalide', ], + 'menu_settings' => 'Paramètres MCP', + 'settings' => [ + 'title' => 'Paramètres MCP', + 'intro' => 'Ces interrupteurs s’appliquent à tout le site et sont enregistrés dans :path. Les autres options (scopes, limites, listes autorisées) se modifient directement dans ce fichier.', + 'save' => 'Enregistrer les paramètres', + 'saved' => 'Paramètres enregistrés.', + 'error_write' => 'Impossible d’enregistrer :path. Vérifiez que le serveur web peut écrire dans ce fichier.', + 'fields' => [ + 'enable' => ['label' => 'Activer eMCP', 'hint' => 'Interrupteur général de tous les endpoints MCP.'], + 'mode_internal' => ['label' => 'Endpoint du manager', 'hint' => 'MCP via la session du manager.'], + 'mode_api' => ['label' => 'Endpoint API', 'hint' => 'Endpoint authentifié par token pour les agents externes.'], + 'require_scopes' => ['label' => 'Exiger les scopes du token', 'hint' => 'Chaque appel doit être couvert par les scopes de son token.'], + 'enable_write_tools' => ['label' => 'Autoriser les outils d’écriture', 'hint' => 'Les agents peuvent créer et modifier des documents et des éléments (evo.write.*). Le token doit aussi avoir le scope mcp:write.'], + 'self_service' => ['label' => 'Page libre-service des tokens', 'hint' => 'Les utilisateurs créent leurs propres tokens dans le manager.'], + 'rate_limit' => ['label' => 'Limitation du débit', 'hint' => 'Limite le nombre de requêtes par minute pour chaque utilisateur.'], + 'audit' => ['label' => 'Journal d’audit', 'hint' => 'Enregistrer chaque appel MCP dans le journal d’audit.'], + 'stream' => ['label' => 'Réponses en streaming', 'hint' => 'Autoriser les réponses en streaming (SSE).'], + ], + ], ]; diff --git a/lang/he/global.php b/lang/he/global.php index f942c20..c55b6eb 100644 --- a/lang/he/global.php +++ b/lang/he/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'השרת לא נמצא', 'invalid_payload' => 'payload לא תקין', ], + 'menu_settings' => 'הגדרות MCP', + 'settings' => [ + 'title' => 'הגדרות MCP', + 'intro' => 'מתגים אלה חלים על כל האתר ונשמרים ב-:path. אפשרויות אחרות (scopes, מגבלות, רשימות מותרות) נערכות ישירות בקובץ זה.', + 'save' => 'שמירת הגדרות', + 'saved' => 'ההגדרות נשמרו.', + 'error_write' => 'לא ניתן לשמור את :path. ודאו ששרת האינטרנט יכול לכתוב לקובץ.', + 'fields' => [ + 'enable' => ['label' => 'הפעלת eMCP', 'hint' => 'מתג ראשי לכל נקודות הקצה של MCP.'], + 'mode_internal' => ['label' => 'נקודת קצה במנהל', 'hint' => 'MCP דרך הסשן של המנהל.'], + 'mode_api' => ['label' => 'נקודת קצה API', 'hint' => 'נקודת קצה עם אימות טוקן לסוכנים חיצוניים.'], + 'require_scopes' => ['label' => 'דרישת scopes של הטוקן', 'hint' => 'כל קריאה חייבת להיות מכוסה על ידי ה-scopes של הטוקן שלה.'], + 'enable_write_tools' => ['label' => 'התרת כלי כתיבה', 'hint' => 'סוכנים יכולים ליצור ולשנות מסמכים ואלמנטים (evo.write.*). הטוקן צריך גם את ה-scope mcp:write.'], + 'self_service' => ['label' => 'דף שירות עצמי לטוקנים', 'hint' => 'משתמשים יוצרים את הטוקנים שלהם במנהל.'], + 'rate_limit' => ['label' => 'הגבלת קצב', 'hint' => 'מגביל את מספר הבקשות לדקה לכל משתמש.'], + 'audit' => ['label' => 'יומן ביקורת', 'hint' => 'רישום כל קריאת MCP ביומן הביקורת.'], + 'stream' => ['label' => 'תגובות בזרימה', 'hint' => 'התרת תגובות בזרימה (SSE).'], + ], + ], ]; diff --git a/lang/it/global.php b/lang/it/global.php index afff995..afa96ca 100644 --- a/lang/it/global.php +++ b/lang/it/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Server non trovato', 'invalid_payload' => 'Payload non valido', ], + 'menu_settings' => 'Impostazioni MCP', + 'settings' => [ + 'title' => 'Impostazioni MCP', + 'intro' => 'Questi interruttori valgono per tutto il sito e vengono salvati in :path. Le altre opzioni (scope, limiti, liste consentite) si modificano direttamente in quel file.', + 'save' => 'Salva impostazioni', + 'saved' => 'Impostazioni salvate.', + 'error_write' => 'Impossibile salvare :path. Verificare che il server web possa scrivere nel file.', + 'fields' => [ + 'enable' => ['label' => 'Attiva eMCP', 'hint' => 'Interruttore generale di tutti gli endpoint MCP.'], + 'mode_internal' => ['label' => 'Endpoint del manager', 'hint' => 'MCP tramite la sessione del manager.'], + 'mode_api' => ['label' => 'Endpoint API', 'hint' => 'Endpoint autenticato con token per agenti esterni.'], + 'require_scopes' => ['label' => 'Richiedi gli scope del token', 'hint' => 'Ogni chiamata deve rientrare negli scope del proprio token.'], + 'enable_write_tools' => ['label' => 'Consenti strumenti di scrittura', 'hint' => 'Gli agenti possono creare e modificare documenti ed elementi (evo.write.*). Il token deve avere anche lo scope mcp:write.'], + 'self_service' => ['label' => 'Pagina self-service dei token', 'hint' => 'Gli utenti creano i propri token nel manager.'], + 'rate_limit' => ['label' => 'Limite di richieste', 'hint' => 'Limita il numero di richieste al minuto per ogni utente.'], + 'audit' => ['label' => 'Registro di audit', 'hint' => 'Registra ogni chiamata MCP nel registro di audit.'], + 'stream' => ['label' => 'Risposte in streaming', 'hint' => 'Consenti risposte in streaming (SSE).'], + ], + ], ]; diff --git a/lang/ja/global.php b/lang/ja/global.php index 2f9c360..3539f26 100644 --- a/lang/ja/global.php +++ b/lang/ja/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'サーバーが見つかりません', 'invalid_payload' => '無効なペイロードです', ], + 'menu_settings' => 'MCP 設定', + 'settings' => [ + 'title' => 'MCP 設定', + 'intro' => 'これらのスイッチはサイト全体に適用され、:path に保存されます。その他のオプション(スコープ、制限、許可リスト)はこのファイルで直接編集します。', + 'save' => '設定を保存', + 'saved' => '設定を保存しました。', + 'error_write' => ':path を保存できませんでした。Web サーバーがこのファイルに書き込めるか確認してください。', + 'fields' => [ + 'enable' => ['label' => 'eMCP を有効化', 'hint' => 'すべての MCP エンドポイントのマスタースイッチです。'], + 'mode_internal' => ['label' => 'マネージャーエンドポイント', 'hint' => 'マネージャーのセッション経由の MCP です。'], + 'mode_api' => ['label' => 'API エンドポイント', 'hint' => '外部エージェント向けのトークン認証エンドポイントです。'], + 'require_scopes' => ['label' => 'トークンのスコープを必須にする', 'hint' => '各呼び出しはトークンのスコープの範囲内である必要があります。'], + 'enable_write_tools' => ['label' => '書き込みツールを許可', 'hint' => 'エージェントがドキュメントとエレメントを作成・変更できます(evo.write.*)。トークンには mcp:write スコープも必要です。'], + 'self_service' => ['label' => 'トークンのセルフサービスページ', 'hint' => 'ユーザーがマネージャーで自分のトークンを作成します。'], + 'rate_limit' => ['label' => 'レート制限', 'hint' => 'ユーザーごとの1分あたりのリクエスト数を制限します。'], + 'audit' => ['label' => '監査ログ', 'hint' => 'すべての MCP 呼び出しを監査ログに記録します。'], + 'stream' => ['label' => 'ストリーミング応答', 'hint' => 'ストリーミング(SSE)応答を許可します。'], + ], + ], ]; diff --git a/lang/nl/global.php b/lang/nl/global.php index fcd002c..e00890d 100644 --- a/lang/nl/global.php +++ b/lang/nl/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Server niet gevonden', 'invalid_payload' => 'Ongeldige payload', ], + 'menu_settings' => 'MCP-instellingen', + 'settings' => [ + 'title' => 'MCP-instellingen', + 'intro' => 'Deze schakelaars gelden voor de hele site en worden opgeslagen in :path. Overige opties (scopes, limieten, toegestane lijsten) bewerkt u rechtstreeks in dat bestand.', + 'save' => 'Instellingen opslaan', + 'saved' => 'Instellingen opgeslagen.', + 'error_write' => ':path kon niet worden opgeslagen. Controleer of de webserver naar het bestand kan schrijven.', + 'fields' => [ + 'enable' => ['label' => 'eMCP inschakelen', 'hint' => 'Hoofdschakelaar voor alle MCP-endpoints.'], + 'mode_internal' => ['label' => 'Manager-endpoint', 'hint' => 'MCP via de managersessie.'], + 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Met token geauthenticeerd endpoint voor externe agents.'], + 'require_scopes' => ['label' => 'Token-scopes vereisen', 'hint' => 'Elke aanroep moet binnen de scopes van zijn token vallen.'], + 'enable_write_tools' => ['label' => 'Schrijftools toestaan', 'hint' => 'Agents mogen documenten en elementen aanmaken en wijzigen (evo.write.*). Het token heeft ook de scope mcp:write nodig.'], + 'self_service' => ['label' => 'Selfservicepagina voor tokens', 'hint' => 'Gebruikers maken hun eigen tokens aan in de manager.'], + 'rate_limit' => ['label' => 'Aanvraaglimiet', 'hint' => 'Beperkt het aantal aanvragen per minuut per gebruiker.'], + 'audit' => ['label' => 'Auditlog', 'hint' => 'Elke MCP-aanroep vastleggen in het auditlog.'], + 'stream' => ['label' => 'Gestreamde antwoorden', 'hint' => 'Gestreamde (SSE) antwoorden toestaan.'], + ], + ], ]; diff --git a/lang/nn/global.php b/lang/nn/global.php index d0b07e3..7876d20 100644 --- a/lang/nn/global.php +++ b/lang/nn/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Fann ikkje tenaren', 'invalid_payload' => 'Ugyldig payload', ], + 'menu_settings' => 'MCP-innstillingar', + 'settings' => [ + 'title' => 'MCP-innstillingar', + 'intro' => 'Desse brytarane gjeld for heile nettstaden og blir lagra i :path. Andre innstillingar (scopes, grenser, lister over tillatne) redigerer du direkte i fila.', + 'save' => 'Lagre innstillingar', + 'saved' => 'Innstillingane er lagra.', + 'error_write' => 'Klarte ikkje å lagre :path. Sjekk at webtenaren kan skrive til fila.', + 'fields' => [ + 'enable' => ['label' => 'Slå på eMCP', 'hint' => 'Hovudbrytar for alle MCP-endepunkt.'], + 'mode_internal' => ['label' => 'Manager-endepunkt', 'hint' => 'MCP via manager-økta.'], + 'mode_api' => ['label' => 'API-endepunkt', 'hint' => 'Endepunkt med token-autentisering for eksterne agentar.'], + 'require_scopes' => ['label' => 'Krev token-scopes', 'hint' => 'Kvart kall må vere dekt av scopane til tokenet.'], + 'enable_write_tools' => ['label' => 'Tillat skriveverktøy', 'hint' => 'Agentar kan opprette og endre dokument og element (evo.write.*). Tokenet treng òg scopet mcp:write.'], + 'self_service' => ['label' => 'Sjølvbeteningsside for token', 'hint' => 'Brukarar lagar sine eigne token i manageren.'], + 'rate_limit' => ['label' => 'Avgrensing av førespurnader', 'hint' => 'Avgrensar talet på førespurnader per minutt for kvar brukar.'], + 'audit' => ['label' => 'Revisjonslogg', 'hint' => 'Registrer kvart MCP-kall i revisjonsloggen.'], + 'stream' => ['label' => 'Straumde svar', 'hint' => 'Tillat straumde (SSE) svar.'], + ], + ], ]; diff --git a/lang/pl/global.php b/lang/pl/global.php index 59e0696..329f00e 100644 --- a/lang/pl/global.php +++ b/lang/pl/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Nie znaleziono serwera', 'invalid_payload' => 'Nieprawidłowy payload', ], + 'menu_settings' => 'Ustawienia MCP', + 'settings' => [ + 'title' => 'Ustawienia MCP', + 'intro' => 'Te przełączniki dotyczą całej witryny i są zapisywane w :path. Pozostałe opcje (scopes, limity, listy dozwolonych) edytuje się bezpośrednio w tym pliku.', + 'save' => 'Zapisz ustawienia', + 'saved' => 'Ustawienia zapisane.', + 'error_write' => 'Nie udało się zapisać :path. Sprawdź, czy serwer WWW może zapisywać do tego pliku.', + 'fields' => [ + 'enable' => ['label' => 'Włącz eMCP', 'hint' => 'Główny przełącznik wszystkich endpointów MCP.'], + 'mode_internal' => ['label' => 'Endpoint menedżera', 'hint' => 'MCP przez sesję menedżera.'], + 'mode_api' => ['label' => 'Endpoint API', 'hint' => 'Endpoint uwierzytelniany tokenem dla zewnętrznych agentów.'], + 'require_scopes' => ['label' => 'Wymagaj scopes tokenu', 'hint' => 'Każde wywołanie musi mieścić się w scopes swojego tokenu.'], + 'enable_write_tools' => ['label' => 'Zezwól na narzędzia zapisu', 'hint' => 'Agenci mogą tworzyć i zmieniać dokumenty oraz elementy (evo.write.*). Token potrzebuje też scope mcp:write.'], + 'self_service' => ['label' => 'Strona samoobsługi tokenów', 'hint' => 'Użytkownicy tworzą własne tokeny w menedżerze.'], + 'rate_limit' => ['label' => 'Limit żądań', 'hint' => 'Ogranicza liczbę żądań na minutę dla każdego użytkownika.'], + 'audit' => ['label' => 'Dziennik audytu', 'hint' => 'Zapisuj każde wywołanie MCP w dzienniku audytu.'], + 'stream' => ['label' => 'Odpowiedzi strumieniowe', 'hint' => 'Zezwól na odpowiedzi strumieniowe (SSE).'], + ], + ], ]; diff --git a/lang/pt/global.php b/lang/pt/global.php index b83f70a..63bf216 100644 --- a/lang/pt/global.php +++ b/lang/pt/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Servidor não encontrado', 'invalid_payload' => 'Payload inválido', ], + 'menu_settings' => 'Configurações do MCP', + 'settings' => [ + 'title' => 'Configurações do MCP', + 'intro' => 'Estes interruptores valem para todo o site e são salvos em :path. As demais opções (scopes, limites, listas permitidas) são editadas diretamente nesse arquivo.', + 'save' => 'Salvar configurações', + 'saved' => 'Configurações salvas.', + 'error_write' => 'Não foi possível salvar :path. Verifique se o servidor web pode gravar nesse arquivo.', + 'fields' => [ + 'enable' => ['label' => 'Ativar eMCP', 'hint' => 'Interruptor geral de todos os endpoints MCP.'], + 'mode_internal' => ['label' => 'Endpoint do manager', 'hint' => 'MCP pela sessão do manager.'], + 'mode_api' => ['label' => 'Endpoint de API', 'hint' => 'Endpoint autenticado por token para agentes externos.'], + 'require_scopes' => ['label' => 'Exigir scopes do token', 'hint' => 'Cada chamada deve estar coberta pelos scopes do seu token.'], + 'enable_write_tools' => ['label' => 'Permitir ferramentas de escrita', 'hint' => 'Agentes podem criar e alterar documentos e elementos (evo.write.*). O token também precisa do scope mcp:write.'], + 'self_service' => ['label' => 'Página de autoatendimento de tokens', 'hint' => 'Usuários criam seus próprios tokens no manager.'], + 'rate_limit' => ['label' => 'Limite de requisições', 'hint' => 'Limita as requisições por minuto de cada usuário.'], + 'audit' => ['label' => 'Log de auditoria', 'hint' => 'Registrar cada chamada MCP no log de auditoria.'], + 'stream' => ['label' => 'Respostas em streaming', 'hint' => 'Permitir respostas em streaming (SSE).'], + ], + ], ]; diff --git a/lang/sk/global.php b/lang/sk/global.php index 087daff..a8a0fd8 100644 --- a/lang/sk/global.php +++ b/lang/sk/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Server sa nenašiel', 'invalid_payload' => 'Neplatný payload', ], + 'menu_settings' => 'Nastavenia MCP', + 'settings' => [ + 'title' => 'Nastavenia MCP', + 'intro' => 'Tieto prepínače platia pre celý web a ukladajú sa do :path. Ostatné voľby (scopes, limity, zoznamy povolených) sa upravujú priamo v tomto súbore.', + 'save' => 'Uložiť nastavenia', + 'saved' => 'Nastavenia uložené.', + 'error_write' => 'Súbor :path sa nepodarilo uložiť. Skontrolujte, či doň webový server môže zapisovať.', + 'fields' => [ + 'enable' => ['label' => 'Zapnúť eMCP', 'hint' => 'Hlavný vypínač všetkých MCP endpointov.'], + 'mode_internal' => ['label' => 'Endpoint v manažéri', 'hint' => 'MCP cez reláciu manažéra.'], + 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint s overením tokenom pre externých agentov.'], + 'require_scopes' => ['label' => 'Vyžadovať scopes tokenu', 'hint' => 'Každé volanie musí byť pokryté scopes svojho tokenu.'], + 'enable_write_tools' => ['label' => 'Povoliť zapisovacie nástroje', 'hint' => 'Agenti môžu vytvárať a meniť dokumenty a elementy (evo.write.*). Token potrebuje aj scope mcp:write.'], + 'self_service' => ['label' => 'Samoobslužná stránka tokenov', 'hint' => 'Používatelia si vytvárajú vlastné tokeny v manažéri.'], + 'rate_limit' => ['label' => 'Obmedzenie počtu požiadaviek', 'hint' => 'Obmedzí počet požiadaviek za minútu pre každého používateľa.'], + 'audit' => ['label' => 'Auditný log', 'hint' => 'Zapisovať každé volanie MCP do auditného logu.'], + 'stream' => ['label' => 'Streamované odpovede', 'hint' => 'Povoliť streamované (SSE) odpovede.'], + ], + ], ]; diff --git a/lang/sv/global.php b/lang/sv/global.php index 974f6e0..ad87c28 100644 --- a/lang/sv/global.php +++ b/lang/sv/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Servern hittades inte', 'invalid_payload' => 'Ogiltig payload', ], + 'menu_settings' => 'MCP-inställningar', + 'settings' => [ + 'title' => 'MCP-inställningar', + 'intro' => 'Dessa reglage gäller hela webbplatsen och sparas i :path. Övriga inställningar (scopes, gränser, tillåtelselistor) redigeras direkt i filen.', + 'save' => 'Spara inställningar', + 'saved' => 'Inställningarna har sparats.', + 'error_write' => 'Det gick inte att spara :path. Kontrollera att webbservern kan skriva till filen.', + 'fields' => [ + 'enable' => ['label' => 'Aktivera eMCP', 'hint' => 'Huvudreglage för alla MCP-endpoints.'], + 'mode_internal' => ['label' => 'Manager-endpoint', 'hint' => 'MCP via managerns session.'], + 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Tokenautentiserad endpoint för externa agenter.'], + 'require_scopes' => ['label' => 'Kräv token-scopes', 'hint' => 'Varje anrop måste täckas av tokenets scopes.'], + 'enable_write_tools' => ['label' => 'Tillåt skrivverktyg', 'hint' => 'Agenter får skapa och ändra dokument och element (evo.write.*). Tokenet behöver även scopet mcp:write.'], + 'self_service' => ['label' => 'Självbetjäningssida för tokens', 'hint' => 'Användare skapar egna tokens i managern.'], + 'rate_limit' => ['label' => 'Begränsning av förfrågningar', 'hint' => 'Begränsar antalet förfrågningar per minut för varje användare.'], + 'audit' => ['label' => 'Granskningslogg', 'hint' => 'Logga varje MCP-anrop i granskningsloggen.'], + 'stream' => ['label' => 'Strömmade svar', 'hint' => 'Tillåt strömmade (SSE) svar.'], + ], + ], ]; diff --git a/lang/uk/global.php b/lang/uk/global.php index da1be39..02aed5c 100644 --- a/lang/uk/global.php +++ b/lang/uk/global.php @@ -13,4 +13,23 @@ 'server_not_found' => 'Сервер не знайдено', 'invalid_payload' => 'Некоректний payload', ], + 'menu_settings' => 'Налаштування MCP', + 'settings' => [ + 'title' => 'Налаштування MCP', + 'intro' => 'Ці перемикачі діють для всього сайту і зберігаються в :path. Інші параметри (scopes, ліміти, списки дозволених) редагуються безпосередньо в цьому файлі.', + 'save' => 'Зберегти налаштування', + 'saved' => 'Налаштування збережено.', + 'error_write' => 'Не вдалося зберегти :path. Перевірте, чи може вебсервер записувати в цей файл.', + 'fields' => [ + 'enable' => ['label' => 'Увімкнути eMCP', 'hint' => 'Головний вимикач для всіх MCP endpoint.'], + 'mode_internal' => ['label' => 'Endpoint менеджера', 'hint' => 'MCP через сесію менеджера.'], + 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint з автентифікацією за токеном для зовнішніх агентів.'], + 'require_scopes' => ['label' => 'Вимагати scopes токена', 'hint' => 'Кожен виклик має покриватися scopes свого токена.'], + 'enable_write_tools' => ['label' => 'Дозволити інструменти запису', 'hint' => 'Агенти можуть створювати й змінювати документи та елементи (evo.write.*). Токену також потрібен scope mcp:write.'], + 'self_service' => ['label' => 'Сторінка самообслуговування токенів', 'hint' => 'Користувачі самі створюють свої токени в менеджері.'], + 'rate_limit' => ['label' => 'Обмеження частоти запитів', 'hint' => 'Обмежує кількість запитів за хвилину для кожного користувача.'], + 'audit' => ['label' => 'Журнал аудиту', 'hint' => 'Записувати кожен виклик MCP у журнал аудиту.'], + 'stream' => ['label' => 'Потокові відповіді', 'hint' => 'Дозволити потокові (SSE) відповіді.'], + ], + ], ]; diff --git a/lang/zh/global.php b/lang/zh/global.php index 05c2d2d..a61e379 100644 --- a/lang/zh/global.php +++ b/lang/zh/global.php @@ -13,4 +13,23 @@ 'server_not_found' => '未找到服务器', 'invalid_payload' => '无效的负载数据', ], + 'menu_settings' => 'MCP 设置', + 'settings' => [ + 'title' => 'MCP 设置', + 'intro' => '这些开关作用于整个站点,并保存在 :path 中。其他选项(scopes、限制、允许列表)请直接在该文件中编辑。', + 'save' => '保存设置', + 'saved' => '设置已保存。', + 'error_write' => '无法保存 :path。请检查 Web 服务器是否有权限写入该文件。', + 'fields' => [ + 'enable' => ['label' => '启用 eMCP', 'hint' => '所有 MCP 端点的总开关。'], + 'mode_internal' => ['label' => '管理后台端点', 'hint' => '通过管理后台会话使用 MCP。'], + 'mode_api' => ['label' => 'API 端点', 'hint' => '供外部代理使用的令牌认证端点。'], + 'require_scopes' => ['label' => '要求令牌 scopes', 'hint' => '每次调用都必须在其令牌的 scopes 范围内。'], + 'enable_write_tools' => ['label' => '允许写入工具', 'hint' => '代理可以创建和修改文档与元素(evo.write.*)。令牌还需要 mcp:write scope。'], + 'self_service' => ['label' => '令牌自助页面', 'hint' => '用户在管理后台创建自己的令牌。'], + 'rate_limit' => ['label' => '请求频率限制', 'hint' => '限制每个用户每分钟的请求数。'], + 'audit' => ['label' => '审计日志', 'hint' => '将每次 MCP 调用记录到审计日志。'], + 'stream' => ['label' => '流式响应', 'hint' => '允许流式(SSE)响应。'], + ], + ], ]; diff --git a/plugins/eMCPPlugin.php b/plugins/eMCPPlugin.php index d0c1921..dddd728 100644 --- a/plugins/eMCPPlugin.php +++ b/plugins/eMCPPlugin.php @@ -44,3 +44,34 @@ function eMCP_settings(): array return serialize($params['menu']); }); + +// "MCP settings" entry under Tools for users who may change the site configuration. +Event::listen('evolution.OnManagerMenuPrerender', function ($params) { + if (!function_exists('evo')) { + return serialize($params['menu']); + } + + $permission = (string)config('cms.settings.eMCP.acl.permission', 'emcp'); + if (!evo()->hasPermission($permission, 'mgr') || !evo()->hasPermission('settings', 'mgr')) { + return serialize($params['menu']); + } + + $prefix = trim((string)config('cms.settings.eMCP.route.manager_prefix', 'emcp'), '/'); + $title = __('eMCP::global.menu_settings'); + $icon = function_exists('svg') ? svg('tabler-settings')->toHtml() : ''; + + $params['menu']['emcp_settings'] = [ + 'emcp_settings', + 'tools', + $icon . $title, + \EvolutionCMS\eMCP\Support\ManagerUrl::to($prefix . '/settings'), + $title, + '', + '', + 'main', + 0, + 9, + ]; + + return serialize($params['menu']); +}); diff --git a/src/Http/Controllers/Concerns/ManagerPage.php b/src/Http/Controllers/Concerns/ManagerPage.php new file mode 100644 index 0000000..9dccbd6 --- /dev/null +++ b/src/Http/Controllers/Concerns/ManagerPage.php @@ -0,0 +1,66 @@ +getConfig('manager_language'); + if ($lang !== '' && is_dir(dirname(__DIR__, 4) . '/lang/' . basename($lang))) { + app()->setLocale($lang); + } + } + + private function isDarkTheme(): bool + { + $modes = ['', 'lightness', 'light', 'dark', 'darkness']; + $index = isset($_COOKIE['MODX_themeMode']) ? (int)$_COOKIE['MODX_themeMode'] : (int)evo()->getConfig('manager_theme_mode'); + + return in_array($modes[$index] ?? '', ['dark', 'darkness'], true); + } + + private function logManagerAction(string $message): void + { + try { + evo()->logEvent(0, 1, $message, 'eMCP'); + } catch (\Throwable) { + // The event log must never break the page. + } + } +} diff --git a/src/Http/Controllers/SettingsPageController.php b/src/Http/Controllers/SettingsPageController.php new file mode 100644 index 0000000..4d1cca6 --- /dev/null +++ b/src/Http/Controllers/SettingsPageController.php @@ -0,0 +1,107 @@ +allowed()) { + return $this->forbidden(); + } + $this->applyManagerLocale(); + + $fields = []; + foreach (SettingsCatalog::fields() as $path => $key) { + $fields[] = [ + 'key' => $key, + 'checked' => (bool)config('cms.settings.eMCP.' . $path, false), + 'label' => __('eMCP::global.settings.fields.' . $key . '.label'), + 'hint' => __('eMCP::global.settings.fields.' . $key . '.hint'), + ]; + } + + return view('eMCP::manager.settings', [ + 'fields' => $fields, + 'filePath' => $this->displayPath(SettingsFile::sitePath()), + 'saveUrl' => ManagerUrl::to('emcp/settings'), + 'tokensUrl' => ManagerUrl::to('emcp/tokens'), + 'message' => $this->pullFlash('message'), + 'error' => $this->pullFlash('error'), + 'darkTheme' => $this->isDarkTheme(), + ]); + } + + public function update(Request $request): Response + { + if (!$this->allowed()) { + return $this->forbidden(); + } + $this->applyManagerLocale(); + + $path = SettingsFile::sitePath(); + $values = SettingsCatalog::valuesFromInput((array)$request->input('settings', [])); + + try { + SettingsFile::ensurePublished(SettingsFile::defaultSource(), $path); + SettingsFile::writeBooleans($path, $values); + SettingsFile::forgetCachedConfig(); + } catch (\RuntimeException $e) { + return $this->back('error', __('eMCP::global.settings.error_write', ['path' => $this->displayPath($path)])); + } + + $changes = []; + foreach ($values as $setting => $value) { + $changes[] = $setting . '=' . ($value ? 'true' : 'false'); + } + $this->logManagerAction('Saved MCP settings: ' . implode(', ', $changes)); + + return $this->back('message', __('eMCP::global.settings.saved')); + } + + private function allowed(): bool + { + if (!function_exists('evo') || !evo()->isLoggedIn('mgr')) { + return false; + } + + $permission = (string)config('cms.settings.eMCP.acl.permission', 'emcp'); + + return evo()->hasPermission($permission, 'mgr') && evo()->hasPermission('settings', 'mgr'); + } + + private function displayPath(string $path): string + { + $normalized = str_replace('\\', '/', realpath($path) ?: $path); + $pos = strpos($normalized, '/core/custom/'); + + return $pos === false ? $normalized : ltrim(substr($normalized, $pos), '/'); + } + + private function back(string $flashKey, string $flashValue): RedirectResponse + { + $this->flash($flashKey, $flashValue); + + return redirect()->to(ManagerUrl::to('emcp/settings')); + } +} diff --git a/src/Http/Controllers/TokensPageController.php b/src/Http/Controllers/TokensPageController.php index cb3462e..cb51f6f 100644 --- a/src/Http/Controllers/TokensPageController.php +++ b/src/Http/Controllers/TokensPageController.php @@ -5,12 +5,14 @@ namespace EvolutionCMS\eMCP\Http\Controllers; use Carbon\Carbon; +use EvolutionCMS\eMCP\Http\Controllers\Concerns\ManagerPage; use EvolutionCMS\eMCP\Models\EmcpToken; use EvolutionCMS\eMCP\Services\TokenService; use EvolutionCMS\eMCP\Support\ManagerUrl; use EvolutionCMS\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; use InvalidArgumentException; /** @@ -21,6 +23,10 @@ */ class TokensPageController { + use ManagerPage; + + private const FLASH_KEY = 'emcp_tokens_flash'; + /** @var array */ private const EXPIRY_CHOICES = ['30', '90', '180', '365', 'never']; @@ -32,6 +38,10 @@ public function __construct( public function index(Request $request) { $userId = $this->currentUserId(); + if ($userId === null) { + return $this->forbidden(); + } + $this->applyManagerLocale(); $tokens = EmcpToken::query() ->where('user_id', $userId) @@ -50,17 +60,18 @@ public function index(Request $request) 'message' => $this->pullFlash('message'), 'error' => $this->pullFlash('error'), 'darkTheme' => $this->isDarkTheme(), + 'settingsUrl' => evo()->hasPermission('settings', 'mgr') ? ManagerUrl::to('emcp/settings') : '', ]); } - public function store(Request $request): RedirectResponse + public function store(Request $request): Response { $userId = $this->currentUserId(); /** @var User|null $user */ - $user = User::query()->with('attributes')->find($userId); + $user = $userId === null ? null : User::query()->with('attributes')->find($userId); if ($user === null) { - abort(403); + return $this->forbidden(); } $name = trim((string)$request->input('name', '')); @@ -80,9 +91,12 @@ public function store(Request $request): RedirectResponse return $this->back('message', 'Token created. Copy it now: it will not be shown again.'); } - public function revoke(Request $request, int $id): RedirectResponse + public function revoke(Request $request, int $id): Response { $userId = $this->currentUserId(); + if ($userId === null) { + return $this->forbidden(); + } /** @var EmcpToken|null $token */ $token = EmcpToken::query()->where('user_id', $userId)->find($id); @@ -96,14 +110,14 @@ public function revoke(Request $request, int $id): RedirectResponse return $this->back('message', 'Token "' . $token->name . '" revoked.'); } - private function currentUserId(): int + private function currentUserId(): ?int { if (!function_exists('evo') || !evo()->isLoggedIn('mgr')) { - abort(403); + return null; } if (!evo()->hasPermission((string)config('cms.settings.eMCP.acl.permission', 'emcp'), 'mgr')) { - abort(403); + return null; } return (int)evo()->getLoginUserID('mgr'); @@ -126,38 +140,4 @@ private function back(?string $flashKey = null, string $flashValue = ''): Redire return redirect()->to(ManagerUrl::to('emcp/tokens')); } - - /** - * One-shot messages ride on Evo's native session: the manager's Laravel session store is not - * reliably shared across a redirect, the PHP session always is. - */ - private function flash(string $key, string $value): void - { - $_SESSION['emcp_tokens_flash'][$key] = $value; - } - - private function pullFlash(string $key): string - { - $value = (string)($_SESSION['emcp_tokens_flash'][$key] ?? ''); - unset($_SESSION['emcp_tokens_flash'][$key]); - - return $value; - } - - private function isDarkTheme(): bool - { - $modes = ['', 'lightness', 'light', 'dark', 'darkness']; - $index = isset($_COOKIE['MODX_themeMode']) ? (int)$_COOKIE['MODX_themeMode'] : (int)evo()->getConfig('manager_theme_mode'); - - return in_array($modes[$index] ?? '', ['dark', 'darkness'], true); - } - - private function logManagerAction(string $message): void - { - try { - evo()->logEvent(0, 1, $message, 'eMCP'); - } catch (\Throwable) { - // The event log must never break the page. - } - } } diff --git a/src/Http/mgrRoutes.php b/src/Http/mgrRoutes.php index cdbb0bb..9a0b2f4 100644 --- a/src/Http/mgrRoutes.php +++ b/src/Http/mgrRoutes.php @@ -3,6 +3,7 @@ use EvolutionCMS\eMCP\Http\Controllers\McpManagerController; use EvolutionCMS\eMCP\Http\Controllers\TokensPageController; use EvolutionCMS\eMCP\Http\Controllers\McpDispatchController; +use EvolutionCMS\eMCP\Http\Controllers\SettingsPageController; use EvolutionCMS\eMCP\Services\ServerRegistry; use EvolutionCMS\eMCP\Support\TransportError; use Illuminate\Http\Request; @@ -22,6 +23,15 @@ }); } +// Site-wide settings page (needs Evo's `settings` permission, checked in the controller). +// Registered before the /{server} catch-all below, which would otherwise swallow it. +Route::middleware(['mgr', 'emcp.permission']) + ->prefix($prefix) + ->group(function (): void { + Route::get('/settings', [SettingsPageController::class, 'index']); + Route::post('/settings', [SettingsPageController::class, 'update']); + }); + Route::middleware(['mgr', 'emcp.permission', 'emcp.actor', 'emcp.rate']) ->prefix($prefix) ->group(function (): void { diff --git a/src/Support/CustomConfigPath.php b/src/Support/CustomConfigPath.php new file mode 100644 index 0000000..1de6060 --- /dev/null +++ b/src/Support/CustomConfigPath.php @@ -0,0 +1,32 @@ +/global.php under `settings.fields.`. + */ +final class SettingsCatalog +{ + /** + * @var array dot path in cms.settings.eMCP => lang key + */ + private const FIELDS = [ + 'enable' => 'enable', + 'mode.internal' => 'mode_internal', + 'mode.api' => 'mode_api', + 'auth.require_scopes' => 'require_scopes', + 'security.enable_write_tools' => 'enable_write_tools', + 'tokens.self_service' => 'self_service', + 'rate_limit.enabled' => 'rate_limit', + 'logging.audit_enabled' => 'audit', + 'stream.enabled' => 'stream', + ]; + + /** + * @return array + */ + public static function fields(): array + { + return self::FIELDS; + } + + /** + * Read checkbox input: every catalog setting is set, unchecked boxes mean false. + * + * @param array $input field lang key => submitted value + * @return array + */ + public static function valuesFromInput(array $input): array + { + $values = []; + foreach (self::FIELDS as $path => $key) { + $values[$path] = in_array($input[$key] ?? null, ['1', 1, true, 'on'], true); + } + + return $values; + } +} diff --git a/src/Support/SettingsFile.php b/src/Support/SettingsFile.php new file mode 100644 index 0000000..477a195 --- /dev/null +++ b/src/Support/SettingsFile.php @@ -0,0 +1,261 @@ + $values + */ + public static function writeBooleans(string $path, array $values): void + { + $code = @file_get_contents($path); + if (!is_string($code)) { + throw new RuntimeException("Unable to read {$path}"); + } + + $updated = self::applyBooleans($code, $values); + if ($updated === $code) { + return; + } + + // Write next to the target, prove it still parses to an array, then swap it in. + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (@file_put_contents($tmp, $updated) === false) { + throw new RuntimeException("Unable to write {$path}"); + } + + try { + $parsed = include $tmp; + } catch (\ParseError $e) { + @unlink($tmp); + throw new RuntimeException("Refusing to save {$path}: " . $e->getMessage(), 0, $e); + } + + if (!is_array($parsed) || !@rename($tmp, $path)) { + @unlink($tmp); + throw new RuntimeException("Unable to write {$path}"); + } + + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($path, true); + } + } + + /** + * @param array $values + */ + public static function applyBooleans(string $code, array $values): string + { + $tokens = token_get_all($code); + [$scalars, $arrays] = self::index($tokens); + + /** @var array $replace token index => new text */ + $replace = []; + /** @var array $insert token index => text inserted after that token */ + $insert = []; + + foreach ($values as $dotPath => $value) { + $literal = $value ? 'true' : 'false'; + + if (isset($scalars[$dotPath])) { + $replace[$scalars[$dotPath]] = $literal; + continue; + } + + // Missing key: nest it into the closest array the file already has. + $segments = explode('.', $dotPath); + $existing = []; + for ($i = count($segments) - 1; $i >= 0; $i--) { + $parent = implode('.', array_slice($segments, 0, $i)); + if (isset($arrays[$parent])) { + $existing = array_slice($segments, $i); + break; + } + } + + if ($existing === [] || !isset($arrays[$parent])) { + throw new RuntimeException("Cannot place setting [{$dotPath}]: the file has no returned array."); + } + + $depth = $parent === '' ? 1 : substr_count($parent, '.') + 2; + $insert[$arrays[$parent]] = ($insert[$arrays[$parent]] ?? '') + . "\n" . str_repeat(' ', $depth) . self::nested($existing, $literal) . ','; + } + + $out = ''; + foreach ($tokens as $i => $token) { + $out .= $replace[$i] ?? (is_array($token) ? $token[1] : $token); + $out .= $insert[$i] ?? ''; + } + + return $out; + } + + /** + * Map dot paths to token indexes: boolean values, and the `[` that opens each array. + * + * @param array $tokens + * @return array{0: array, 1: array} + */ + private static function index(array $tokens): array + { + $scalars = []; + $arrays = []; + $stack = []; + $pendingKey = null; + $inReturn = false; + + foreach ($tokens as $i => $token) { + $id = is_array($token) ? $token[0] : null; + $text = is_array($token) ? $token[1] : $token; + + if ($id === T_WHITESPACE || $id === T_COMMENT || $id === T_DOC_COMMENT) { + continue; + } + + if ($id === T_RETURN) { + $inReturn = true; + continue; + } + + if (!$inReturn) { + continue; + } + + if ($text === '[') { + $key = $stack === [] ? '' : $pendingKey; + $path = $key === null ? null : self::join($stack, $key); + if ($path !== null) { + $arrays[$path] = $i; + } + $stack[] = $path; + $pendingKey = null; + continue; + } + + if ($text === ']') { + array_pop($stack); + $pendingKey = null; + continue; + } + + if ($id === T_CONSTANT_ENCAPSED_STRING && self::nextSignificant($tokens, $i) === T_DOUBLE_ARROW) { + $pendingKey = substr($text, 1, -1); + continue; + } + + if ($id === T_STRING && $pendingKey !== null && in_array(strtolower($text), ['true', 'false'], true)) { + $path = self::join($stack, $pendingKey); + if ($path !== null) { + $scalars[$path] = $i; + } + } + + if ($text === ',') { + $pendingKey = null; + } + } + + return [$scalars, $arrays]; + } + + /** + * @param array $stack + */ + private static function join(array $stack, string $key): ?string + { + $parent = end($stack); + if ($parent === null || $parent === false) { + return $stack === [] ? $key : null; + } + + return $parent === '' ? $key : $parent . '.' . $key; + } + + /** + * @param array $tokens + */ + private static function nextSignificant(array $tokens, int $i): int|string|null + { + $count = count($tokens); + for ($j = $i + 1; $j < $count; $j++) { + $token = $tokens[$j]; + if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + return is_array($token) ? $token[0] : $token; + } + + return null; + } + + /** + * @param array $segments + */ + private static function nested(array $segments, string $literal): string + { + $key = array_shift($segments); + $value = $segments === [] ? $literal : '[' . self::nested($segments, $literal) . ']'; + + return var_export($key, true) . ' => ' . $value; + } +} diff --git a/src/eMCPServiceProvider.php b/src/eMCPServiceProvider.php index fe72117..c4eb4a7 100644 --- a/src/eMCPServiceProvider.php +++ b/src/eMCPServiceProvider.php @@ -27,7 +27,9 @@ use EvolutionCMS\eMCP\Services\ServerRegistry; use EvolutionCMS\eMCP\Services\TokenService; use EvolutionCMS\eMCP\Services\ToolRegistry; +use EvolutionCMS\eMCP\Support\CustomConfigPath; use EvolutionCMS\eMCP\Support\Redactor; +use EvolutionCMS\eMCP\Support\SettingsFile; class eMCPServiceProvider extends ServiceProvider { @@ -63,6 +65,7 @@ public function boot(): void $this->loadMigrationsFrom(dirname(__DIR__) . '/database/migrations'); $this->loadTranslationsFrom(dirname(__DIR__) . '/lang', 'eMCP'); $this->loadViewsFrom(dirname(__DIR__) . '/views', 'eMCP'); + $this->publishSettingsFile(); $this->loadMgrRoutes(); $this->loadApiRoutes(); @@ -197,23 +200,24 @@ protected function publishResources(): void protected function customConfigPath(string $path): string { - $path = ltrim($path, '/\\'); - - if (function_exists('config_path')) { - $candidate = config_path($path, true); - if (is_string($candidate) && $candidate !== '') { - $normalized = str_replace('\\', '/', $candidate); - if (str_contains($normalized, '/custom/config/')) { - return $candidate; - } - } - } + return CustomConfigPath::to($path); + } - if (defined('EVO_CORE_PATH')) { - return rtrim((string)EVO_CORE_PATH, '/\\') . '/custom/config/' . $path; + /** + * A plain `composer require` never runs vendor:publish, so the site copy of the settings is + * created on first boot: the manager settings page edits that file. + */ + protected function publishSettingsFile(): void + { + try { + if (SettingsFile::ensurePublished(SettingsFile::defaultSource(), SettingsFile::sitePath())) { + SettingsFile::forgetCachedConfig(); + } + } catch (\Throwable $e) { + if (function_exists('logger')) { + logger()->warning('eMCP: could not publish settings file: ' . $e->getMessage()); + } } - - return base_path('core/custom/config/' . $path); } protected function autoRegisterDispatchWorker(): void diff --git a/tests/Unit/SettingsFileTest.php b/tests/Unit/SettingsFileTest.php new file mode 100644 index 0000000..318e36c --- /dev/null +++ b/tests/Unit/SettingsFileTest.php @@ -0,0 +1,136 @@ + false];\n"); +assertTrue(SettingsFile::ensurePublished($defaultPath, $target) === false, 'Existing file must not be republished'); +assertTrue(str_contains((string)file_get_contents($target), "'enable' => false"), 'Existing file must not be overwritten'); + +// --- applyBooleans on the shipped defaults: values flip, comments and other keys survive. +$code = SettingsFile::applyBooleans($defaultCode, [ + 'security.enable_write_tools' => true, + 'stream.enabled' => true, + 'enable' => false, +]); +$config = evalConfig($code); +assertTrue($config['security']['enable_write_tools'] === true, 'security.enable_write_tools must be true'); +assertTrue($config['stream']['enabled'] === true, 'stream.enabled must be true'); +assertTrue($config['enable'] === false, 'Top-level enable must be false'); +assertTrue($config['rate_limit']['enabled'] === $defaults['rate_limit']['enabled'], 'Same-named key in another section must not change'); +assertTrue($config['auth'] === $defaults['auth'], 'Untouched sections must stay identical'); +assertTrue($config['domain'] === $defaults['domain'], 'Nested lists must stay identical'); +assertTrue(str_contains($code, '// Extra scope a token needs to call evo.write.* tools.'), 'Comments must be preserved'); +assertTrue(SettingsFile::applyBooleans($defaultCode, ['enable' => true]) === $defaultCode, 'Unchanged values must leave the file byte-identical'); + +// --- Keys the site file lacks are added to the closest existing section. +$trimmed = " [\n 'deny_tools' => [],\n ],\n];\n"; +$config = evalConfig(SettingsFile::applyBooleans($trimmed, [ + 'security.enable_write_tools' => true, + 'stream.enabled' => false, + 'enable' => true, +])); +assertTrue($config['security']['enable_write_tools'] === true, 'Missing key must be inserted into its existing section'); +assertTrue($config['security']['deny_tools'] === [], 'Existing keys in that section must survive'); +assertTrue($config['stream'] === ['enabled' => false], 'Missing section must be created'); +assertTrue($config['enable'] === true, 'Missing top-level key must be inserted'); + +// --- writeBooleans: saves to disk and the result parses. +SettingsFile::writeBooleans($target, ['security.enable_write_tools' => true, 'enable' => true]); +$config = include $target; +assertTrue($config['enable'] === true && $config['security']['enable_write_tools'] === true, 'writeBooleans must persist values'); +assertTrue(glob($target . '.*.tmp') === [], 'writeBooleans must not leave temp files behind'); + +$broken = $dir . '/broken.php'; +file_put_contents($broken, " true]); +} catch (RuntimeException) { + $failed = true; +} +assertTrue($failed, 'A file without a returned array must be refused'); + +// --- SettingsCatalog: every switch is a boolean in the shipped defaults. +foreach (SettingsCatalog::fields() as $path => $key) { + $value = $defaults; + foreach (explode('.', $path) as $segment) { + assertTrue(is_array($value) && array_key_exists($segment, $value), "Catalog setting [{$path}] missing from defaults"); + $value = $value[$segment]; + } + assertTrue(is_bool($value), "Catalog setting [{$path}] must be boolean"); +} + +$values = SettingsCatalog::valuesFromInput(['enable_write_tools' => '1', 'audit' => 'on']); +assertTrue($values['security.enable_write_tools'] === true, 'Checked box must map to true'); +assertTrue($values['logging.audit_enabled'] === true, '"on" must map to true'); +assertTrue($values['enable'] === false, 'Unchecked box must map to false'); +assertTrue(count($values) === count(SettingsCatalog::fields()), 'Every catalog setting must be written'); + +// --- Every locale translates the settings page. +foreach (glob(__DIR__ . '/../../lang/*/global.php') as $langFile) { + $locale = basename(dirname($langFile)); + $lang = include $langFile; + assertTrue(is_string($lang['menu_settings'] ?? null) && $lang['menu_settings'] !== '', "[{$locale}] menu_settings missing"); + foreach (['title', 'save', 'saved'] as $key) { + assertTrue(($lang['settings'][$key] ?? '') !== '', "[{$locale}] settings.{$key} missing"); + } + foreach (['intro', 'error_write'] as $key) { + assertTrue(str_contains((string)($lang['settings'][$key] ?? ''), ':path'), "[{$locale}] settings.{$key} must contain :path"); + } + foreach (SettingsCatalog::fields() as $key) { + assertTrue(($lang['settings']['fields'][$key]['label'] ?? '') !== '', "[{$locale}] settings.fields.{$key}.label missing"); + assertTrue(($lang['settings']['fields'][$key]['hint'] ?? '') !== '', "[{$locale}] settings.fields.{$key}.hint missing"); + } +} + +// --- forgetCachedConfig: drops Evo's configuration cache when the loader exists. +eval('namespace EvolutionCMS\Bootstrap; class EnvCacheLoader { public static array $calls = []; ' + . 'public static function invalidate(string $root): void { self::$calls[] = $root; } }'); +define('EVO_BASE_PATH', '/site/'); +SettingsFile::forgetCachedConfig(); +assertTrue(\EvolutionCMS\Bootstrap\EnvCacheLoader::$calls === ['/site/'], 'forgetCachedConfig must invalidate the Evo config cache'); + +array_map('unlink', glob($dir . '/*.php') ?: []); +array_map('unlink', glob($dir . '/cms/settings/*') ?: []); +@rmdir($dir . '/cms/settings'); +@rmdir($dir . '/cms'); +@rmdir($dir); + +echo "Settings file checks passed.\n"; diff --git a/views/manager/settings.blade.php b/views/manager/settings.blade.php new file mode 100644 index 0000000..4264c87 --- /dev/null +++ b/views/manager/settings.blade.php @@ -0,0 +1,70 @@ + + + + + +{{ __('eMCP::global.settings.title') }} + + + +{{ __('eMCP::global.menu_tokens') }} +

{{ __('eMCP::global.settings.title') }}

+

{!! str_replace(':path', '' . e($filePath) . '', e(__('eMCP::global.settings.intro'))) !!}

+ +@if ($error !== '') +
{{ $error }}
+@endif +@if ($message !== '') +
{{ $message }}
+@endif + +
+
+ {!! csrf_field() !!} + @foreach ($fields as $field) + + @endforeach + +
+
+ + diff --git a/views/manager/tokens.blade.php b/views/manager/tokens.blade.php index 3ed9481..9664b54 100644 --- a/views/manager/tokens.blade.php +++ b/views/manager/tokens.blade.php @@ -35,6 +35,9 @@ +@if ($settingsUrl !== '') + {{ __('eMCP::global.menu_settings') }} +@endif

MCP access tokens

Tokens let an AI agent (Claude Code, Codex, ...) act on this site as you, limited to your role, your document groups and the scopes you pick here. Revoke a token the moment you stop using it.

From 173578c8d742d45d14d0a3c2221b70e6c3b451f3 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 17 Oct 2026 17:02:09 +0200 Subject: [PATCH 3/4] add(scopes): destructive and code scopes so settings and extensions can limit those --- CHANGELOG.md | 8 + DOCS.md | 88 +++ DOCS.uk.md | 88 +++ README.md | 17 +- composer.json | 4 + config/eMCPSettings.php | 9 + lang/az/global.php | 2 + lang/be/global.php | 2 + lang/bg/global.php | 2 + lang/cs/global.php | 2 + lang/da/global.php | 2 + lang/de/global.php | 2 + lang/en/global.php | 2 + lang/es/global.php | 2 + lang/fa/global.php | 2 + lang/fi/global.php | 2 + lang/fr/global.php | 2 + lang/he/global.php | 2 + lang/it/global.php | 2 + lang/ja/global.php | 2 + lang/nl/global.php | 2 + lang/nn/global.php | 2 + lang/pl/global.php | 2 + lang/pt/global.php | 2 + lang/sk/global.php | 2 + lang/sv/global.php | 2 + lang/uk/global.php | 2 + lang/zh/global.php | 2 + .../Commands/eMcpTokenCreateCommand.php | 2 +- src/Contracts/DestructiveSite.php | 21 + src/Contracts/ExecutesCode.php | 23 + src/Contracts/RequiresPermission.php | 25 + src/Contracts/ToolProvider.php | 9 +- .../Controllers/McpDispatchController.php | 14 +- src/Http/Controllers/McpManagerController.php | 96 +++- src/Middleware/EnsureMcpScopes.php | 25 +- src/Servers/ContentServer.php | 9 + src/Services/McpExecutionService.php | 10 +- src/Services/ScopePolicy.php | 63 ++- src/Services/SecurityPolicy.php | 124 ++++- src/Services/TokenService.php | 6 +- src/Services/ToolRegistry.php | 320 +++++++++-- src/Support/BackendCode.php | 131 +++++ src/Support/JsonRpcPayload.php | 86 +++ src/Support/SettingsCatalog.php | 2 + src/Support/ToolError.php | 137 +++++ .../DispatchAsyncFailoverBehaviorTest.php | 1 + .../DispatchIdempotencyBehaviorTest.php | 1 + .../DispatchMaterializationPolicyTest.php | 1 + .../StreamingAndResultLimitsBehaviorTest.php | 1 + tests/Unit/ApiAuthModeMiddlewareTest.php | 1 + tests/Unit/ScopePolicyTest.php | 1 + tests/Unit/ToolErrorTest.php | 119 ++++ tests/Unit/ToolRegistryTest.php | 509 ++++++++++++++++++ views/manager/tokens.blade.php | 2 + 55 files changed, 1904 insertions(+), 93 deletions(-) create mode 100644 src/Contracts/DestructiveSite.php create mode 100644 src/Contracts/ExecutesCode.php create mode 100644 src/Contracts/RequiresPermission.php create mode 100644 src/Support/BackendCode.php create mode 100644 src/Support/JsonRpcPayload.php create mode 100644 src/Support/ToolError.php create mode 100644 tests/Unit/ToolErrorTest.php create mode 100644 tests/Unit/ToolRegistryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bbb67a..13361ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,15 @@ - `docker/` compose setup with a preinstalled site and smoke test. - `core/custom/config/cms/settings/eMCP.php` is published automatically on first boot when missing (plain `composer require` never runs `vendor:publish`). - **Tools → MCP settings** manager page (`{manager_url}/emcp/settings`, `settings` permission): checkboxes for the boolean switches, saved into that file with its comments kept. +- Extension tool contract (DOCS 7.0.1): `RequiresPermission` (checked centrally on every transport, `403 permission_denied`, tool hidden from `tools/list` without it), `DestructiveSite` (behind `security.enable_destructive_tools` and the new `mcp:destructive` scope, marked in the audit log), `Support\ToolError` for structured `{ok:false, error, message}` results, and `readOnlyHint` / `destructiveHint` annotations derived from the markers. +- `tools/list` leaves out tools matched by `deny_tools`. +- Backend code gate: `ExecutesCode` marker, `security.enable_code_tools` (off by default) and the `mcp:code` scope. `evo.write.elements.save` for snippets, plugins and TVs with `@EVAL` / `@SELECT` / `@INCLUDE` bindings, and document TV values with code bindings, now need them plus a `save_snippet` / `save_plugin` / `save_module` permission (Evo's `BackendCodePermissions`), even when the user has `save_template` or `save_document`. +### Changed +- `ToolRegistry` follows SPEC 6.4/6.5: contributed tools in `evo.*`, with a duplicate name, or not a `Tool` are rejected (throws in debug mode, logged otherwise). +- The JSON-RPC body is decoded once per request and shared by the scope and rate-limit middleware. +- `destructiveHint` is `true` for `evo.write.content.update` and `evo.write.elements.save` (they overwrite without a revision); `WritesSite` tools no longer get `destructiveHint: false` they did not declare. ### Fixed +- A contributed tool without `#[Name]` was invisible to the write gates: it was served under its class-derived name while the policy layer looked only at the attribute. The registry now resolves names the way Laravel MCP does and rejects tools it cannot name. - `evo.model.list|get` for `User`, `UserRole` and `Permissions` selected columns that do not exist in Evolution CMS 3.x (`isfrontend`, `frames`, ...) and failed with an SQL error; allowlists now match the 3.x schema. diff --git a/DOCS.md b/DOCS.md index 91f893c..0de2b1d 100644 --- a/DOCS.md +++ b/DOCS.md @@ -176,6 +176,8 @@ return [ 'allow_servers' => ['*'], 'deny_tools' => [], 'enable_write_tools' => false, + 'enable_destructive_tools' => false, + 'enable_code_tools' => false, ], 'domain' => [ @@ -286,6 +288,87 @@ This keeps upstream runtime behavior while fitting Evo package architecture. - Add server entries in `core/custom/config/mcp.php` with unique `handle`. - Extend per-server policy via `scope_map`, `limits`, `rate_limit`, `security.deny_tools`. - Add tools/resources/prompts through generators (`make:mcp-*`) and shared stubs. +- Contribute tools to an existing server from another extra: see 7.0.1. + +## 7.0.1) Extension Tool Contract +An extra adds tools to a server in one of two ways: + +```php +// 1. A ToolProvider, registered from the extra's service provider boot() +if (interface_exists(\EvolutionCMS\eMCP\Contracts\ToolProvider::class)) { + app(\EvolutionCMS\eMCP\Services\ToolRegistry::class)->register(new ShopToolProvider()); +} + +// 2. Class names in core/custom/config/mcp.php +'servers' => [['handle' => 'content', /* ... */ 'extra_tools' => [\Shop\Mcp\OrdersListTool::class]]], +``` + +`ShopToolProvider::server()` returns the server handle (`content` for the default server) and +`tools()` the tool classes. + +Registration rules (SPEC 6.4, 6.5): +- A tool is a `Laravel\Mcp\Server\Tool` subclass with a `#[Name('vendor.domain.action')]` + attribute. Without `#[Name]` the name is what Laravel MCP derives (the `$name` property, else + the kebab-cased class name); a tool that overrides `name()` and has no attribute is rejected, + because the policy layer could not recognise it on call. +- `evo.*` names are reserved for eMCP. Names are unique: the first registration wins. +- A rejected tool is not served at all. In debug mode (`app.debug`) registration throws; otherwise + a warning goes to the `emcp` log channel. + +Markers and contracts (all optional, in `EvolutionCMS\eMCP\Contracts`): + +| Contract | Effect | +|---|---| +| `WritesSite` | Write tool: refused unless `security.enable_write_tools` is on and the token has `auth.write_scope` (`mcp:write`). Listed either way. | +| `DestructiveSite` (extends `WritesSite`) | Deletes or irreversibly overwrites data. Also needs `security.enable_destructive_tools` and `auth.destructive_scope` (`mcp:destructive`); hidden from `tools/list` while disabled; calls are marked in the audit log (`extra.destructive`, `extra.reversible`, `extra.source = mcp`). Add `public static function reversible(): bool` returning true for soft deletes. | +| `ExecutesCode` (extends `WritesSite`) | Stores code the server executes: PHP, an executable file, an `@EVAL` / `@SELECT` / `@INCLUDE` binding, SQL. Also needs `security.enable_code_tools`, `auth.code_scope` (`mcp:code`) and, for the token owner, a permission that already grants backend code in the manager (`save_snippet`, `save_plugin` or `save_module`, as Evo's `BackendCodePermissions` defines it). Hidden from `tools/list` while disabled or not permitted; calls are marked in the audit log (`extra.executes_code`). Mark the whole tool if any of its calls can write code. | +| `RequiresPermission` | `public static function permission(): string\|array` returns an Evo permission, or a list where any one is enough. Checked for the token owner before the tool runs (`403 permission_denied`), on every transport; the tool is hidden from `tools/list` for users without it. Role 1 always passes. No manager identity (`auth.mode = none`, CLI, an async worker) means no access. | + +`tools/list` also gets `readOnlyHint` / `destructiveHint` annotations from these markers (hints a +tool sets itself are kept). They are hints for the client, not protection: +- `DestructiveSite`, `ExecutesCode`: `destructiveHint: true`. +- `WritesSite`: only `readOnlyHint: false`. MCP reads a missing `destructiveHint` as true, so a + tool that only adds data says so itself with `#[IsDestructive(false)]`. +- Built-in: `evo.write.content.update` and `evo.write.elements.save` overwrite existing data with + no revision to return to, so they are `destructiveHint: true`; `content.create`, + `content.publish` and `cache.clear` are `false`; `evo.*` read tools are `readOnlyHint: true`. + +Built-in write tools pass the same code gate per call, judged from their arguments: saving a +`snippet` or `plugin` with `evo.write.elements.save`, a TV definition with a code binding in +`elements`, `default_text` or `display_params`, or a document whose field or TV value starts with +`@EVAL` / `@SELECT` (also behind `@INHERIT`). Templates and chunks are ordinary writes: they only +call existing snippets. Binding detection uses Evo's `TvBindingGuard` when the core has it. + +On an older eMCP without these contracts, check `interface_exists(...)` and do the same checks in +the tool itself. With them, keep the in-tool checks for what the tool touches (document access, +ownership): the contracts are an entry gate, not a replacement. + +### Identity rules +- The acting user is `evo()->getLoginUserID('mgr')`, nothing else. Never take a `user_id` from + tool arguments and never read `$_SESSION` directly. +- With `auth.mode = none` there is no manager identity: refuse (`forbidden`) instead of acting as + "the first admin" or any fallback user. +- For queued work store the owner's id in the task when it is created; do not rely on the worker's + session, which belongs to nobody. +- Somebody else's personal resource (a job, a draft, a token) is answered as `not_found`, so ids + cannot be probed. + +### Errors +Return `EvolutionCMS\eMCP\Support\ToolError::make($code, $message, $status = 422, $extra = [])` +from `handle()`. The result has `isError: true` and one JSON text block: + +```json +{"ok": false, "error": "ambiguous", "message": "Several orders match.", "status": 409, + "candidates": [{"id": 12, "title": "Order #12"}, {"id": 14, "title": "Order #14"}]} +``` + +- Codes: `forbidden`, `not_found`, `ambiguous`, `invalid`, `refused`, `store_failure`, or the + extra's own with a namespace prefix (`aimage.quota_exceeded`). Any other code throws. +- `errors`: per-field messages (`ToolError::fromValidation($e)` converts a `ValidationException`). +- `candidates`: only `id` and `title` (a `name` counts as title); at most 10 are kept + (`candidates_truncated: true` when more were given). Put nothing there the user may not see. +- eMCP's own tools still answer with `ValidationException` messages (their output is fixed by + golden fixtures). ## 7.1) Evo Domain Tool Profiles Canonical source for tool names/params/examples/errors is `TOOLSET.md`. @@ -365,6 +448,11 @@ Minimum scope policy: - `mcp:read`: `initialize`, `ping`, list/read/get methods - `mcp:call`: `tools/call` - `mcp:admin`: server admin/service actions +- `mcp:write` (`auth.write_scope`): additionally required by `evo.write.*` and `WritesSite` tools +- `mcp:destructive` (`auth.destructive_scope`): additionally required by `DestructiveSite` tools; + a token issued with it also gets `mcp:write`, `mcp:call` and `mcp:read` +- `mcp:code` (`auth.code_scope`): additionally required by calls that store backend code (see + `ExecutesCode`); a token issued with it also gets `mcp:write`, `mcp:call` and `mcp:read` Rules: - If `auth.require_scopes=true`, scope check is mandatory. diff --git a/DOCS.uk.md b/DOCS.uk.md index cf4eb14..4539c1b 100644 --- a/DOCS.uk.md +++ b/DOCS.uk.md @@ -176,6 +176,8 @@ return [ 'allow_servers' => ['*'], 'deny_tools' => [], 'enable_write_tools' => false, + 'enable_destructive_tools' => false, + 'enable_code_tools' => false, ], 'domain' => [ @@ -286,6 +288,87 @@ Upstream Laravel MCP очікує `routes/ai.php`. - Додати server entries у `core/custom/config/mcp.php` з унікальним `handle`. - Розширювати per-server policy через `scope_map`, `limits`, `rate_limit`, `security.deny_tools`. - Додавати tools/resources/prompts через генератори (`make:mcp-*`) і спільні stubs. +- Додавати tools до наявного сервера з іншого extra: див. 7.0.1. + +## 7.0.1) Extension Tool Contract +Extra додає tools до сервера одним із двох способів: + +```php +// 1. A ToolProvider, registered from the extra's service provider boot() +if (interface_exists(\EvolutionCMS\eMCP\Contracts\ToolProvider::class)) { + app(\EvolutionCMS\eMCP\Services\ToolRegistry::class)->register(new ShopToolProvider()); +} + +// 2. Class names in core/custom/config/mcp.php +'servers' => [['handle' => 'content', /* ... */ 'extra_tools' => [\Shop\Mcp\OrdersListTool::class]]], +``` + +`ShopToolProvider::server()` повертає handle сервера (`content` для сервера за замовчуванням), а +`tools()` — класи tools. + +Правила реєстрації (SPEC 6.4, 6.5): +- Tool — це підклас `Laravel\Mcp\Server\Tool` з атрибутом `#[Name('vendor.domain.action')]`. + Без `#[Name]` назвою стає те, що виводить Laravel MCP (властивість `$name`, інакше + kebab-case назва класу); tool, що перевизначає `name()` і не має атрибута, відхиляється, + бо policy-шар не зміг би розпізнати його під час виклику. +- Назви `evo.*` зарезервовані за eMCP. Назви унікальні: перемагає перша реєстрація. +- Відхилений tool не обслуговується взагалі. У debug-режимі (`app.debug`) реєстрація кидає виняток; + інакше попередження йде в log channel `emcp`. + +Маркери й контракти (усі необовʼязкові, у `EvolutionCMS\eMCP\Contracts`): + +| Контракт | Ефект | +|---|---| +| `WritesSite` | Write tool: відхиляється, якщо не ввімкнено `security.enable_write_tools` і токен не має `auth.write_scope` (`mcp:write`). У списку показується в будь-якому разі. | +| `DestructiveSite` (розширює `WritesSite`) | Видаляє або незворотно перезаписує дані. Додатково потребує `security.enable_destructive_tools` і `auth.destructive_scope` (`mcp:destructive`); поки вимкнено, прихований у `tools/list`; виклики позначаються в audit log (`extra.destructive`, `extra.reversible`, `extra.source = mcp`). Додайте `public static function reversible(): bool`, що повертає true для soft delete. | +| `ExecutesCode` (розширює `WritesSite`) | Зберігає код, який виконує сервер: PHP, виконуваний файл, binding `@EVAL` / `@SELECT` / `@INCLUDE`, SQL. Додатково потребує `security.enable_code_tools`, `auth.code_scope` (`mcp:code`) і, для власника токена, permission, що вже дає доступ до backend-коду в менеджері (`save_snippet`, `save_plugin` або `save_module`, як це визначає `BackendCodePermissions` в Evo). Поки вимкнено або не дозволено, прихований у `tools/list`; виклики позначаються в audit log (`extra.executes_code`). Позначайте весь tool, якщо будь-який його виклик може записувати код. | +| `RequiresPermission` | `public static function permission(): string\|array` повертає Evo permission або список, де достатньо будь-якого одного. Перевіряється для власника токена до запуску tool (`403 permission_denied`) на кожному transport; для користувачів без нього tool прихований у `tools/list`. Роль 1 проходить завжди. Немає manager identity (`auth.mode = none`, CLI, async worker) — немає доступу. | + +`tools/list` також отримує анотації `readOnlyHint` / `destructiveHint` з цих маркерів (hints, які +tool задає сам, зберігаються). Це підказки для клієнта, а не захист: +- `DestructiveSite`, `ExecutesCode`: `destructiveHint: true`. +- `WritesSite`: лише `readOnlyHint: false`. MCP трактує відсутній `destructiveHint` як true, тож + tool, що лише додає дані, сам вказує це через `#[IsDestructive(false)]`. +- Вбудовані: `evo.write.content.update` і `evo.write.elements.save` перезаписують наявні дані без + ревізії, до якої можна повернутися, тому вони `destructiveHint: true`; `content.create`, + `content.publish` і `cache.clear` — `false`; read tools `evo.*` — `readOnlyHint: true`. + +Вбудовані write tools проходять той самий code gate для кожного виклику, судячи з їхніх аргументів: +збереження `snippet` або `plugin` через `evo.write.elements.save`, визначення TV з code binding в +`elements`, `default_text` або `display_params`, або документа, поле чи значення TV якого починається +з `@EVAL` / `@SELECT` (також за `@INHERIT`). Шаблони й чанки — звичайний запис: вони лише викликають +наявні сніпети. Виявлення bindings використовує `TvBindingGuard` з Evo, якщо ядро його має. + +На старішому eMCP без цих контрактів перевіряйте `interface_exists(...)` і робіть ті самі перевірки +в самому tool. З ними залишайте перевірки в tool для того, чого він торкається (доступ до документів, +власність): контракти — це вхідний бар'єр, а не заміна. + +### Правила ідентичності +- Діючий користувач — це `evo()->getLoginUserID('mgr')`, і ніхто інший. Ніколи не беріть `user_id` + з аргументів tool і ніколи не читайте `$_SESSION` напряму. +- З `auth.mode = none` manager identity немає: відмовляйте (`forbidden`), а не дійте як + «перший адмін» чи будь-який fallback-користувач. +- Для queued-роботи зберігайте id власника в задачі під час її створення; не покладайтеся на сесію + worker, яка не належить нікому. +- На чужий персональний ресурс (job, чернетку, токен) відповідайте `not_found`, щоб id не можна + було перебирати. + +### Помилки +Повертайте `EvolutionCMS\eMCP\Support\ToolError::make($code, $message, $status = 422, $extra = [])` +з `handle()`. Результат має `isError: true` і один JSON text block: + +```json +{"ok": false, "error": "ambiguous", "message": "Several orders match.", "status": 409, + "candidates": [{"id": 12, "title": "Order #12"}, {"id": 14, "title": "Order #14"}]} +``` + +- Коди: `forbidden`, `not_found`, `ambiguous`, `invalid`, `refused`, `store_failure` або власні коди + extra з namespace-префіксом (`aimage.quota_exceeded`). Будь-який інший код кидає виняток. +- `errors`: повідомлення по полях (`ToolError::fromValidation($e)` перетворює `ValidationException`). +- `candidates`: лише `id` і `title` (`name` вважається title); зберігається не більше 10 + (`candidates_truncated: true`, якщо передано більше). Не кладіть туди нічого, чого користувач не може бачити. +- Власні tools eMCP і далі відповідають повідомленнями `ValidationException` (їхній вивід зафіксовано + golden fixtures). ## 7.1) Evo Domain Tool Profiles Канонічне джерело tool names/params/examples/errors: `TOOLSET.md`. @@ -365,6 +448,11 @@ Default allowlist моделей: - `mcp:read`: `initialize`, `ping`, list/read/get методи - `mcp:call`: `tools/call` - `mcp:admin`: admin/service дії +- `mcp:write` (`auth.write_scope`): додатково потрібен для `evo.write.*` і `WritesSite` tools +- `mcp:destructive` (`auth.destructive_scope`): додатково потрібен для `DestructiveSite` tools; + токен, виданий з ним, також отримує `mcp:write`, `mcp:call` і `mcp:read` +- `mcp:code` (`auth.code_scope`): додатково потрібен для викликів, що зберігають backend-код (див. + `ExecutesCode`); токен, виданий з ним, також отримує `mcp:write`, `mcp:call` і `mcp:read` Правила: - якщо `auth.require_scopes=true`, перевірка scope обовʼязкова diff --git a/README.md b/README.md index 626adc5..cffcaa7 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ bearer_token_env_var = "EVO_MCP_TOKEN" Scopes narrow a token, never widen the user: `mcp:read` (list/read), `mcp:call` (read-only tools), `mcp:write` (`evo.write.*` tools, also gated by `security.enable_write_tools`), +`mcp:destructive` (extension tools that delete data, also gated by +`security.enable_destructive_tools`), `mcp:code` (saving snippets, plugins and `@EVAL`-style +bindings, also gated by `security.enable_code_tools` and a snippet/plugin/module permission), `mcp:admin`. Tokens are stored hashed, can expire, and are revoked from the same page or with `emcp:token:revoke`. `emcp:token:list` shows what exists. @@ -130,11 +133,15 @@ if (class_exists(\EvolutionCMS\eMCP\Services\ToolRegistry::class)) { } ``` -Tools are plain `Laravel\Mcp\Server\Tool` classes; mark the ones that change the site with -`EvolutionCMS\eMCP\Contracts\WritesSite` so `security.enable_write_tools` and the `mcp:write` -scope apply to them. Tool code runs as the impersonated manager, so `evo()->hasPermission()` and -the extra's own guards behave as on the page. Alternatively list classes under -`mcp.servers[].extra_tools`. Example: `elcreator/aimage` ships `aimage.*` tools this way. +Tools are plain `Laravel\Mcp\Server\Tool` classes with a `#[Name('vendor.domain.action')]`. +Mark the ones that change the site with `EvolutionCMS\eMCP\Contracts\WritesSite` so +`security.enable_write_tools` and the `mcp:write` scope apply to them, the ones that delete data +with `DestructiveSite`, the ones that store server-side code with `ExecutesCode`, and declare a +required Evo permission with `RequiresPermission`. Tool code +runs as the impersonated manager, so `evo()->hasPermission()` and the extra's own guards behave as +on the page; `Support\ToolError` builds structured error results. Alternatively list classes under +`mcp.servers[].extra_tools`. Example: `elcreator/aimage` ships `aimage.*` tools this way. The full +contract (naming, identity rules, error format) is in [DOCS.md](DOCS.md#701-extension-tool-contract). ### Try it in Docker ```bash diff --git a/composer.json b/composer.json index 5e0e617..7c95259 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,8 @@ "test:unit:model-field-leakage": "php tests/Unit/ModelFieldPolicyLeakageTest.php", "test:unit:model-field-schema": "php tests/Unit/ModelFieldPolicySchemaTest.php", "test:unit:settings-file": "php tests/Unit/SettingsFileTest.php", + "test:unit:tool-registry": "php tests/Unit/ToolRegistryTest.php", + "test:unit:tool-error": "php tests/Unit/ToolErrorTest.php", "test:feature:dispatch-idempotency": "php tests/Feature/DispatchIdempotencyBehaviorTest.php", "test:feature:dispatch-async-failover": "php tests/Feature/DispatchAsyncFailoverBehaviorTest.php", "test:feature:dispatch-materialization": "php tests/Feature/DispatchMaterializationPolicyTest.php", @@ -79,6 +81,8 @@ "@test:unit:model-field-leakage", "@test:unit:model-field-schema", "@test:unit:settings-file", + "@test:unit:tool-registry", + "@test:unit:tool-error", "@test:phase25", "@test:phase25:raw-tv", "@test:phase3", diff --git a/config/eMCPSettings.php b/config/eMCPSettings.php index 1cc6be5..8348187 100644 --- a/config/eMCPSettings.php +++ b/config/eMCPSettings.php @@ -23,6 +23,11 @@ 'require_scopes' => true, // Extra scope a token needs to call evo.write.* tools. 'write_scope' => 'mcp:write', + // Extra scope (on top of write_scope) a token needs to call DestructiveSite tools. + 'destructive_scope' => 'mcp:destructive', + // Extra scope (on top of write_scope) a token needs to store backend code: snippets, + // plugins, @EVAL/@SELECT/@INCLUDE bindings, ExecutesCode tools. + 'code_scope' => 'mcp:code', 'scope_map' => [ 'mcp:read' => [ 'initialize', @@ -92,6 +97,10 @@ 'allow_servers' => ['*'], 'deny_tools' => [], 'enable_write_tools' => false, + // Tools that delete data (DestructiveSite). Off: they are hidden and refused. + 'enable_destructive_tools' => false, + // Writing code the server executes (PHP, code bindings). Off: refused, even for admins. + 'enable_code_tools' => false, ], 'actor' => [ diff --git a/lang/az/global.php b/lang/az/global.php index 051ffa7..00a1042 100644 --- a/lang/az/global.php +++ b/lang/az/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API endpoint-i', 'hint' => 'Xarici agentlər üçün tokenlə autentifikasiya olunan endpoint.'], 'require_scopes' => ['label' => 'Token scope-larını tələb et', 'hint' => 'Hər çağırış tokenin scope-ları ilə əhatə olunmalıdır.'], 'enable_write_tools' => ['label' => 'Yazma alətlərinə icazə ver', 'hint' => 'Agentlər sənədləri və elementləri yarada və dəyişə bilər (evo.write.*). Tokenə həmçinin mcp:write scope-u lazımdır.'], + 'enable_destructive_tools' => ['label' => 'Dağıdıcı alətlərə icazə ver', 'hint' => 'Agentlər məlumatları silən genişlənmə alətlərini çağıra bilər. Yazma alətləri də lazımdır; tokenə həmçinin mcp:destructive scope-u lazımdır.'], + 'enable_code_tools' => ['label' => 'Backend kodunu yazmağa icazə ver', 'hint' => 'Agentlər serverdə kod icra edən snippetləri, pluginləri və TV bağlamalarını (@EVAL, @SELECT, @INCLUDE) saxlaya bilər. Yazma alətləri də lazımdır; tokenə həmçinin mcp:code scope-u, onun sahibinə isə snippetləri, pluginləri və ya modulları redaktə etmək icazəsi lazımdır.'], 'self_service' => ['label' => 'Tokenlərin özünəxidmət səhifəsi', 'hint' => 'İstifadəçilər öz tokenlərini managerdə yaradır.'], 'rate_limit' => ['label' => 'Sorğu limiti', 'hint' => 'Hər istifadəçi üçün dəqiqədəki sorğu sayını məhdudlaşdırır.'], 'audit' => ['label' => 'Audit jurnalı', 'hint' => 'Hər MCP çağırışını audit jurnalına yazır.'], diff --git a/lang/be/global.php b/lang/be/global.php index 8f7c4e7..b4b0851 100644 --- a/lang/be/global.php +++ b/lang/be/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint з аўтэнтыфікацыяй па токене для знешніх агентаў.'], 'require_scopes' => ['label' => 'Патрабаваць scopes токена', 'hint' => 'Кожны выклік павінен пакрывацца scopes свайго токена.'], 'enable_write_tools' => ['label' => 'Дазволіць інструменты запісу', 'hint' => 'Агенты могуць ствараць і змяняць дакументы і элементы (evo.write.*). Токену таксама патрэбны scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Дазволіць дэструктыўныя інструменты', 'hint' => 'Агенты могуць выклікаць інструменты пашырэнняў, якія выдаляюць даныя. Патрэбны таксама інструменты запісу; токену таксама патрэбны scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Дазволіць запіс бэкенд-кода', 'hint' => 'Агенты могуць захоўваць сніпеты, плагіны і прывязкі TV, якія выконваюць код на серверы (@EVAL, @SELECT, @INCLUDE). Патрэбны таксама інструменты запісу; токену таксама патрэбны scope mcp:code, а яго ўладальніку — дазвол на рэдагаванне сніпетаў, плагінаў або модуляў.'], 'self_service' => ['label' => 'Старонка самаабслугоўвання токенаў', 'hint' => 'Карыстальнікі самі ствараюць свае токены ў мэнэджары.'], 'rate_limit' => ['label' => 'Абмежаванне частаты запытаў', 'hint' => 'Абмяжоўвае колькасць запытаў у хвіліну для кожнага карыстальніка.'], 'audit' => ['label' => 'Журнал аўдыту', 'hint' => 'Запісваць кожны выклік MCP у журнал аўдыту.'], diff --git a/lang/bg/global.php b/lang/bg/global.php index 7bbecc9..6fac6f7 100644 --- a/lang/bg/global.php +++ b/lang/bg/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint с удостоверяване чрез токен за външни агенти.'], 'require_scopes' => ['label' => 'Изисквай scopes на токена', 'hint' => 'Всяко извикване трябва да е покрито от scopes на своя токен.'], 'enable_write_tools' => ['label' => 'Разреши инструменти за запис', 'hint' => 'Агентите могат да създават и променят документи и елементи (evo.write.*). Токенът трябва да има и scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Разреши деструктивни инструменти', 'hint' => 'Агентите могат да извикват инструменти на разширения, които изтриват данни. Изисква и инструментите за запис; токенът трябва да има и scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Разреши запис на бекенд код', 'hint' => 'Агентите могат да записват снипети, плъгини и TV връзки, които изпълняват код на сървъра (@EVAL, @SELECT, @INCLUDE). Изисква и инструментите за запис; токенът трябва да има и scope mcp:code, а собственикът му — право да редактира снипети, плъгини или модули.'], 'self_service' => ['label' => 'Страница за самообслужване с токени', 'hint' => 'Потребителите създават свои токени в мениджъра.'], 'rate_limit' => ['label' => 'Ограничение на заявките', 'hint' => 'Ограничава броя заявки в минута за всеки потребител.'], 'audit' => ['label' => 'Одитен журнал', 'hint' => 'Записва всяко MCP извикване в одитния журнал.'], diff --git a/lang/cs/global.php b/lang/cs/global.php index c054e1f..6be2eff 100644 --- a/lang/cs/global.php +++ b/lang/cs/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint s ověřením tokenem pro externí agenty.'], 'require_scopes' => ['label' => 'Vyžadovat scopes tokenu', 'hint' => 'Každé volání musí být pokryto scopes svého tokenu.'], 'enable_write_tools' => ['label' => 'Povolit zapisovací nástroje', 'hint' => 'Agenti mohou vytvářet a měnit dokumenty a elementy (evo.write.*). Token navíc potřebuje scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Povolit destruktivní nástroje', 'hint' => 'Agenti mohou volat nástroje rozšíření, které mažou data. Vyžaduje také zapisovací nástroje; token navíc potřebuje scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Povolit zápis backendového kódu', 'hint' => 'Agenti mohou ukládat snippety, pluginy a vazby TV, které spouštějí kód na serveru (@EVAL, @SELECT, @INCLUDE). Vyžaduje také zapisovací nástroje; token navíc potřebuje scope mcp:code a jeho vlastník oprávnění upravovat snippety, pluginy nebo moduly.'], 'self_service' => ['label' => 'Samoobslužná stránka tokenů', 'hint' => 'Uživatelé si vytvářejí vlastní tokeny v manažeru.'], 'rate_limit' => ['label' => 'Omezení počtu požadavků', 'hint' => 'Omezí počet požadavků za minutu pro každého uživatele.'], 'audit' => ['label' => 'Auditní log', 'hint' => 'Zapisovat každé volání MCP do auditního logu.'], diff --git a/lang/da/global.php b/lang/da/global.php index 54fbac6..786f907 100644 --- a/lang/da/global.php +++ b/lang/da/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Token-godkendt endpoint til eksterne agenter.'], 'require_scopes' => ['label' => 'Kræv token-scopes', 'hint' => 'Hvert kald skal være dækket af tokenets scopes.'], 'enable_write_tools' => ['label' => 'Tillad skriveværktøjer', 'hint' => 'Agenter må oprette og ændre dokumenter og elementer (evo.write.*). Tokenet skal også have scopet mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Tillad destruktive værktøjer', 'hint' => 'Agenter må kalde udvidelsesværktøjer, der sletter data. Kræver også skriveværktøjer; tokenet skal også have scopet mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Tillad skrivning af backend-kode', 'hint' => 'Agenter må gemme snippets, plugins og TV-bindinger, der kører kode på serveren (@EVAL, @SELECT, @INCLUDE). Kræver også skriveværktøjer; tokenet skal også have scopet mcp:code, og dets ejer en tilladelse til at redigere snippets, plugins eller moduler.'], 'self_service' => ['label' => 'Selvbetjeningsside for tokens', 'hint' => 'Brugere opretter selv deres tokens i manageren.'], 'rate_limit' => ['label' => 'Begrænsning af forespørgsler', 'hint' => 'Begrænser antallet af forespørgsler pr. minut for hver bruger.'], 'audit' => ['label' => 'Revisionslog', 'hint' => 'Registrér hvert MCP-kald i revisionsloggen.'], diff --git a/lang/de/global.php b/lang/de/global.php index c2b9d80..470dad6 100644 --- a/lang/de/global.php +++ b/lang/de/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API-Endpunkt', 'hint' => 'Per Token authentifizierter Endpunkt für externe Agenten.'], 'require_scopes' => ['label' => 'Token-Scopes erzwingen', 'hint' => 'Jeder Aufruf muss von den Scopes seines Tokens abgedeckt sein.'], 'enable_write_tools' => ['label' => 'Schreib-Tools erlauben', 'hint' => 'Agenten dürfen Dokumente und Elemente anlegen und ändern (evo.write.*). Das Token braucht zusätzlich den Scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Destruktive Tools erlauben', 'hint' => 'Agenten dürfen Erweiterungs-Tools aufrufen, die Daten löschen. Setzt auch die Schreib-Tools voraus; das Token braucht zusätzlich den Scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Schreiben von Backend-Code erlauben', 'hint' => 'Agenten dürfen Snippets, Plugins und TV-Bindungen speichern, die Code auf dem Server ausführen (@EVAL, @SELECT, @INCLUDE). Setzt auch die Schreib-Tools voraus; das Token braucht zusätzlich den Scope mcp:code und sein Besitzer eine Berechtigung zum Bearbeiten von Snippets, Plugins oder Modulen.'], 'self_service' => ['label' => 'Self-Service-Seite für Tokens', 'hint' => 'Benutzer erstellen ihre eigenen Tokens im Manager.'], 'rate_limit' => ['label' => 'Ratenbegrenzung', 'hint' => 'Begrenzt die Anfragen pro Minute für jeden Benutzer.'], 'audit' => ['label' => 'Audit-Log', 'hint' => 'Jeden MCP-Aufruf im Audit-Log protokollieren.'], diff --git a/lang/en/global.php b/lang/en/global.php index 2168396..2ec2834 100644 --- a/lang/en/global.php +++ b/lang/en/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Token-authenticated endpoint for external agents.'], 'require_scopes' => ['label' => 'Require token scopes', 'hint' => 'Every call must be covered by the scopes of its token.'], 'enable_write_tools' => ['label' => 'Allow write tools', 'hint' => 'Agents may create and change documents and elements (evo.write.*). A token also needs the mcp:write scope.'], + 'enable_destructive_tools' => ['label' => 'Allow destructive tools', 'hint' => 'Agents may call extension tools that delete data. Needs write tools as well; a token also needs the mcp:destructive scope.'], + 'enable_code_tools' => ['label' => 'Allow writing backend code', 'hint' => 'Agents may save snippets, plugins and TV bindings that run code on the server (@EVAL, @SELECT, @INCLUDE). Needs write tools as well; a token also needs the mcp:code scope, and its owner a permission to edit snippets, plugins or modules.'], 'self_service' => ['label' => 'Token self-service page', 'hint' => 'Users create their own tokens in the manager.'], 'rate_limit' => ['label' => 'Rate limiting', 'hint' => 'Limit the number of requests per minute for each user.'], 'audit' => ['label' => 'Audit log', 'hint' => 'Record every MCP call in the audit log.'], diff --git a/lang/es/global.php b/lang/es/global.php index d11df94..c1adc4a 100644 --- a/lang/es/global.php +++ b/lang/es/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'Endpoint de API', 'hint' => 'Endpoint autenticado por token para agentes externos.'], 'require_scopes' => ['label' => 'Exigir scopes del token', 'hint' => 'Cada llamada debe estar cubierta por los scopes de su token.'], 'enable_write_tools' => ['label' => 'Permitir herramientas de escritura', 'hint' => 'Los agentes pueden crear y modificar documentos y elementos (evo.write.*). El token también necesita el scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Permitir herramientas destructivas', 'hint' => 'Los agentes pueden llamar a herramientas de extensiones que eliminan datos. Requiere también las herramientas de escritura; el token también necesita el scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Permitir escribir código de backend', 'hint' => 'Los agentes pueden guardar snippets, plugins y vinculaciones de TV que ejecutan código en el servidor (@EVAL, @SELECT, @INCLUDE). Requiere también las herramientas de escritura; el token también necesita el scope mcp:code, y su propietario, un permiso para editar snippets, plugins o módulos.'], 'self_service' => ['label' => 'Página de autoservicio de tokens', 'hint' => 'Los usuarios crean sus propios tokens en el manager.'], 'rate_limit' => ['label' => 'Límite de peticiones', 'hint' => 'Limita las peticiones por minuto de cada usuario.'], 'audit' => ['label' => 'Registro de auditoría', 'hint' => 'Registrar cada llamada MCP en el registro de auditoría.'], diff --git a/lang/fa/global.php b/lang/fa/global.php index d1a5b75..f5b42c5 100644 --- a/lang/fa/global.php +++ b/lang/fa/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'endpoint API', 'hint' => 'endpoint با احراز هویت توکن برای عامل‌های خارجی.'], 'require_scopes' => ['label' => 'الزام scopeهای توکن', 'hint' => 'هر فراخوانی باید در scopeهای توکن خود پوشش داده شود.'], 'enable_write_tools' => ['label' => 'اجازه ابزارهای نوشتن', 'hint' => 'عامل‌ها می‌توانند اسناد و عناصر را ایجاد و ویرایش کنند (evo.write.*). توکن به scope mcp:write نیز نیاز دارد.'], + 'enable_destructive_tools' => ['label' => 'اجازه ابزارهای مخرب', 'hint' => 'عامل‌ها می‌توانند ابزارهای افزونه‌ای را که داده‌ها را حذف می‌کنند فراخوانی کنند. ابزارهای نوشتن نیز لازم است؛ توکن به scope mcp:destructive نیز نیاز دارد.'], + 'enable_code_tools' => ['label' => 'اجازه نوشتن کد بک‌اند', 'hint' => 'عامل‌ها می‌توانند اسنیپت‌ها، افزونه‌ها و اتصال‌های TV را که روی سرور کد اجرا می‌کنند ذخیره کنند (@EVAL, @SELECT, @INCLUDE). ابزارهای نوشتن نیز لازم است؛ توکن به scope mcp:code نیز نیاز دارد و مالک آن به مجوز ویرایش اسنیپت‌ها، افزونه‌ها یا ماژول‌ها.'], 'self_service' => ['label' => 'صفحه سلف‌سرویس توکن', 'hint' => 'کاربران توکن‌های خود را در بخش مدیریت می‌سازند.'], 'rate_limit' => ['label' => 'محدودیت نرخ درخواست', 'hint' => 'تعداد درخواست‌ها در دقیقه را برای هر کاربر محدود می‌کند.'], 'audit' => ['label' => 'گزارش حسابرسی', 'hint' => 'هر فراخوانی MCP در گزارش حسابرسی ثبت شود.'], diff --git a/lang/fi/global.php b/lang/fi/global.php index 68ec136..30f6d71 100644 --- a/lang/fi/global.php +++ b/lang/fi/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Tokenilla todennettu endpoint ulkoisille agenteille.'], 'require_scopes' => ['label' => 'Vaadi tokenin scopet', 'hint' => 'Jokaisen kutsun on kuuluttava tokenin scopeihin.'], 'enable_write_tools' => ['label' => 'Salli kirjoitustyökalut', 'hint' => 'Agentit voivat luoda ja muuttaa dokumentteja ja elementtejä (evo.write.*). Token tarvitsee lisäksi scopen mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Salli tuhoavat työkalut', 'hint' => 'Agentit voivat kutsua laajennusten työkaluja, jotka poistavat tietoja. Vaatii myös kirjoitustyökalut; token tarvitsee lisäksi scopen mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Salli taustajärjestelmän koodin kirjoittaminen', 'hint' => 'Agentit voivat tallentaa snippetejä, laajennuksia ja TV-sidontoja, jotka suorittavat koodia palvelimella (@EVAL, @SELECT, @INCLUDE). Vaatii myös kirjoitustyökalut; token tarvitsee lisäksi scopen mcp:code ja sen omistaja oikeuden muokata snippetejä, laajennuksia tai moduuleja.'], 'self_service' => ['label' => 'Tokenien itsepalvelusivu', 'hint' => 'Käyttäjät luovat omat tokeninsa managerissa.'], 'rate_limit' => ['label' => 'Pyyntörajoitus', 'hint' => 'Rajoittaa kunkin käyttäjän pyyntöjä minuutissa.'], 'audit' => ['label' => 'Auditointiloki', 'hint' => 'Kirjaa jokainen MCP-kutsu auditointilokiin.'], diff --git a/lang/fr/global.php b/lang/fr/global.php index a2d5541..540dce8 100644 --- a/lang/fr/global.php +++ b/lang/fr/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'Endpoint API', 'hint' => 'Endpoint authentifié par token pour les agents externes.'], 'require_scopes' => ['label' => 'Exiger les scopes du token', 'hint' => 'Chaque appel doit être couvert par les scopes de son token.'], 'enable_write_tools' => ['label' => 'Autoriser les outils d’écriture', 'hint' => 'Les agents peuvent créer et modifier des documents et des éléments (evo.write.*). Le token doit aussi avoir le scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Autoriser les outils destructifs', 'hint' => 'Les agents peuvent appeler des outils d’extensions qui suppriment des données. Nécessite aussi les outils d’écriture ; le token doit aussi avoir le scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Autoriser l’écriture de code backend', 'hint' => 'Les agents peuvent enregistrer des snippets, des plugins et des liaisons de TV qui exécutent du code sur le serveur (@EVAL, @SELECT, @INCLUDE). Nécessite aussi les outils d’écriture ; le token doit aussi avoir le scope mcp:code, et son propriétaire une permission de modifier des snippets, des plugins ou des modules.'], 'self_service' => ['label' => 'Page libre-service des tokens', 'hint' => 'Les utilisateurs créent leurs propres tokens dans le manager.'], 'rate_limit' => ['label' => 'Limitation du débit', 'hint' => 'Limite le nombre de requêtes par minute pour chaque utilisateur.'], 'audit' => ['label' => 'Journal d’audit', 'hint' => 'Enregistrer chaque appel MCP dans le journal d’audit.'], diff --git a/lang/he/global.php b/lang/he/global.php index c55b6eb..f4c9ccf 100644 --- a/lang/he/global.php +++ b/lang/he/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'נקודת קצה API', 'hint' => 'נקודת קצה עם אימות טוקן לסוכנים חיצוניים.'], 'require_scopes' => ['label' => 'דרישת scopes של הטוקן', 'hint' => 'כל קריאה חייבת להיות מכוסה על ידי ה-scopes של הטוקן שלה.'], 'enable_write_tools' => ['label' => 'התרת כלי כתיבה', 'hint' => 'סוכנים יכולים ליצור ולשנות מסמכים ואלמנטים (evo.write.*). הטוקן צריך גם את ה-scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'התרת כלים הרסניים', 'hint' => 'סוכנים יכולים לקרוא לכלי הרחבות שמוחקים נתונים. נדרשים גם כלי כתיבה; הטוקן צריך גם את ה-scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'התרת כתיבת קוד צד שרת', 'hint' => 'סוכנים יכולים לשמור סניפטים, תוספים וקישורי TV שמריצים קוד בשרת (@EVAL, @SELECT, @INCLUDE). נדרשים גם כלי כתיבה; הטוקן צריך גם את ה-scope mcp:code, והבעלים שלו — הרשאה לערוך סניפטים, תוספים או מודולים.'], 'self_service' => ['label' => 'דף שירות עצמי לטוקנים', 'hint' => 'משתמשים יוצרים את הטוקנים שלהם במנהל.'], 'rate_limit' => ['label' => 'הגבלת קצב', 'hint' => 'מגביל את מספר הבקשות לדקה לכל משתמש.'], 'audit' => ['label' => 'יומן ביקורת', 'hint' => 'רישום כל קריאת MCP ביומן הביקורת.'], diff --git a/lang/it/global.php b/lang/it/global.php index afa96ca..8e72039 100644 --- a/lang/it/global.php +++ b/lang/it/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'Endpoint API', 'hint' => 'Endpoint autenticato con token per agenti esterni.'], 'require_scopes' => ['label' => 'Richiedi gli scope del token', 'hint' => 'Ogni chiamata deve rientrare negli scope del proprio token.'], 'enable_write_tools' => ['label' => 'Consenti strumenti di scrittura', 'hint' => 'Gli agenti possono creare e modificare documenti ed elementi (evo.write.*). Il token deve avere anche lo scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Consenti strumenti distruttivi', 'hint' => 'Gli agenti possono chiamare strumenti delle estensioni che eliminano dati. Richiede anche gli strumenti di scrittura; il token deve avere anche lo scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Consenti la scrittura di codice backend', 'hint' => 'Gli agenti possono salvare snippet, plugin e associazioni TV che eseguono codice sul server (@EVAL, @SELECT, @INCLUDE). Richiede anche gli strumenti di scrittura; il token deve avere anche lo scope mcp:code e il suo proprietario un permesso per modificare snippet, plugin o moduli.'], 'self_service' => ['label' => 'Pagina self-service dei token', 'hint' => 'Gli utenti creano i propri token nel manager.'], 'rate_limit' => ['label' => 'Limite di richieste', 'hint' => 'Limita il numero di richieste al minuto per ogni utente.'], 'audit' => ['label' => 'Registro di audit', 'hint' => 'Registra ogni chiamata MCP nel registro di audit.'], diff --git a/lang/ja/global.php b/lang/ja/global.php index 3539f26..8ebcc39 100644 --- a/lang/ja/global.php +++ b/lang/ja/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API エンドポイント', 'hint' => '外部エージェント向けのトークン認証エンドポイントです。'], 'require_scopes' => ['label' => 'トークンのスコープを必須にする', 'hint' => '各呼び出しはトークンのスコープの範囲内である必要があります。'], 'enable_write_tools' => ['label' => '書き込みツールを許可', 'hint' => 'エージェントがドキュメントとエレメントを作成・変更できます(evo.write.*)。トークンには mcp:write スコープも必要です。'], + 'enable_destructive_tools' => ['label' => '破壊的ツールを許可', 'hint' => 'エージェントがデータを削除する拡張ツールを呼び出せます。書き込みツールも必要です。トークンには mcp:destructive スコープも必要です。'], + 'enable_code_tools' => ['label' => 'バックエンドコードの書き込みを許可', 'hint' => 'エージェントがサーバー上でコードを実行するスニペット、プラグイン、TV バインディング(@EVAL, @SELECT, @INCLUDE)を保存できます。書き込みツールも必要です。トークンには mcp:code スコープも必要で、その所有者にはスニペット、プラグイン、またはモジュールを編集する権限が必要です。'], 'self_service' => ['label' => 'トークンのセルフサービスページ', 'hint' => 'ユーザーがマネージャーで自分のトークンを作成します。'], 'rate_limit' => ['label' => 'レート制限', 'hint' => 'ユーザーごとの1分あたりのリクエスト数を制限します。'], 'audit' => ['label' => '監査ログ', 'hint' => 'すべての MCP 呼び出しを監査ログに記録します。'], diff --git a/lang/nl/global.php b/lang/nl/global.php index e00890d..67f782d 100644 --- a/lang/nl/global.php +++ b/lang/nl/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Met token geauthenticeerd endpoint voor externe agents.'], 'require_scopes' => ['label' => 'Token-scopes vereisen', 'hint' => 'Elke aanroep moet binnen de scopes van zijn token vallen.'], 'enable_write_tools' => ['label' => 'Schrijftools toestaan', 'hint' => 'Agents mogen documenten en elementen aanmaken en wijzigen (evo.write.*). Het token heeft ook de scope mcp:write nodig.'], + 'enable_destructive_tools' => ['label' => 'Destructieve tools toestaan', 'hint' => 'Agents mogen tools van extensies aanroepen die gegevens verwijderen. Vereist ook schrijftools; het token heeft ook de scope mcp:destructive nodig.'], + 'enable_code_tools' => ['label' => 'Schrijven van backendcode toestaan', 'hint' => 'Agents mogen snippets, plugins en TV-bindingen opslaan die code op de server uitvoeren (@EVAL, @SELECT, @INCLUDE). Vereist ook schrijftools; het token heeft ook de scope mcp:code nodig, en de eigenaar ervan een recht om snippets, plugins of modules te bewerken.'], 'self_service' => ['label' => 'Selfservicepagina voor tokens', 'hint' => 'Gebruikers maken hun eigen tokens aan in de manager.'], 'rate_limit' => ['label' => 'Aanvraaglimiet', 'hint' => 'Beperkt het aantal aanvragen per minuut per gebruiker.'], 'audit' => ['label' => 'Auditlog', 'hint' => 'Elke MCP-aanroep vastleggen in het auditlog.'], diff --git a/lang/nn/global.php b/lang/nn/global.php index 7876d20..4c4d4b7 100644 --- a/lang/nn/global.php +++ b/lang/nn/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API-endepunkt', 'hint' => 'Endepunkt med token-autentisering for eksterne agentar.'], 'require_scopes' => ['label' => 'Krev token-scopes', 'hint' => 'Kvart kall må vere dekt av scopane til tokenet.'], 'enable_write_tools' => ['label' => 'Tillat skriveverktøy', 'hint' => 'Agentar kan opprette og endre dokument og element (evo.write.*). Tokenet treng òg scopet mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Tillat destruktive verktøy', 'hint' => 'Agentar kan kalle utvidingsverktøy som slettar data. Krev òg skriveverktøy; tokenet treng òg scopet mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Tillat skriving av backend-kode', 'hint' => 'Agentar kan lagre snippets, plugins og TV-bindingar som køyrer kode på serveren (@EVAL, @SELECT, @INCLUDE). Krev òg skriveverktøy; tokenet treng òg scopet mcp:code, og eigaren løyve til å redigere snippets, plugins eller modular.'], 'self_service' => ['label' => 'Sjølvbeteningsside for token', 'hint' => 'Brukarar lagar sine eigne token i manageren.'], 'rate_limit' => ['label' => 'Avgrensing av førespurnader', 'hint' => 'Avgrensar talet på førespurnader per minutt for kvar brukar.'], 'audit' => ['label' => 'Revisjonslogg', 'hint' => 'Registrer kvart MCP-kall i revisjonsloggen.'], diff --git a/lang/pl/global.php b/lang/pl/global.php index 329f00e..a73e6de 100644 --- a/lang/pl/global.php +++ b/lang/pl/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'Endpoint API', 'hint' => 'Endpoint uwierzytelniany tokenem dla zewnętrznych agentów.'], 'require_scopes' => ['label' => 'Wymagaj scopes tokenu', 'hint' => 'Każde wywołanie musi mieścić się w scopes swojego tokenu.'], 'enable_write_tools' => ['label' => 'Zezwól na narzędzia zapisu', 'hint' => 'Agenci mogą tworzyć i zmieniać dokumenty oraz elementy (evo.write.*). Token potrzebuje też scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Zezwól na narzędzia destrukcyjne', 'hint' => 'Agenci mogą wywoływać narzędzia rozszerzeń, które usuwają dane. Wymaga też narzędzi zapisu; token potrzebuje też scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Zezwól na zapis kodu backendu', 'hint' => 'Agenci mogą zapisywać snippety, pluginy i powiązania TV, które uruchamiają kod na serwerze (@EVAL, @SELECT, @INCLUDE). Wymaga też narzędzi zapisu; token potrzebuje też scope mcp:code, a jego właściciel uprawnienia do edycji snippetów, pluginów lub modułów.'], 'self_service' => ['label' => 'Strona samoobsługi tokenów', 'hint' => 'Użytkownicy tworzą własne tokeny w menedżerze.'], 'rate_limit' => ['label' => 'Limit żądań', 'hint' => 'Ogranicza liczbę żądań na minutę dla każdego użytkownika.'], 'audit' => ['label' => 'Dziennik audytu', 'hint' => 'Zapisuj każde wywołanie MCP w dzienniku audytu.'], diff --git a/lang/pt/global.php b/lang/pt/global.php index 63bf216..f383e4e 100644 --- a/lang/pt/global.php +++ b/lang/pt/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'Endpoint de API', 'hint' => 'Endpoint autenticado por token para agentes externos.'], 'require_scopes' => ['label' => 'Exigir scopes do token', 'hint' => 'Cada chamada deve estar coberta pelos scopes do seu token.'], 'enable_write_tools' => ['label' => 'Permitir ferramentas de escrita', 'hint' => 'Agentes podem criar e alterar documentos e elementos (evo.write.*). O token também precisa do scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Permitir ferramentas destrutivas', 'hint' => 'Agentes podem chamar ferramentas de extensões que excluem dados. Requer também as ferramentas de escrita; o token também precisa do scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Permitir escrita de código de backend', 'hint' => 'Agentes podem salvar snippets, plugins e vinculações de TV que executam código no servidor (@EVAL, @SELECT, @INCLUDE). Requer também as ferramentas de escrita; o token também precisa do scope mcp:code, e seu proprietário, de permissão para editar snippets, plugins ou módulos.'], 'self_service' => ['label' => 'Página de autoatendimento de tokens', 'hint' => 'Usuários criam seus próprios tokens no manager.'], 'rate_limit' => ['label' => 'Limite de requisições', 'hint' => 'Limita as requisições por minuto de cada usuário.'], 'audit' => ['label' => 'Log de auditoria', 'hint' => 'Registrar cada chamada MCP no log de auditoria.'], diff --git a/lang/sk/global.php b/lang/sk/global.php index a8a0fd8..1b76d2c 100644 --- a/lang/sk/global.php +++ b/lang/sk/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint s overením tokenom pre externých agentov.'], 'require_scopes' => ['label' => 'Vyžadovať scopes tokenu', 'hint' => 'Každé volanie musí byť pokryté scopes svojho tokenu.'], 'enable_write_tools' => ['label' => 'Povoliť zapisovacie nástroje', 'hint' => 'Agenti môžu vytvárať a meniť dokumenty a elementy (evo.write.*). Token potrebuje aj scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Povoliť deštruktívne nástroje', 'hint' => 'Agenti môžu volať nástroje rozšírení, ktoré mažú dáta. Vyžaduje aj zapisovacie nástroje; token potrebuje aj scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Povoliť zápis backendového kódu', 'hint' => 'Agenti môžu ukladať snippety, pluginy a väzby TV, ktoré spúšťajú kód na serveri (@EVAL, @SELECT, @INCLUDE). Vyžaduje aj zapisovacie nástroje; token potrebuje aj scope mcp:code a jeho vlastník oprávnenie upravovať snippety, pluginy alebo moduly.'], 'self_service' => ['label' => 'Samoobslužná stránka tokenov', 'hint' => 'Používatelia si vytvárajú vlastné tokeny v manažéri.'], 'rate_limit' => ['label' => 'Obmedzenie počtu požiadaviek', 'hint' => 'Obmedzí počet požiadaviek za minútu pre každého používateľa.'], 'audit' => ['label' => 'Auditný log', 'hint' => 'Zapisovať každé volanie MCP do auditného logu.'], diff --git a/lang/sv/global.php b/lang/sv/global.php index ad87c28..20ec9e8 100644 --- a/lang/sv/global.php +++ b/lang/sv/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API-endpoint', 'hint' => 'Tokenautentiserad endpoint för externa agenter.'], 'require_scopes' => ['label' => 'Kräv token-scopes', 'hint' => 'Varje anrop måste täckas av tokenets scopes.'], 'enable_write_tools' => ['label' => 'Tillåt skrivverktyg', 'hint' => 'Agenter får skapa och ändra dokument och element (evo.write.*). Tokenet behöver även scopet mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Tillåt destruktiva verktyg', 'hint' => 'Agenter får anropa tilläggsverktyg som raderar data. Kräver även skrivverktyg; tokenet behöver även scopet mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Tillåt skrivning av backend-kod', 'hint' => 'Agenter får spara snippets, plugins och TV-bindningar som kör kod på servern (@EVAL, @SELECT, @INCLUDE). Kräver även skrivverktyg; tokenet behöver även scopet mcp:code, och dess ägare en behörighet att redigera snippets, plugins eller moduler.'], 'self_service' => ['label' => 'Självbetjäningssida för tokens', 'hint' => 'Användare skapar egna tokens i managern.'], 'rate_limit' => ['label' => 'Begränsning av förfrågningar', 'hint' => 'Begränsar antalet förfrågningar per minut för varje användare.'], 'audit' => ['label' => 'Granskningslogg', 'hint' => 'Logga varje MCP-anrop i granskningsloggen.'], diff --git a/lang/uk/global.php b/lang/uk/global.php index 02aed5c..18bde5a 100644 --- a/lang/uk/global.php +++ b/lang/uk/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API endpoint', 'hint' => 'Endpoint з автентифікацією за токеном для зовнішніх агентів.'], 'require_scopes' => ['label' => 'Вимагати scopes токена', 'hint' => 'Кожен виклик має покриватися scopes свого токена.'], 'enable_write_tools' => ['label' => 'Дозволити інструменти запису', 'hint' => 'Агенти можуть створювати й змінювати документи та елементи (evo.write.*). Токену також потрібен scope mcp:write.'], + 'enable_destructive_tools' => ['label' => 'Дозволити деструктивні інструменти', 'hint' => 'Агенти можуть викликати інструменти розширень, які видаляють дані. Потрібні також інструменти запису; токену також потрібен scope mcp:destructive.'], + 'enable_code_tools' => ['label' => 'Дозволити запис бекенд-коду', 'hint' => 'Агенти можуть зберігати сніпети, плагіни та прив’язки TV, які виконують код на сервері (@EVAL, @SELECT, @INCLUDE). Потрібні також інструменти запису; токену також потрібен scope mcp:code, а його власнику — дозвіл на редагування сніпетів, плагінів або модулів.'], 'self_service' => ['label' => 'Сторінка самообслуговування токенів', 'hint' => 'Користувачі самі створюють свої токени в менеджері.'], 'rate_limit' => ['label' => 'Обмеження частоти запитів', 'hint' => 'Обмежує кількість запитів за хвилину для кожного користувача.'], 'audit' => ['label' => 'Журнал аудиту', 'hint' => 'Записувати кожен виклик MCP у журнал аудиту.'], diff --git a/lang/zh/global.php b/lang/zh/global.php index a61e379..278ca6f 100644 --- a/lang/zh/global.php +++ b/lang/zh/global.php @@ -26,6 +26,8 @@ 'mode_api' => ['label' => 'API 端点', 'hint' => '供外部代理使用的令牌认证端点。'], 'require_scopes' => ['label' => '要求令牌 scopes', 'hint' => '每次调用都必须在其令牌的 scopes 范围内。'], 'enable_write_tools' => ['label' => '允许写入工具', 'hint' => '代理可以创建和修改文档与元素(evo.write.*)。令牌还需要 mcp:write scope。'], + 'enable_destructive_tools' => ['label' => '允许破坏性工具', 'hint' => '代理可以调用会删除数据的扩展工具。还需要启用写入工具;令牌还需要 mcp:destructive scope。'], + 'enable_code_tools' => ['label' => '允许写入后端代码', 'hint' => '代理可以保存在服务器上执行代码的代码片段、插件和 TV 绑定(@EVAL, @SELECT, @INCLUDE)。还需要启用写入工具;令牌还需要 mcp:code scope,其所有者还需要编辑代码片段、插件或模块的权限。'], 'self_service' => ['label' => '令牌自助页面', 'hint' => '用户在管理后台创建自己的令牌。'], 'rate_limit' => ['label' => '请求频率限制', 'hint' => '限制每个用户每分钟的请求数。'], 'audit' => ['label' => '审计日志', 'hint' => '将每次 MCP 调用记录到审计日志。'], diff --git a/src/Console/Commands/eMcpTokenCreateCommand.php b/src/Console/Commands/eMcpTokenCreateCommand.php index 8989de5..55e6cc8 100644 --- a/src/Console/Commands/eMcpTokenCreateCommand.php +++ b/src/Console/Commands/eMcpTokenCreateCommand.php @@ -15,7 +15,7 @@ class eMcpTokenCreateCommand extends Command protected $signature = 'emcp:token:create {username : Manager user the token acts as} {--name=cli : Label shown in the token list} - {--scopes=mcp:read,mcp:call : Comma-separated scopes (mcp:read, mcp:call, mcp:write, mcp:admin)} + {--scopes=mcp:read,mcp:call : Comma-separated scopes (mcp:read, mcp:call, mcp:write, mcp:destructive, mcp:code, mcp:admin)} {--expires=90 : Days until expiry, or "never"} {--json : Print the result as JSON}'; diff --git a/src/Contracts/DestructiveSite.php b/src/Contracts/DestructiveSite.php new file mode 100644 index 0000000..c4371fa --- /dev/null +++ b/src/Contracts/DestructiveSite.php @@ -0,0 +1,21 @@ + + */ + public static function permission(): string|array; +} diff --git a/src/Contracts/ToolProvider.php b/src/Contracts/ToolProvider.php index cafbd9e..8aa6cd5 100644 --- a/src/Contracts/ToolProvider.php +++ b/src/Contracts/ToolProvider.php @@ -13,10 +13,13 @@ * app(\EvolutionCMS\eMCP\Services\ToolRegistry::class)->register(new MyToolProvider()); * } * - * Tools are ordinary Laravel\Mcp\Server\Tool classes. A tool that changes the site should - * implement WritesSite so eMCP applies its write gates (enable_write_tools, mcp:write scope). + * Tools are ordinary Laravel\Mcp\Server\Tool classes and must carry a #[Name] attribute: + * the policy layer finds a tool by that name, and a tool it cannot name is rejected. + * A tool that changes the site should implement WritesSite so eMCP applies its write gates + * (enable_write_tools, mcp:write scope); one that deletes data, DestructiveSite. A tool limited + * to holders of an Evo permission implements RequiresPermission. * Permission checks inside a tool see the impersonated manager user: evo()->hasPermission() - * and friends answer for the token owner. + * and friends answer for the token owner. See DOCS.md, "Extension tool contract". */ interface ToolProvider { diff --git a/src/Http/Controllers/McpDispatchController.php b/src/Http/Controllers/McpDispatchController.php index 72d4d6d..8a99443 100644 --- a/src/Http/Controllers/McpDispatchController.php +++ b/src/Http/Controllers/McpDispatchController.php @@ -9,6 +9,7 @@ use EvolutionCMS\eMCP\Services\McpExecutionService; use EvolutionCMS\eMCP\Services\SecurityPolicy; use EvolutionCMS\eMCP\Services\ServerRegistry; +use EvolutionCMS\eMCP\Support\JsonRpcPayload; use EvolutionCMS\eMCP\Support\TraceContext; use EvolutionCMS\eMCP\Support\TransportError; use Illuminate\Http\JsonResponse; @@ -79,7 +80,8 @@ public function __invoke(Request $request, string $server): JsonResponse } $toolName = $this->securityPolicy->resolveToolName($jsonrpc); - if ($toolName !== null && $this->securityPolicy->isToolDenied($server, $toolName)) { + $arguments = JsonRpcPayload::argumentsOf($jsonrpc); + if ($toolName !== null && $this->securityPolicy->isToolDenied($server, $toolName, $arguments)) { return $this->finalize( $request, TransportError::response($request, 403, 'tool_denied', 'Tool denied by policy'), @@ -89,6 +91,16 @@ public function __invoke(Request $request, string $server): JsonResponse ); } + if ($toolName !== null && !$this->securityPolicy->isToolPermitted($toolName, $arguments)) { + return $this->finalize( + $request, + TransportError::response($request, 403, 'permission_denied', 'Permission denied'), + $server, + 'dispatch', + $startedAt + ); + } + $traceId = TraceContext::resolve($request); $meta = $this->buildMeta($request, $server, $jsonrpc, $traceId); $asyncAvailable = $this->canQueueAsync(); diff --git a/src/Http/Controllers/McpManagerController.php b/src/Http/Controllers/McpManagerController.php index a826fc1..07880a2 100644 --- a/src/Http/Controllers/McpManagerController.php +++ b/src/Http/Controllers/McpManagerController.php @@ -7,6 +7,8 @@ use EvolutionCMS\eMCP\Services\AuditLogger; use EvolutionCMS\eMCP\Services\SecurityPolicy; use EvolutionCMS\eMCP\Services\ServerRegistry; +use EvolutionCMS\eMCP\Services\ToolRegistry; +use EvolutionCMS\eMCP\Support\JsonRpcPayload; use EvolutionCMS\eMCP\Support\TraceContext; use EvolutionCMS\eMCP\Support\TransportError; use Illuminate\Http\Request; @@ -47,7 +49,8 @@ public function __invoke(Request $request, string $server): Response $payload = $request->json()->all(); if (is_array($payload)) { $toolName = $this->securityPolicy->resolveToolName($payload); - if ($toolName !== null && $this->securityPolicy->isToolDenied($server, $toolName)) { + $arguments = JsonRpcPayload::argumentsOf($payload); + if ($toolName !== null && $this->securityPolicy->isToolDenied($server, $toolName, $arguments)) { return $this->finalizeResponse( $request, TransportError::response($request, 403, 'tool_denied', 'Tool denied by policy'), @@ -56,6 +59,16 @@ public function __invoke(Request $request, string $server): Response $startedAt ); } + + if ($toolName !== null && !$this->securityPolicy->isToolPermitted($toolName, $arguments)) { + return $this->finalizeResponse( + $request, + TransportError::response($request, 403, 'permission_denied', 'Permission denied'), + $server, + $auditMethod, + $startedAt + ); + } } $serverClass = $this->registry->resolveWebServerClassByHandle($server); @@ -96,6 +109,10 @@ public function __invoke(Request $request, string $server): Response $this->injectPlatformMetadata($response); } + if (!$response instanceof StreamedResponse && $this->isToolsListRequest($request)) { + $this->injectToolAnnotations($response); + } + if ($response instanceof StreamedResponse) { $streamError = $this->enforceStreamingPolicy($request, $server, $response); if ($streamError !== null) { @@ -151,6 +168,52 @@ private function isInitializeRequest(Request $request): bool return is_array($payload) && ($payload['method'] ?? null) === 'initialize'; } + private function isToolsListRequest(Request $request): bool + { + $payload = $request->json()->all(); + + return is_array($payload) && ($payload['method'] ?? null) === 'tools/list'; + } + + /** + * Fills readOnlyHint / destructiveHint from the WritesSite / DestructiveSite markers. + * Hints a tool sets itself are kept. + */ + private function injectToolAnnotations(Response $response): void + { + $raw = $response->getContent(); + if (!is_string($raw) || $raw === '') { + return; + } + + // Decoded as objects, so empty JSON objects ({} schemas, annotations) survive re-encoding. + $payload = json_decode($raw); + if (!is_object($payload) || !is_array($payload->result->tools ?? null)) { + return; + } + + $registry = app(ToolRegistry::class); + $changed = false; + foreach ($payload->result->tools as $tool) { + if (!is_object($tool) || !is_string($tool->name ?? null)) { + continue; + } + + $hints = $registry->annotationsFor($tool->name); + if ($hints === []) { + continue; + } + + $existing = is_object($tool->annotations ?? null) ? get_object_vars($tool->annotations) : []; + $tool->annotations = (object)($existing + $hints); + $changed = true; + } + + if ($changed) { + $response->setContent(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + } + private function injectPlatformMetadata(Response $response): void { $raw = $response->getContent(); @@ -355,6 +418,33 @@ private function resolveServerConfig(string $server): array return []; } + /** + * Calls to destructive tools and calls that store backend code are marked so they can be + * found in the audit log. + * + * @return array + */ + private function auditExtra(Request $request): array + { + $payload = $request->json()->all(); + $toolName = is_array($payload) ? $this->securityPolicy->resolveToolName($payload) : null; + if ($toolName === null) { + return []; + } + + $registry = app(ToolRegistry::class); + $extra = []; + if ($registry->isDestructiveTool($toolName)) { + $extra['destructive'] = true; + $extra['reversible'] = $registry->isReversible($toolName); + } + if ($registry->writesCode($toolName, JsonRpcPayload::argumentsOf($payload))) { + $extra['executes_code'] = true; + } + + return $extra === [] ? [] : ['source' => 'mcp', 'tool' => $toolName] + $extra; + } + private function resolveAuditMethod(Request $request): string { $payload = $request->json()->all(); @@ -376,7 +466,9 @@ private function finalizeResponse(Request $request, Response $response, string $ $server, $method, $response->getStatusCode(), - $startedAt + $startedAt, + null, + $this->auditExtra($request) ); return $response; diff --git a/src/Middleware/EnsureMcpScopes.php b/src/Middleware/EnsureMcpScopes.php index 9eb48fe..ce17072 100644 --- a/src/Middleware/EnsureMcpScopes.php +++ b/src/Middleware/EnsureMcpScopes.php @@ -6,6 +6,7 @@ use Closure; use EvolutionCMS\eMCP\Services\ScopePolicy; +use EvolutionCMS\eMCP\Support\JsonRpcPayload; use EvolutionCMS\eMCP\Support\TransportError; use Illuminate\Http\Request; @@ -50,30 +51,16 @@ public function handle(Request $request, Closure $next) $request->attributes->set('emcp.required_scope', $requiredScope); - $toolScope = $this->resolveToolScope($request, $method); - if ($toolScope !== null && !$this->scopePolicy->requestHasScope($request, $toolScope)) { - return TransportError::response($request, 403, 'scope_denied', 'Scope denied'); + $toolName = JsonRpcPayload::toolName($request); + foreach ($toolName !== null ? $this->scopePolicy->resolveToolScopes($toolName, JsonRpcPayload::toolArguments($request)) : [] as $toolScope) { + if (!$this->scopePolicy->requestHasScope($request, $toolScope)) { + return TransportError::response($request, 403, 'scope_denied', 'Scope denied'); + } } return $next($request); } - private function resolveToolScope(Request $request, string $method): ?string - { - if ($method !== 'tools/call') { - return null; - } - - $payload = json_decode((string)$request->getContent(), true); - if (!is_array($payload)) { - return null; - } - - $toolName = trim((string)($payload['params']['name'] ?? '')); - - return $toolName !== '' ? $this->scopePolicy->resolveToolScope($toolName) : null; - } - private function hasAuthContext(Request $request): bool { if ($request->attributes->has('emcp.auth.user_id')) { diff --git a/src/Servers/ContentServer.php b/src/Servers/ContentServer.php index c6cfe64..dda9e91 100644 --- a/src/Servers/ContentServer.php +++ b/src/Servers/ContentServer.php @@ -25,6 +25,7 @@ use EvolutionCMS\eMCP\Tools\Write\ContentPublishTool; use EvolutionCMS\eMCP\Tools\Write\ContentUpdateTool; use EvolutionCMS\eMCP\Tools\Write\ElementsSaveTool; +use EvolutionCMS\eMCP\Services\SecurityPolicy; use EvolutionCMS\eMCP\Services\ToolRegistry; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Contracts\Transport; @@ -75,6 +76,14 @@ public function __construct(Transport $transport) } } + // Leave out what this user may not call (deny_tools, disabled destructive tools, missing + // RequiresPermission). This also covers the local transport, which has no HTTP policy layer. + $policy = app(SecurityPolicy::class); + $this->tools = array_values(array_filter( + $this->tools, + static fn(string $class): bool => !$policy->isToolHidden(self::HANDLE, (string)ToolRegistry::resolveName($class)) + )); + parent::__construct($transport); } diff --git a/src/Services/McpExecutionService.php b/src/Services/McpExecutionService.php index 947b70b..bcbf6e4 100644 --- a/src/Services/McpExecutionService.php +++ b/src/Services/McpExecutionService.php @@ -5,6 +5,7 @@ namespace EvolutionCMS\eMCP\Services; use EvolutionCMS\eMCP\Http\Controllers\McpManagerController; +use EvolutionCMS\eMCP\Support\JsonRpcPayload; use EvolutionCMS\eMCP\Support\TraceContext; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -44,10 +45,17 @@ public function call(array $meta): array ]; $toolName = $this->securityPolicy->resolveToolName($payload); - if ($toolName !== null && $this->securityPolicy->isToolDenied($serverHandle, $toolName)) { + $arguments = JsonRpcPayload::argumentsOf($payload); + if ($toolName !== null && $this->securityPolicy->isToolDenied($serverHandle, $toolName, $arguments)) { throw new \DomainException('Tool is denied by security policy.'); } + // Workers run without a manager session, so a RequiresPermission tool is refused here + // unless the caller already runs as a manager (sync failover inside an HTTP request). + if ($toolName !== null && !$this->securityPolicy->isToolPermitted($toolName, $arguments)) { + throw new \DomainException('Tool requires a permission the caller does not have.'); + } + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded)) { throw new \RuntimeException('Unable to encode JSON-RPC payload.'); diff --git a/src/Services/ScopePolicy.php b/src/Services/ScopePolicy.php index 49ea456..f422dd8 100644 --- a/src/Services/ScopePolicy.php +++ b/src/Services/ScopePolicy.php @@ -4,6 +4,7 @@ namespace EvolutionCMS\eMCP\Services; +use EvolutionCMS\eMCP\Support\JsonRpcPayload; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -40,18 +41,8 @@ public function resolveRequestedMethod(Request $request): ?string return $cached; } - $rawBody = $request->getContent(); - if (!is_string($rawBody) || trim($rawBody) === '') { - return null; - } - - $payload = json_decode($rawBody, true); - if (!is_array($payload)) { - return null; - } - - $method = trim((string)($payload['method'] ?? '')); - if ($method === '') { + $method = JsonRpcPayload::method($request); + if ($method === null) { return null; } @@ -119,15 +110,57 @@ public function requestScopes(Request $request): array public function resolveToolScope(string $toolName): ?string { $isWrite = str_starts_with(trim($toolName), 'evo.write.') - || (function_exists('app') && app()->bound(ToolRegistry::class) && app(ToolRegistry::class)->isWriteTool($toolName)); + || ($this->registry()?->isWriteTool($toolName) ?? false); if (!$isWrite) { return null; } - $scope = trim((string)config('cms.settings.eMCP.auth.write_scope', 'mcp:write')); + return $this->configuredScope('write_scope', 'mcp:write'); + } + + /** + * Every scope a `tools/call` needs on top of `mcp:call`: the write scope for write tools, + * plus the destructive scope for DestructiveSite tools and the code scope for a call that + * stores backend code (ExecutesCode, or a built-in tool judged from its arguments). + * + * @param array $arguments + * @return array + */ + public function resolveToolScopes(string $toolName, array $arguments = []): array + { + $scopes = []; + + $writeScope = $this->resolveToolScope($toolName); + if ($writeScope !== null) { + $scopes[] = $writeScope; + } + + if ($this->registry()?->isDestructiveTool($toolName) ?? false) { + $scopes[] = $this->configuredScope('destructive_scope', 'mcp:destructive'); + } + + if ($this->registry()?->writesCode($toolName, $arguments) ?? false) { + $scopes[] = $this->configuredScope('code_scope', 'mcp:code'); + } + + return array_values(array_unique($scopes)); + } + + private function configuredScope(string $key, string $default): string + { + $scope = trim((string)config('cms.settings.eMCP.auth.' . $key, $default)); + + return $scope !== '' ? $scope : $default; + } + + private function registry(): ?ToolRegistry + { + if (!function_exists('app') || !app()->bound(ToolRegistry::class)) { + return null; + } - return $scope !== '' ? $scope : 'mcp:write'; + return app(ToolRegistry::class); } /** diff --git a/src/Services/SecurityPolicy.php b/src/Services/SecurityPolicy.php index ba9922f..fb7f98a 100644 --- a/src/Services/SecurityPolicy.php +++ b/src/Services/SecurityPolicy.php @@ -4,6 +4,7 @@ namespace EvolutionCMS\eMCP\Services; +use EvolutionCMS\eMCP\Support\BackendCode; use Illuminate\Support\Str; final class SecurityPolicy @@ -34,17 +35,48 @@ public function isServerAllowed(string $serverHandle): bool return false; } - public function isToolDenied(string $serverHandle, string $toolName): bool + /** + * @param array $arguments tools/call arguments; they tell whether a built-in + * write tool stores backend code + */ + public function isToolDenied(string $serverHandle, string $toolName, array $arguments = []): bool { $toolName = trim($toolName); if ($toolName === '') { return false; } - if ($this->isWriteToolDenied($toolName)) { + if ( + $this->isWriteToolDenied($toolName) + || $this->isDestructiveToolDenied($toolName) + || $this->isCodeWriteDenied($toolName, $arguments) + ) { return true; } + return $this->matchesDenyList($serverHandle, $toolName); + } + + /** + * Whether `tools/list` should leave the tool out for the current user: it is on a deny list, + * it is a disabled destructive or code tool, or the user lacks the permission it requires. + * Disabled evo.write.* / WritesSite tools stay listed (and are refused on call), as before. + */ + public function isToolHidden(string $serverHandle, string $toolName): bool + { + $toolName = trim($toolName); + if ($toolName === '') { + return false; + } + + return $this->matchesDenyList($serverHandle, $toolName) + || $this->isDestructiveToolDenied($toolName) + || (($this->registry()?->isCodeTool($toolName) ?? false) && $this->isCodeWriteDenied($toolName)) + || !$this->isToolPermitted($toolName); + } + + private function matchesDenyList(string $serverHandle, string $toolName): bool + { $global = $this->normalizePatterns(config('cms.settings.eMCP.security.deny_tools', [])); $server = $this->serverToolDenyPatterns($serverHandle); @@ -70,6 +102,85 @@ public function isWriteToolDenied(string $toolName): bool return $this->isWriteTool($toolName); } + public function isDestructiveToolDenied(string $toolName): bool + { + if ((bool)config('cms.settings.eMCP.security.enable_destructive_tools', false)) { + return false; + } + + return $this->registry()?->isDestructiveTool($toolName) ?? false; + } + + /** + * @param array $arguments + */ + public function isCodeWriteDenied(string $toolName, array $arguments = []): bool + { + if ((bool)config('cms.settings.eMCP.security.enable_code_tools', false)) { + return false; + } + + return $this->writesCode($toolName, $arguments); + } + + /** + * Whether this call stores backend code (ExecutesCode tool, or a built-in write tool whose + * arguments carry PHP or a code binding). + * + * @param array $arguments + */ + public function writesCode(string $toolName, array $arguments = []): bool + { + return $this->registry()?->writesCode(trim($toolName), $arguments) ?? false; + } + + /** + * Whether the current manager may make this call: the permission a RequiresPermission tool + * declares, and for a call that stores backend code one of the permissions that already grant + * PHP in the manager (BackendCode::permissions()). Other calls always pass here (the tools + * check permissions themselves). A manager identity is required: no session (auth.mode=none, + * CLI, a worker without an initiator) means no access. Role 1 passes like it does in the manager. + * + * @param array $arguments + */ + public function isToolPermitted(string $toolName, array $arguments = []): bool + { + $toolName = trim($toolName); + $permissions = $this->registry()?->permissionFor($toolName); + $code = $this->writesCode($toolName, $arguments); + if ($permissions === null && !$code) { + return true; + } + + if (!function_exists('evo') || !evo()->isLoggedIn('mgr')) { + return false; + } + + if ((int)($_SESSION['mgrRole'] ?? 0) === 1) { + return true; + } + + if ($code && !$this->hasAnyPermission(BackendCode::permissions())) { + return false; + } + + return $permissions === null || $this->hasAnyPermission($permissions); + } + + /** + * @param array $permissions + */ + private function hasAnyPermission(array $permissions): bool + { + foreach ($permissions as $permission) { + if (evo()->hasPermission($permission, 'mgr')) { + return true; + } + } + + return false; + } + /** * eMCP's own evo.write.* tools, plus contributed tools marked WritesSite. */ @@ -79,11 +190,16 @@ public function isWriteTool(string $toolName): bool return true; } + return $this->registry()?->isWriteTool($toolName) ?? false; + } + + private function registry(): ?ToolRegistry + { if (!function_exists('app') || !app()->bound(ToolRegistry::class)) { - return false; + return null; } - return app(ToolRegistry::class)->isWriteTool($toolName); + return app(ToolRegistry::class); } /** diff --git a/src/Services/TokenService.php b/src/Services/TokenService.php index 8727468..71cb86f 100644 --- a/src/Services/TokenService.php +++ b/src/Services/TokenService.php @@ -20,7 +20,7 @@ final class TokenService public const PREFIX = 'emcp_'; /** @var array */ - public const KNOWN_SCOPES = ['mcp:read', 'mcp:call', 'mcp:write', 'mcp:admin']; + public const KNOWN_SCOPES = ['mcp:read', 'mcp:call', 'mcp:write', 'mcp:destructive', 'mcp:code', 'mcp:admin']; /** * @param array $scopes @@ -111,6 +111,10 @@ public function normalizeScopes(array $scopes): array } // Calling tools without being able to list them is useless, so mcp:call implies mcp:read. + // Destructive and code tools are write tools, so mcp:destructive and mcp:code imply mcp:write. + if (in_array('mcp:destructive', $normalized, true) || in_array('mcp:code', $normalized, true)) { + $normalized[] = 'mcp:write'; + } if (in_array('mcp:call', $normalized, true) || in_array('mcp:write', $normalized, true)) { $normalized[] = 'mcp:read'; } diff --git a/src/Services/ToolRegistry.php b/src/Services/ToolRegistry.php index 33bedc2..03c4586 100644 --- a/src/Services/ToolRegistry.php +++ b/src/Services/ToolRegistry.php @@ -4,25 +4,54 @@ namespace EvolutionCMS\eMCP\Services; +use EvolutionCMS\eMCP\Contracts\DestructiveSite; +use EvolutionCMS\eMCP\Contracts\ExecutesCode; +use EvolutionCMS\eMCP\Contracts\RequiresPermission; use EvolutionCMS\eMCP\Contracts\ToolProvider; use EvolutionCMS\eMCP\Contracts\WritesSite; +use EvolutionCMS\eMCP\Support\BackendCode; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use InvalidArgumentException; use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Primitive; +use Laravel\Mcp\Server\Tool; use ReflectionClass; /** * Tools contributed by other extras, keyed by server handle. * * Two ways in: a ToolProvider registered at runtime, or class names listed under - * `mcp.servers[].extra_tools` in config. The registry also answers "is this a write tool?" - * for names it knows, so the policy layer needs no naming convention from extras. + * `mcp.servers[].extra_tools` in config. The registry also answers policy questions (write, + * destructive, required permission) for names it knows, so the policy layer needs no naming + * convention from extras. + * + * A tool is accepted only when the policy layer can see it under the same name the MCP server + * will call it by. Anything else (not a Tool, a name that cannot be read without running the + * tool, the reserved `evo.*` namespace, a name taken by another tool) is rejected: it throws + * in debug mode and is logged and left out otherwise (SPEC 6.4, 6.5), never served ungated. */ final class ToolRegistry { - /** @var array> */ + /** destructiveHint of eMCP's own write tools: true where a call overwrites existing data. */ + private const BUILTIN_DESTRUCTIVE = [ + 'evo.write.content.update' => true, + 'evo.write.elements.save' => true, + 'evo.write.content.create' => false, + 'evo.write.content.publish' => false, + 'evo.write.cache.clear' => false, + ]; + + /** @var array> handle => classes from providers, in order */ private array $tools = []; - /** @var array|null tool name => class, built lazily */ - private ?array $names = null; + /** + * @var array{names: array, tools: array>}|null + */ + private ?array $index = null; + + /** @var array class => reason */ + private array $rejected = []; public function register(ToolProvider $provider): void { @@ -41,6 +70,7 @@ public function register(ToolProvider $provider): void */ public function add(string $handle, string $class): void { + // A provider may list tools of an optional dependency that is not installed. if (!class_exists($class)) { return; } @@ -48,28 +78,20 @@ public function add(string $handle, string $class): void $this->tools[$handle] ??= []; if (!in_array($class, $this->tools[$handle], true)) { $this->tools[$handle][] = $class; - $this->names = null; + $this->index = null; + // Surface a bad registration where it happens (throws in debug mode). + $this->index(); } } /** + * Accepted tools of a server: provider tools first, then `extra_tools` from config. + * * @return array */ public function toolsFor(string $handle): array { - $configured = []; - foreach ((array)config('mcp.servers', []) as $server) { - if (!is_array($server) || trim((string)($server['handle'] ?? '')) !== $handle) { - continue; - } - foreach ((array)($server['extra_tools'] ?? []) as $class) { - if (is_string($class) && class_exists($class)) { - $configured[] = $class; - } - } - } - - return array_values(array_unique(array_merge($this->tools[$handle] ?? [], $configured))); + return $this->index()['tools'][$handle] ?? []; } /** @@ -88,54 +110,270 @@ public function isWriteTool(string $toolName): bool return $class !== null && is_subclass_of($class, WritesSite::class); } + public function isDestructiveTool(string $toolName): bool + { + $class = $this->classFor(trim($toolName)); + + return $class !== null && is_subclass_of($class, DestructiveSite::class); + } + + /** + * A contributed tool marked ExecutesCode (every call stores backend code). + */ + public function isCodeTool(string $toolName): bool + { + $class = $this->classFor(trim($toolName)); + + return $class !== null && is_subclass_of($class, ExecutesCode::class); + } + + /** + * Whether this call stores backend code: an ExecutesCode tool, or a built-in write tool whose + * arguments carry PHP (snippet, plugin) or a code binding (@EVAL, @SELECT, @INCLUDE). + * + * @param array $arguments + */ + public function writesCode(string $toolName, array $arguments = []): bool + { + return $this->isCodeTool($toolName) || BackendCode::inArguments($toolName, $arguments); + } + + /** + * Whether a destructive tool's effect can be undone (soft delete). Irreversible unless the + * tool says otherwise through a static reversible() method. + */ + public function isReversible(string $toolName): bool + { + $class = $this->classFor(trim($toolName)); + if ($class === null || !method_exists($class, 'reversible')) { + return false; + } + + try { + return $class::reversible() === true; + } catch (\Throwable) { + return false; + } + } + + /** + * Permissions a contributed tool requires (any one is enough), or null when it declares none. + * An empty list means the declaration is unusable and nobody may call the tool. + * + * @return array|null + */ + public function permissionFor(string $toolName): ?array + { + $class = $this->classFor(trim($toolName)); + if ($class === null || !is_subclass_of($class, RequiresPermission::class)) { + return null; + } + + try { + $declared = $class::permission(); + } catch (\Throwable) { + return []; + } + + $permissions = []; + foreach ((array)$declared as $permission) { + if (is_string($permission) && trim($permission) !== '') { + $permissions[] = trim($permission); + } + } + + return array_values(array_unique($permissions)); + } + + /** + * MCP tool annotations derived from the markers. Only what the markers prove: a contributed + * tool without WritesSite is not claimed to be read-only, and a WritesSite tool is not claimed + * to be harmless (MCP reads a missing destructiveHint as true; a tool that only adds can say + * so itself with #[IsDestructive(false)]). + * + * @return array + */ + public function annotationsFor(string $toolName): array + { + $toolName = trim($toolName); + + // Built-in write tools: update and elements.save overwrite without a revision to go back to. + if (array_key_exists($toolName, self::BUILTIN_DESTRUCTIVE)) { + return ['readOnlyHint' => false, 'destructiveHint' => self::BUILTIN_DESTRUCTIVE[$toolName]]; + } + + if ($this->isDestructiveTool($toolName) || $this->isCodeTool($toolName)) { + return ['readOnlyHint' => false, 'destructiveHint' => true]; + } + + if ($this->isWriteTool($toolName)) { + return ['readOnlyHint' => false]; + } + + if (str_starts_with($toolName, 'evo.')) { + return ['readOnlyHint' => true]; + } + + return []; + } + /** * @return class-string|null */ public function classFor(string $toolName): ?string { - if ($this->names === null) { - $this->names = []; - $handles = array_unique(array_merge(array_keys($this->tools), $this->configuredHandles())); - foreach ($handles as $handle) { - foreach ($this->toolsFor($handle) as $class) { - $name = $this->nameOf($class); - if ($name !== null) { - $this->names[$name] = $class; - } + return $this->index()['names'][$toolName] ?? null; + } + + /** + * Classes that were offered but not accepted, with the reason. + * + * @return array + */ + public function rejected(): array + { + $this->index(); + + return $this->rejected; + } + + /** + * The name Laravel MCP will expose the tool under (Primitive::name()), read without + * instantiating it: #[Name], else the `$name` property default, else the kebab-cased class + * basename. Null when the class overrides name(), since only running it would tell. + * + * @param class-string $class + */ + public static function resolveName(string $class): ?string + { + $reflection = new ReflectionClass($class); + + $attributes = $reflection->getAttributes(Name::class); + if ($attributes !== []) { + $name = $attributes[0]->newInstance()->value; + + return is_string($name) && trim($name) !== '' ? $name : null; + } + + if ($reflection->hasMethod('name') && $reflection->getMethod('name')->getDeclaringClass()->getName() !== Primitive::class) { + return null; + } + + $default = $reflection->getDefaultProperties()['name'] ?? ''; + if (is_string($default) && $default !== '') { + return $default; + } + + return Str::kebab($reflection->getShortName()); + } + + /** + * @return array{names: array, tools: array>} + */ + private function index(): array + { + if ($this->index !== null) { + return $this->index; + } + + $names = []; + $tools = []; + $configured = $this->configuredTools(); + $handles = array_unique(array_merge(array_keys($this->tools), array_keys($configured))); + + foreach ($handles as $handle) { + $candidates = array_unique(array_merge($this->tools[$handle] ?? [], $configured[$handle] ?? [])); + foreach ($candidates as $class) { + $name = $this->acceptableName($class); + if ($name === null) { + continue; + } + + if (isset($names[$name]) && $names[$name] !== $class) { + $this->reject($class, "tool name [{$name}] is already used by {$names[$name]}"); + continue; } + + $names[$name] = $class; + $tools[$handle][] = $class; } } - return $this->names[$toolName] ?? null; + return $this->index = ['names' => $names, 'tools' => $tools]; + } + + /** + * @param class-string $class + */ + private function acceptableName(string $class): ?string + { + if (!is_subclass_of($class, Tool::class)) { + $this->reject($class, 'not a ' . Tool::class); + + return null; + } + + $name = self::resolveName($class); + if ($name === null) { + $this->reject($class, 'its name cannot be determined without running it; add a #[Name] attribute'); + + return null; + } + + if (str_starts_with($name, 'evo.')) { + $this->reject($class, "tool name [{$name}] is in the evo.* namespace reserved for eMCP"); + + return null; + } + + return $name; } /** - * @return array + * @return array> */ - private function configuredHandles(): array + private function configuredTools(): array { - $handles = []; + $configured = []; foreach ((array)config('mcp.servers', []) as $server) { - if (is_array($server) && !empty($server['extra_tools'])) { - $handles[] = trim((string)($server['handle'] ?? '')); + if (!is_array($server)) { + continue; + } + $handle = trim((string)($server['handle'] ?? '')); + if ($handle === '') { + continue; + } + foreach ((array)($server['extra_tools'] ?? []) as $class) { + if (is_string($class) && class_exists($class)) { + $configured[$handle][] = $class; + } } } - return array_filter($handles); + return $configured; } /** * @param class-string $class */ - private function nameOf(string $class): ?string + private function reject(string $class, string $reason): void { - $attributes = (new ReflectionClass($class))->getAttributes(Name::class); - if ($attributes === []) { - return null; + $message = "eMCP rejected tool {$class}: {$reason}."; + + if ((bool)config('app.debug', false)) { + throw new InvalidArgumentException($message); } - $name = $attributes[0]->newInstance()->value; + if (isset($this->rejected[$class])) { + return; + } + $this->rejected[$class] = $reason; - return is_string($name) && $name !== '' ? $name : null; + try { + $channel = trim((string)config('cms.settings.eMCP.logging.channel', 'emcp')) ?: 'emcp'; + Log::channel($channel)->warning($message); + } catch (\Throwable) { + // A logging failure must not take the site down. + } } } diff --git a/src/Support/BackendCode.php b/src/Support/BackendCode.php new file mode 100644 index 0000000..ea5732a --- /dev/null +++ b/src/Support/BackendCode.php @@ -0,0 +1,131 @@ + + */ + public static function permissions(): array + { + $core = 'EvolutionCMS\\Support\\BackendCodePermissions'; + + return class_exists($core) ? $core::PERMISSIONS : ['save_snippet', 'save_plugin', 'save_module']; + } + + /** + * Whether a call of a built-in tool stores backend code, judged from its arguments. + * + * @param array $arguments + */ + public static function inArguments(string $toolName, array $arguments): bool + { + $fields = is_array($arguments['fields'] ?? null) ? $arguments['fields'] : []; + + switch (trim($toolName)) { + case 'evo.write.elements.save': + $type = strtolower(trim((string)($arguments['type'] ?? ''))); + if (in_array($type, self::CODE_ELEMENT_TYPES, true)) { + return true; + } + if ($type !== 'tv') { + return false; + } + foreach (self::TV_DEFINITION_FIELDS as $column) { + if (is_scalar($fields[$column] ?? null) && self::definitionRunsCode((string)$fields[$column], $column)) { + return true; + } + } + + return false; + + case 'evo.write.content.update': + case 'evo.write.content.create': + $tvs = is_array($arguments['tvs'] ?? null) ? $arguments['tvs'] : []; + foreach (array_merge(array_values($fields), array_values($tvs)) as $value) { + foreach (is_array($value) ? $value : [$value] as $part) { + if (is_scalar($part) && self::valueRunsCode((string)$part)) { + return true; + } + } + } + + return false; + } + + return false; + } + + /** + * A TV value or document field that reaches @EVAL / @SELECT (also behind @INHERIT). + */ + public static function valueRunsCode(string $value): bool + { + $core = 'EvolutionCMS\\Support\\TvBindingGuard'; + if (class_exists($core)) { + return $core::hasCodeBinding($value); + } + + for ($depth = 0; $depth < 20; $depth++) { + $value = ltrim($value, " \t\n\r\0\x0B"); + if (preg_match('/^@{1,2}(?:EVAL|SELECT)/i', $value)) { + return true; + } + if (!preg_match('/^@INHERIT/i', $value)) { + return false; + } + $value = substr($value, 8); + } + + return true; + } + + /** + * A TV definition column holding a binding that runs code; @INCLUDE counts there, and each + * value of display_params is a binding of its own. + */ + public static function definitionRunsCode(string $value, string $column = ''): bool + { + $core = 'EvolutionCMS\\Support\\TvBindingGuard'; + if (class_exists($core)) { + return $core::definitionHasCodeBinding($value, $column); + } + + $candidates = [$value]; + if ($column === 'display_params') { + foreach (explode('&', $value) as $pair) { + $pos = strpos($pair, '='); + if ($pos !== false) { + $candidates[] = str_replace(['%3D', '%26'], ['=', '&'], substr($pair, $pos + 1)); + } + } + } + + foreach ($candidates as $candidate) { + if (self::valueRunsCode($candidate) || preg_match('/^@{1,2}INCLUDE/i', ltrim($candidate, " \t\n\r\0\x0B"))) { + return true; + } + } + + return false; + } +} diff --git a/src/Support/JsonRpcPayload.php b/src/Support/JsonRpcPayload.php new file mode 100644 index 0000000..3600677 --- /dev/null +++ b/src/Support/JsonRpcPayload.php @@ -0,0 +1,86 @@ +|null null when the body is empty or not a JSON object + */ + public static function of(Request $request): ?array + { + $cached = $request->attributes->get(self::ATTRIBUTE); + if (is_array($cached)) { + return $cached; + } + if ($cached === false) { + return null; + } + + $rawBody = $request->getContent(); + $payload = is_string($rawBody) && trim($rawBody) !== '' ? json_decode($rawBody, true) : null; + $payload = is_array($payload) ? $payload : null; + + $request->attributes->set(self::ATTRIBUTE, $payload ?? false); + + return $payload; + } + + public static function method(Request $request): ?string + { + $method = trim((string)(self::of($request)['method'] ?? '')); + + return $method !== '' ? $method : null; + } + + /** + * Tool name of a `tools/call`, null for any other method. + */ + public static function toolName(Request $request): ?string + { + $payload = self::of($request); + if ($payload === null || trim((string)($payload['method'] ?? '')) !== 'tools/call') { + return null; + } + + $name = $payload['params']['name'] ?? null; + $name = is_string($name) ? trim($name) : ''; + + return $name !== '' ? $name : null; + } + + /** + * Arguments of a `tools/call` (empty for any other method). + * + * @return array + */ + public static function toolArguments(Request $request): array + { + if (self::toolName($request) === null) { + return []; + } + + return self::argumentsOf(self::of($request) ?? []); + } + + /** + * @param array $jsonRpc + * @return array + */ + public static function argumentsOf(array $jsonRpc): array + { + $arguments = $jsonRpc['params']['arguments'] ?? null; + + return is_array($arguments) ? $arguments : []; + } +} diff --git a/src/Support/SettingsCatalog.php b/src/Support/SettingsCatalog.php index fef48be..318cc88 100644 --- a/src/Support/SettingsCatalog.php +++ b/src/Support/SettingsCatalog.php @@ -19,6 +19,8 @@ final class SettingsCatalog 'mode.api' => 'mode_api', 'auth.require_scopes' => 'require_scopes', 'security.enable_write_tools' => 'enable_write_tools', + 'security.enable_destructive_tools' => 'enable_destructive_tools', + 'security.enable_code_tools' => 'enable_code_tools', 'tokens.self_service' => 'self_service', 'rate_limit.enabled' => 'rate_limit', 'logging.audit_enabled' => 'audit', diff --git a/src/Support/ToolError.php b/src/Support/ToolError.php new file mode 100644 index 0000000..68ddda1 --- /dev/null +++ b/src/Support/ToolError.php @@ -0,0 +1,137 @@ + */ + public const CODES = ['forbidden', 'not_found', 'ambiguous', 'invalid', 'refused', 'store_failure']; + + public const MAX_CANDIDATES = 10; + + /** + * @param array $extra `errors` (field => messages), `candidates` + * (id + title only, at most 10 are kept), or other keys + */ + public static function make(string $code, string $message, int $status = 422, array $extra = []): Response + { + return Response::error(self::encode(self::payload($code, $message, $status, $extra))); + } + + /** + * The JSON object make() puts in the text block. + * + * @param array $extra + * @return array + */ + public static function payload(string $code, string $message, int $status = 422, array $extra = []): array + { + $code = trim($code); + if (!self::isValidCode($code)) { + throw new InvalidArgumentException( + "Unknown tool error code [{$code}]. Use one of " . implode(', ', self::CODES) . ' or a prefixed code like vendor.code.' + ); + } + + $payload = ['ok' => false, 'error' => $code, 'message' => $message, 'status' => $status]; + + if (isset($extra['errors']) && is_array($extra['errors']) && $extra['errors'] !== []) { + $payload['errors'] = $extra['errors']; + } + + if (isset($extra['candidates']) && is_array($extra['candidates'])) { + $candidates = self::candidates($extra['candidates']); + $payload['candidates'] = $candidates; + if (count($extra['candidates']) > count($candidates)) { + $payload['candidates_truncated'] = true; + } + } + + unset($extra['errors'], $extra['candidates']); + foreach ($extra as $key => $value) { + if (is_string($key) && !array_key_exists($key, $payload)) { + $payload[$key] = $value; + } + } + + return $payload; + } + + /** + * `invalid` from a ValidationException, keeping its per-field messages. + */ + public static function fromValidation(\Illuminate\Validation\ValidationException $exception): Response + { + $errors = $exception->errors(); + $first = ''; + foreach ($errors as $messages) { + $first = (string)(is_array($messages) ? ($messages[0] ?? '') : $messages); + break; + } + + return self::make('invalid', $first !== '' ? $first : $exception->getMessage(), 422, ['errors' => $errors]); + } + + public static function isValidCode(string $code): bool + { + if (in_array($code, self::CODES, true)) { + return true; + } + + return (bool)preg_match('/^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$/', $code); + } + + /** + * Keeps id and title of each candidate (a `name` counts as title), at most MAX_CANDIDATES, + * so an error never carries more than the caller needs to pick one. + * + * @param array $candidates + * @return array + */ + private static function candidates(array $candidates): array + { + $result = []; + foreach ($candidates as $candidate) { + if (count($result) >= self::MAX_CANDIDATES) { + break; + } + + $candidate = is_object($candidate) ? get_object_vars($candidate) : $candidate; + if (!is_array($candidate) || !isset($candidate['id']) || !(is_int($candidate['id']) || is_string($candidate['id']))) { + continue; + } + + $result[] = [ + 'id' => $candidate['id'], + 'title' => (string)($candidate['title'] ?? $candidate['name'] ?? ''), + ]; + } + + return $result; + } + + /** + * @param array $payload + */ + private static function encode(array $payload): string + { + return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR); + } +} diff --git a/tests/Feature/DispatchAsyncFailoverBehaviorTest.php b/tests/Feature/DispatchAsyncFailoverBehaviorTest.php index e8a6e2b..bbd88ea 100644 --- a/tests/Feature/DispatchAsyncFailoverBehaviorTest.php +++ b/tests/Feature/DispatchAsyncFailoverBehaviorTest.php @@ -283,6 +283,7 @@ public function toIso8601String(): string require_once __DIR__ . '/../../src/Support/RateLimitIdentityResolver.php'; require_once __DIR__ . '/../../src/Support/Redactor.php'; require_once __DIR__ . '/../../src/Services/AuditLogger.php'; + require_once __DIR__ . '/../../src/Support/JsonRpcPayload.php'; require_once __DIR__ . '/../../src/Services/SecurityPolicy.php'; require_once __DIR__ . '/../../src/Services/IdempotencyStore.php'; require_once __DIR__ . '/../../src/Http/Controllers/McpDispatchController.php'; diff --git a/tests/Feature/DispatchIdempotencyBehaviorTest.php b/tests/Feature/DispatchIdempotencyBehaviorTest.php index 1d3f3d8..cc3fe7e 100644 --- a/tests/Feature/DispatchIdempotencyBehaviorTest.php +++ b/tests/Feature/DispatchIdempotencyBehaviorTest.php @@ -244,6 +244,7 @@ public function toIso8601String(): string require_once __DIR__ . '/../../src/Support/RateLimitIdentityResolver.php'; require_once __DIR__ . '/../../src/Support/Redactor.php'; require_once __DIR__ . '/../../src/Services/AuditLogger.php'; + require_once __DIR__ . '/../../src/Support/JsonRpcPayload.php'; require_once __DIR__ . '/../../src/Services/SecurityPolicy.php'; require_once __DIR__ . '/../../src/Services/IdempotencyStore.php'; require_once __DIR__ . '/../../src/Http/Controllers/McpDispatchController.php'; diff --git a/tests/Feature/DispatchMaterializationPolicyTest.php b/tests/Feature/DispatchMaterializationPolicyTest.php index 59141bf..8364da4 100644 --- a/tests/Feature/DispatchMaterializationPolicyTest.php +++ b/tests/Feature/DispatchMaterializationPolicyTest.php @@ -279,6 +279,7 @@ public function toIso8601String(): string require_once __DIR__ . '/../../src/Support/RateLimitIdentityResolver.php'; require_once __DIR__ . '/../../src/Support/Redactor.php'; require_once __DIR__ . '/../../src/Services/AuditLogger.php'; + require_once __DIR__ . '/../../src/Support/JsonRpcPayload.php'; require_once __DIR__ . '/../../src/Services/SecurityPolicy.php'; require_once __DIR__ . '/../../src/Services/IdempotencyStore.php'; require_once __DIR__ . '/../../src/Http/Controllers/McpDispatchController.php'; diff --git a/tests/Feature/StreamingAndResultLimitsBehaviorTest.php b/tests/Feature/StreamingAndResultLimitsBehaviorTest.php index d2c8006..c86dfd0 100644 --- a/tests/Feature/StreamingAndResultLimitsBehaviorTest.php +++ b/tests/Feature/StreamingAndResultLimitsBehaviorTest.php @@ -324,6 +324,7 @@ function data_get(array $target, string $key, mixed $default = null): mixed require_once __DIR__ . '/../../src/Support/TraceContext.php'; require_once __DIR__ . '/../../src/Support/TransportError.php'; + require_once __DIR__ . '/../../src/Support/JsonRpcPayload.php'; require_once __DIR__ . '/../../src/Http/Controllers/McpManagerController.php'; /** @var array $configValues */ diff --git a/tests/Unit/ApiAuthModeMiddlewareTest.php b/tests/Unit/ApiAuthModeMiddlewareTest.php index ed61cc3..ffa44af 100644 --- a/tests/Unit/ApiAuthModeMiddlewareTest.php +++ b/tests/Unit/ApiAuthModeMiddlewareTest.php @@ -233,6 +233,7 @@ public function make(string $class): object require_once __DIR__ . '/../../src/Support/TraceContext.php'; require_once __DIR__ . '/../../src/Support/TransportError.php'; + require_once __DIR__ . '/../../src/Support/JsonRpcPayload.php'; require_once __DIR__ . '/../../src/Services/ScopePolicy.php'; require_once __DIR__ . '/../../src/Middleware/EnsureApiJwt.php'; require_once __DIR__ . '/../../src/Middleware/EnsureMcpScopes.php'; diff --git a/tests/Unit/ScopePolicyTest.php b/tests/Unit/ScopePolicyTest.php index 7389973..66181ea 100644 --- a/tests/Unit/ScopePolicyTest.php +++ b/tests/Unit/ScopePolicyTest.php @@ -110,6 +110,7 @@ function config(string $key, mixed $default = null): mixed } } + require_once __DIR__ . '/../../src/Support/JsonRpcPayload.php'; require_once __DIR__ . '/../../src/Services/ScopePolicy.php'; $policy = new ScopePolicy(); diff --git a/tests/Unit/ToolErrorTest.php b/tests/Unit/ToolErrorTest.php new file mode 100644 index 0000000..e75e8de --- /dev/null +++ b/tests/Unit/ToolErrorTest.php @@ -0,0 +1,119 @@ +isError; + } + } +} + +namespace Illuminate\Validation { + final class ValidationException extends \Exception + { + /** + * @param array> $errors + */ + public function __construct(private array $errors) + { + parent::__construct('The given data was invalid.'); + } + + /** + * @return array> + */ + public function errors(): array + { + return $this->errors; + } + } +} + +namespace { + use EvolutionCMS\eMCP\Support\ToolError; + use Illuminate\Validation\ValidationException; + + require_once __DIR__ . '/../../src/Support/ToolError.php'; + + function assertTrue(bool $condition, string $message): void + { + if (!$condition) { + throw new RuntimeException($message); + } + } + + function decode(\Laravel\Mcp\Response $response): array + { + $payload = json_decode($response->text, true); + assertTrue(is_array($payload), 'the text block is one JSON object'); + + return $payload; + } + + $response = ToolError::make('not_found', 'Order [42] not found.', 404); + assertTrue($response->isError(), 'result is marked isError'); + assertTrue( + decode($response) === ['ok' => false, 'error' => 'not_found', 'message' => 'Order [42] not found.', 'status' => 404], + 'minimal shape' + ); + + assertTrue(decode(ToolError::make('refused', 'No.'))['status'] === 422, 'status defaults to 422'); + + $payload = decode(ToolError::make('invalid', 'Bad input.', 422, [ + 'errors' => ['status' => ['Unknown status.']], + 'hint' => 'Use orders_list to see statuses.', + 'ok' => true, + 'error' => 'overridden', + ])); + assertTrue($payload['errors'] === ['status' => ['Unknown status.']], 'errors are passed through'); + assertTrue($payload['hint'] === 'Use orders_list to see statuses.', 'extra keys are kept'); + assertTrue($payload['ok'] === false && $payload['error'] === 'invalid', 'extra keys cannot override the envelope'); + + $many = []; + for ($i = 1; $i <= 15; $i++) { + $many[] = ['id' => $i, 'title' => "Order {$i}", 'email' => "buyer{$i}@example.com"]; + } + $payload = decode(ToolError::make('ambiguous', 'Several orders match.', 409, ['candidates' => $many])); + assertTrue(count($payload['candidates']) === ToolError::MAX_CANDIDATES, 'at most 10 candidates'); + assertTrue($payload['candidates'][0] === ['id' => 1, 'title' => 'Order 1'], 'candidates keep only id and title'); + assertTrue(($payload['candidates_truncated'] ?? false) === true, 'truncation is reported'); + + $payload = decode(ToolError::make('ambiguous', 'Two match.', 409, ['candidates' => [ + ['id' => 'a', 'name' => 'Alpha'], + (object)['id' => 7, 'title' => 'Seven'], + ]])); + assertTrue( + $payload['candidates'] === [['id' => 'a', 'title' => 'Alpha'], ['id' => 7, 'title' => 'Seven']], + 'name counts as title; objects are accepted' + ); + assertTrue(!isset($payload['candidates_truncated']), 'no truncation flag when everything fits'); + + assertTrue(ToolError::isValidCode('aimage.quota_exceeded'), 'prefixed extension codes are allowed'); + assertTrue(!ToolError::isValidCode('quota_exceeded'), 'unprefixed unknown codes are not'); + $threw = false; + try { + ToolError::make('oops', 'x'); + } catch (InvalidArgumentException) { + $threw = true; + } + assertTrue($threw, 'an unknown code is a programming error'); + + $payload = decode(ToolError::fromValidation(new ValidationException(['id' => ['Document [5] not found.']]))); + assertTrue($payload['error'] === 'invalid' && $payload['message'] === 'Document [5] not found.', 'ValidationException maps to invalid'); + assertTrue($payload['errors'] === ['id' => ['Document [5] not found.']], 'field errors are kept'); + + echo "ToolError unit checks passed.\n"; +} diff --git a/tests/Unit/ToolRegistryTest.php b/tests/Unit/ToolRegistryTest.php new file mode 100644 index 0000000..d608650 --- /dev/null +++ b/tests/Unit/ToolRegistryTest.php @@ -0,0 +1,509 @@ + */ + private array $items = []; + + public function set(string $key, mixed $value): void + { + $this->items[$key] = $value; + } + + public function get(string $key, mixed $default = null): mixed + { + return array_key_exists($key, $this->items) ? $this->items[$key] : $default; + } + } + + final class Request + { + public AttributeBag $attributes; + + public int $reads = 0; + + public function __construct(private string $content = '') + { + $this->attributes = new AttributeBag(); + } + + public function getContent(): string + { + $this->reads++; + + return $this->content; + } + } + } +} + +namespace Laravel\Mcp\Server\Attributes { + #[\Attribute(\Attribute::TARGET_CLASS)] + final class Name + { + public function __construct(public string $value) + { + } + } +} + +namespace Laravel\Mcp\Server { + abstract class Primitive + { + protected string $name = ''; + + public function name(): string + { + return $this->name; + } + } + + abstract class Tool extends Primitive + { + } +} + +namespace Fixtures { + use EvolutionCMS\eMCP\Contracts\DestructiveSite; + use EvolutionCMS\eMCP\Contracts\RequiresPermission; + use EvolutionCMS\eMCP\Contracts\ToolProvider; + use EvolutionCMS\eMCP\Contracts\WritesSite; + use Laravel\Mcp\Server\Attributes\Name; + use Laravel\Mcp\Server\Tool; + + // Contracts must exist before the fixtures below are declared. + foreach (['WritesSite', 'DestructiveSite', 'ExecutesCode', 'RequiresPermission', 'ToolProvider'] as $contract) { + require_once dirname(__DIR__, 2) . "/src/Contracts/{$contract}.php"; + } + + #[Name('shop.orders.list')] + final class ReadTool extends Tool + { + } + + #[Name('shop.orders.set_status')] + final class WriteTool extends Tool implements WritesSite, RequiresPermission + { + public static function permission(): string|array + { + return 'shop_orders'; + } + } + + #[Name('shop.orders.delete')] + final class SoftDeleteTool extends Tool implements DestructiveSite, RequiresPermission + { + public static function permission(): string|array + { + return ['shop_orders_delete', 'shop_admin']; + } + + public static function reversible(): bool + { + return true; + } + } + + #[Name('shop.products.delete')] + final class HardDeleteTool extends Tool implements DestructiveSite + { + } + + #[Name('shop.files.write')] + final class FileWriteTool extends Tool implements \EvolutionCMS\eMCP\Contracts\ExecutesCode + { + } + + #[Name('shop.broken')] + final class EmptyPermissionTool extends Tool implements RequiresPermission + { + public static function permission(): string|array + { + return [' ']; + } + } + + // No #[Name]: Laravel MCP calls it "unnamed-write-tool", and so must the gates. + final class UnnamedWriteTool extends Tool implements WritesSite + { + } + + final class PropertyNamedTool extends Tool implements WritesSite + { + protected string $name = 'shop.property_named'; + } + + final class DynamicNameTool extends Tool implements WritesSite + { + public function name(): string + { + return 'shop.dynamic_' . date('s'); + } + } + + #[Name('evo.content.get')] + final class ReservedNameTool extends Tool + { + } + + #[Name('shop.orders.list')] + final class DuplicateNameTool extends Tool implements WritesSite + { + } + + #[Name('shop.not_a_tool')] + final class NotATool + { + } + + final class ShopProvider implements ToolProvider + { + public function server(): string + { + return 'content'; + } + + public function tools(): array + { + return [ReadTool::class, WriteTool::class, SoftDeleteTool::class, HardDeleteTool::class, EmptyPermissionTool::class, FileWriteTool::class, 'Fixtures\\Missing']; + } + } +} + +namespace { + use EvolutionCMS\eMCP\Services\ScopePolicy; + use EvolutionCMS\eMCP\Services\SecurityPolicy; + use EvolutionCMS\eMCP\Services\ToolRegistry; + use EvolutionCMS\eMCP\Support\JsonRpcPayload; + use Fixtures\DuplicateNameTool; + use Fixtures\DynamicNameTool; + use Fixtures\NotATool; + use Fixtures\PropertyNamedTool; + use Fixtures\ReadTool; + use Fixtures\ReservedNameTool; + use Fixtures\ShopProvider; + use Fixtures\UnnamedWriteTool; + use Fixtures\WriteTool; + use Illuminate\Http\Request; + + $root = dirname(__DIR__, 2); + require_once $root . '/src/Support/BackendCode.php'; + require_once $root . '/src/Services/ToolRegistry.php'; + require_once $root . '/src/Services/SecurityPolicy.php'; + require_once $root . '/src/Support/JsonRpcPayload.php'; + require_once $root . '/src/Services/ScopePolicy.php'; + + function assertTrue(bool $condition, string $message): void + { + if (!$condition) { + throw new RuntimeException($message); + } + } + + /** @var array $config */ + $config = []; + + function config(string $key, mixed $default = null): mixed + { + global $config; + + return array_key_exists($key, $config) ? $config[$key] : $default; + } + + final class TestContainer + { + /** @var array */ + public array $instances = []; + + public function bound(string $id): bool + { + return isset($this->instances[$id]); + } + } + + $container = new TestContainer(); + + function app(?string $id = null): mixed + { + global $container; + + return $id === null ? $container : $container->instances[$id]; + } + + final class TestEvo + { + public bool $loggedIn = false; + + /** @var array */ + public array $permissions = []; + + public function isLoggedIn(string $context): bool + { + return $this->loggedIn && $context === 'mgr'; + } + + public function hasPermission(string $permission, string $context): bool + { + return $context === 'mgr' && in_array($permission, $this->permissions, true); + } + } + + $evo = new TestEvo(); + + function evo(): TestEvo + { + global $evo; + + return $evo; + } + + function freshRegistry(): ToolRegistry + { + global $container; + $registry = new ToolRegistry(); + $container->instances[ToolRegistry::class] = $registry; + + return $registry; + } + + // --- Naming: the registry must see a tool under the name Laravel MCP calls it by ------------- + + assertTrue(ToolRegistry::resolveName(ReadTool::class) === 'shop.orders.list', '#[Name] is the tool name'); + assertTrue(ToolRegistry::resolveName(UnnamedWriteTool::class) === 'unnamed-write-tool', 'without #[Name] the name is the kebab-cased class basename'); + assertTrue(ToolRegistry::resolveName(PropertyNamedTool::class) === 'shop.property_named', 'the $name property default counts'); + assertTrue(ToolRegistry::resolveName(DynamicNameTool::class) === null, 'an overridden name() cannot be resolved statically'); + + // --- Provider registration -------------------------------------------------------------------- + + $registry = freshRegistry(); + $registry->register(new ShopProvider()); + + assertTrue( + $registry->toolsFor('content') === [ReadTool::class, WriteTool::class, Fixtures\SoftDeleteTool::class, Fixtures\HardDeleteTool::class, Fixtures\EmptyPermissionTool::class, Fixtures\FileWriteTool::class], + 'provider tools are kept in order; missing classes are skipped' + ); + assertTrue($registry->toolsFor('other') === [], 'unknown handle has no tools'); + assertTrue($registry->classFor('shop.orders.list') === ReadTool::class, 'classFor finds a provider tool'); + assertTrue(!$registry->isWriteTool('shop.orders.list'), 'a tool without WritesSite is not a write tool'); + assertTrue($registry->isWriteTool('shop.orders.set_status'), 'WritesSite makes a write tool'); + assertTrue($registry->isWriteTool('shop.orders.delete'), 'DestructiveSite is also a write tool'); + assertTrue($registry->isWriteTool('evo.write.content.update'), 'evo.write.* is always a write tool'); + assertTrue($registry->isDestructiveTool('shop.orders.delete'), 'DestructiveSite is destructive'); + assertTrue(!$registry->isDestructiveTool('shop.orders.set_status'), 'WritesSite alone is not destructive'); + assertTrue($registry->isReversible('shop.orders.delete'), 'reversible() = true is reported'); + assertTrue(!$registry->isReversible('shop.products.delete'), 'without reversible() an operation is irreversible'); + assertTrue($registry->permissionFor('shop.orders.list') === null, 'no RequiresPermission, no required permission'); + assertTrue($registry->permissionFor('shop.orders.set_status') === ['shop_orders'], 'string permission becomes a list'); + assertTrue($registry->permissionFor('shop.orders.delete') === ['shop_orders_delete', 'shop_admin'], 'array permission is any-of'); + assertTrue($registry->permissionFor('shop.broken') === [], 'blank permission declaration denies everyone'); + assertTrue($registry->annotationsFor('shop.orders.delete') === ['readOnlyHint' => false, 'destructiveHint' => true], 'destructive hints'); + assertTrue($registry->annotationsFor('shop.orders.set_status') === ['readOnlyHint' => false], 'a WritesSite tool is not claimed harmless'); + assertTrue($registry->annotationsFor('shop.files.write') === ['readOnlyHint' => false, 'destructiveHint' => true], 'code tools are destructive'); + assertTrue($registry->annotationsFor('evo.write.content.update') === ['readOnlyHint' => false, 'destructiveHint' => true], 'update overwrites'); + assertTrue($registry->annotationsFor('evo.write.elements.save') === ['readOnlyHint' => false, 'destructiveHint' => true], 'elements.save overwrites'); + assertTrue($registry->annotationsFor('evo.write.content.create') === ['readOnlyHint' => false, 'destructiveHint' => false], 'create only adds'); + assertTrue($registry->annotationsFor('evo.write.cache.clear') === ['readOnlyHint' => false, 'destructiveHint' => false], 'cache is rebuilt'); + assertTrue($registry->annotationsFor('shop.orders.list') === [], 'a contributed tool is not claimed read-only'); + assertTrue($registry->annotationsFor('evo.content.get') === ['readOnlyHint' => true], 'built-in read tools are read-only'); + assertTrue($registry->rejected() === [], 'valid provider tools are not rejected'); + + // --- extra_tools from config, and fail-closed rejection --------------------------------------- + + $config['mcp.servers'] = [ + ['handle' => 'content', 'extra_tools' => [UnnamedWriteTool::class, DynamicNameTool::class, ReservedNameTool::class, NotATool::class, 'Fixtures\\Missing']], + ['handle' => 'shop', 'extra_tools' => [PropertyNamedTool::class, DuplicateNameTool::class]], + ]; + + $registry = freshRegistry(); + $registry->add('content', ReadTool::class); + + assertTrue( + $registry->toolsFor('content') === [ReadTool::class, UnnamedWriteTool::class], + 'only nameable Tool classes outside evo.* are served' + ); + assertTrue($registry->toolsFor('shop') === [PropertyNamedTool::class], 'a duplicate name is rejected, the first registration wins'); + assertTrue($registry->isWriteTool('unnamed-write-tool'), 'a WritesSite tool without #[Name] is still gated (no fail-open)'); + assertTrue($registry->isWriteTool('shop.property_named'), 'extra_tools tools are gated'); + assertTrue(!$registry->isWriteTool('shop.orders.list'), 'the duplicate WritesSite tool did not take over the name'); + assertTrue($registry->classFor('evo.content.get') === null, 'a reserved name never maps to a contributed class'); + + $rejected = $registry->rejected(); + assertTrue( + array_keys($rejected) === [DynamicNameTool::class, ReservedNameTool::class, NotATool::class, DuplicateNameTool::class], + 'rejected tools are reported: ' . implode(', ', array_keys($rejected)) + ); + assertTrue(str_contains($rejected[DynamicNameTool::class], '#[Name]'), 'the reason tells the author to add #[Name]'); + + $config['app.debug'] = true; + $registry = freshRegistry(); + $threw = false; + try { + $registry->toolsFor('content'); + } catch (InvalidArgumentException $e) { + $threw = str_contains($e->getMessage(), DynamicNameTool::class); + } + assertTrue($threw, 'debug mode fails fast on a bad registration'); + unset($config['app.debug']); + $config['mcp.servers'] = []; + + // --- SecurityPolicy: destructive gate, RequiresPermission, tools/list hiding ------------------ + + $registry = freshRegistry(); + $registry->register(new ShopProvider()); + $policy = new SecurityPolicy(); + + assertTrue($policy->isToolDenied('content', 'shop.orders.delete'), 'destructive tools are denied by default'); + assertTrue($policy->isToolHidden('content', 'shop.orders.delete'), 'disabled destructive tools are hidden'); + $config['cms.settings.eMCP.security.enable_write_tools'] = true; + assertTrue($policy->isToolDenied('content', 'shop.orders.delete'), 'enabling write tools does not enable destructive ones'); + $config['cms.settings.eMCP.security.enable_destructive_tools'] = true; + assertTrue(!$policy->isToolDenied('content', 'shop.orders.delete'), 'destructive tools pass when enabled'); + $config['cms.settings.eMCP.security.enable_write_tools'] = false; + assertTrue($policy->isToolDenied('content', 'shop.orders.delete'), 'destructive tools stay behind the write switch'); + $config['cms.settings.eMCP.security.enable_write_tools'] = true; + + $_SESSION = []; + assertTrue($policy->isToolPermitted('shop.orders.list'), 'tools without RequiresPermission are not gated here'); + assertTrue(!$policy->isToolPermitted('shop.orders.set_status'), 'no manager session (auth.mode=none, CLI) means no access'); + + $evo->loggedIn = true; + assertTrue(!$policy->isToolPermitted('shop.orders.set_status'), 'a manager without the permission is refused'); + assertTrue($policy->isToolHidden('content', 'shop.orders.set_status'), 'and does not see the tool'); + + $evo->permissions = ['shop_orders']; + assertTrue($policy->isToolPermitted('shop.orders.set_status'), 'a manager with the permission passes'); + assertTrue(!$policy->isToolHidden('content', 'shop.orders.set_status'), 'and sees the tool'); + + $evo->permissions = ['shop_admin']; + assertTrue($policy->isToolPermitted('shop.orders.delete'), 'any permission of a list is enough'); + assertTrue(!$policy->isToolPermitted('shop.broken'), 'a blank declaration refuses non-admins'); + + $evo->permissions = []; + $_SESSION['mgrRole'] = 1; + assertTrue($policy->isToolPermitted('shop.orders.delete'), 'role 1 passes like in the manager'); + $_SESSION = []; + $evo->loggedIn = false; + + $config['cms.settings.eMCP.security.deny_tools'] = ['shop.orders.*']; + assertTrue($policy->isToolHidden('content', 'shop.orders.list'), 'deny_tools also hides from tools/list'); + assertTrue($policy->isToolDenied('content', 'shop.orders.list'), 'deny_tools denies the call'); + assertTrue(!$policy->isToolHidden('content', 'evo.write.cache.clear'), 'disabled-or-not, evo.write.* stays listed'); + $config['cms.settings.eMCP.security.deny_tools'] = []; + + // --- ScopePolicy: write and destructive scopes for contributed tools -------------------------- + + $scopes = new ScopePolicy(); + assertTrue($scopes->resolveToolScope('shop.orders.list') === null, 'read tool needs no extra scope'); + assertTrue($scopes->resolveToolScope('shop.orders.set_status') === 'mcp:write', 'WritesSite tool needs the write scope'); + assertTrue($scopes->resolveToolScope('unknown.tool') === null, 'unknown tools need no extra scope'); + assertTrue($scopes->resolveToolScopes('shop.orders.set_status') === ['mcp:write'], 'write tool scope list'); + assertTrue($scopes->resolveToolScopes('shop.orders.delete') === ['mcp:write', 'mcp:destructive'], 'destructive tools need write + destructive'); + $config['cms.settings.eMCP.auth.destructive_scope'] = 'shop:purge'; + assertTrue($scopes->resolveToolScopes('shop.orders.delete') === ['mcp:write', 'shop:purge'], 'destructive scope is configurable'); + unset($config['cms.settings.eMCP.auth.destructive_scope']); + + // --- Backend code: ExecutesCode tools and built-in calls that carry code ------------------------- + + $config['cms.settings.eMCP.security.enable_write_tools'] = true; + $config['cms.settings.eMCP.security.enable_destructive_tools'] = true; + $snippet = ['type' => 'Snippet', 'fields' => ['snippet' => 'return 1;']]; + $chunk = ['type' => 'chunk', 'fields' => ['snippet' => '

[[Wayfinder]]

']]; + $tvEval = ['type' => 'tv', 'fields' => ['default_text' => " @EVAL return 1;"]]; + $tvInclude = ['type' => 'tv', 'fields' => ['display_params' => '&output=@INCLUDE /etc/passwd']]; + $tvPlain = ['type' => 'tv', 'fields' => ['default_text' => 'Hello @EVAL']]; + $docEval = ['id' => 1, 'tvs' => ['price' => '@INHERIT @SELECT 1']]; + $docArray = ['fields' => ['pagetitle' => 'x'], 'tvs' => ['tags' => ['a', '@eval return 2;']]]; + $docPlain = ['id' => 1, 'fields' => ['content' => 'Write to @evo']]; + + assertTrue($registry->isCodeTool('shop.files.write') && !$registry->isCodeTool('shop.orders.set_status'), 'ExecutesCode marks a code tool'); + assertTrue($registry->isWriteTool('shop.files.write'), 'a code tool is a write tool'); + assertTrue($registry->writesCode('evo.write.elements.save', $snippet), 'saving a snippet (any case) writes PHP'); + assertTrue($registry->writesCode('evo.write.elements.save', ['type' => 'plugin']), 'saving a plugin writes PHP'); + assertTrue(!$registry->writesCode('evo.write.elements.save', $chunk), 'a chunk calling a snippet is not code'); + assertTrue($registry->writesCode('evo.write.elements.save', $tvEval), '@EVAL in a TV definition, after whitespace'); + assertTrue($registry->writesCode('evo.write.elements.save', $tvInclude), '@INCLUDE inside display_params'); + assertTrue(!$registry->writesCode('evo.write.elements.save', $tvPlain), 'a binding only counts at the start'); + assertTrue($registry->writesCode('evo.write.content.update', $docEval), '@SELECT behind @INHERIT in a TV value'); + assertTrue($registry->writesCode('evo.write.content.create', $docArray), 'a code binding in a multi-value TV'); + assertTrue(!$registry->writesCode('evo.write.content.update', $docPlain), 'plain content is not code'); + assertTrue(!$registry->writesCode('shop.orders.set_status', $snippet), 'arguments of contributed tools are not inspected'); + + assertTrue($policy->isToolDenied('content', 'evo.write.elements.save', $snippet), 'code writes are off by default'); + assertTrue(!$policy->isToolDenied('content', 'evo.write.elements.save', $chunk), 'other element writes are unaffected'); + assertTrue($policy->isToolHidden('content', 'shop.files.write'), 'a disabled code tool is hidden'); + assertTrue(!$policy->isToolHidden('content', 'evo.write.elements.save'), 'elements.save stays listed'); + + $config['cms.settings.eMCP.security.enable_code_tools'] = true; + assertTrue(!$policy->isToolDenied('content', 'evo.write.elements.save', $snippet), 'code writes pass when enabled'); + $config['cms.settings.eMCP.security.enable_write_tools'] = false; + assertTrue($policy->isToolDenied('content', 'evo.write.elements.save', $snippet), 'code writes stay behind the write switch'); + $config['cms.settings.eMCP.security.enable_write_tools'] = true; + + $_SESSION = []; + $evo->loggedIn = true; + $evo->permissions = ['save_template', 'save_document']; + assertTrue(!$policy->isToolPermitted('evo.write.elements.save', $tvEval), 'save_template alone may not store @EVAL'); + assertTrue(!$policy->isToolPermitted('evo.write.content.update', $docEval), 'save_document alone may not store @SELECT'); + assertTrue($policy->isToolPermitted('evo.write.elements.save', $tvPlain), 'a plain TV needs no PHP permission here'); + assertTrue($policy->isToolHidden('content', 'shop.files.write'), 'a code tool is hidden from managers without a PHP permission'); + $evo->permissions = ['save_module']; + assertTrue($policy->isToolPermitted('evo.write.elements.save', $tvEval), 'save_module grants backend code'); + assertTrue(!$policy->isToolHidden('content', 'shop.files.write'), 'and shows code tools'); + $evo->loggedIn = false; + assertTrue(!$policy->isToolPermitted('evo.write.elements.save', $snippet), 'no manager session, no code'); + $evo->permissions = []; + + assertTrue($scopes->resolveToolScopes('evo.write.elements.save', $chunk) === ['mcp:write'], 'chunk needs only the write scope'); + assertTrue($scopes->resolveToolScopes('evo.write.elements.save', $snippet) === ['mcp:write', 'mcp:code'], 'snippet needs the code scope'); + assertTrue($scopes->resolveToolScopes('shop.files.write') === ['mcp:write', 'mcp:code'], 'code tools need the code scope'); + + // --- JsonRpcPayload: one decode shared by the middleware --------------------------------------- + + $request = new Request('{"jsonrpc":"2.0","method":"tools/call","params":{"name":"shop.orders.delete"}}'); + assertTrue(JsonRpcPayload::method($request) === 'tools/call', 'method is read from the body'); + assertTrue(JsonRpcPayload::toolName($request) === 'shop.orders.delete', 'tool name is read from the body'); + assertTrue($scopes->resolveRequestedMethod($request) === 'tools/call', 'ScopePolicy uses the shared payload'); + assertTrue($request->reads === 1, 'the body is decoded once'); + assertTrue(JsonRpcPayload::toolName(new Request('{"method":"tools/list"}')) === null, 'no tool name outside tools/call'); + $withArgs = new Request('{"method":"tools/call","params":{"name":"evo.write.elements.save","arguments":{"type":"plugin"}}}'); + assertTrue(JsonRpcPayload::toolArguments($withArgs) === ['type' => 'plugin'], 'tool arguments are read from the body'); + assertTrue(JsonRpcPayload::toolArguments(new Request('{"method":"tools/list","params":{"arguments":{"x":1}}}')) === [], 'no arguments outside tools/call'); + $broken = new Request('not-json'); + assertTrue(JsonRpcPayload::of($broken) === null && JsonRpcPayload::of($broken) === null && $broken->reads === 1, 'invalid bodies are cached too'); + + echo "ToolRegistry / extension policy unit checks passed.\n"; +} diff --git a/views/manager/tokens.blade.php b/views/manager/tokens.blade.php index 9664b54..b0855ce 100644 --- a/views/manager/tokens.blade.php +++ b/views/manager/tokens.blade.php @@ -75,6 +75,8 @@ @if ($scope === 'mcp:read') — list tools, read resources @elseif ($scope === 'mcp:call') — call read-only tools @elseif ($scope === 'mcp:write') — call evo.write.* tools (still limited by your permissions) + @elseif ($scope === 'mcp:destructive') — call tools that delete data (only if the site allows them) + @elseif ($scope === 'mcp:code') — save snippets, plugins and code bindings that run on the server (only if the site allows it) @elseif ($scope === 'mcp:admin') — administrative methods @endif From 99025cc53d09eaa9be17ed40b7da354ec3e1dd60 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sat, 17 Oct 2026 17:27:16 +0200 Subject: [PATCH 4/4] fix(ci): composer warnings and updated_at noise --- CHANGELOG.md | 1 + DOCS.md | 2 +- DOCS.uk.md | 2 +- README.md | 2 +- README.uk.md | 2 +- SPEC.md | 2 +- composer.json | 6 ++-- scripts/governance_helpers.php | 35 ++++++++++++++++++ scripts/update_governance_lock.php | 12 +++---- tests/Unit/GovernanceLockWriteTest.php | 49 ++++++++++++++++++++++++++ 10 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/GovernanceLockWriteTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 13361ba..5d3ac30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - `tools/list` leaves out tools matched by `deny_tools`. - Backend code gate: `ExecutesCode` marker, `security.enable_code_tools` (off by default) and the `mcp:code` scope. `evo.write.elements.save` for snippets, plugins and TVs with `@EVAL` / `@SELECT` / `@INCLUDE` bindings, and document TV values with code bindings, now need them plus a `save_snippet` / `save_plugin` / `save_module` permission (Evo's `BackendCodePermissions`), even when the user has `save_template` or `save_document`. ### Changed +- `seiger/sapi` and `seiger/stask` have version constraints (`^1.0`, `^1.0.10 || ^2.0`) instead of `*`; `composer validate --strict` passes without warnings again. - `ToolRegistry` follows SPEC 6.4/6.5: contributed tools in `evo.*`, with a duplicate name, or not a `Tool` are rejected (throws in debug mode, logged otherwise). - The JSON-RPC body is decoded once per request and shared by the scope and rate-limit middleware. - `destructiveHint` is `true` for `evo.write.content.update` and `evo.write.elements.save` (they overwrite without a revision); `WritesSite` tools no longer get `destructiveHint: false` they did not declare. diff --git a/DOCS.md b/DOCS.md index 0de2b1d..aa574c9 100644 --- a/DOCS.md +++ b/DOCS.md @@ -81,7 +81,7 @@ Mandatory: - PHP 8.3+ - Composer 2.2+ - `seiger/sapi` 1.x -- `seiger/stask` 1.x +- `seiger/stask` 1.x (from 1.0.10) or 2.x (2.x needs PHP 8.4) ## 4) Install From Evo `core` directory: diff --git a/DOCS.uk.md b/DOCS.uk.md index 4539c1b..743b676 100644 --- a/DOCS.uk.md +++ b/DOCS.uk.md @@ -81,7 +81,7 @@ php artisan make:mcp-tool HealthTool - PHP 8.3+ - Composer 2.2+ - `seiger/sapi` 1.x -- `seiger/stask` 1.x +- `seiger/stask` 1.x (від 1.0.10) або 2.x (2.x потребує PHP 8.4) ## 4) Встановлення З директорії `core` Evo: diff --git a/README.md b/README.md index cffcaa7..34c5264 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Operations runbook: `OPERATIONS.md`. - PHP 8.3+ - Composer 2.2+ - `seiger/sapi` 1.x (installed as dependency; only used when `auth.mode = sapi_jwt`) -- `seiger/stask` 1.x (installed as dependency) +- `seiger/stask` 1.x (from 1.0.10) or 2.x, installed as dependency (2.x needs PHP 8.4) ## Install From your Evo `core` directory: diff --git a/README.uk.md b/README.uk.md index fa0ba17..92dc8e5 100644 --- a/README.uk.md +++ b/README.uk.md @@ -35,7 +35,7 @@ Versioning і BC policy: `PRD.md` (розділ `API Stability Policy`). - PHP 8.3+ - Composer 2.2+ - `seiger/sapi` 1.x (встановлюється як залежність) -- `seiger/stask` 1.x (встановлюється як залежність) +- `seiger/stask` 1.x (від 1.0.10) або 2.x, встановлюється як залежність (2.x потребує PHP 8.4) ## Встановлення З директорії `core` вашого Evo: diff --git a/SPEC.md b/SPEC.md index c41ad18..3889c8d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -165,7 +165,7 @@ eMCP/ ### 3.2 Required integrations - `seiger/sapi` (^1.0, для API mode). -- `seiger/stask` (^1.0, для async mode). +- `seiger/stask` (^1.0.10 || ^2.0, для async mode). ### 3.3 Autoload - PSR-4 namespace `EvolutionCMS\eMCP\`. diff --git a/composer.json b/composer.json index c7558c9..0023821 100644 --- a/composer.json +++ b/composer.json @@ -14,8 +14,8 @@ "php": "^8.3", "evolution-cms/evolution": "^3.5.2", "laravel/mcp": "^0.5.9", - "seiger/sapi": "*", - "seiger/stask": "*" + "seiger/sapi": "^1.0", + "seiger/stask": "^1.0.10 || ^2.0" }, "autoload": { "psr-4": { @@ -39,6 +39,7 @@ "test:unit:settings-file": "php tests/Unit/SettingsFileTest.php", "test:unit:tool-registry": "php tests/Unit/ToolRegistryTest.php", "test:unit:tool-error": "php tests/Unit/ToolErrorTest.php", + "test:unit:governance-lock": "php tests/Unit/GovernanceLockWriteTest.php", "test:feature:dispatch-idempotency": "php tests/Feature/DispatchIdempotencyBehaviorTest.php", "test:feature:dispatch-async-failover": "php tests/Feature/DispatchAsyncFailoverBehaviorTest.php", "test:feature:dispatch-materialization": "php tests/Feature/DispatchMaterializationPolicyTest.php", @@ -83,6 +84,7 @@ "@test:unit:settings-file", "@test:unit:tool-registry", "@test:unit:tool-error", + "@test:unit:governance-lock", "@test:phase25", "@test:phase25:raw-tv", "@test:phase3", diff --git a/scripts/governance_helpers.php b/scripts/governance_helpers.php index d8f64d0..fbda5d7 100644 --- a/scripts/governance_helpers.php +++ b/scripts/governance_helpers.php @@ -181,3 +181,38 @@ function governance_golden_fixtures_hash(string $fixturesDir): string return hash('sha256', implode('|', $chunks)); } + +/** + * Writes the governance lock, unless only `updated_at` would change: a lock whose hashes and + * markers already match is left untouched, so regenerating it adds no noise to the diff. + * + * @param array $payload lock contents without `updated_at` + * @return bool true when the file was written + */ +function governance_write_lock(string $lockPath, array $payload, ?string $updatedAt = null): bool +{ + unset($payload['updated_at']); + + if (is_file($lockPath)) { + $current = json_decode((string)file_get_contents($lockPath), true); + if (is_array($current)) { + unset($current['updated_at']); + if ($current === $payload) { + return false; + } + } + } + + $payload['updated_at'] = $updatedAt ?? date(DATE_ATOM); + + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + throw new RuntimeException('Unable to encode governance lock payload.'); + } + + if (file_put_contents($lockPath, $json . "\n") === false) { + throw new RuntimeException("Unable to write governance lock: {$lockPath}"); + } + + return true; +} diff --git a/scripts/update_governance_lock.php b/scripts/update_governance_lock.php index b2923e3..f31d6cd 100644 --- a/scripts/update_governance_lock.php +++ b/scripts/update_governance_lock.php @@ -20,14 +20,10 @@ 'spec_public_contract_hash' => $spec['public_contract_hash'], 'model_allowlists_hash' => $modelHash, 'golden_fixtures_hash' => $fixturesHash, - 'updated_at' => date(DATE_ATOM), ]; -$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; -if (!is_string($json)) { - throw new RuntimeException('Unable to encode governance lock payload.'); +if (governance_write_lock($lockPath, $payload)) { + echo "Governance lock updated: {$lockPath}\n"; +} else { + echo "Governance lock unchanged: {$lockPath}\n"; } - -file_put_contents($lockPath, $json); - -echo "Governance lock updated: {$lockPath}\n"; diff --git a/tests/Unit/GovernanceLockWriteTest.php b/tests/Unit/GovernanceLockWriteTest.php new file mode 100644 index 0000000..6f53cc2 --- /dev/null +++ b/tests/Unit/GovernanceLockWriteTest.php @@ -0,0 +1,49 @@ + '1.0', 'canonical_tools' => ['evo.content.get'], 'model_allowlists_hash' => 'aaa']; + + assertTrue(governance_write_lock($lock, $payload, '2026-01-01T00:00:00+00:00'), 'a missing lock is written'); + $written = json_decode((string)file_get_contents($lock), true); + assertTrue($written === $payload + ['updated_at' => '2026-01-01T00:00:00+00:00'], 'payload and updated_at are stored'); + assertTrue(str_ends_with((string)file_get_contents($lock), "}\n"), 'the file ends with a newline'); + + $before = file_get_contents($lock); + assertTrue(!governance_write_lock($lock, $payload, '2026-02-02T00:00:00+00:00'), 'same hashes: nothing is written'); + assertTrue(file_get_contents($lock) === $before, 'updated_at is not bumped when nothing else changed'); + + assertTrue( + !governance_write_lock($lock, $payload + ['updated_at' => 'ignored'], '2026-02-02T00:00:00+00:00'), + 'an updated_at in the payload does not count as a change' + ); + + $changed = ['model_allowlists_hash' => 'bbb'] + $payload; + assertTrue(governance_write_lock($lock, $changed, '2026-03-03T00:00:00+00:00'), 'a changed hash is written'); + $written = json_decode((string)file_get_contents($lock), true); + assertTrue($written['model_allowlists_hash'] === 'bbb', 'the new hash is stored'); + assertTrue($written['updated_at'] === '2026-03-03T00:00:00+00:00', 'updated_at moves with a real change'); + + file_put_contents($lock, 'not json'); + assertTrue(governance_write_lock($lock, $payload, '2026-04-04T00:00:00+00:00'), 'a broken lock is rewritten'); + assertTrue(json_decode((string)file_get_contents($lock), true)['model_allowlists_hash'] === 'aaa', 'and holds the payload'); +} finally { + @unlink($lock); + @rmdir($dir); +} + +echo "Governance lock write checks passed.\n";