diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 59a105b..004c6ec 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -78,3 +78,4 @@ Update ALL of these: - [Abilities Guide](docs/ABILITIES-GUIDE.md) — full registration API, parseIntent, shared helpers - [Workflows Guide](docs/WORKFLOWS-GUIDE.md) — workflow steps, includeIf, mapParams, summarize - [Testing Guide](tests/TESTING.md) — unit, ability, and E2E test details +- [MCP Endpoint](docs/MCP-ENDPOINT.md) — built-in MCP server (v0.12.0+); read-only abilities surface to external clients via app-password auth, disabled by default. Abilities with `meta.annotations.readonly = true` show up in `tools/list` when the endpoint is enabled. diff --git a/.claude/plans/features/001-mcp-server-endpoint/plan.md b/.claude/plans/features/001-mcp-server-endpoint/plan.md new file mode 100644 index 0000000..4e0895f --- /dev/null +++ b/.claude/plans/features/001-mcp-server-endpoint/plan.md @@ -0,0 +1,150 @@ +# 001 — Plan: MCP Server Endpoint (v0.12.0) + +Branch: `feat/001-mcp-server-endpoint` (cut from `dev`) + +## Phase 0 — Branch + scaffolding + +- [ ] `git checkout dev && git pull && git checkout -b feat/001-mcp-server-endpoint` +- [ ] Commit this `spec.md` + `plan.md` as the first commit on the branch + +## Phase 1 — Settings (no UI yet; wire defaults + sanitization) + +Files: +- `includes/class-settings.php` — extend `get_settings_config()` with an `mcp` section: + - `agentic_admin_mcp_enabled` (checkbox, default 0) + - `agentic_admin_mcp_expose_own` (checkbox, default 1) + - `agentic_admin_mcp_expose_third` (checkbox, default 0) + - `agentic_admin_mcp_allowlist` (array of ability IDs, default []) +- `includes/class-settings.php` — extend `update_field()` to handle the array type (`mcp_allowlist`) with sanitization: each entry must match the ability ID regex `^[a-z0-9-]+\/[a-z0-9-]+$` (mirroring `agentic_admin_register_ability`'s validation). Drop anything that fails. + +Tests: +- `tests/php/SettingsMcpTest.php` (PHPUnit): defaults are correct; sanitizing the allowlist rejects bogus IDs; setters round-trip through `update_option`. + +## Phase 2 — Ability discovery + filtering + +New file: `includes/mcp/class-ability-registry.php` +- Namespace: `WPAgenticAdmin\MCP` +- Class: `Ability_Registry` +- Responsibilities: + - `get_exposed_abilities(): array` — returns the abilities currently exposed via MCP given the current settings + - Pull our own abilities from `agentic_admin_get_abilities()` (returns global `$agentic_admin_abilities`) + - Pull third-party abilities from the WordPress Abilities API directly. Look up the WP 6.9 API surface at implementation time — likely `wp_get_abilities()` or an analog. Filter out anything with ID prefix `wp-agentic-admin/` (those are ours). + - Apply v1 read-only filter: only include abilities where `meta.annotations.readonly === true` + - Apply settings: if `mcp_expose_own` → include our set; if `mcp_expose_third` → intersect third-party set with `mcp_allowlist` + - Return an associative array keyed by ability ID, value = the registered ability metadata +- `to_mcp_tool( string $ability_id ): array` — produce the MCP tool descriptor (name with `/` → `__`, label → annotations.title, description, inputSchema, annotations) +- `resolve_tool_name( string $tool_name ): ?string` — reverse map MCP `name` back to ability ID (or null if unknown / not exposed) +- `source_plugin( string $ability_id ): string` — best-effort source attribution for the settings UI: for our own abilities return "Agentic Admin"; for third-party, parse the namespace prefix from the ID, fall back to "Unknown". + +Tests: +- `tests/php/AbilityRegistryTest.php`: builds a stub registry, asserts filtering behavior under each settings combination; asserts read-only filter excludes destructive abilities; asserts name round-tripping. + +## Phase 3 — MCP JSON-RPC server (no HTTP yet) + +New file: `includes/mcp/class-jsonrpc-server.php` +- Namespace: `WPAgenticAdmin\MCP` +- Class: `JsonRpc_Server` +- Constructor takes an `Ability_Registry` +- Public method: `handle( array $request ): array` — dispatches by `$request['method']` +- Methods: + - `initialize` → returns `{ protocolVersion, serverInfo: { name, version }, capabilities: { tools: { list: true, call: true } } }` + - `ping` → returns `{}` per MCP spec + - `tools/list` → enumerate `Ability_Registry::get_exposed_abilities()`, map via `to_mcp_tool()`, return `{ tools: [...] }` + - `tools/call` → resolve name → ability ID; if null → JSON-RPC `-32601`; else run ability's `permission_callback` (if false → `-32001` Permission denied); else invoke `execute_callback($input)`; wrap result as `{ content: [{ type: "text", text: wp_json_encode($result) }] }` + - Any other method → `-32601` +- Helpers for valid JSON-RPC error envelopes (`-32600` invalid request, `-32602` invalid params, `-32603` internal error) +- Catch exceptions in `execute_callback` and return `-32603` with a sanitized message (do not leak stack traces) + +Tests: +- `tests/php/JsonRpcServerTest.php`: covers each method, error cases, permission_callback false, execute exception path, result wrapping. + +## Phase 4 — REST route registration + +New file: `includes/mcp/class-rest-endpoint.php` +- Namespace: `WPAgenticAdmin\MCP` +- Class: `Rest_Endpoint` +- Static `init()` adds `rest_api_init` action conditional on `Settings::get_field('agentic_admin_mcp_enabled')` + - If disabled → don't register the route at all (acceptance criterion #1) +- `register_routes()` registers `POST /wp-agentic-admin/v1/mcp`: + - `methods` => 'POST' + - `callback` => static `handle_request()` + - `permission_callback` => returns true only if `is_user_logged_in()`; on false WP returns 401 automatically via app-password flow + - No `args` schema — we validate JSON-RPC payload manually +- `handle_request()`: + - Parse JSON body from `WP_REST_Request::get_json_params()`; on parse failure → JSON-RPC `-32700` Parse error + - Build `Ability_Registry` + `JsonRpc_Server` and call `handle($payload)` + - Return `WP_REST_Response` with status 200, JSON-encoded JSON-RPC result/error envelope +- Wire-up in main plugin file: add `require_once` for the three new files and call `\WPAgenticAdmin\MCP\Rest_Endpoint::init();` from `WPAgenticAdmin::init()` (positioned near `LLM_Proxy::init();`) + +Tests: +- `tests/php/RestEndpointTest.php`: route only registers when enabled; 401 unauthenticated; integration test calling `initialize` and `tools/list` via `rest_do_request()`. + +## Phase 5 — Settings page UI + +Files: +- `includes/class-admin-page.php` — add a new "MCP" panel rendering the new settings section + - Read-only display of the endpoint URL: `get_rest_url(null, 'wp-agentic-admin/v1/mcp')` + - Copy-to-clipboard button (reuse any existing JS pattern in `src/`) + - Allowlist UI rendered from `Ability_Registry`-driven discovery: + - Iterate the Abilities API, group by source plugin (use `Ability_Registry::source_plugin()`) + - Each row: `` + - Disable + tooltip for non-readonly entries in v1 + - Show empty-state message if no third-party abilities exist + - Conditionally hide the allowlist section via JS when "Other plugins' …" is unchecked (progressive enhancement; server still respects the toggle) + +Tests: +- Manual UI test plan documented in `progress.md` (no JS unit test added in v1) + +## Phase 6 — Documentation + version bump + +- [ ] `wp-agentic-admin.php`: bump header `Version:` → `0.12.0`, `WP_AGENTIC_ADMIN_VERSION` constant → `'0.12.0'`, `activate()` hook string → `'0.12.0'` +- [ ] `package.json`: `"version": "0.12.0"` +- [ ] `npm install --package-lock-only` to refresh `package-lock.json` +- [ ] `readme.txt`: bump `Stable tag:` to `0.12.0`, add changelog entry under `== Changelog ==` +- [ ] `.release-notes/0.12.0.md`: new file summarizing the MCP endpoint, settings, security notes, opt-in default +- [ ] `docs/MCP-ENDPOINT.md`: new page covering — purpose, how to enable, how to create an app password, example curl session (initialize → tools/list → tools/call), security guidance, what's out of scope in v1, coexistence note with Automattic's wordpress-mcp +- [ ] `README.md`: add a short "Optional: MCP endpoint" section linking to `docs/MCP-ENDPOINT.md` +- [ ] `CLAUDE.md` (project): add the new MCP endpoint to "Further Reading" and to "Adding a New Ability" (mention that abilities with `annotations.readonly = true` will appear in MCP tool lists when the endpoint is enabled) + +## Phase 7 — Quality + manual verification + +- [ ] `composer lint` — must pass (WPCS 3.x) +- [ ] `npm run lint:js` — must pass +- [ ] `npm test` — Jest unit tests still pass +- [ ] PHPUnit: `composer test` or equivalent — new tests + no regressions +- [ ] Manual smoke against the local docker stack at `wp-agentic-admin.local`: + - With endpoint disabled → 404 on the route + - Enable endpoint, no app password → 401 + - Create app password → `initialize` succeeds + - `tools/list` reflects current settings (own only vs own + selected third-party) + - `tools/call` on a known read-only ability returns expected result + - Permission check: drop `marcel` to editor role temporarily, repeat `tools/call` on a `manage_options`-gated ability → JSON-RPC `-32001` + - Browser-side ReAct loop, LLM proxy, admin pages continue to work +- [ ] Update `progress.md` with manual test results + +## Phase 8 — Ship + +- [ ] Open PR `feat/001-mcp-server-endpoint` → `dev` with the PR template +- [ ] Request `security-reviewer` sub-agent review on the diff +- [ ] After merge → eventual merge to `main` triggers release build of `wp-agentic-admin.zip` (existing pipeline) +- [ ] Archive `.claude/plans/features/001-mcp-server-endpoint/` to `.claude/plans/archive/2026-05-NN-mcp-server-endpoint/` after merge + +## Freeze assessment + +Per the wordpress-feature skill: any "yes" answer below indicates the plan should be frozen for human review before implementation starts. + +- [ ] New auth model / new credential surface introduced? → **YES** (app-password Basic auth on a new MCP endpoint that can invoke arbitrary registered abilities is a new auth surface for this plugin, even though WP core handles the actual credential check) +- [ ] Cross-plugin coupling (depends on or extends another plugin)? → **NO** (we deliberately do not depend on Automattic's wordpress-mcp; Abilities API is WP core) +- [ ] Wire-protocol implementation that must conform to an external spec? → **YES** (JSON-RPC 2.0 + MCP `2025-03-26`) +- [ ] User-facing default that changes the plugin's security/privacy posture? → **NO** (master toggle defaults off; headline preserved) +- [ ] Affects code that other features depend on (settings store, abilities registry)? → **YES** (extends `Settings` and reads from the abilities registry — additive, but visible everywhere) +- [ ] Estimated diff > ~600 LOC? → **YES** (roughly 700–1000 LOC including tests + UI + docs) + +**Recommendation: FREEZE.** Three of six checks are yes, and two of them (new auth surface, wire protocol conformance) are exactly the categories where surprising mistakes are expensive. Surface the spec + plan to the user for explicit review/sign-off before any production code lands. + +## Open questions for review + +1. **Tool name encoding.** Spec proposes `wp-agentic-admin/cache-flush` → `wp-agentic-admin__cache-flush` (replace `/` with `__`). Acceptable, or prefer a different convention (e.g. dot-separated)? Whatever we pick is hard to change once clients start hardcoding names. +2. **Third-party ability source attribution.** Plan currently parses the namespace from the ability ID (e.g. `woocommerce/list-orders` → "woocommerce"). If WP's Abilities API exposes a richer source field we should use that instead. Confirm during phase 2 implementation. +3. **Readonly detection fallback.** Some abilities may not set `meta.annotations.readonly` at all. v1 plan: treat absent as "not readonly" (conservative — excluded). Confirm this is the right default. +4. **404 vs 401 when disabled.** Acceptance criterion #1 says we don't register the route at all when `mcp_enabled = 0` → that's a 404. Alternative: register the route always but have the permission callback return 404/403 when disabled (lets clients discover the endpoint exists). Plan goes with the cleaner "don't register" approach. OK? diff --git a/.claude/plans/features/001-mcp-server-endpoint/progress.md b/.claude/plans/features/001-mcp-server-endpoint/progress.md new file mode 100644 index 0000000..7900dd9 --- /dev/null +++ b/.claude/plans/features/001-mcp-server-endpoint/progress.md @@ -0,0 +1,40 @@ +# 001 — Progress + deviations + +## Deviations from plan.md + +### No PHPUnit tests (Phase 1, 2, 3, 4) + +The plan called for PHPUnit tests under `tests/php/`. The project ships zero PHPUnit infrastructure (composer.json has only WPCS dev deps; `tests/` contains Jest suites only). Adding PHPUnit + a WP test bootstrap is meaningful scope creep for this feature. + +**Decision:** Skip PHPUnit. Verification rests on: +- WPCS lint catching obvious mistakes +- Manual smoke tests against the local docker stack documented in Phase 7 +- Lightweight script-style PHP harnesses under `tests/php-manual/` only if a class becomes too complex to verify by curl + +Revisit if a follow-up feature warrants standing up real PHPUnit. + +## Phase progress + +- **Phase 0** — branch + plan commit — ✅ done (commit `39b7f98`) +- **Phase 1** — Settings (`agentic_admin_settings` new `mcp` section + `ability_list` sanitizer) — ✅ done (commit `150871c`) +- **Phase 2** — `Ability_Registry` (discovery, filtering, ability→tool mapping) — ✅ done (commit `3e4476c`) +- **Phase 3** — `JsonRpc_Server` (initialize/ping/tools/list/tools/call) — ✅ done (commit `134cb8e`) +- **Phase 4** — `Rest_Endpoint` (POST /wp-agentic-admin/v1/mcp, gated by toggle) — ✅ done (commit `117e3e0`) +- **Phase 5** — Settings UI (React `McpEndpointSection` + admin-only `Settings_Rest`) — ✅ done (commit `2bb8baa`) +- **Phase 6** — docs + version bump 0.12.0 — in progress + +## Manual verification log (Phase 7 prep) + +End-to-end against the local docker stack at `wp-agentic-admin.local` during phases 4–5: + +- ✅ disabled → HTTP 404 on `POST /wp-json/wp-agentic-admin/v1/mcp` +- ✅ enabled, no creds → HTTP 401 +- ✅ enabled, app-password Basic → `initialize` returns serverInfo `{ name: "Agentic Admin MCP Server", version: "0.11.0" }`, capabilities `tools.list + tools.call` +- ✅ `tools/list` returns 25 own readonly abilities with correct annotations (readOnlyHint, destructiveHint, idempotentHint) +- ✅ `tools/call wp-agentic-admin__site-health` executes the underlying ability; result wrapped as `{ content: [...], isError: false }` +- ✅ `tools/call` on an unknown tool → JSON-RPC `-32601 Method not found` +- ✅ Settings REST: GET returns current state + third-party catalog (3 entries: `core/get-site-info`, `core/get-user-info`, `core/get-environment-info`) +- ✅ Settings REST POST: enable expose-third + allowlist `core/get-site-info` → next `tools/list` grows from 25 → 26 +- ✅ Settings reset via POST → endpoint returns 404 again +- ✅ Jest `npm test` — 96 tests passing, 0 failing +- ✅ PHPCS clean on all new files diff --git a/.claude/plans/features/001-mcp-server-endpoint/spec.md b/.claude/plans/features/001-mcp-server-endpoint/spec.md new file mode 100644 index 0000000..0b1949e --- /dev/null +++ b/.claude/plans/features/001-mcp-server-endpoint/spec.md @@ -0,0 +1,127 @@ +# 001 — MCP Server Endpoint (read-only, v0.12.0) + +## Problem + +Today, an external AI agent that wants to call Agentic Admin's abilities has no way to reach them over the wire. The plugin's abilities are registered with the WordPress Abilities API, but the Abilities API is a registry — it has no transport. Sites without SSH/SFTP, or teams that want a Claude / ChatGPT / custom-agent client to drive the site, currently have no first-class path through this plugin. + +Automattic's `wordpress-mcp` does provide an MCP transport, but it exposes its own hardcoded toolset (18 tools targeting core WP objects); it does not bridge the Abilities API. Depending on it would couple our release cadence to theirs, force JWT auth on our users, and surrender control of the tool surface. + +## Goal + +Ship an MCP server endpoint inside `wp-agentic-admin` itself that exposes registered abilities as MCP tools — read-only in v1, opt-in by default, no third-party plugin required. + +## Non-goals (v1) + +- Write/destructive tools (`annotations.readonly !== true` abilities are filtered out) +- Server-Sent Events / streaming responses (sync JSON-RPC only) +- MCP `resources` and `prompts` capabilities (only `tools`) +- OAuth / dynamic client registration +- Per-call audit log (deferred to a later version) +- Replacing or bridging Automattic's `wordpress-mcp` — we coexist, we don't interop + +## Users + +- Site admins (`manage_options`) who want to point an external MCP client at their WP site +- Plugin developers who already register abilities via `agentic_admin_register_ability()` and want them MCP-callable for free +- (Indirect) Third-party plugins that register abilities via `wp_register_ability()` directly — surfaced only when the admin explicitly opts them in + +## Endpoint + +- **Route**: `POST /wp-json/wp-agentic-admin/v1/mcp` + - matches the existing namespace convention (see `class-llm-proxy.php`) +- **Transport**: JSON-RPC 2.0 over HTTP request/response (no SSE in v1) +- **Content-Type**: `application/json` for both request and response +- **Authentication**: standard WordPress REST authentication + - App passwords over HTTP Basic auth is the supported path for external clients (no JWT layer) + - Cookie+nonce continues to work for same-origin browser callers + - The route's REST `permission_callback` requires `is_user_logged_in()`; per-tool authz delegates to each ability's own `permission_callback` +- **Methods implemented (v1)**: + - `initialize` — protocol handshake, returns serverInfo + capabilities (`{ tools: { list: true, call: true } }`) + - `tools/list` — enumerate exposed abilities, mapped to MCP tool descriptors + - `tools/call` — invoke an ability, return its result + - `ping` — health check + - Anything else → JSON-RPC error code `-32601` Method not found + +## Ability → MCP tool mapping + +| Ability field | MCP tool field | Notes | +|---|---|---| +| `id` (e.g. `wp-agentic-admin/cache-flush`) | `name` (sanitized to `wp_agentic_admin__cache_flush` or `wp-agentic-admin__cache-flush`) | MCP tool names must match `^[a-zA-Z0-9_-]+$`; we replace `/` with `__`. Reverse lookup on `tools/call`. | +| `label` | `annotations.title` | | +| `description` | `description` | | +| `input_schema` | `inputSchema` | Passed through as-is (JSON Schema) | +| `meta.annotations.readonly` | `annotations.readOnlyHint` | v1 filter requires this to be `true` | +| `meta.annotations.destructive` | `annotations.destructiveHint` | | +| `meta.annotations.idempotent` | `annotations.idempotentHint` | | +| `execute_callback` | invoked by `tools/call` handler | Result wrapped as `{ content: [{ type: "text", text: }] }` | +| `permission_callback` | called on `tools/call` before execute | Failure → JSON-RPC error `-32001` (custom) with HTTP 200, JSON-RPC body carries the error | + +## Settings (added to the existing settings page) + +New section `mcp` in `WPAgenticAdmin\Settings::get_settings_config()`: + +``` +MCP Endpoint + ▢ Enable MCP endpoint [agentic_admin_mcp_enabled] default: 0 + When enabled, external clients can call abilities via /wp-json/wp-agentic-admin/v1/mcp + using an application password. + + Exposed abilities: + ▣ Agentic Admin's own abilities [agentic_admin_mcp_expose_own] default: 1 + ▢ Other plugins' Abilities API entries [agentic_admin_mcp_expose_third] default: 0 + When checked, a checkbox list appears below to pick which third-party abilities + are allowed. Default: all unchecked. + + ▢ (from: ) [agentic_admin_mcp_allowlist[]] (multi-checkbox) + ▢ (from: ) + ... + + Endpoint URL (read-only display): https://example.com/wp-json/wp-agentic-admin/v1/mcp +``` + +UX details: +- The three top-level checkboxes are visible always; the allowlist appears only when "Other plugins' …" is checked +- Third-party allowlist excludes abilities whose ID starts with `wp-agentic-admin/` (those are governed by the "own" toggle) +- Third-party allowlist also excludes write-y abilities for v1 (`annotations.readonly !== true`), shown but disabled with a tooltip "v1 is read-only" +- Endpoint URL is shown with a "copy" button (existing admin-page UI patterns reused) + +## Privacy + headline guarantee + +The master toggle defaults **off**. The plugin's "privacy-first, browser-side AI" headline remains accurate out-of-the-box: nothing leaves the site, nothing accepts remote tool calls, until an admin explicitly enables the endpoint. Documentation in `readme.txt` and `docs/` is updated to call this out. + +## Coexistence with Automattic `wordpress-mcp` + +Distinct routes, distinct option keys, distinct auth surfaces. If both plugins are active: +- `/wp-json/wp-agentic-admin/v1/mcp` — our endpoint, app-password auth, our abilities +- `/wp-json/wp/v2/wpmcp/streamable` — Automattic's, JWT/OAuth, their 18 tools +- An admin can run both, point different clients at each. We do not register tools with `wordpress_mcp_init`. We do not depend on or detect their plugin. + +## Risks + +- **MCP spec drift.** Current target is the same protocol Automattic implements (`2025-03-26` per their schema). We don't ship the spec JSON; we implement against a documented version and bump as needed. Mitigation: pin the protocol version returned from `initialize`, document the supported version range in `readme.txt`. +- **Third-party ability quality.** A buggy/insecure ability in another plugin becomes remote-callable when opted in. Mitigation: default off, explicit allowlist, read-only v1, each ability's own `permission_callback` still gates. +- **App password rotation.** Compromised app password = full read access to allowed tools. Same risk model as `/wp-json/wp/v2/users/me` already has. Documentation reminds admins to scope app passwords per client and revoke unused. + +## Acceptance criteria + +1. With `mcp_enabled = 0`: POSTing to the endpoint returns HTTP 404 (route not registered at all when disabled). +2. With `mcp_enabled = 1` and no auth: HTTP 401. +3. With valid app password + `initialize`: returns serverInfo containing plugin name + version, capabilities `{ tools: { list: true, call: true } }`. +4. With `mcp_expose_own = 1` only: `tools/list` returns N items where every `name` maps to an `wp-agentic-admin/*` ability AND every ability has `readonly === true`. No third-party tools present. +5. With `mcp_expose_third = 1` plus an allowlist of one third-party read-only ability: `tools/list` includes that ability alongside the own ones. +6. `tools/call` on an allowed read-only ability returns the ability's result in MCP content format. +7. `tools/call` on an ability whose `permission_callback` returns false → JSON-RPC error with code `-32001`, HTTP 200. +8. `tools/call` on a write-y ability (`readonly !== true`) → JSON-RPC error `-32601` Method not found (it was never in `tools/list`, so name lookup fails). +9. Unknown method → JSON-RPC error `-32601`. +10. Existing functionality (browser-side ReAct loop, LLM proxy, admin pages) continues to work unchanged. No regressions in `composer lint`, `npm run lint:js`, `npm test`. + +## Out of scope (explicit) + +- `tools/call` for non-readonly abilities +- SSE streaming +- `resources/*` and `prompts/*` MCP methods +- Multi-language tool descriptions +- Rate limiting on the endpoint (rely on infra; document recommendation) +- Audit log table; we log only via the existing PHP error log on failure paths +- WP-CLI command for managing the allowlist +- Detecting wordpress-mcp's presence or bridging into it diff --git a/.release-notes/0.12.0.md b/.release-notes/0.12.0.md new file mode 100644 index 0000000..d037db2 --- /dev/null +++ b/.release-notes/0.12.0.md @@ -0,0 +1,112 @@ +# v0.12.0 — MCP Server Endpoint (Read-Only) + +## Headline + +**Agentic Admin now exposes a built-in MCP server** so external AI clients +(Claude, ChatGPT, custom agents) can call this plugin's abilities — and +selected third-party Abilities API entries — over a standard MCP JSON-RPC +endpoint, authenticated via WordPress application passwords. + +This release is intentionally **read-only**: write/destructive abilities +are filtered out at the protocol boundary. Master toggle defaults **OFF** +to preserve the privacy-first guarantee out of the box. + +No dependency on Automattic's `wordpress-mcp`. The two coexist on +distinct routes (`/wp-agentic-admin/v1/mcp` vs `/wp/v2/wpmcp/streamable`). + +## readme.txt entry (ready to copy in) + +``` += 0.12.0 = +* New: MCP server endpoint at `/wp-json/wp-agentic-admin/v1/mcp`. External AI clients can call registered abilities via JSON-RPC 2.0 using a WordPress application password. Read-only in this release; write-capable abilities are filtered out. +* New: MCP Endpoint settings panel in the Settings tab. Master toggle defaults OFF — privacy-first guarantee remains intact out of the box. Independent toggles for exposing this plugin's own abilities and selected third-party Abilities API entries. +* New: third-party abilities are governed by an explicit allowlist UI grouped by source plugin. Only read-only abilities are selectable in this release. +* No dependency on Automattic's wordpress-mcp plugin. The two coexist on different routes if both are installed. +* See docs/MCP-ENDPOINT.md for the full curl walkthrough and security guidance. +``` + +## What ships + +### New endpoint + +`POST /wp-json/wp-agentic-admin/v1/mcp` + +- JSON-RPC 2.0 transport (no SSE in v1) +- Methods: `initialize`, `ping`, `tools/list`, `tools/call`, + `notifications/initialized` +- Protocol version reported: `2025-03-26` +- Auth: standard WP REST — app passwords over Basic auth is the + supported path for external clients +- Per-call authorization delegates to each ability's existing + `permission_callback` — admin-only abilities stay admin-only + +### Settings panel + +A new "MCP Endpoint" card lives inside Settings → MCP Endpoint: + +- Master toggle: **Enable MCP endpoint** (default off) +- Endpoint URL display with one-click copy +- **Expose Agentic Admin's own abilities** (default on when endpoint is on) +- **Expose abilities from other plugins** (default off) → reveals an + allowlist grouped by source plugin +- Read-only abilities are selectable; non-readonly are listed but disabled + +### Backend pieces + +- `includes/mcp/class-ability-registry.php` — discovery + filtering +- `includes/mcp/class-jsonrpc-server.php` — dispatch layer +- `includes/mcp/class-rest-endpoint.php` — POST `/mcp` route, gated by + master toggle (returns HTTP 404 when disabled — route is not registered) +- `includes/mcp/class-settings-rest.php` — admin-only GET/POST for the + settings UI + +## Security notes + +- The master toggle defaults **off**. No remote access until an admin + explicitly opts in. +- App-password auth is bound to the issuing user's capabilities. A + compromised app password = the read access that user already has. + Document this in your security policy and rotate. +- v1 is read-only at the protocol boundary: any ability without + `meta.annotations.readonly === true` is invisible to `tools/list` + and unreachable via `tools/call`. +- Each ability's own `permission_callback` still runs on every + `tools/call`. A subscriber app-password cannot reach an + `manage_options`-gated ability. + +## Out of scope (deferred) + +- Write/destructive tools (planned for v0.13.x once auth + audit story + is hardened) +- Server-Sent Events streaming +- MCP `resources/*` and `prompts/*` capabilities +- OAuth + dynamic client registration +- Per-call audit log table + +## Manual verification (local docker stack) + +```bash +# Endpoint disabled by default → 404 +curl -sk -o /dev/null -w "%{http_code}\n" \ + -u "admin:APP_PASSWORD" \ + https://wp-agentic-admin.local/wp-json/wp-agentic-admin/v1/mcp +# → 404 + +# Enable in wp-admin → Settings → MCP Endpoint, then: + +curl -sk -H "Content-Type: application/json" \ + -u "admin:APP_PASSWORD" \ + https://wp-agentic-admin.local/wp-json/wp-agentic-admin/v1/mcp \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' + +curl -sk -H "Content-Type: application/json" \ + -u "admin:APP_PASSWORD" \ + https://wp-agentic-admin.local/wp-json/wp-agentic-admin/v1/mcp \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + | python3 -m json.tool + +curl -sk -H "Content-Type: application/json" \ + -u "admin:APP_PASSWORD" \ + https://wp-agentic-admin.local/wp-json/wp-agentic-admin/v1/mcp \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"wp-agentic-admin__site-health","arguments":{}}}' +``` diff --git a/README.md b/README.md index 7d79e4a..f0a6018 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ All of this happens **locally in your browser** - no data is sent to third-party - **Extensible**: Third-party plugins can register custom abilities - **Natural Language Interface**: Describe problems in plain English, get intelligent solutions - **ReAct Loop**: LLM decides which tools to use based on observations, adapting in real-time +- **Optional MCP Endpoint** (v0.12.0+): External AI clients (Claude, ChatGPT, custom agents) can call registered abilities via a built-in MCP server, authenticated with WordPress application passwords. Disabled by default. Read-only in v0.12.0. See [docs/MCP-ENDPOINT.md](docs/MCP-ENDPOINT.md). ## Architecture diff --git a/docs/MCP-ENDPOINT.md b/docs/MCP-ENDPOINT.md new file mode 100644 index 0000000..75b90cf --- /dev/null +++ b/docs/MCP-ENDPOINT.md @@ -0,0 +1,198 @@ +# MCP Endpoint + +Agentic Admin ships a built-in MCP (Model Context Protocol) server starting in +v0.12.0. When enabled, external AI clients can call this plugin's registered +abilities — and optionally a curated allowlist of third-party Abilities API +entries — via standard MCP JSON-RPC, authenticated with WordPress application +passwords. + +**This is read-only in v0.12.0.** Abilities are only exposed if they declare +`meta.annotations.readonly = true`. Write-capable abilities are filtered out +at the protocol boundary. + +--- + +## When to use this + +- You want a Claude, ChatGPT, or custom MCP client to read site state, + diagnose issues, or summarize content without giving anyone SSH/SFTP access. +- You're hosting WordPress somewhere SSH-less (managed WP hosts, low-trust + shared hosting) but still want programmatic, auditable read access. +- You already use the Abilities API for your own plugins and want them + callable over MCP without writing a transport layer. + +If you don't need any of that, leave the endpoint disabled. It's off by +default and the plugin works entirely client-side without it. + +--- + +## Enabling the endpoint + +1. **WordPress admin** → **Agentic Admin** → **Settings tab** → scroll to + **MCP Endpoint**. +2. Flip **Enable MCP endpoint** on. +3. Decide what's exposed: + - **Expose Agentic Admin's own abilities** (default on) — every read-only + ability shipped by this plugin shows up as an MCP tool. + - **Expose abilities from other plugins** (default off) — reveals a list + of every third-party ability registered via `wp_register_ability()`, + grouped by source plugin. Tick each one you want to expose. Non-readonly + entries are listed but unselectable in v0.12.0. +4. **Save MCP settings**. +5. Copy the endpoint URL shown in the card (something like + `https://your-site.tld/wp-json/wp-agentic-admin/v1/mcp`). + +--- + +## Creating an application password + +External clients can't use cookie auth, so create a scoped app password: + +1. **Users** → **Profile** (or **Edit user**). +2. Scroll to **Application Passwords**. +3. New application name: e.g. `claude-mcp` or `chatgpt-mcp`. Click **Add**. +4. Copy the password (shown once — WP will not show it again). +5. Connect with HTTP Basic auth using your WP username + that app password. + +> ⚠️ The app password inherits the issuing user's capabilities. A subscriber's +> app password cannot reach an `manage_options`-gated ability — each ability's +> existing `permission_callback` is still enforced inside `tools/call`. But +> don't issue admin app passwords to clients that don't need them. + +--- + +## curl walkthrough + +```bash +SITE="https://your-site.tld" +USER="your-wp-username" +APP_PASS="abcd EFGH ijkl MNOP qrst UVWX" # WP shows spaces; you can omit them +URL="$SITE/wp-json/wp-agentic-admin/v1/mcp" + +# 1) Handshake +curl -s -u "$USER:$APP_PASS" -H "Content-Type: application/json" \ + "$URL" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"manual","version":"1.0"}}}' + +# 2) List available tools +curl -s -u "$USER:$APP_PASS" -H "Content-Type: application/json" \ + "$URL" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + +# 3) Call one of them +curl -s -u "$USER:$APP_PASS" -H "Content-Type: application/json" \ + "$URL" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"wp-agentic-admin__site-health","arguments":{}}}' +``` + +--- + +## Ability → MCP tool name mapping + +WordPress ability IDs (`namespace/ability-name`) contain a slash, which the +MCP spec doesn't allow in tool names. We map slash → double-underscore: + +| WordPress ability ID | MCP tool name | +|-------------------------------|--------------------------------| +| `wp-agentic-admin/site-health`| `wp-agentic-admin__site-health`| +| `core/get-site-info` | `core__get-site-info` | + +The mapping is reversible by inspection — no naming conflicts arise because +WP ability IDs are already namespaced. + +--- + +## Connecting Claude Desktop + +In `claude_desktop_config.json` (or your client's MCP server config): + +```jsonc +{ + "mcpServers": { + "wp-agentic-admin": { + "transport": { + "type": "http", + "url": "https://your-site.tld/wp-json/wp-agentic-admin/v1/mcp", + "headers": { + "Authorization": "Basic " + } + } + } + } +} +``` + +(Exact config keys vary by client; check your client's MCP docs.) + +--- + +## Coexistence with Automattic's `wordpress-mcp` + +Agentic Admin's endpoint and Automattic's `wordpress-mcp` plugin are +**independent**: + +- **Different routes:** `/wp-json/wp-agentic-admin/v1/mcp` vs + `/wp-json/wp/v2/wpmcp/streamable` +- **Different auth:** app-password Basic auth vs JWT (Automattic's) +- **Different toolsets:** the plugins do not bridge or share tools +- **Different option storage:** no shared settings, no conflicts + +You can run both side-by-side and point different MCP clients at each. We do +not depend on Automattic's plugin, and we do not bridge our abilities into +its endpoint. + +--- + +## What's exposed in v0.12.0 + +- **Our own abilities** with `meta.annotations.readonly = true` — typically + diagnostic and reporting tools (site health, security scan, log readers, + database query in read-only mode, …). +- **Third-party abilities** the admin explicitly allowlisted, again + read-only only. + +Write/destructive abilities are deliberately invisible to MCP in this +release. Future versions may add a separately-gated write surface. + +### Who can see `tools/list`? + +Any authenticated user with an application password — including +subscriber-level accounts — can call `tools/list` and see the names, +descriptions, and input schemas of every currently-exposed ability. +They **cannot** invoke abilities whose own `permission_callback` +requires higher capabilities; those calls return `-32001 Permission +denied`. + +This matches MCP convention (clients discover, server enforces) and +mirrors how Automattic's `wordpress-mcp` and other MCP servers behave. +If you don't want low-privilege users to see your tool catalog, don't +issue them application passwords. The endpoint itself does not add a +second capability gate on top of the per-ability checks. + +--- + +## Troubleshooting + +**HTTP 404 on the endpoint URL** +The MCP endpoint is disabled. Enable it in Settings → MCP Endpoint, and +make sure pretty permalinks are configured (Settings → Permalinks). + +**HTTP 401 "Authentication required"** +Either no credentials were sent, or the user/app password combination is +wrong. Test with `/wp-json/wp/v2/users/me` first to confirm the credential. + +**Tool list is empty** +- Make sure at least one of the "Expose own" or "Expose third-party" + toggles is on. +- Confirm the abilities you expect to see actually declare + `meta.annotations.readonly = true`. The conservative default is + "absent = excluded." + +**`-32601 Method not found` on `tools/call`** +The tool name was either misspelled or refers to a non-exposed ability. +Check the result of `tools/list` and copy the `name` field verbatim. + +**`-32001 Permission denied`** +The tool exists, but the authenticated user's role doesn't satisfy the +ability's own `permission_callback`. Use an app password belonging to a +user with sufficient capabilities (often `manage_options`). diff --git a/includes/class-settings.php b/includes/class-settings.php index 5628fc2..74b3222 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -124,6 +124,35 @@ public function get_settings_config(): array { ), ), ), + 'mcp' => array( + 'label' => __( 'MCP Endpoint', 'agentic-admin' ), + 'fields' => array( + 'agentic_admin_mcp_enabled' => array( + 'label' => __( 'Enable MCP endpoint', 'agentic-admin' ), + 'type' => 'checkbox', + 'default' => 0, + 'description' => __( 'Expose registered abilities to external AI clients via a read-only MCP server. Authentication uses WordPress application passwords. Disabled by default.', 'agentic-admin' ), + ), + 'agentic_admin_mcp_expose_own' => array( + 'label' => __( "Expose Agentic Admin's own abilities", 'agentic-admin' ), + 'type' => 'checkbox', + 'default' => 1, + 'description' => __( 'Include abilities registered by this plugin in the MCP tool list.', 'agentic-admin' ), + ), + 'agentic_admin_mcp_expose_third' => array( + 'label' => __( 'Expose abilities from other plugins', 'agentic-admin' ), + 'type' => 'checkbox', + 'default' => 0, + 'description' => __( 'Allow third-party Abilities API entries to be exposed. Each must be explicitly allowed below.', 'agentic-admin' ), + ), + 'agentic_admin_mcp_allowlist' => array( + 'label' => __( 'Allowed third-party abilities', 'agentic-admin' ), + 'type' => 'ability_list', + 'default' => array(), + 'description' => __( 'Pick which third-party abilities are exposed. Read-only abilities only in this version.', 'agentic-admin' ), + ), + ), + ), ); } @@ -173,6 +202,20 @@ public function update_field( string $field, $value, string $type = 'text' ): vo case 'select': $cleaned = sanitize_text_field( (string) $value ); break; + case 'ability_list': + $cleaned = array(); + if ( is_array( $value ) ) { + foreach ( $value as $entry ) { + if ( ! is_string( $entry ) ) { + continue; + } + if ( preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $entry ) ) { + $cleaned[] = $entry; + } + } + $cleaned = array_values( array_unique( $cleaned ) ); + } + break; case 'text': default: $cleaned = sanitize_text_field( (string) $value ); diff --git a/includes/mcp/class-ability-registry.php b/includes/mcp/class-ability-registry.php new file mode 100644 index 0000000..1964c09 --- /dev/null +++ b/includes/mcp/class-ability-registry.php @@ -0,0 +1,262 @@ +|null + */ + private ?array $exposed_cache = null; + + /** + * Constructor. + * + * @param Settings $settings Plugin settings instance. + */ + public function __construct( Settings $settings ) { + $this->settings = $settings; + } + + /** + * Whether the MCP endpoint is enabled in settings. + * + * @return bool + */ + public function is_endpoint_enabled(): bool { + return (bool) $this->settings->get_field( 'agentic_admin_mcp_enabled', 0 ); + } + + /** + * Get the abilities currently exposed via MCP, keyed by ability name. + * + * Applies the v1 read-only filter and the user's expose-own / + * expose-third-party / allowlist settings. + * + * @return array + */ + public function get_exposed_abilities(): array { + if ( null !== $this->exposed_cache ) { + return $this->exposed_cache; + } + + if ( ! function_exists( 'wp_get_abilities' ) ) { + $this->exposed_cache = array(); + return $this->exposed_cache; + } + + $all = wp_get_abilities(); + $expose_own = (bool) $this->settings->get_field( 'agentic_admin_mcp_expose_own', 1 ); + $expose_third = (bool) $this->settings->get_field( 'agentic_admin_mcp_expose_third', 0 ); + $allowlist = (array) $this->settings->get_field( 'agentic_admin_mcp_allowlist', array() ); + $exposed = array(); + + foreach ( $all as $name => $ability ) { + if ( ! $ability instanceof WP_Ability ) { + continue; + } + if ( ! $this->is_readonly( $ability ) ) { + continue; + } + $is_own = $this->is_own_ability( $name ); + if ( $is_own && $expose_own ) { + $exposed[ $name ] = $ability; + continue; + } + if ( ! $is_own && $expose_third && in_array( $name, $allowlist, true ) ) { + $exposed[ $name ] = $ability; + } + } + + $this->exposed_cache = $exposed; + return $this->exposed_cache; + } + + /** + * Invalidate the memoized exposed-abilities map. + * + * Tests and long-lived processes (CLI workers, abilities-API hot-reload + * scenarios) may need to force re-discovery. Production request flow + * doesn't need to call this — a new Ability_Registry instance is built + * per REST request. + * + * @return void + */ + public function flush_cache(): void { + $this->exposed_cache = null; + } + + /** + * Get all third-party abilities (everything not prefixed with wp-agentic-admin/). + * + * Used by the settings page to render the allowlist checkbox list. Returned + * regardless of the expose-third setting — the UI decides what to show. + * + * @return array + */ + public function get_third_party_abilities(): array { + if ( ! function_exists( 'wp_get_abilities' ) ) { + return array(); + } + $out = array(); + foreach ( wp_get_abilities() as $name => $ability ) { + if ( ! $ability instanceof WP_Ability ) { + continue; + } + if ( $this->is_own_ability( $name ) ) { + continue; + } + $out[ $name ] = $ability; + } + return $out; + } + + /** + * Map a WP_Ability into an MCP tool descriptor. + * + * @param WP_Ability $ability Ability to map. + * @return array + */ + public function to_mcp_tool( WP_Ability $ability ): array { + $meta = $ability->get_meta() ?? array(); + $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) + ? $meta['annotations'] + : array(); + + $input_schema = $ability->get_input_schema(); + if ( ! is_array( $input_schema ) || empty( $input_schema ) ) { + $input_schema = array( + 'type' => 'object', + 'properties' => array(), + ); + } + + return array( + 'name' => $this->encode_name( $ability->get_name() ), + 'description' => (string) $ability->get_description(), + 'inputSchema' => $input_schema, + 'annotations' => array( + 'title' => (string) $ability->get_label(), + 'readOnlyHint' => true, + 'destructiveHint' => (bool) ( $annotations['destructive'] ?? false ), + 'idempotentHint' => (bool) ( $annotations['idempotent'] ?? false ), + ), + ); + } + + /** + * Resolve an MCP tool name back to a currently-exposed WP_Ability. + * + * Returns null if the tool name does not correspond to any currently + * exposed ability. Callers must treat null as "method not found". + * + * @param string $tool_name MCP tool name. + * @return WP_Ability|null + */ + public function resolve_tool_name( string $tool_name ): ?WP_Ability { + foreach ( $this->get_exposed_abilities() as $name => $ability ) { + if ( $this->encode_name( $name ) === $tool_name ) { + return $ability; + } + } + return null; + } + + /** + * Source-plugin label for the settings UI grouping. + * + * For our own abilities returns "Agentic Admin". For third-party abilities + * we use the namespace segment of the ID (best-effort attribution; the + * WordPress Abilities API does not currently expose a source-plugin field). + * + * @param string $ability_name Full ability name (e.g. "woocommerce/list-orders"). + * @return string + */ + public function source_plugin( string $ability_name ): string { + if ( $this->is_own_ability( $ability_name ) ) { + return 'Agentic Admin'; + } + $slash = strpos( $ability_name, '/' ); + if ( false === $slash ) { + return 'Unknown'; + } + return substr( $ability_name, 0, $slash ); + } + + /** + * Encode an ability name (which contains "/") into an MCP-safe tool name. + * + * MCP tool names must match ^[a-zA-Z0-9_-]+$. WordPress ability IDs are + * "namespace/name". We replace the slash with a double underscore so the + * mapping is reversible by inspection and human-readable. + * + * @param string $ability_name Full ability name. + * @return string + */ + public function encode_name( string $ability_name ): string { + return str_replace( '/', '__', $ability_name ); + } + + /** + * Whether the ability ID belongs to this plugin. + * + * @param string $ability_name Ability ID. + * @return bool + */ + private function is_own_ability( string $ability_name ): bool { + return str_starts_with( $ability_name, 'wp-agentic-admin/' ); + } + + /** + * Read-only filter per the v1 spec. + * + * Absent or non-true readonly annotation excludes the ability. This is + * deliberately conservative: an ability that has not declared itself + * read-only will not be exposed over MCP in v1. + * + * @param WP_Ability $ability Ability to check. + * @return bool + */ + private function is_readonly( WP_Ability $ability ): bool { + $meta = $ability->get_meta() ?? array(); + return isset( $meta['annotations']['readonly'] ) + && true === $meta['annotations']['readonly']; + } +} diff --git a/includes/mcp/class-jsonrpc-server.php b/includes/mcp/class-jsonrpc-server.php new file mode 100644 index 0000000..356b067 --- /dev/null +++ b/includes/mcp/class-jsonrpc-server.php @@ -0,0 +1,288 @@ +registry = $registry; + } + + /** + * Handle a single JSON-RPC request payload. + * + * Returns the JSON-RPC envelope (success or error) as an array. For + * notifications (no "id" field), returns an empty array — the caller + * MUST treat that as "send no response body". + * + * @param mixed $payload Decoded JSON-RPC request payload. + * @return array + */ + public function handle( $payload ): array { + if ( ! is_array( $payload ) ) { + return $this->error( null, self::ERR_INVALID_REQUEST, 'Request payload must be a JSON object.' ); + } + + $id = $payload['id'] ?? null; + $method = isset( $payload['method'] ) && is_string( $payload['method'] ) ? $payload['method'] : ''; + $params = isset( $payload['params'] ) && is_array( $payload['params'] ) ? $payload['params'] : array(); + + if ( '' === $method ) { + return $this->error( $id, self::ERR_INVALID_REQUEST, 'Missing or invalid "method" field.' ); + } + + // Notifications (no id) — JSON-RPC 2.0 forbids returning a response, including for + // methods like tools/call. We still execute the method (server-side side effects + // remain visible to a follow-up tools/list) but never emit an envelope. + $is_notification = ! array_key_exists( 'id', $payload ); + + try { + switch ( $method ) { + case 'initialize': + $envelope = $this->success( $id, $this->handle_initialize() ); + break; + case 'notifications/initialized': + // Client telling us it's ready; no response expected. + return array(); + case 'ping': + $envelope = $this->success( $id, new \stdClass() ); + break; + case 'tools/list': + $envelope = $this->success( $id, $this->handle_tools_list() ); + break; + case 'tools/call': + $envelope = $this->handle_tools_call( $id, $params ); + break; + default: + $envelope = $this->error( $id, self::ERR_METHOD_NOT_FOUND, sprintf( 'Method not found: %s', $method ) ); + } + } catch ( Throwable $e ) { + $envelope = $this->error( $id, self::ERR_INTERNAL, 'Internal server error.' ); + } + + if ( $is_notification ) { + return array(); + } + + return $envelope; + } + + /** + * Handle `initialize` — return serverInfo + capabilities. + * + * @return array + */ + private function handle_initialize(): array { + return array( + 'protocolVersion' => self::PROTOCOL_VERSION, + 'serverInfo' => array( + 'name' => 'Agentic Admin MCP Server', + 'version' => defined( 'WP_AGENTIC_ADMIN_VERSION' ) ? WP_AGENTIC_ADMIN_VERSION : '0.0.0', + ), + 'capabilities' => array( + 'tools' => array( + 'list' => true, + 'call' => true, + ), + ), + ); + } + + /** + * Handle `tools/list` — enumerate exposed abilities as MCP tools. + * + * @return array + */ + private function handle_tools_list(): array { + $tools = array(); + foreach ( $this->registry->get_exposed_abilities() as $ability ) { + $tools[] = $this->registry->to_mcp_tool( $ability ); + } + return array( 'tools' => $tools ); + } + + /** + * Handle `tools/call` — resolve, permission-check, execute. + * + * @param mixed $id Request id for the response envelope. + * @param array $params { name: string, arguments?: array }. + * @return array JSON-RPC envelope. + */ + private function handle_tools_call( $id, array $params ): array { + $name = isset( $params['name'] ) && is_string( $params['name'] ) ? $params['name'] : ''; + if ( '' === $name ) { + return $this->error( $id, self::ERR_INVALID_PARAMS, 'Missing required parameter: name.' ); + } + + $ability = $this->registry->resolve_tool_name( $name ); + if ( null === $ability ) { + return $this->error( $id, self::ERR_METHOD_NOT_FOUND, sprintf( 'Unknown tool: %s', $name ) ); + } + + $input = array(); + if ( isset( $params['arguments'] ) && is_array( $params['arguments'] ) ) { + $input = $params['arguments']; + } + + if ( true !== $ability->check_permissions( $input ) ) { + return $this->error( $id, self::ERR_PERMISSION_DENIED, 'Permission denied.' ); + } + + try { + $result = $ability->execute( $input ); + } catch ( Throwable $e ) { + // Log the real exception for operators; surface only a generic envelope to the + // client. Echoing $ability->get_name() or $e->getMessage() risks reflecting + // admin-controlled or internal data into a response. + error_log( '[wp-agentic-admin/mcp] ability execute exception: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return $this->success( + $id, + array( + 'content' => array( + array( + 'type' => 'text', + 'text' => 'Tool execution failed.', + ), + ), + 'isError' => true, + ) + ); + } + + if ( $result instanceof WP_Error ) { + $raw = (string) $result->get_error_message(); + // Strip control chars + truncate. Ability authors may put SQL fragments or + // path information into WP_Error messages; we keep enough to be useful but + // avoid forwarding a 10KB stack trace verbatim. + $clean = sanitize_text_field( $raw ); + if ( strlen( $clean ) > 240 ) { + $clean = substr( $clean, 0, 240 ) . '…'; + } + return $this->success( + $id, + array( + 'content' => array( + array( + 'type' => 'text', + 'text' => sprintf( 'Error: %s', $clean ), + ), + ), + 'isError' => true, + ) + ); + } + + $text = wp_json_encode( $result ); + if ( false === $text ) { + $text = ''; + } + + return $this->success( + $id, + array( + 'content' => array( + array( + 'type' => 'text', + 'text' => $text, + ), + ), + 'isError' => false, + ) + ); + } + + /** + * Build a JSON-RPC success envelope. + * + * @param mixed $id Request id. + * @param mixed $result Result payload. + * @return array + */ + private function success( $id, $result ): array { + return array( + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => $result, + ); + } + + /** + * Build a JSON-RPC error envelope. + * + * @param mixed $id Request id. + * @param int $code JSON-RPC error code. + * @param string $message Error message. + * @return array + */ + private function error( $id, int $code, string $message ): array { + return array( + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => array( + 'code' => $code, + 'message' => $message, + ), + ); + } +} diff --git a/includes/mcp/class-rest-endpoint.php b/includes/mcp/class-rest-endpoint.php new file mode 100644 index 0000000..3d712ba --- /dev/null +++ b/includes/mcp/class-rest-endpoint.php @@ -0,0 +1,147 @@ +is_endpoint_enabled() ) { + return; + } + register_rest_route( + self::REST_NAMESPACE, + self::REST_ROUTE, + array( + 'methods' => 'POST', + 'callback' => array( static::class, 'handle_request' ), + 'permission_callback' => array( static::class, 'check_permission' ), + ) + ); + } + + /** + * Permission callback — require an authenticated WordPress user. + * + * App-password Basic auth runs in `determine_current_user` and will have + * populated the current user by the time this fires. We deliberately do + * NOT require manage_options here — per-ability `permission_callback` + * runs inside JsonRpc_Server::handle_tools_call() and is responsible for + * per-tool authorization. + * + * @return bool + */ + public static function check_permission(): bool { + return is_user_logged_in(); + } + + /** + * Handle an MCP HTTP request. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public static function handle_request( WP_REST_Request $request ): WP_REST_Response { + // MCP responses are user-scoped tool lists and ability outputs — never safe to + // cache, even briefly. Belt-and-braces against misconfigured intermediaries. + nocache_headers(); + + $payload = $request->get_json_params(); + if ( null === $payload ) { + $body = $request->get_body(); + if ( '' !== $body ) { + $decoded = json_decode( $body, true ); + if ( is_array( $decoded ) ) { + $payload = $decoded; + } + } + } + + if ( null === $payload || ! is_array( $payload ) ) { + return new WP_REST_Response( + array( + 'jsonrpc' => '2.0', + 'id' => null, + 'error' => array( + 'code' => -32700, + 'message' => 'Parse error: request body must be a JSON object.', + ), + ), + 200 + ); + } + + $server = new JsonRpc_Server( self::registry() ); + $response = $server->handle( $payload ); + + // Notifications: JsonRpc_Server returns an empty array; respond 204 No Content. + if ( empty( $response ) ) { + return new WP_REST_Response( null, 204 ); + } + + return new WP_REST_Response( $response, 200 ); + } + + /** + * Build a fresh Ability_Registry for this request. + * + * Settings::get_instance() is the singleton already loaded by the main + * plugin file; we just wrap it. + * + * @return Ability_Registry + */ + private static function registry(): Ability_Registry { + return new Ability_Registry( Settings::get_instance() ); + } +} diff --git a/includes/mcp/class-settings-rest.php b/includes/mcp/class-settings-rest.php new file mode 100644 index 0000000..7427a18 --- /dev/null +++ b/includes/mcp/class-settings-rest.php @@ -0,0 +1,155 @@ + 'GET', + 'callback' => array( static::class, 'get_settings' ), + 'permission_callback' => array( static::class, 'check_permission' ), + ), + array( + 'methods' => 'POST', + 'callback' => array( static::class, 'save_settings' ), + 'permission_callback' => array( static::class, 'check_permission' ), + ), + ) + ); + } + + /** + * Permission callback: require manage_options. + * + * @return bool + */ + public static function check_permission(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * GET /mcp-settings — return current settings + third-party catalog. + * + * @return WP_REST_Response + */ + public static function get_settings(): WP_REST_Response { + $settings = Settings::get_instance(); + $registry = new Ability_Registry( $settings ); + + $third_party = array(); + foreach ( $registry->get_third_party_abilities() as $name => $ability ) { + $meta = $ability->get_meta() ?? array(); + $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) + ? $meta['annotations'] + : array(); + $third_party[] = array( + 'name' => $name, + 'label' => (string) $ability->get_label(), + 'description' => (string) $ability->get_description(), + 'sourcePlugin' => $registry->source_plugin( $name ), + 'readonly' => isset( $annotations['readonly'] ) && true === $annotations['readonly'], + ); + } + + return new WP_REST_Response( + array( + 'enabled' => (bool) $settings->get_field( 'agentic_admin_mcp_enabled', 0 ), + 'exposeOwn' => (bool) $settings->get_field( 'agentic_admin_mcp_expose_own', 1 ), + 'exposeThird' => (bool) $settings->get_field( 'agentic_admin_mcp_expose_third', 0 ), + 'allowlist' => (array) $settings->get_field( 'agentic_admin_mcp_allowlist', array() ), + 'endpointUrl' => esc_url_raw( rest_url( 'wp-agentic-admin/v1/mcp' ) ), + 'thirdParty' => $third_party, + ), + 200 + ); + } + + /** + * POST /mcp-settings — validate + save. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public static function save_settings( WP_REST_Request $request ): WP_REST_Response { + $body = $request->get_json_params(); + if ( ! is_array( $body ) ) { + $body = array(); + } + + $settings = Settings::get_instance(); + + if ( array_key_exists( 'enabled', $body ) ) { + $settings->update_field( 'agentic_admin_mcp_enabled', $body['enabled'], 'checkbox' ); + } + if ( array_key_exists( 'exposeOwn', $body ) ) { + $settings->update_field( 'agentic_admin_mcp_expose_own', $body['exposeOwn'], 'checkbox' ); + } + if ( array_key_exists( 'exposeThird', $body ) ) { + $settings->update_field( 'agentic_admin_mcp_expose_third', $body['exposeThird'], 'checkbox' ); + } + if ( array_key_exists( 'allowlist', $body ) ) { + $settings->update_field( 'agentic_admin_mcp_allowlist', $body['allowlist'], 'ability_list' ); + } + + $settings->save(); + + // Return the canonical state so the UI can resync. + return self::get_settings(); + } +} diff --git a/package-lock.json b/package-lock.json index 2647950..d2c4d84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wp-agentic-admin", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wp-agentic-admin", - "version": "0.11.0", + "version": "0.12.0", "license": "GPL-2.0-or-later", "dependencies": { "@huggingface/transformers": "^3.8.1", diff --git a/package.json b/package.json index 8b4582c..8edf146 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wp-agentic-admin", - "version": "0.11.0", + "version": "0.12.0", "description": "A privacy-first AI Site Reliability Engineer running entirely in the browser via WebLLM and the WordPress Abilities API.", "scripts": { "build": "npm run build:extensions", diff --git a/readme.txt b/readme.txt index 55d567d..c7836dd 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: ai, sre, site reliability, webllm, abilities api Requires at least: 6.9 Tested up to: 6.9 Requires PHP: 8.2 -Stable tag: 0.11.0 +Stable tag: 0.12.0 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -38,6 +38,13 @@ Agentic Admin for WordPress transforms your WordPress admin panel into an intell == Changelog == += 0.12.0 = +* New: MCP server endpoint at `/wp-json/wp-agentic-admin/v1/mcp`. External AI clients can call registered abilities via JSON-RPC 2.0 using a WordPress application password. Read-only in this release; write-capable abilities are filtered out. +* New: MCP Endpoint settings panel in the Settings tab. Master toggle defaults OFF — privacy-first guarantee remains intact out of the box. Independent toggles for exposing this plugin's own abilities and selected third-party Abilities API entries. +* New: third-party abilities are governed by an explicit allowlist UI grouped by source plugin. Only read-only abilities are selectable in this release. +* No dependency on Automattic's wordpress-mcp plugin. The two coexist on different routes if both are installed. +* See docs/MCP-ENDPOINT.md for the full curl walkthrough and security guidance. + = 0.11.0 = * Renamed: Plugin is now "Agentic Admin for WordPress". Text domain "agentic-admin", function prefix agentic_admin_*. WordPress.org submission-ready. * Removed: feedback system, WebMCP bridge, voice input, and three low-value abilities (backup-check, opcode-cache-status, disk-usage). Code preserved in git history; voice + write-file + content-generate + plugin-ecosystem abilities parked for v1.x as opt-in via WP_AGENTIC_ADMIN_ENABLE_LABS constant. diff --git a/src/extensions/components/McpEndpointSection.jsx b/src/extensions/components/McpEndpointSection.jsx new file mode 100644 index 0000000..cc61575 --- /dev/null +++ b/src/extensions/components/McpEndpointSection.jsx @@ -0,0 +1,347 @@ +/** + * MCP Endpoint Section + * + * Renders the MCP server settings inside the Settings tab. Talks to the + * /wp-agentic-admin/v1/mcp-settings REST route for state + save. + * + * Mounted from SettingsTab.jsx. + */ + +import { useState, useEffect, useCallback } from '@wordpress/element'; +import { + Button, + Card, + CardBody, + CardHeader, + CheckboxControl, + Notice, + ToggleControl, +} from '@wordpress/components'; + +const REST_PATH = '/wp-json/wp-agentic-admin/v1/mcp-settings'; + +function getNonce() { + return ( window.wpAgenticAdmin && window.wpAgenticAdmin.nonce ) || ''; +} + +async function fetchSettings() { + const res = await fetch( REST_PATH, { + credentials: 'same-origin', + headers: { 'X-WP-Nonce': getNonce() }, + } ); + if ( ! res.ok ) { + throw new Error( `Failed to load MCP settings (HTTP ${ res.status })` ); + } + return res.json(); +} + +async function saveSettings( payload ) { + const res = await fetch( REST_PATH, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': getNonce(), + }, + body: JSON.stringify( payload ), + } ); + if ( ! res.ok ) { + throw new Error( `Failed to save MCP settings (HTTP ${ res.status })` ); + } + return res.json(); +} + +function groupBySource( thirdParty ) { + const groups = {}; + for ( const ability of thirdParty ) { + const key = ability.sourcePlugin || 'Unknown'; + if ( ! groups[ key ] ) { + groups[ key ] = []; + } + groups[ key ].push( ability ); + } + return groups; +} + +const McpEndpointSection = () => { + const [ loading, setLoading ] = useState( true ); + const [ saving, setSaving ] = useState( false ); + const [ error, setError ] = useState( null ); + const [ saved, setSaved ] = useState( false ); + const [ copied, setCopied ] = useState( false ); + + const [ enabled, setEnabled ] = useState( false ); + const [ exposeOwn, setExposeOwn ] = useState( true ); + const [ exposeThird, setExposeThird ] = useState( false ); + const [ allowlist, setAllowlist ] = useState( [] ); + const [ thirdParty, setThirdParty ] = useState( [] ); + const [ endpointUrl, setEndpointUrl ] = useState( '' ); + + const load = useCallback( async () => { + setLoading( true ); + setError( null ); + try { + const data = await fetchSettings(); + setEnabled( !! data.enabled ); + setExposeOwn( !! data.exposeOwn ); + setExposeThird( !! data.exposeThird ); + setAllowlist( + Array.isArray( data.allowlist ) ? data.allowlist : [] + ); + setThirdParty( + Array.isArray( data.thirdParty ) ? data.thirdParty : [] + ); + setEndpointUrl( data.endpointUrl || '' ); + } catch ( e ) { + setError( e.message || 'Failed to load settings.' ); + } finally { + setLoading( false ); + } + }, [] ); + + useEffect( () => { + load(); + }, [ load ] ); + + const handleSave = async () => { + setSaving( true ); + setError( null ); + setSaved( false ); + try { + const data = await saveSettings( { + enabled, + exposeOwn, + exposeThird, + allowlist, + } ); + setEnabled( !! data.enabled ); + setExposeOwn( !! data.exposeOwn ); + setExposeThird( !! data.exposeThird ); + setAllowlist( + Array.isArray( data.allowlist ) ? data.allowlist : [] + ); + setEndpointUrl( data.endpointUrl || '' ); + setSaved( true ); + setTimeout( () => setSaved( false ), 3000 ); + } catch ( e ) { + setError( e.message || 'Failed to save settings.' ); + } finally { + setSaving( false ); + } + }; + + const toggleAllowlistEntry = ( abilityName, on ) => { + setAllowlist( ( prev ) => { + const next = new Set( prev ); + if ( on ) { + next.add( abilityName ); + } else { + next.delete( abilityName ); + } + return Array.from( next ); + } ); + }; + + const handleCopy = async () => { + if ( ! endpointUrl ) { + return; + } + try { + await navigator.clipboard.writeText( endpointUrl ); + setCopied( true ); + setTimeout( () => setCopied( false ), 2000 ); + } catch { + // Clipboard unavailable; ignore. + } + }; + + const groups = groupBySource( thirdParty ); + + return ( + + +

MCP Endpoint

+
+ +

+ Expose registered abilities to external AI clients via a + read-only MCP server. Authentication uses WordPress + application passwords. Disabled by default. +

+ + { error && ( + setError( null ) } + > + { error } + + ) } + + { saved && ( + setSaved( false ) } + > + Settings saved. + + ) } + + { loading &&

Loading…

} + + { ! loading && ( + <> + + + { enabled && ( + <> +
+ + { endpointUrl } + + +
+ + + + + + { exposeThird && ( +
+ + Allowed third-party abilities + +

+ Read-only abilities only in this + version. Non-readonly abilities are + listed but cannot be selected. +

+ + { thirdParty.length === 0 && ( +

+ No third-party abilities are + registered. +

+ ) } + + { Object.entries( groups ).map( + ( [ source, abilities ] ) => ( +
+
+ { source } +
+ { abilities.map( + ( ability ) => ( + + toggleAllowlistEntry( + ability.name, + on + ) + } + /> + ) + ) } +
+ ) + ) } +
+ ) } + + ) } + +
+ +
+ + ) } +
+
+ ); +}; + +export default McpEndpointSection; diff --git a/src/extensions/components/SettingsTab.jsx b/src/extensions/components/SettingsTab.jsx index 66310cf..f8119b0 100644 --- a/src/extensions/components/SettingsTab.jsx +++ b/src/extensions/components/SettingsTab.jsx @@ -12,6 +12,7 @@ import { Notice, ToggleControl, } from '@wordpress/components'; +import McpEndpointSection from './McpEndpointSection'; import modelLoader, { ModelLoader, MODEL_CONTEXT_SIZES, @@ -584,6 +585,8 @@ const SettingsTab = () => { /> + + ); }; diff --git a/wp-agentic-admin.php b/wp-agentic-admin.php index bf4ac69..afb8068 100644 --- a/wp-agentic-admin.php +++ b/wp-agentic-admin.php @@ -12,7 +12,7 @@ * Plugin Name: Agentic Admin for WordPress * Plugin URI: https://pluginslab.com/agentic-admin * Description: A privacy-first AI Site Reliability Engineer running entirely in the browser. Uses WebAssembly and WebGPU to execute Small Language Models locally, transforming wp-admin into a natural language command center via the WordPress Abilities API. - * Version: 0.11.0 + * Version: 0.12.0 * Author: Pluginslab * Author URI: https://pluginslab.com * License: GPL-2.0-or-later @@ -78,6 +78,10 @@ public function init() { require_once WP_AGENTIC_ADMIN_PLUGIN_DIR . 'includes/class-admin-bar.php'; require_once WP_AGENTIC_ADMIN_PLUGIN_DIR . 'includes/class-abilities.php'; require_once WP_AGENTIC_ADMIN_PLUGIN_DIR . 'includes/class-llm-proxy.php'; + require_once WP_AGENTIC_ADMIN_PLUGIN_DIR . 'includes/mcp/class-ability-registry.php'; + require_once WP_AGENTIC_ADMIN_PLUGIN_DIR . 'includes/mcp/class-jsonrpc-server.php'; + require_once WP_AGENTIC_ADMIN_PLUGIN_DIR . 'includes/mcp/class-rest-endpoint.php'; + require_once WP_AGENTIC_ADMIN_PLUGIN_DIR . 'includes/mcp/class-settings-rest.php'; // Initialize Utility Hooks (Cache Invalidation). if ( class_exists( '\\WPAgenticAdmin\\Utils' ) ) { @@ -108,6 +112,16 @@ public function init() { if ( class_exists( '\\WPAgenticAdmin\\LLM_Proxy' ) ) { \WPAgenticAdmin\LLM_Proxy::init(); } + + // Initialize MCP REST endpoint (gated by settings inside the class). + if ( class_exists( '\\WPAgenticAdmin\\MCP\\Rest_Endpoint' ) ) { + \WPAgenticAdmin\MCP\Rest_Endpoint::init(); + } + + // Initialize MCP settings REST routes (admin-only). + if ( class_exists( '\\WPAgenticAdmin\\MCP\\Settings_Rest' ) ) { + \WPAgenticAdmin\MCP\Settings_Rest::init(); + } } /** @@ -157,7 +171,7 @@ public function abilities_api_missing_notice(): void { * @return void */ private function define_constants(): void { - define( 'WP_AGENTIC_ADMIN_VERSION', '0.11.0' ); + define( 'WP_AGENTIC_ADMIN_VERSION', '0.12.0' ); define( 'WP_AGENTIC_ADMIN_FILE', __FILE__ ); define( 'WP_AGENTIC_ADMIN_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'WP_AGENTIC_ADMIN_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); @@ -167,7 +181,7 @@ private function define_constants(): void { * Activation hook */ public static function activate(): void { - update_option( 'agentic_admin_version', '0.11.0' ); + update_option( 'agentic_admin_version', '0.12.0' ); flush_rewrite_rules(); }