From 1566b00ba96061c438a2753c8547fed46e438aad Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 15 Sep 2026 17:17:34 +0200 Subject: [PATCH 1/2] add(auth): personal access tokens, user impersonation and permission-aware write tools - auth.mode=pat (default): emcp_tokens table, Bearer endpoint at /{api_prefix}/{server} without sApi, self-service manager page (Tools -> MCP tokens), emcp:token:* commands - ImpersonateManagerUser: API requests run as the token/JWT owner with their role, permissions, document groups and locks (mirrors UserLogin::writeSession, never persisted); the emcp permission is required in API mode too - content read tools honour document groups and view_unpublished; content.get returns the body fields; model catalog reads need the permission of the matching manager screen - evo.elements.list|get and evo.write.content.update|create|publish, evo.write.elements.save, evo.write.cache.clear, gated by manager permissions, enable_write_tools and the mcp:write scope; each writes a manager_log row - ToolProvider / WritesSite / ToolRegistry: other extras contribute tools to a server handle at runtime or via mcp.servers[].extra_tools, with the same write gates - JSON-RPC notifications and ping map to mcp:read (sent by MCP clients on connect) - docker/ compose environment with a preinstalled site, token, smoke test and opt-in extras --- CHANGELOG.md | 9 + README.md | 61 ++++++- config/eMCPSettings.php | 16 +- config/mcp.php | 5 +- ..._09_15_000004_create_emcp_tokens_table.php | 33 ++++ docker/Dockerfile | 41 +++++ docker/docker-compose.yml | 29 ++++ docker/entrypoint.sh | 102 +++++++++++ docker/smoke.sh | 42 +++++ lang/en/global.php | 1 + lang/ru/global.php | 1 + lang/uk/global.php | 1 + plugins/eMCPPlugin.php | 33 ++++ src/Api/Routes/McpRouteProvider.php | 2 + .../Commands/eMcpTokenCreateCommand.php | 83 +++++++++ src/Console/Commands/eMcpTokenListCommand.php | 62 +++++++ .../Commands/eMcpTokenRevokeCommand.php | 62 +++++++ src/Contracts/ToolProvider.php | 32 ++++ src/Contracts/WritesSite.php | 15 ++ src/Http/Controllers/TokensPageController.php | 163 ++++++++++++++++++ src/Http/apiRoutes.php | 29 ++++ src/Http/mgrRoutes.php | 12 ++ src/Middleware/EnsureApiPat.php | 66 +++++++ src/Middleware/EnsureMcpScopes.php | 29 +++- src/Middleware/ImpersonateManagerUser.php | 72 ++++++++ src/Middleware/ResolveMcpActor.php | 7 +- src/Models/EmcpToken.php | 78 +++++++++ src/Servers/ContentServer.php | 35 +++- src/Services/ManagerIdentity.php | 163 ++++++++++++++++++ src/Services/ScopePolicy.php | 44 ++++- src/Services/SecurityPolicy.php | 18 +- src/Services/TokenService.php | 128 ++++++++++++++ src/Services/ToolRegistry.php | 141 +++++++++++++++ src/Support/ElementTypes.php | 97 +++++++++++ src/Support/ManagerUrl.php | 49 ++++++ src/Support/RateLimitIdentityResolver.php | 5 + src/Tools/Content/BaseContentTool.php | 39 +++++ src/Tools/Content/ContentAncestorsTool.php | 2 + .../Content/ContentChildrenRangeTool.php | 2 + src/Tools/Content/ContentChildrenTool.php | 2 + src/Tools/Content/ContentDescendantsTool.php | 2 + src/Tools/Content/ContentGetTool.php | 15 +- src/Tools/Content/ContentNeighborsTool.php | 2 + src/Tools/Content/ContentNextSiblingsTool.php | 2 + src/Tools/Content/ContentPrevSiblingsTool.php | 2 + src/Tools/Content/ContentRootTreeTool.php | 2 + src/Tools/Content/ContentSearchTool.php | 1 + .../Content/ContentSiblingsRangeTool.php | 2 + src/Tools/Content/ContentSiblingsTool.php | 2 + src/Tools/Elements/ElementsGetTool.php | 79 +++++++++ src/Tools/Elements/ElementsListTool.php | 94 ++++++++++ src/Tools/ModelCatalog/BaseModelTool.php | 41 +++++ src/Tools/Write/BaseWriteTool.php | 157 +++++++++++++++++ src/Tools/Write/CacheClearTool.php | 34 ++++ src/Tools/Write/ContentCreateTool.php | 121 +++++++++++++ src/Tools/Write/ContentPublishTool.php | 78 +++++++++ src/Tools/Write/ContentUpdateTool.php | 134 ++++++++++++++ src/Tools/Write/ElementsSaveTool.php | 114 ++++++++++++ src/eMCPServiceProvider.php | 34 ++++ views/manager/tokens.blade.php | 142 +++++++++++++++ 60 files changed, 2853 insertions(+), 16 deletions(-) create mode 100644 database/migrations/2026_09_15_000004_create_emcp_tokens_table.php create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 docker/smoke.sh create mode 100644 src/Console/Commands/eMcpTokenCreateCommand.php create mode 100644 src/Console/Commands/eMcpTokenListCommand.php create mode 100644 src/Console/Commands/eMcpTokenRevokeCommand.php create mode 100644 src/Contracts/ToolProvider.php create mode 100644 src/Contracts/WritesSite.php create mode 100644 src/Http/Controllers/TokensPageController.php create mode 100644 src/Http/apiRoutes.php create mode 100644 src/Middleware/EnsureApiPat.php create mode 100644 src/Middleware/ImpersonateManagerUser.php create mode 100644 src/Models/EmcpToken.php create mode 100644 src/Services/ManagerIdentity.php create mode 100644 src/Services/TokenService.php create mode 100644 src/Services/ToolRegistry.php create mode 100644 src/Support/ElementTypes.php create mode 100644 src/Support/ManagerUrl.php create mode 100644 src/Tools/Elements/ElementsGetTool.php create mode 100644 src/Tools/Elements/ElementsListTool.php create mode 100644 src/Tools/Write/BaseWriteTool.php create mode 100644 src/Tools/Write/CacheClearTool.php create mode 100644 src/Tools/Write/ContentCreateTool.php create mode 100644 src/Tools/Write/ContentPublishTool.php create mode 100644 src/Tools/Write/ContentUpdateTool.php create mode 100644 src/Tools/Write/ElementsSaveTool.php create mode 100644 views/manager/tokens.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d1d40..00e859c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] +### Added +- Personal access tokens (`auth.mode = pat`, now the default): `emcp_tokens` table, `Authorization: Bearer emcp_...` endpoint at `/{api_prefix}/{server}` without sApi, self-service **Tools → MCP tokens** manager page, `emcp:token:create|list|revoke` commands. +- API requests now impersonate the token/JWT owner (`ImpersonateManagerUser`): `evo()->isLoggedIn('mgr')`, `hasPermission()`, document groups and locks reflect that user; the `emcp` permission is required in API mode too. +- 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. + + All notable changes to this project will be documented in this file. ## [Unreleased] diff --git a/README.md b/README.md index 9c13dbf..49f38a6 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Operations runbook: `OPERATIONS.md`. - Evolution CMS 3.5.2+ - PHP 8.3+ - Composer 2.2+ -- `seiger/sapi` 1.x (installed as dependency) +- `seiger/sapi` 1.x (installed as dependency; only used when `auth.mode = sapi_jwt`) - `seiger/stask` 1.x (installed as dependency) ## Install @@ -80,6 +80,65 @@ Generated classes are placed in `core/custom/app/Mcp/...`. 5. Optional async: - set `queue.driver=stask`, ensure `sTask` installed, use dispatch endpoint for long-running jobs. +## Personal access tokens (default API auth) +Since this version the API endpoint needs **no extra packages**: a manager user creates a +personal access token and every request runs **as that user**, with that user's role, +permissions and document groups — exactly as if they were logged into the manager. + +1. Give the role the `emcp` permission (admins have it after `migrate`). +2. Open **Tools → MCP tokens** in the manager (`{manager_url}/emcp/tokens`), or run + `php artisan emcp:token:create --scopes=mcp:read,mcp:call --expires=90`. +3. Connect the agent: + +```bash +claude mcp add --transport http evo https://example.com/mcp/content --header "Authorization: Bearer emcp_..." +``` + +```toml +# Codex ~/.codex/config.toml +[mcp_servers.evo] +url = "https://example.com/mcp/content" +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: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. + +`auth.mode` in `core/custom/config/cms/settings/eMCP.php` selects `pat` (default), `sapi_jwt` +(seiger/sapi JWT, now also impersonating the JWT user) or `none`. + +### Write tools +`evo.write.content.update|create|publish`, `evo.write.elements.save`, `evo.write.cache.clear` +plus the read tools `evo.elements.list|get`. Each one re-checks the manager permission of the +matching manager action (`save_document`, `publish_document`, `save_chunk`, `new_snippet`, ...), +document-group access, element locks, fires the same `OnBefore*FormSave`/`On*FormSave` events +and writes a `manager_log` row, so an admin sees API changes next to browser ones. + +### Tools from other extras +An extra contributes tools by implementing `EvolutionCMS\eMCP\Contracts\ToolProvider` and +registering it in its service provider's `boot()`: + +```php +if (class_exists(\EvolutionCMS\eMCP\Services\ToolRegistry::class)) { + app(\EvolutionCMS\eMCP\Services\ToolRegistry::class)->register(new MyToolProvider()); +} +``` + +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. + +### Try it in Docker +```bash +cd docker && docker compose up --build # prints the site URL and a ready-made token +EVO_EXTRAS=elcreator/aimage AIMAGE_API_KEY=... docker compose up --build # with extras +docker/smoke.sh # runs an end-to-end check against it +``` + ## Design Philosophy (Optional Reading) ### Why This Product Exists (4 Core Questions, Aristotle) This is the shortest way to understand eMCP as a product, not just a package. diff --git a/config/eMCPSettings.php b/config/eMCPSettings.php index a3b3731..1cc6be5 100644 --- a/config/eMCPSettings.php +++ b/config/eMCPSettings.php @@ -15,8 +15,14 @@ ], 'auth' => [ - 'mode' => 'sapi_jwt', + // pat - personal access tokens issued in the manager / by artisan (no extra packages) + // sapi_jwt - JWT issued by seiger/sapi + // none - no API authentication (development only) + // In every mode the request runs as the authenticated manager user, with that user's permissions. + 'mode' => 'pat', 'require_scopes' => true, + // Extra scope a token needs to call evo.write.* tools. + 'write_scope' => 'mcp:write', 'scope_map' => [ 'mcp:read' => [ 'initialize', @@ -27,12 +33,20 @@ 'prompts/list', 'prompts/get', 'completion/complete', + 'resources/templates/list', + // JSON-RPC notifications (notifications/initialized, notifications/cancelled, ...) + 'notifications/*', ], 'mcp:call' => ['tools/call'], 'mcp:admin' => ['admin/*'], ], ], + 'tokens' => [ + // Manager page where a user creates their own tokens: {manager_url}/{manager_prefix}/tokens + 'self_service' => true, + ], + 'acl' => [ 'permission' => 'emcp', ], diff --git a/config/mcp.php b/config/mcp.php index 31be7ad..78a2f3a 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -15,7 +15,7 @@ 'auth' => 'sapi_jwt', 'scopes' => ['mcp:read', 'mcp:call'], 'scope_map' => [ - 'mcp:read' => ['initialize', 'tools/list', 'resources/read'], + 'mcp:read' => ['initialize', 'ping', 'tools/list', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'prompts/get', 'notifications/*'], 'mcp:call' => ['tools/call'], ], 'limits' => [ @@ -28,6 +28,9 @@ 'security' => [ 'deny_tools' => [], ], + // Tool classes from other packages to expose on this server (alternative to + // registering an EvolutionCMS\eMCP\Contracts\ToolProvider at runtime). + 'extra_tools' => [], ], [ 'handle' => 'content-local', diff --git a/database/migrations/2026_09_15_000004_create_emcp_tokens_table.php b/database/migrations/2026_09_15_000004_create_emcp_tokens_table.php new file mode 100644 index 0000000..f8a764e --- /dev/null +++ b/database/migrations/2026_09_15_000004_create_emcp_tokens_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->unsignedInteger('user_id')->index(); + $table->string('name', 100); + // First characters of the plaintext token, shown in lists so a user can tell tokens apart. + $table->string('token_prefix', 16); + $table->string('token_hash', 64)->unique(); + $table->text('scopes')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('emcp_tokens'); + } +}; diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..f1a0b14 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,41 @@ +# Evolution CMS 3.5.x + eMCP from the working copy, for testing the MCP API end to end. +# +# cd docker && docker compose up --build +# +# Site: http://localhost:8080/ manager: http://localhost:8080/manager/ (admin / 123456) +# MCP: http://localhost:8080/mcp/content token: printed at first start, also in /var/www/html/core/storage/emcp-token.txt +FROM php:8.3-apache + +ARG EVO_BRANCH=3.5.x + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git unzip curl libzip-dev libpng-dev libjpeg-dev libwebp-dev libfreetype6-dev libicu-dev \ + && docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype \ + && docker-php-ext-install -j"$(nproc)" gd zip intl pdo_mysql opcache \ + && a2enmod rewrite \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_MEMORY_LIMIT=-1 \ + APACHE_DOCUMENT_ROOT=/var/www/html + +RUN sed -ri 's!AllowOverride None!AllowOverride All!g' /etc/apache2/apache2.conf \ + # ServerName silences the FQDN warning; SetEnvIf hands Bearer tokens to PHP under mod_php + && printf 'ServerName localhost\nSetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1\n' > /etc/apache2/conf-enabled/evo-mcp.conf \ + && printf 'memory_limit=512M\nupload_max_filesize=64M\npost_max_size=64M\n' > /usr/local/etc/php/conf.d/evo.ini + +WORKDIR /var/www/html + +# Core checkout + core dependencies are baked into the image; site install happens at first start. +RUN git clone --depth 1 --branch "${EVO_BRANCH}" https://github.com/evolution-cms/evolution.git /tmp/evo \ + && cp -a /tmp/evo/. /var/www/html/ && rm -rf /tmp/evo \ + && cp ht.access .htaccess \ + && cd core && composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + +COPY entrypoint.sh /usr/local/bin/evo-entrypoint.sh +RUN chmod +x /usr/local/bin/evo-entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/evo-entrypoint.sh"] +CMD ["apache2-foreground"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..d600c2d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,29 @@ +services: + evo: + build: + context: . + args: + EVO_BRANCH: ${EVO_BRANCH:-3.5.x} + ports: + - "${EVO_PUBLIC_PORT:-8080}:80" + environment: + EVO_ADMIN_USERNAME: ${EVO_ADMIN_USERNAME:-admin} + EVO_ADMIN_PASSWORD: ${EVO_ADMIN_PASSWORD:-123456} + EVO_PUBLIC_PORT: ${EVO_PUBLIC_PORT:-8080} + # Comma-separated composer packages to install at first start (none by default) + EVO_EXTRAS: ${EVO_EXTRAS:-} + # Passed through for extras that read it (e.g. aIMage's site-wide key) + AIMAGE_API_KEY: ${AIMAGE_API_KEY:-} + volumes: + # The package under test, symlinked into core/vendor so edits are live (no rebuild needed). + - ..:/package + # Installed site survives container restarts; `docker compose down -v` starts from scratch. + - evo-site:/var/www/html + healthcheck: + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost/"] + interval: 15s + timeout: 5s + retries: 20 + +volumes: + evo-site: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..bcc3e5a --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# First start: install Evolution CMS (SQLite), install eMCP from /package, migrate, issue a token. +# Later starts: only refresh the package autoload and re-run migrations. +set -euo pipefail + +SITE=/var/www/html +CORE="$SITE/core" +PACKAGE=/package +ADMIN_USER="${EVO_ADMIN_USERNAME:-admin}" +ADMIN_PASS="${EVO_ADMIN_PASSWORD:-123456}" +ADMIN_EMAIL="${EVO_ADMIN_EMAIL:-admin@example.com}" +TOKEN_FILE="$CORE/storage/emcp-token.txt" +# Optional extras to install at first start, e.g. EVO_EXTRAS="elcreator/aimage" +EXTRAS="${EVO_EXTRAS:-}" + +log() { printf '\n\033[1;34m[evo-mcp]\033[0m %s\n' "$*"; } + +if [ ! -f "$CORE/config/database/connections/default.php" ]; then + log "Installing Evolution CMS (sqlite) ..." + (cd "$SITE/install" && php cli-install.php \ + --typeInstall=1 --databaseType=sqlite --database=evolution --tablePrefix=evo_ \ + --cmsAdmin="$ADMIN_USER" --cmsAdminEmail="$ADMIN_EMAIL" --cmsPassword="$ADMIN_PASS" \ + --language=en --removeInstall=y --skipComposer=y) + + log "Wiring eMCP from $PACKAGE (path repository, symlinked) ..." + mkdir -p "$CORE/custom" + # composer-merge-plugin resolves the path repository relative to core/custom, hence the ../ chain to /package. + cat > "$CORE/custom/composer.json" <exec("update evo_system_settings set setting_value=(select min(id) from evo_site_templates) where setting_name=\"default_template\"");') + + if [ -n "$EXTRAS" ]; then + log "Installing extras: $EXTRAS ..." + for pkg in $(echo "$EXTRAS" | tr ',' ' '); do + (cd "$CORE" && php -r '$p="custom/composer.json";$d=json_decode(file_get_contents($p),true);$d["require"][$argv[1]]="*";file_put_contents($p,json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));' "$pkg") + done + (cd "$CORE" && composer update --no-dev --no-interaction --optimize-autoloader --with-all-dependencies $EXTRAS && php artisan package:discover >/dev/null && php artisan migrate --force) + fi + + # Test site: write tools on, so the token below can exercise the whole toolset. + sed -i "s/'enable_write_tools' => false/'enable_write_tools' => true/" "$CORE/custom/config/cms/settings/eMCP.php" +fi + +log "Running migrations ..." +(cd "$CORE" && composer dump-autoload -o -q && php artisan migrate --force && php artisan cache:clear-full >/dev/null 2>&1 || true) + +if [ ! -f "$TOKEN_FILE" ]; then + log "Issuing an MCP token for '$ADMIN_USER' ..." + (cd "$CORE" && php artisan emcp:token:create "$ADMIN_USER" --name="docker" --scopes=mcp:read,mcp:call,mcp:write --expires=never --json) \ + | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["token"] ?? "";' > "$TOKEN_FILE" +fi + +chown -R www-data:www-data "$SITE/assets" "$CORE/storage" "$CORE/database" "$CORE/custom" "$CORE/config" 2>/dev/null || true + +TOKEN="$(cat "$TOKEN_FILE")" +PORT="${EVO_PUBLIC_PORT:-8080}" +cat </dev/null 2>&1; sleep 60; done) & +fi + +exec "$@" diff --git a/docker/smoke.sh b/docker/smoke.sh new file mode 100644 index 0000000..7b68a28 --- /dev/null +++ b/docker/smoke.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# End-to-end check against the docker site: auth, impersonation, permission-aware tools. +# docker/smoke.sh [base_url] [token] +set -euo pipefail + +BASE="${1:-http://localhost:${EVO_PUBLIC_PORT:-8080}}" +TOKEN="${2:-$(docker compose -f "$(dirname "$0")/docker-compose.yml" exec -T evo cat //var/www/html/core/storage/emcp-token.txt)}" +URL="$BASE/mcp/content" +BODY="$(mktemp)" +trap 'rm -f "$BODY"' EXIT + +rpc() { # rpc + curl -sS -o "$BODY" -w '%{http_code}' -X POST "$URL" \ + -H 'Content-Type: application/json' -H 'Accept: application/json' \ + ${1:+-H "Authorization: Bearer $1"} -d "$2" +} +expect() { # expect