@wickedevolutions/abilities-mcp 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  All notable changes to Abilities MCP are documented here.
4
4
 
5
+ ## [1.6.2] - 2026-05-10
6
+
7
+ Schema validity polish + boot fragility MVP + multisite topology gate. Four Bridge fixes ship together as a bundled release closing the operator-side schema-400 + the silent-bridge-death-on-expired-refresh-token (both connect-time and request-time refresh boundaries) + the misplaced-multisite-block-cross-product issues.
8
+
9
+ ### Fixed
10
+
11
+ - **Sanitizer defensively normalizes broken `inputSchema` (Issue [#78](https://github.com/Wicked-Evolutions/abilities-mcp/issues/78)).** `sanitizeToolsList` in `lib/sanitizer.js` now coerces non-object `inputSchema` values (`[]` / `null` / `undefined` / primitives) to `{ type: 'object' }` before forwarding `tools/list` responses. The previous behavior detected non-object schemas via a warn-only log path but forwarded the broken schema unchanged, allowing the Anthropic API to reject the entire request with `400 tools.N.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12`. Companion source-of-truth fix in [abilities-for-fluent-plugins#41](https://github.com/Wicked-Evolutions/abilities-for-fluent-plugins/issues/41) (v1.1.3); shipping both as defense-in-depth — either fix alone closes the operator-side 400, the bridge-side normalization absorbs any future upstream registry bug regardless of source plugin. Valid object `inputSchema` values pass through byte-identical (regression-guarded with reference-equality assertion in `test/sanitizer.test.js`).
12
+
13
+ - **Per-site auth-init isolation prevents silent bridge death on expired refresh token — connect-time AND request-time boundaries (Issue [#76](https://github.com/Wicked-Evolutions/abilities-mcp/issues/76), Schema Validity Polish Sprint Phase B Wave 2 + Wave 2.5).** Pre-1.6.2, the bridge's `pool.connectDefault()` exited the entire process via `process.exit(1)` when the default site's refresh token was expired (or any other auth-init failure on the default site). MCP clients saw EOF on bridge stdout, the JSON-RPC SDK reported the `initialize` response missing required fields (`protocolVersion` / `capabilities` / `serverInfo`), and the runtime became unreachable with no actionable error message — operators had to recover manually via `abilities-mcp reauth <site>` or direct `wp-sites.json` edit. v1.6.2 fixes this at TWO refresh boundaries:
14
+ - **Connect-time boundary (Wave 2 / PR #81):** per-site try/catch isolation now lives at the auth-init boundary in `lib/connection-pool.js`; one site's expired refresh token (or any per-site connect failure) no longer propagates to bridge-wide exit. Healthy sites' tools remain fully usable. Degraded sites are surfaced through three channels: (i) `tools/list` annotations on the bridge-only `wp_bridge_health` tool, (ii) per-call errors when an operator attempts a tool against a degraded site, (iii) the dedicated `wp_bridge_health` tools/call shape for explicit per-site health querying. The `process.exit(1)` paths in `abilities-mcp.js` bootstrap now fire only on bridge-level bugs (e.g., schema migration failure), never on per-site auth state.
15
+ - **Request-time boundary (Wave 2.5 / PR #82):** when `transport.connect()` succeeds (transport object created without validating tokens) but the cached `initialize` request gets forwarded and triggers a lazy `refresh()` on first use that fails, the OAuth error was previously wrapped in `CallToolResult` shape (`content[]` + `isError: true`) per the OAuth-error-handling convention — invalid response shape for an `initialize` request, SDK rejects, bridge appears unreachable. `lib/router.js` `handleTransportMessage` now intercepts error responses whose `id` matches the cached `initialize` request, synthesizes a valid `InitializeResult` with all three required fields, and enters degraded mode (reusing the same `enterDegradedMode` plumbing introduced by Wave 2). Live-verified against the operator's actual reproduction shape; pre-fix control output is byte-equivalent to the operator's captured failure. Both connect-time AND request-time refresh failures now produce valid `InitializeResult` responses.
16
+ - Healthy-site-only boot path is byte-equivalent to pre-fix (regression-guarded with explicit "all-healthy still boots correctly" tests). Together these two fixes close the silent-bridge-death failure mode for the most common operator state — multi-site setup with one site's refresh token aged out, regardless of which OAuth-refresh boundary the failure surfaces at.
17
+
18
+ - **Multisite probe gates `add-site` block writes on detected-network-root (Issue [#77](https://github.com/Wicked-Evolutions/abilities-mcp/issues/77), Schema Validity Polish Sprint Phase B Wave 2 — gate side).** Pre-1.6.2, `add-site` against a subsite URL ran an unconditional post-OAuth `multisite/list-sites` probe and wrote a multisite block on the subsite's wp-sites.json entry (regardless of whether the URL was a network root or a subsite). For multi-subsite operators who ran `add-site` on each of N subsites independently, this produced N misplaced multisite blocks generating N×N dot-notation cross-products at the MCP tool surface (e.g., a 4-blog network seen via 4 subsite-rooted blocks produces 16 dot-notation site aliases instead of the correct 4 routes from the network root). `lib/cli/multisite-probe.js` now gates `buildMultisiteBlock()` write on detected-network-root verification; subsite URLs skip the block write and emit an operator message redirecting to the network-root URL. Future `add-site` invocations on subsite URLs no longer re-introduce the cross-product noise.
19
+
20
+ - **Boot-time auto-migration cleans existing misplaced multisite blocks (Issue [#77](https://github.com/Wicked-Evolutions/abilities-mcp/issues/77) — migrate side).** Operators upgrading from pre-1.6.2 with existing wrong wp-sites.json state get auto-recovery on next bridge boot — no manual `abilities-mcp reauth` or wp-sites.json edit required. `migrateMisplacedMultisiteBlocks` in `lib/config.js` runs as part of `loadConfig` on every bridge startup (idempotent — second boot finds nothing to migrate). The migration scans persisted multisite blocks and drops subsite-entry blocks **only** when the network root for that domain is also configured; if the network root is absent, the subsite-entry block is left intact (operators may need it for dot-notation routing until they add the network-root URL via `add-site`). Operators who had been seeing N×N dot-notation cross-products (e.g. 15 sites for a 4-subsite multisite + 2 standalone sites) see clean enumeration after upgrade (e.g. 6 sites total — 4 distinct subsites + 2 standalone). Tests cover three migration paths: subsite-skip-on-add, migration-with-network-root-present (drops the misplaced block), migration-with-network-root-absent (no-op preserves block). Live-verified against operator wp-sites.json on first invocation of v1.6.2 npm-linked bridge: three subsite-rooted multisite blocks dropped (wicked-community, wicked-test1, wicked-knowledge); operator-visible diagnostic output emitted; site enumeration dropped from 15 to 6.
21
+
22
+ ### Compatibility & operator notes
23
+
24
+ - **All four Bridge fixes ship together** as a single coordinated release per the Schema Validity Polish Sprint v2.2 marketing-launch coupling. Marketing-launch authorization (per the sprint plan) is gated on three smoke checks against an operator-approved test target: (i) Fluent-scoped reconnect produces no 400, (ii) bridge boots and serves valid InitializeResult when ≥1 configured site has expired refresh token (BOTH connect-time AND request-time refresh boundaries verified), (iii) multi-subsite multisite operator sees clean enumeration after upgrade.
25
+ - **No protocol semantics change** for any of the four fixes. Healthy operators on single-site or correctly-configured network-root multisite see no behavioral difference. The fixes activate only on the failure modes they close.
26
+ - **No operator action required** — schema-validity defense (#78), boot-fragility recovery at both refresh boundaries (#76), and multisite-block migration (#77) are all passive on next bridge restart.
27
+
28
+ ### Out of scope (deferred)
29
+
30
+ - Bridge Boot Fragility research draft surfaces other than #76 + #77 — auth lifecycle redesign (Surface 1 beyond per-site try/catch isolation + request-time refresh-error interception), multi-client coordination races (Surface 3), broader multisite probe architecture (Surface 4 fix-shape-#4), test coverage gap remediation (Surface 5), broader operator-state migrations (Surface 6 beyond multisite-block migration), README troubleshooting section (Surface 7) — deferred to a future sprint.
31
+
32
+ Closes [#76](https://github.com/Wicked-Evolutions/abilities-mcp/issues/76). Closes [#77](https://github.com/Wicked-Evolutions/abilities-mcp/issues/77). Closes [#78](https://github.com/Wicked-Evolutions/abilities-mcp/issues/78).
33
+
34
+ ## [1.6.1] - 2026-05-08
35
+
36
+ Documentation update — README rewrite for OAuth-from-`.mcpb` recommended path + post-v1.5.0/v1.6.0 surface coverage. Code unchanged from v1.6.0.
37
+
38
+ ### Documentation
39
+
40
+ - README rewritten to lead operators into the alpha-recommended OAuth path (Path 1: `.mcpb` install + `npm install -g @wickedevolutions/abilities-mcp` + `upgrade-auth` + `add-site` + `reauth --add-scope=`). OAuth was invisible in the v1.6.0 README despite being available since v1.5.0.
41
+ - New CLI Reference section covering all subcommands: `add-site`, `reauth` (with the `--add-scope=` / `--remove-scope=` / `--scope=` triad), `revoke`, `list-sites`, `test`, `upgrade-auth`, `force-downgrade`, `self-check`. Plus global flags + exit-code table.
42
+ - Multisite `wp-sites.json` example rewritten: dropped the stale `main` slug (which v1.6.0's [#70](https://github.com/Wicked-Evolutions/abilities-mcp/issues/70) explicitly removed from `add-site` generation), replaced with the dot-suffix routing model documentation.
43
+ - macOS keychain note rewritten: `/usr/bin/security` is now the default backend on darwin under the `auto` setting per v1.6.0's [#61](https://github.com/Wicked-Evolutions/abilities-mcp/issues/61). Removed instruction to opt into `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` (no longer needed).
44
+ - New Notes section (replaces Known Limitations) covering: four-layer permissions model with the runtime-error-as-teacher pattern, paired ability classes architecture (`content-list-structure` ↔ `content-list`; `content-get-text` ↔ `content-get`), multisite OAuth subsite execution path, and session lock contention as the only remaining concrete product constraint.
45
+ - Welcome section at top with verbatim *"Welcome, Wordpressnaut"* spaceship paragraph + 3 URL pointers (knowledge.wickedevolutions.com, wickedevolutions.com, abilitiesforai.io). Disclaimer block from J at the very top.
46
+ - Pointer to [PRINCIPLES.md](PRINCIPLES.md) as the *Official WordPress Compatibility Contract* binding all four suite repos.
47
+ - Architecture section updated to reflect both OAuth 2.1 + Application Password as transport options (was Application Password only).
48
+ - Existing bottom *Disclaimer* section retired (replaced by J's disclaimer at the top).
49
+
50
+ Closes [#74](https://github.com/Wicked-Evolutions/abilities-mcp/issues/74).
51
+
5
52
  ## [1.6.0] - 2026-05-07
6
53
 
7
54
  ### Fixed
package/README.md CHANGED
@@ -1,18 +1,33 @@
1
1
  # Abilities MCP
2
2
 
3
- > One MCP to Rule Your WordPress World.
3
+ > **A word from J, the director of this creation.**
4
+ >
5
+ > Everything you see here is built by a single human who does not read or write code and is written by AI. Everything is in constant motion and by observing that movement we create the illusion of being still. Change happens at any given moment. It is simply a law of evolution. Stillness is an act of conscious awareness, not a reality of life.
4
6
 
5
- Open-source MCP bridge that connects any AI client to your WordPress sites through the [WordPress Abilities API](https://developer.wordpress.org/reference/functions/wp_register_ability/). Single STDIO server, multi-site routing, zero dependencies.
7
+ ## Welcome, Wordpressnaut
8
+
9
+ Here is the spaceship, now you'll have to learn how to fly and please do remember, humans make mistakes, humans created AI so AI makes mistakes. Learning to fly is your job and to do that you'll need structure, systems, checklists, principles and understanding you stand before a magical leap of a steep and wonderful learning curve. Be patient and do backup things.
10
+
11
+ → Knowledge layer (deeper traversal): [https://knowledge.wickedevolutions.com](https://knowledge.wickedevolutions.com)
12
+ → [https://wickedevolutions.com](https://wickedevolutions.com)
13
+ → [https://abilitiesforai.io](https://abilitiesforai.io)
14
+
15
+ Our development aim is the *Official WordPress Compatibility Contract* — see [PRINCIPLES.md](PRINCIPLES.md) for the full binding principles across the four-repo suite.
16
+
17
+ ---
18
+
19
+ Open-source MCP bridge that connects any AI client to your WordPress sites through the [WordPress Abilities API](https://developer.wordpress.org/reference/functions/wp_register_ability/). Single STDIO server, multi-site routing, OAuth 2.1 or Application Password authentication, zero npm dependencies.
6
20
 
7
21
  ## Features
8
22
 
9
23
  - **Multi-site routing** — Single MCP server serves all your WordPress sites
24
+ - **OAuth 2.1** — Dynamic Client Registration, PKCE, browser-loopback consent, keychain-backed tokens, automatic refresh, scope expansion
25
+ - **Application Password** — Application Passwords with MCP session management (the legacy alpha-supported path; OAuth is recommended)
10
26
  - **Site parameter injection** — LLM sees a `site` enum on every tool, defaults to your primary site
11
27
  - **Lazy connections** — Sites connect on first use, not at startup
12
- - **HTTP transport** — Application Passwords with MCP session management
13
- - **WordPress multisite** — Subdomain/subdirectory multisites via dot notation (`site.blog`)
28
+ - **WordPress multisite** — Subdomain-style multisite with cross-site routing through the dot-suffix model
14
29
  - **Auto-reconnect** — Exponential backoff, healthcheck pings, session recovery
15
- - **Zero dependencies** — Node.js built-in modules only
30
+ - **Zero npm dependencies** — Node.js built-in modules only
16
31
 
17
32
  ## What You Can Do
18
33
 
@@ -23,17 +38,17 @@ The abilities available to your AI agent depend on which ability plugins you ins
23
38
  **Infrastructure** — filesystem, meta, REST discovery, knowledge layer
24
39
  **Third-party integrations** — auto-detected modules for supported plugins (Astra, Spectra, SureCart, Presto Player, and more)
25
40
 
26
- **[Abilities for Fluent Plugins](https://github.com/Wicked-Evolutions/abilities-for-fluent-plugins)** is our continuously-enhanced third-party translator — bringing AI control to FluentCRM, FluentCommunity, FluentForms, FluentBooking, FluentSupport, FluentBoards, FluentSMTP, FluentAuth, FluentSnippets, FluentMessaging, FluentCart, and FluentAffiliate. We build and maintain it because we use Fluent's plugins ourselves and wanted them AI-native.
41
+ **[Abilities for Fluent Plugins](https://github.com/Wicked-Evolutions/abilities-for-fluent-plugins)** is our continuously-enhanced first-party translator — bringing AI control to FluentCRM, FluentCommunity, FluentForms, FluentBooking, FluentSupport, FluentBoards, FluentSMTP, FluentAuth, FluentSnippets, FluentMessaging, FluentCart, and FluentAffiliate. We build and maintain it because we use Fluent's plugins ourselves and wanted them AI-native.
27
42
 
28
43
  Beyond Fluent, the bridge is plugin-agnostic by design. Any plugin that registers abilities through the WordPress Abilities API becomes available automatically — no configuration in this bridge required. We urge every WordPress plugin developer to prioritize native Abilities API support over anything else.
29
44
 
30
- Every ability enforces `current_user_can()` at execution time — your WordPress role is the security boundary.
45
+ Every ability enforces `current_user_can()` at execution time — your WordPress role is the security boundary, with OAuth scopes and the [Abilities for AI](https://community.wickedevolutions.com/item/abilities-for-ai/) per-module permission gate as additional layers above it (see [Notes — Four-layer permissions model](#four-layer-permissions-model)).
31
46
 
32
47
  > **Sign up for the Abilities for AI alpha release:** https://community.wickedevolutions.com/item/abilities-for-ai/
33
48
 
34
49
  ## Install
35
50
 
36
- There are three install paths. Pick the one that matches how you use AI clients.
51
+ There are three install paths. Pick the one that matches how you use AI clients. **Path 1 (`.mcpb` for Claude Desktop, with the OAuth upgrade) is the recommended operator entry for the alpha.**
37
52
 
38
53
  ### Set up WordPress (required for all paths)
39
54
 
@@ -55,42 +70,75 @@ Go to **Users → Edit (your mcp-agent user) → Application Passwords**, enter
55
70
  | Role | Access | Use case |
56
71
  |------|--------|----------|
57
72
  | **Administrator** | All modules — content, plugins, themes, settings, users, cache, cron, filesystem, and more | Full site management |
58
- | **Editor** | Content, Blocks, Taxonomies, Patterns, Meta, Media | Content publishing workflows — safe for teams where AI should write but not configure |
73
+ | **Editor** | Content, Blocks, Taxonomies, Patterns, Meta, Media | Content publishing workflows — safe for teams where AI writes but does not configure |
59
74
 
60
- > **Tip:** Start with Editor. Upgrade to Administrator when you need infrastructure abilities like plugin management, theme switching, or settings changes.
75
+ > **Tip:** Start with Editor. Move to Administrator when you need infrastructure abilities like plugin management, theme switching, or settings changes.
61
76
 
62
77
  #### Required plugins
63
78
 
64
79
  Install both on your WordPress site:
65
80
 
66
81
  1. **[Abilities for AI](https://community.wickedevolutions.com/item/abilities-for-ai/)** — registers WordPress abilities across content, site management, infrastructure, and third-party integration modules
67
- 2. **[Abilities MCP Adapter](https://community.wickedevolutions.com/item/abilities-mcp-adapter/)** — exposes abilities as MCP tools via REST API
82
+ 2. **[Abilities MCP Adapter](https://community.wickedevolutions.com/item/abilities-mcp-adapter/)** — exposes abilities as MCP tools via REST API and runs the OAuth 2.1 resource server + authorization server
68
83
 
69
84
  Both are available as free downloads from our store, or install from GitHub: [abilities-for-ai](https://github.com/Wicked-Evolutions/abilities-for-ai) and [abilities-mcp-adapter](https://github.com/Wicked-Evolutions/abilities-mcp-adapter).
70
85
 
71
86
  ---
72
87
 
73
- ### Path 1 — `.mcpb` bundle for Claude Desktop (recommended)
88
+ ### Path 1 — `.mcpb` bundle for Claude Desktop with OAuth upgrade (recommended)
74
89
 
75
- Single-click install for Claude Desktop on macOS and Windows. The Application Password is stored encrypted in your OS keychain (macOS Keychain / Windows Credential Manager).
90
+ The full alpha-recommended operator path: install the `.mcpb` for single-click Claude Desktop integration, then upgrade in place to OAuth so tokens live in your OS keychain (macOS Keychain / Windows Credential Manager) and refresh automatically.
91
+
92
+ #### Step 1 — install the `.mcpb` (Application Password baseline)
76
93
 
77
94
  1. Download `abilities-mcp.mcpb` from the [latest GitHub Release](https://github.com/Wicked-Evolutions/abilities-mcp/releases/latest).
78
95
  2. Double-click the file. Claude Desktop opens an "Install Extension" dialog.
79
96
  3. Type three things:
80
97
  - **WordPress Site URL** — `https://example.com`
81
98
  - **WordPress Username** — `mcp-agent`
82
- - **Application Password** — paste the password from the previous step
83
- 4. Click **Install**. The connection is live.
99
+ - **Application Password** — paste the password from the WordPress setup step
100
+ 4. Click **Install**. The connection is live with an Application Password.
101
+
102
+ The bundle covers the single-site case. To unlock OAuth, multi-site, and the `add-site` / `reauth` / `revoke` / `list-sites` / `test` operator flows, continue to Step 2.
103
+
104
+ #### Step 2 — install the CLI globally for OAuth and multi-site flows
105
+
106
+ ```bash
107
+ npm install -g @wickedevolutions/abilities-mcp
108
+ ```
109
+
110
+ This puts the `abilities-mcp` command on your PATH so you can run the OAuth subcommands from a terminal.
111
+
112
+ #### Step 3 — upgrade your Claude Desktop site to OAuth in place
84
113
 
85
- The bundle covers the single-site case. For multi-site (one bridge connected to several WordPress sites at once), use Path 3.
114
+ ```bash
115
+ abilities-mcp upgrade-auth <site>
116
+ ```
86
117
 
87
- **macOS + Claude Desktop keychain note.** Claude Desktop's `.mcpb` runtime may use Apple's `/usr/bin/security` keychain path when macOS rejects native keytar loading. If you set up OAuth from Terminal and then Claude Desktop repeatedly asks for Keychain access, rerun the terminal setup with the same backend Claude Desktop uses:
118
+ This runs the OAuth 2.1 authorization-code flow with PKCE in your default browser, mints fresh tokens, writes them to your OS keychain, and updates `~/.abilities-mcp/wp-sites.json` so the existing Claude Desktop "Abilities MCP" entry now uses OAuth. The Application Password fallback stays in place until you confirm with `--confirm` in a follow-up `upgrade-auth` run.
119
+
120
+ #### Step 4 — add additional sites (OAuth by default)
88
121
 
89
122
  ```bash
90
- ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli abilities-mcp add-site --force https://example.com
123
+ abilities-mcp add-site https://second-site.com
124
+ abilities-mcp add-site https://third-site.com --label="My Staging Site"
91
125
  ```
92
126
 
93
- This is macOS-only and opt-in. It does not loosen Keychain permissions; it just writes tokens through the same Keychain doorway Claude Desktop reads through.
127
+ Each added site provisions OAuth via Dynamic Client Registration, runs the consent flow in your browser, and persists tokens to the keychain. New sites surface through the same Claude Desktop "Abilities MCP" entry — no Claude Desktop config edit needed.
128
+
129
+ For App Password sites instead of OAuth (legacy path), pass `--apppassword --username=<user> --password=<pw>`.
130
+
131
+ #### Step 5 — expand scopes when broader powers are needed
132
+
133
+ Default OAuth grants are baseline-scoped — they cover the common read/write categories (`abilities:read`, `abilities:write`, `abilities:multisite:read`, `abilities:multisite:write`). For delete-tier operations, sensitive WordPress core categories (`users`, `settings`, `filesystem`, `plugins`, `cron`, `themes`, `rewrite`), suite scopes (`astra`, `spectra`, `surecart`, `surecart-ecommerce`, `presto-player`), or Fluent suite scopes, extend explicitly:
134
+
135
+ ```bash
136
+ abilities-mcp reauth <site> --add-scope="abilities:users:delete abilities:settings:write"
137
+ ```
138
+
139
+ The `--add-scope=` flag merges the new scopes into the existing set (deduped, order-preserved). Use `--remove-scope=` to drop scopes by exact match, or `--scope=` to replace the entire set (warns if dropping any). The three flags are mutually exclusive.
140
+
141
+ **macOS keychain note (since v1.6.0).** On macOS, the bridge uses `/usr/bin/security` as its keychain backend by default under the `auto` backend setting. Every bridge spawn — Claude Desktop, Claude Code, Codex, terminal CLI — issues keychain syscalls through the same caller binary, so macOS's per-binary ACL trusted-application list contains exactly one entry. After your first "Always Allow" the entry reads silently from every runtime. If you upgraded from v1.5.x and Claude Desktop repeatedly asks for keychain access, click **Always Allow** once at the prompt OR run `abilities-mcp add-site --force <site>` / `abilities-mcp reauth <site>` to write a fresh entry under the unified backend.
94
142
 
95
143
  ---
96
144
 
@@ -119,7 +167,7 @@ In your client's MCP config:
119
167
  }
120
168
  ```
121
169
 
122
- The endpoint is auto-derived as `<URL>/wp-json/mcp/mcp-adapter-default-server`. Single-site only — for multi-site, use Path 3.
170
+ The endpoint is auto-derived as `<URL>/wp-json/mcp/mcp-adapter-default-server`. Single-site, Application Password only — for OAuth or multi-site, use Path 1 or Path 3.
123
171
 
124
172
  For `claude mcp add` users:
125
173
 
@@ -135,7 +183,7 @@ claude mcp add wordpress \
135
183
 
136
184
  ### Path 3 — `wp-sites.json` (multi-site, power users)
137
185
 
138
- Use this when you connect one bridge to multiple WordPress sites, when you want passwords sourced from a keychain or shell command, or when you're targeting WordPress multisite networks via dot-notation routing.
186
+ Use this when you want passwords sourced from a keychain or shell command without going through the OAuth provisioning flow, when you're connecting one bridge to multiple WordPress sites with hand-curated config, or when you're targeting WordPress multisite networks with explicit per-subsite entries.
139
187
 
140
188
  ```bash
141
189
  cp wp-sites.example.json wp-sites.json
@@ -169,6 +217,37 @@ For Claude Desktop, you can also auto-register:
169
217
  node abilities-mcp.js --register
170
218
  ```
171
219
 
220
+ ## CLI Reference
221
+
222
+ Once `@wickedevolutions/abilities-mcp` is installed globally (or you run `node abilities-mcp.js <subcommand>` from a clone), the following subcommands are available:
223
+
224
+ | Subcommand | What it does | Common flags |
225
+ |------------|--------------|--------------|
226
+ | `add-site <url>` | Register a new site (OAuth by default) | `--apppassword` `--username=` `--password=` `--scope=` `--site-id=` `--label=` `--force` |
227
+ | `reauth <site_id>` | Re-run the OAuth flow for an existing site | `--add-scope=` (recommended) · `--remove-scope=` · `--scope=` (mutually exclusive) |
228
+ | `revoke <site_id>` | Revoke OAuth tokens (local + remote) | — |
229
+ | `list-sites` | Show configured sites + auth status | — |
230
+ | `test <site_id>` | Ping the adapter and report scopes | — |
231
+ | `upgrade-auth <site_id>` | Migrate an existing Application Password site to OAuth in place | `--confirm` (Step 4: drop the App Password fallback after OAuth is verified) |
232
+ | `force-downgrade <site_id>` | Override OAuth pinning (escape hatch for capability-pin failure) | `--i-understand-the-risk` (required) · `--reason="<text>"` |
233
+ | `self-check <site_id>` | Probe Authorization-header survival end-to-end | — |
234
+
235
+ Bare `abilities-mcp` (no subcommand) starts the MCP STDIO server — the mode every MCP client config invokes.
236
+
237
+ ### Global flags
238
+
239
+ | Flag | Description |
240
+ |------|-------------|
241
+ | `--config=<path>` | Path to `wp-sites.json` |
242
+ | `--debug` | Include cause stack on errors; debug logging to `/tmp/abilities-mcp.log` |
243
+ | `--allow-insecure` | Allow plain HTTP (localhost dev only) |
244
+ | `--register` | Register in Claude Desktop config |
245
+ | `--name=<name>` | Server name for `--register` (default: `wordpress`) |
246
+
247
+ ### Exit codes
248
+
249
+ `0` success · `1` unexpected error · `2` usage error · `3` config error · `4` auth failure · `5` capability-pinning violation
250
+
172
251
  ## Configuration
173
252
 
174
253
  ### `wp-sites.json`
@@ -191,6 +270,8 @@ node abilities-mcp.js --register
191
270
  }
192
271
  ```
193
272
 
273
+ OAuth-managed sites added through `abilities-mcp add-site` are written to `~/.abilities-mcp/wp-sites.json` automatically — they carry an `auth.method: "oauth"` block with keychain references rather than inline secrets.
274
+
194
275
  ### Config search order
195
276
 
196
277
  1. `--config=/path/to/wp-sites.json` (explicit)
@@ -203,7 +284,9 @@ The first one found wins. If a `wp-sites.json` exists, env vars are ignored.
203
284
 
204
285
  ### WordPress Multisite
205
286
 
206
- For WordPress multisites, add a `multisite` object mapping subsite keys to their URLs:
287
+ For WordPress multisite networks, OAuth multi-site is provisioned through `abilities-mcp add-site --site-id=<subsite-id> https://<subsite-host>` for each subsite you want to operate on. Each subsite provisions its own OAuth token bound to that subsite's resource. Use the explicit subsite IDs in your MCP client.
288
+
289
+ For App Password multisite (Path 3 hand-curated config), add a `multisite` object mapping subsite slugs to their URLs:
207
290
 
208
291
  ```json
209
292
  {
@@ -216,15 +299,15 @@ For WordPress multisites, add a `multisite` object mapping subsite keys to their
216
299
  "passwordCommand": "security find-generic-password -a mcp-agent -s example.com -w"
217
300
  },
218
301
  "multisite": {
219
- "main": "https://example.com/",
220
302
  "blog": "https://blog.example.com/",
221
- "shop": "https://shop.example.com/"
303
+ "shop": "https://shop.example.com/",
304
+ "example": "https://example.com/"
222
305
  }
223
306
  }
224
307
  }
225
308
  ```
226
309
 
227
- Use dot notation to target subsites: `"site": "network.blog"`
310
+ Use the dot-suffix routing pattern to target subsites: `"site": "network.blog"`. The bare bridge name (`"site": "network"`) routes to the bridge's own root; the dot suffix routes cross-site through the same adapter. The recommended cross-site context naming uses the network root's domain label (e.g., `wickedevolutions` for `wickedevolutions.com`) — `abilities-mcp add-site` against a Multisite Network root populates this automatically per the dot-suffix model.
228
311
 
229
312
  ### Secure password storage
230
313
 
@@ -294,7 +377,7 @@ export WP_MCP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"
294
377
  WP_MCP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
295
378
  ```
296
379
 
297
- The bridge reads `process.env.WP_MCP_PASSWORD` at connection time. If the variable is not set, it throws an error immediately.
380
+ The bridge reads `process.env.WP_MCP_PASSWORD` at connection time. If the variable is not set, it surfaces an error immediately.
298
381
 
299
382
  #### `password` (not recommended)
300
383
 
@@ -338,25 +421,15 @@ When multiple sites are configured, every tool gets an optional `site` parameter
338
421
  }
339
422
  ```
340
423
 
341
- Omit `site` to use the default site.
342
-
343
- ## CLI Options
344
-
345
- | Flag | Description |
346
- |------|-------------|
347
- | `--config=<path>` | Path to wp-sites.json |
348
- | `--server=<name>` | MCP adapter server name |
349
- | `--debug` | Enable debug logging to `/tmp/abilities-mcp.log` |
350
- | `--register` | Register in Claude Desktop config |
351
- | `--name=<name>` | Server name for `--register` (default: `wordpress`) |
424
+ Omit `site` to use the default site. For multisite networks, the `site` enum surfaces both the bare bridge name and the dot-suffix cross-site contexts (e.g., `network`, `network.blog`, `network.shop`).
352
425
 
353
426
  ## Architecture
354
427
 
355
428
  ```mermaid
356
429
  graph TD
357
430
  Client[AI Client<br/>Claude Code · Gemini CLI · Cursor · any MCP client] -->|STDIO| Bridge[Abilities MCP]
358
- Bridge -->|HTTP POST| SiteA[Site A]
359
- Bridge -->|HTTP POST| SiteB[Site B]
431
+ Bridge -->|OAuth 2.1 / HTTP POST| SiteA[Site A]
432
+ Bridge -->|App Password / HTTP POST| SiteB[Site B]
360
433
  Bridge -->|SSH + WP-CLI| SiteC[Site C]
361
434
 
362
435
  subgraph "Each WordPress Site"
@@ -366,35 +439,56 @@ graph TD
366
439
  ```
367
440
 
368
441
  - One STDIO process handles all sites through a unified connection pool
369
- - **HTTP transport** — Application Passwords with MCP session management, batch coalescing, auto-reconnect
442
+ - **OAuth 2.1 transport** — Bearer tokens with automatic refresh, keychain-backed persistence, scope expansion via `reauth --add-scope=`
443
+ - **Application Password transport** — HTTP Basic with MCP session management, batch coalescing, auto-reconnect
370
444
  - **SSH transport** — WP-CLI over SSH tunnel, healthcheck pings, handshake replay
371
445
  - Lazy connections — non-default sites connect on first tool call
372
446
  - Tool list comes from the default site with `site` enum injected
373
447
  - Permission metadata (`permission`, `enabled`) flows through annotations to the LLM
374
448
  - Error responses include `input_schema` for AI self-correction
375
449
 
376
- See [docs/architecture.md](docs/architecture.md) for the full technical deep dive — transport comparison tables, session management, multi-site routing internals, and security model.
450
+ See [docs/architecture.md](docs/architecture.md) for the full technical deep dive — transport comparison tables, session management, multi-site routing internals, OAuth state machine, and security model.
451
+
452
+ ## Notes
453
+
454
+ ### Four-layer permissions model
455
+
456
+ When an ability is denied, the rejection comes from one of four independent layers. The runtime error names the layer:
457
+
458
+ 1. **Abilities for AI module permission** — per-blog read/write/delete toggle in *WP Admin → Abilities for AI → Permissions*. The runtime returns `[ability_disabled]` with the module name and where to fix it. (Fix in *Abilities for AI → Permissions*.)
459
+ 2. **WordPress capability** — the WordPress user the bridge authenticates as lacks the relevant capability. WordPress core REST returns `rest_forbidden` / `rest_cannot_*` codes. (Fix by granting the cap to the user, or use a higher-privilege user.)
460
+ 3. **OAuth scope** — the bridge's OAuth token does not include the scope the ability requires. The adapter returns an `insufficient_scope` rejection. (Fix with `abilities-mcp reauth <site> --add-scope="<scope>"`.)
461
+ 4. **Unclear** — generic 500, timeout, or malformed response. Check server logs.
462
+
463
+ The four gates apply together by design (see [PRINCIPLES.md](PRINCIPLES.md), Principle 5 — *Permissions Stay Layered*). The runtime error tells you which gate fired so you can act at the right layer.
464
+
465
+ ### Paired ability classes
466
+
467
+ The product ships compact-vs-full pairs across the API by design. Pick the pair member that matches the traversal you intend:
377
468
 
378
- ## Known Limitations
469
+ - `content-list-structure` (id/title/slug/status/date/link, ~0.5KB/post) ↔ `content-list` (full block markup, ~50–200KB/post). Use `content-list-structure` for bulk discovery; use `content-list` for targeted full inspection.
470
+ - `content-get-text` (plain text stripped, ~2–20KB) ↔ `content-get` (full block markup, ~50–200KB). Use `content-get-text` when you want the readable content; use `content-get` when you need the block structure.
379
471
 
380
- - **Session lock contention** ([#4](https://github.com/Wicked-Evolutions/abilities-mcp/issues/4)) Concurrent bridge instances targeting the same site can cause session loss. Use a single bridge process per site.
472
+ Each ability description names its payload tradeoff. The pattern recurs across other categories — read the description before reaching for the heavy member when a compact member is available.
473
+
474
+ ### Multisite OAuth subsite execution
475
+
476
+ For WordPress multisite networks under OAuth, add each subsite you want to operate on as a separate site entry: `abilities-mcp add-site --site-id=<subsite-id> https://<subsite-host>`. Each subsite provisions its own OAuth token bound to that subsite's resource by the adapter's `mcp_resource` check. Use the explicit subsite IDs in your MCP client. Tracked on [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60) for further iteration.
477
+
478
+ ### Session lock contention ([#4](https://github.com/Wicked-Evolutions/abilities-mcp/issues/4))
479
+
480
+ Concurrent bridge instances targeting the same site can cause session loss. Use a single bridge process per site.
381
481
 
382
482
  ## Requirements
383
483
 
384
484
  - Node.js >= 18
385
485
  - WordPress 6.9+ with [Abilities for AI](https://community.wickedevolutions.com/item/abilities-for-ai/) and [Abilities MCP Adapter](https://github.com/Wicked-Evolutions/abilities-mcp-adapter) installed
386
- - Application Passwords enabled (default in WordPress 5.6+)
486
+ - Application Passwords enabled (default in WordPress 5.6+) for the App Password path; OAuth path needs no additional setup beyond the adapter
387
487
 
388
488
  ## Evolving Knowledge
389
489
 
390
490
  We continuously add knowledge docs, skills, and agent patterns to [knowledge.wickedevolutions.com](https://knowledge.wickedevolutions.com).
391
491
 
392
- ## Disclaimer
393
-
394
- Humans make mistakes — as we know from the present day and history. Humans trained AI. AI acts accordingly. AI predicts probability based on the context window it holds. It is trained to sound certain, as if everything is truth, and to "fix" everything so the human becomes satisfied.
395
-
396
- Learn how to communicate with AI. You are fully responsible for using AI in your life, business, and projects. Using these products is your personal responsibility to learn and own.
397
-
398
492
  ## License
399
493
 
400
494
  GPL-2.0-or-later
package/abilities-mcp.js CHANGED
@@ -212,15 +212,42 @@ if (!isSubcommandInvocation) {
212
212
  // Startup — connect to default site
213
213
  // -----------------------------------------------------------------------
214
214
 
215
+ // Issue #76: per-site auth-init isolation. `pool.connectDefault` tries the
216
+ // configured default first and falls back to other configured sites on a
217
+ // per-site failure (typically RefreshError when refresh tokens expire).
218
+ // Returns the connected transport, or null when ALL sites failed — in
219
+ // that case the bridge enters degraded mode (router synthesizes a valid
220
+ // InitializeResult locally, returns bridge tools only on tools/list, and
221
+ // surfaces per-call errors on non-bridge tools/call) instead of dying
222
+ // with the malformed-InitializeResult / EOF symptom that motivated #76.
215
223
  try {
216
224
  const transport = await pool.connectDefault((parsedMsg, rawLine) => {
217
225
  router.handleTransportMessage(parsedMsg, rawLine);
218
226
  });
219
- router.setDefaultTransport(transport);
220
- log(`Default transport connected: ${config.defaultSite}`);
227
+ if (transport) {
228
+ router.setDefaultTransport(transport);
229
+ log(`Default transport connected: ${config.defaultSite}`);
230
+ } else {
231
+ const degradedSites = Object.entries(config.sites).map(([siteId, site]) => ({
232
+ siteId,
233
+ reason: (site && site._degraded_reason) || 'connect failed',
234
+ }));
235
+ process.stderr.write(
236
+ `abilities-mcp: all configured sites failed to connect at boot — ` +
237
+ `entering degraded mode. Operators can call wp_bridge_health to see ` +
238
+ `per-site status; reauth a site to recover.\n`
239
+ );
240
+ for (const ds of degradedSites) {
241
+ process.stderr.write(` - ${ds.siteId}: ${ds.reason}\n`);
242
+ }
243
+ router.enterDegradedMode(degradedSites);
244
+ }
221
245
  router.drainEarlyQueue();
222
246
  } catch (err) {
223
- process.stderr.write(`abilities-mcp: Failed to connect to default site: ${err.message}\n`);
247
+ // Reached only on non-per-site errors (bug in connectDefault itself,
248
+ // or a thrown synchronous error during bootstrap). Per-site failures
249
+ // are handled inside connectDefault and never surface here.
250
+ process.stderr.write(`abilities-mcp: bootstrap failed unexpectedly: ${err.message}\n`);
224
251
  process.exit(1);
225
252
  }
226
253
 
@@ -222,6 +222,23 @@ async function run(args, ctx) {
222
222
  config.sites[siteId].multisite = probeResult.block;
223
223
  const slugs = Object.keys(probeResult.block).join(', ');
224
224
  out.push(` Multisite: discovered ${Object.keys(probeResult.block).length} subsite(s) → ${slugs}`);
225
+ } else if (probeResult && probeResult.reason === 'subsite-not-root') {
226
+ // Issue #77: the operator's URL is a subsite of a multisite network,
227
+ // not the network root. Writing a multisite block from the subsite's
228
+ // perspective produces parallel cross-product blocks at the MCP tool
229
+ // surface — so we skip the block write and redirect the operator to
230
+ // the network-root URL where dot-notation routing belongs.
231
+ const rootUrl = probeResult.networkRootUrl;
232
+ const redirect = rootUrl
233
+ ? `Run: abilities-mcp add-site ${rootUrl}`
234
+ : 'Re-run add-site with the network-root URL.';
235
+ errLines.push(
236
+ `Multisite block not written for "${siteId}": this URL is a subsite, not the ` +
237
+ `network root${rootUrl ? ` (${rootUrl})` : ''}. Dot-notation routing belongs on ` +
238
+ `the network-root entry; subsite-rooted blocks describe the network from one ` +
239
+ `subsite's perspective and surface N×(N-1) cross-products at the MCP tool ` +
240
+ `surface. Site entry written without multisite block. ${redirect}`
241
+ );
225
242
  }
226
243
  } catch (probeErr) {
227
244
  _appendProbeAdvisory(probeErr, siteId, errLines);
@@ -34,9 +34,14 @@ const PROBE_PAGE_CAP = 50;
34
34
  /**
35
35
  * @typedef {object} ProbeResult
36
36
  * @property {object|null} block Multisite block, or null when no block
37
- * should be written (single-site / empty).
37
+ * should be written (single-site / empty /
38
+ * subsite-not-root).
38
39
  * @property {string} reason One of: 'multisite-root', 'single-site',
39
- * 'tool-not-registered', 'empty-list'.
40
+ * 'tool-not-registered', 'empty-list',
41
+ * 'subsite-not-root'.
42
+ * @property {string} [networkRootUrl] Set when reason === 'subsite-not-root'
43
+ * so callers can emit an operator message
44
+ * redirecting to the network-root URL.
40
45
  */
41
46
 
42
47
  /**
@@ -144,6 +149,21 @@ async function probeMultisite(opts) {
144
149
  return { block: null, reason: 'single-site' };
145
150
  }
146
151
 
152
+ // Issue #77: gate block write on detected-network-root.
153
+ // Without this gate, OAuth super-admin invocations from a subsite URL get
154
+ // the full network's site list back from multisite/list-sites and write a
155
+ // subsite-rooted block that describes the network from the subsite's
156
+ // perspective — surfacing N×(N-1) dot-notation cross-products at the MCP
157
+ // tool surface (one parallel block per subsite entry).
158
+ const rootCheck = detectNetworkRoot(siteUrl, items);
159
+ if (!rootCheck.isRoot) {
160
+ return {
161
+ block: null,
162
+ reason: 'subsite-not-root',
163
+ networkRootUrl: rootCheck.networkRootUrl,
164
+ };
165
+ }
166
+
147
167
  const block = buildMultisiteBlock(siteUrl, items);
148
168
  if (!block || Object.keys(block).length === 0) {
149
169
  return { block: null, reason: 'empty-list' };
@@ -151,6 +171,55 @@ async function probeMultisite(opts) {
151
171
  return { block, reason: 'multisite-root' };
152
172
  }
153
173
 
174
+ /**
175
+ * Detect whether `parentSiteUrl` points at the network root of the multisite
176
+ * list returned by `multisite/list-sites`. Pure function — no I/O.
177
+ *
178
+ * Primary signal: the canonical WordPress network-root marker `blog_id === 1`.
179
+ * When that item exists, parent IS the root iff its URL origin matches.
180
+ *
181
+ * Fallback (no blog_id metadata): parent IS the root iff any item's URL
182
+ * origin matches `parentSiteUrl`. This is conservative — it accepts the
183
+ * pre-#77 behavior whenever we can't positively identify a non-root
184
+ * invocation, so an exotic adapter that omits `blog_id` doesn't lose probe
185
+ * functionality. The new gate fires only when we have positive evidence
186
+ * (blog_id===1 present and pointing elsewhere, or no URL match at all).
187
+ *
188
+ * @param {string} parentSiteUrl
189
+ * @param {Array<object>} items
190
+ * @returns {{ isRoot: boolean, networkRootUrl: string|null }}
191
+ */
192
+ function detectNetworkRoot(parentSiteUrl, items) {
193
+ let parentOrigin;
194
+ try { parentOrigin = new URL(parentSiteUrl).origin.toLowerCase(); }
195
+ catch { return { isRoot: false, networkRootUrl: null }; }
196
+
197
+ const rootItem = (items || []).find((it) => it && it.blog_id === 1);
198
+ if (rootItem && typeof rootItem.url === 'string' && rootItem.url.length > 0) {
199
+ let rootOrigin;
200
+ try { rootOrigin = new URL(rootItem.url).origin.toLowerCase(); }
201
+ catch { return { isRoot: false, networkRootUrl: rootItem.url }; }
202
+ return {
203
+ isRoot: rootOrigin === parentOrigin,
204
+ networkRootUrl: rootItem.url,
205
+ };
206
+ }
207
+
208
+ // No blog_id metadata — fall back to URL match. Accept the parent as root
209
+ // when any item's URL origin matches; otherwise this is a subsite invocation
210
+ // whose root is unknown to us (return isRoot: false, networkRootUrl: null).
211
+ for (const it of (items || [])) {
212
+ if (!it || typeof it.url !== 'string' || it.url.length === 0) continue;
213
+ let itOrigin;
214
+ try { itOrigin = new URL(it.url).origin.toLowerCase(); }
215
+ catch { continue; }
216
+ if (itOrigin === parentOrigin) {
217
+ return { isRoot: true, networkRootUrl: it.url };
218
+ }
219
+ }
220
+ return { isRoot: false, networkRootUrl: null };
221
+ }
222
+
154
223
  /**
155
224
  * Build the multisite block (slug → subsite URL) from a `multisite/list-sites`
156
225
  * response. Pure function — no I/O, no logging. Exported so `add-site.js`
@@ -485,6 +554,7 @@ class InjectedClient {
485
554
  module.exports = {
486
555
  probeMultisite,
487
556
  buildMultisiteBlock,
557
+ detectNetworkRoot,
488
558
  deriveSubsiteSlug,
489
559
  parseToolResponse,
490
560
  // Exported for direct testing of the per-session HMAC echo contract
package/lib/config.js CHANGED
@@ -228,6 +228,16 @@ async function loadConfigFile(filePath, source = 'explicit-config') {
228
228
  await validateSiteConfig(key, site);
229
229
  }
230
230
 
231
+ // Issue #77: bridge-boot migration — drop subsite-rooted multisite blocks
232
+ // when the network-root URL is also configured. Operator-visible advisory
233
+ // is emitted to stderr so the change is auditable; the file on disk is left
234
+ // alone so operators can choose to remove or re-add the network-root entry
235
+ // without losing the original subsite block. See migrateMisplacedMultisiteBlocks.
236
+ const migrationMessages = migrateMisplacedMultisiteBlocks(config);
237
+ for (const msg of migrationMessages) {
238
+ process.stderr.write(`abilities-mcp: ${msg}\n`);
239
+ }
240
+
231
241
  config._isMultiSite = Object.keys(config.sites).length > 1 ||
232
242
  Object.values(config.sites).some(s => s.multisite);
233
243
  config._configPath = filePath;
@@ -237,6 +247,88 @@ async function loadConfigFile(filePath, source = 'explicit-config') {
237
247
  return config;
238
248
  }
239
249
 
250
+ /**
251
+ * Issue #77 migration. Scan persisted multisite blocks; drop those that were
252
+ * written from a subsite's perspective when the network-root URL is also a
253
+ * configured site. Pure function — mutates `config.sites[*].multisite` in
254
+ * place and returns a list of operator-visible advisory messages. The on-disk
255
+ * file is NOT rewritten; the migration runs every boot (idempotent — second
256
+ * boot finds nothing to drop) so operators who later remove the network-root
257
+ * entry get the original subsite-rooted block back as fallback routing.
258
+ *
259
+ * Detection: a multisite block is "subsite-rooted" iff it contains an entry
260
+ * whose hostname is a parent of the owning site's hostname (e.g. site URL
261
+ * `https://community.example.com` with a block entry pointing at
262
+ * `https://example.com`). This matches the failure shape pinned in #77 —
263
+ * `buildMultisiteBlock` called from a subsite invocation maps the network's
264
+ * other sites (including the network root) into the block.
265
+ *
266
+ * Drop condition: any OTHER configured site has `url` whose origin matches
267
+ * the candidate network-root entry. Without that, the operator still needs
268
+ * the misplaced block for dot-notation routing until they add the
269
+ * network-root URL — leaving it intact preserves utility on the upgrade path.
270
+ *
271
+ * @param {object} config Parsed wp-sites.json — must have `config.sites`.
272
+ * @returns {string[]} Advisory messages, one per dropped block.
273
+ */
274
+ function migrateMisplacedMultisiteBlocks(config) {
275
+ const messages = [];
276
+ if (!config || !config.sites || typeof config.sites !== 'object') return messages;
277
+
278
+ const siteOrigins = new Map();
279
+ for (const [key, site] of Object.entries(config.sites)) {
280
+ if (!site || typeof site.url !== 'string') continue;
281
+ let origin;
282
+ try { origin = new URL(site.url).origin.toLowerCase(); }
283
+ catch { continue; }
284
+ siteOrigins.set(origin, key);
285
+ }
286
+
287
+ for (const [siteKey, site] of Object.entries(config.sites)) {
288
+ if (!site || !site.multisite || typeof site.multisite !== 'object') continue;
289
+ if (typeof site.url !== 'string') continue;
290
+
291
+ let siteHost;
292
+ try { siteHost = new URL(site.url).hostname.toLowerCase().replace(/^www\./, ''); }
293
+ catch { continue; }
294
+
295
+ // Find a block entry whose hostname is a parent domain of this site's
296
+ // hostname AND maps to a different origin from the site itself. That's
297
+ // the network-root candidate.
298
+ let networkRootUrl = null;
299
+ let networkRootOrigin = null;
300
+ for (const subsiteUrl of Object.values(site.multisite)) {
301
+ if (typeof subsiteUrl !== 'string' || subsiteUrl.length === 0) continue;
302
+ let entryHost, entryOrigin;
303
+ try {
304
+ const u = new URL(subsiteUrl);
305
+ entryHost = u.hostname.toLowerCase().replace(/^www\./, '');
306
+ entryOrigin = u.origin.toLowerCase();
307
+ } catch { continue; }
308
+ if (entryHost === siteHost) continue;
309
+ if (siteHost.endsWith('.' + entryHost)) {
310
+ networkRootUrl = subsiteUrl;
311
+ networkRootOrigin = entryOrigin;
312
+ break;
313
+ }
314
+ }
315
+ if (!networkRootUrl) continue;
316
+
317
+ const rootSiteKey = siteOrigins.get(networkRootOrigin);
318
+ if (!rootSiteKey || rootSiteKey === siteKey) continue;
319
+
320
+ delete site.multisite;
321
+ messages.push(
322
+ `Migration (#77): dropped subsite-rooted multisite block from "${siteKey}" ` +
323
+ `(${site.url}); the network-root entry "${rootSiteKey}" (${networkRootUrl}) ` +
324
+ `is also configured, so dot-notation routing belongs there. Subsite-rooted ` +
325
+ `blocks describe the network from one subsite's perspective and surface ` +
326
+ `parallel cross-product enumerations at the MCP tool surface.`
327
+ );
328
+ }
329
+ return messages;
330
+ }
331
+
240
332
  async function validateSiteConfig(key, site) {
241
333
  // v2 OAuth sites carry no transport block (Appendix F.5 + add-site flow).
242
334
  // The runtime treats them as HTTP — endpoint comes from auth.mcp_resource
@@ -477,4 +569,5 @@ module.exports = {
477
569
  buildSiteKeyEnum,
478
570
  buildEnvConfig,
479
571
  validateSiteConfig,
572
+ migrateMisplacedMultisiteBlocks,
480
573
  };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { SshTransport } = require('./transports/ssh-transport');
4
4
  const { resolveSiteKey, resolveSitePassword } = require('./config');
5
+ const { AUTH_STATUS } = require('./auth/events');
5
6
 
6
7
  // Incrementing counter for synthetic handshake IDs.
7
8
  // Avoids integer overflow from Date.now() (13-digit ms timestamps exceed
@@ -138,14 +139,69 @@ class ConnectionPool {
138
139
  /**
139
140
  * Create and connect transport for the default site (no handshake replay).
140
141
  * Called once at startup — the client handles the handshake directly.
142
+ *
143
+ * Issue #76: per-site auth-init isolation. The configured `defaultSite` is
144
+ * tried first; if its `_createTransport` / `transport.connect()` throws
145
+ * (typically a `RefreshError` when the refresh token is expired — confirmed
146
+ * via static trace from `lib/auth/token-manager.js:147-152`), the failure
147
+ * is captured against the site's in-memory `auth_status` and the next
148
+ * configured site is tried in `Object.keys(config.sites)` order. The first
149
+ * site that connects becomes the runtime default (`config.defaultSite` is
150
+ * reassigned in-memory only — wp-sites.json on disk is untouched).
151
+ *
152
+ * Returns `null` when ALL configured sites fail. The bootstrap caller pairs
153
+ * a `null` return with `router.enterDegradedMode(...)` so the bridge still
154
+ * answers `initialize` with a valid `InitializeResult` — the failure mode
155
+ * the gate explicitly forbids (init-time bridge death) cannot recur.
156
+ *
157
+ * @param {function} onMessage Per-site message router callback.
158
+ * @returns {Promise<Transport|null>}
141
159
  */
142
160
  async connectDefault(onMessage) {
143
- const key = this.config.defaultSite;
144
- const transport = await this._createTransport(key, null);
145
- transport.onMessage = onMessage;
146
- await transport.connect();
147
- this.transports.set(key, transport);
148
- return transport;
161
+ const configuredDefault = this.config.defaultSite;
162
+ const otherKeys = Object.keys(this.config.sites).filter((k) => k !== configuredDefault);
163
+ const tryOrder = [configuredDefault, ...otherKeys];
164
+
165
+ for (const key of tryOrder) {
166
+ try {
167
+ const transport = await this._createTransport(key, null);
168
+ transport.onMessage = onMessage;
169
+ await transport.connect();
170
+ this.transports.set(key, transport);
171
+
172
+ if (key !== configuredDefault) {
173
+ this.log(
174
+ `Configured default "${configuredDefault}" failed to connect; ` +
175
+ `runtime default fell back to "${key}". The configured default ` +
176
+ `is marked degraded in-memory; reauth it to restore.`
177
+ );
178
+ this.config.defaultSite = key;
179
+ }
180
+ return transport;
181
+ } catch (err) {
182
+ this.log(`Site "${key}" failed to connect at boot: ${err.message}`);
183
+ this._markSiteDegraded(key, err);
184
+ }
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Mark a site degraded in the in-memory config so other lookups (tools/list,
192
+ * resources/read wp-abilities://sites, per-call routing) can surface it
193
+ * without re-attempting the failed connection. The on-disk wp-sites.json is
194
+ * NOT rewritten here — degraded state is recoverable (operator runs reauth)
195
+ * and the next bridge boot will re-attempt the connection from the existing
196
+ * persisted state.
197
+ *
198
+ * @private
199
+ */
200
+ _markSiteDegraded(siteId, err) {
201
+ const site = this.config.sites && this.config.sites[siteId];
202
+ if (!site) return;
203
+ site.auth_status = AUTH_STATUS.EXPIRED;
204
+ site._degraded_reason = (err && err.message) || 'connect failed';
149
205
  }
150
206
 
151
207
  /**
package/lib/router.js CHANGED
@@ -54,6 +54,15 @@ class McpRouter {
54
54
  // Early queue for messages before transport is ready
55
55
  this.MAX_EARLY_QUEUE = 50;
56
56
  this.earlyQueue = [];
57
+
58
+ // Issue #76: degraded mode — entered when ALL configured sites fail to
59
+ // connect at boot. The bridge still answers `initialize` with a locally
60
+ // synthesized InitializeResult (so the MCP runtime stays connectable and
61
+ // the operator can call wp_bridge_health to see which sites are degraded);
62
+ // tools/list returns the bridge's three local tools only; non-bridge
63
+ // tools/call surfaces a per-call error naming the degraded sites.
64
+ this.degraded = false;
65
+ this.degradedSites = []; // [{ siteId, reason }]
57
66
  }
58
67
 
59
68
  /**
@@ -63,6 +72,22 @@ class McpRouter {
63
72
  this.defaultTransport = transport;
64
73
  }
65
74
 
75
+ /**
76
+ * Issue #76: enter degraded mode when no configured site connected at boot.
77
+ * The router will synthesize an InitializeResult on the next `initialize`
78
+ * request and refuse non-bridge tool calls with a descriptive error.
79
+ *
80
+ * @param {Array<{siteId:string, reason:string}>} degradedSites
81
+ */
82
+ enterDegradedMode(degradedSites) {
83
+ this.degraded = true;
84
+ this.degradedSites = Array.isArray(degradedSites) ? degradedSites.slice() : [];
85
+ this.log(
86
+ `Router entering degraded mode: ${this.degradedSites.length} site(s) failed to ` +
87
+ `connect at boot — ` + this.degradedSites.map((s) => `${s.siteId} (${s.reason})`).join('; ')
88
+ );
89
+ }
90
+
66
91
  /**
67
92
  * Drain messages queued before the default transport was ready.
68
93
  */
@@ -90,6 +115,14 @@ class McpRouter {
90
115
  if (msg.params && msg.params.protocolVersion) {
91
116
  this.clientProtocolVersion = msg.params.protocolVersion;
92
117
  }
118
+ // Issue #76: in degraded mode (no site transport at boot), synthesize
119
+ // a locally-valid InitializeResult so the MCP runtime stays connectable
120
+ // and the client can still issue wp_bridge_health / etc. against the
121
+ // bridge's local tools.
122
+ if (this.degraded) {
123
+ this._sendSynthesizedInitializeResult(msg);
124
+ return;
125
+ }
93
126
  this._forwardToDefault(line);
94
127
  return;
95
128
  }
@@ -99,12 +132,22 @@ class McpRouter {
99
132
  this.cachedInitNotification = msg;
100
133
  this.initHandshakeComplete = true;
101
134
  this.pool.setHandshakeCache(this.cachedInitRequest, this.cachedInitNotification, this.clientProtocolVersion);
135
+ // In degraded mode there is no transport to forward to; the notification
136
+ // is purely informational once the synthesized InitializeResult has been
137
+ // sent.
138
+ if (this.degraded) return;
102
139
  this._forwardToDefault(line);
103
140
  return;
104
141
  }
105
142
 
106
143
  // tools/list — route to default, then inject site param
107
144
  if (msg.method === 'tools/list') {
145
+ // Issue #76: in degraded mode, return only the bridge's local tools so
146
+ // operators can call wp_bridge_health and see which sites are degraded.
147
+ if (this.degraded) {
148
+ this._sendSynthesizedToolsListResult(msg);
149
+ return;
150
+ }
108
151
  this._forwardToDefault(line);
109
152
  return;
110
153
  }
@@ -115,6 +158,13 @@ class McpRouter {
115
158
  this._handleBridgeToolCall(msg);
116
159
  return;
117
160
  }
161
+ // Issue #76: in degraded mode there is no backing site transport; surface
162
+ // a per-call error naming the degraded sites so the client sees a clear
163
+ // diagnostic, not a hang.
164
+ if (this.degraded) {
165
+ this._sendDegradedToolsCallError(msg);
166
+ return;
167
+ }
118
168
  this._handleToolsCall(msg);
119
169
  return;
120
170
  }
@@ -153,6 +203,37 @@ class McpRouter {
153
203
  return;
154
204
  }
155
205
 
206
+ // Issue #76 follow-up — request-time boundary.
207
+ //
208
+ // OAuthHttpTransport.connect() does NOT pre-validate tokens (lib/transports/
209
+ // oauth-http-transport.js:139-143 — sets ready=true, returns). When the
210
+ // configured default site has an expired refresh token, _createTransport
211
+ // and connect() both succeed; the bridge does NOT enter the connect-time
212
+ // degraded path covered by #81. Instead, drainEarlyQueue() forwards the
213
+ // cached `initialize` request through the transport, _processMessage
214
+ // calls _postWithRetry → getAccessToken → refresh → throws RefreshError
215
+ // synchronously when authStatus===EXPIRED (lib/auth/token-manager.js:147).
216
+ // _processMessage catches the throw at lib/transports/oauth-http-transport.js:
217
+ // 379-388 and emits onMessage with `{jsonrpc, id, error}` — the cached
218
+ // initialize id paired with `OAuth HTTP bridge error: …`. Without this
219
+ // intercept, the error→CallToolResult conversion below blanket-converts
220
+ // it to `{result:{content:[],isError:true}}` and the MCP runtime rejects
221
+ // the response shape. Pin the gate-violating shape here: when the failed
222
+ // response carries the cached initialize id, synthesize a valid
223
+ // InitializeResult locally and enter degraded mode for the failed site.
224
+ if (parsedMsg.error && parsedMsg.id !== undefined &&
225
+ this.cachedInitRequest && parsedMsg.id === this.cachedInitRequest.id) {
226
+ const failedSite = this.config && this.config.defaultSite;
227
+ const reason = (parsedMsg.error && parsedMsg.error.message) || 'unknown';
228
+ this.log(
229
+ `Initialize forward failed for "${failedSite || '(unknown)'}": ${reason} — ` +
230
+ `synthesizing degraded-mode InitializeResult to satisfy MCP runtime`
231
+ );
232
+ this._sendSynthesizedInitializeResult(this.cachedInitRequest);
233
+ this.enterDegradedMode([{ siteId: failedSite || '(unknown)', reason }]);
234
+ return;
235
+ }
236
+
156
237
  // Sanitize tools/list responses
157
238
  if (isToolsListResponse(parsedMsg)) {
158
239
  sanitizeToolsList(parsedMsg, this.log);
@@ -234,22 +315,112 @@ class McpRouter {
234
315
  _forwardToDefault(line) {
235
316
  if (this.defaultTransport) {
236
317
  this.defaultTransport.send(line);
237
- } else {
238
- if (this.earlyQueue.length >= this.MAX_EARLY_QUEUE) {
239
- this.log('Early queue full — rejecting message');
240
- try {
241
- const msg = JSON.parse(line);
242
- if (msg.id !== undefined) {
243
- this.sendToClient(JSON.stringify({
244
- jsonrpc: '2.0', id: msg.id,
245
- error: { code: -32603, message: 'Server not ready — queue full' }
246
- }));
247
- }
248
- } catch (e) { /* non-JSON, drop */ }
249
- return;
250
- }
251
- this.earlyQueue.push(line);
318
+ return;
252
319
  }
320
+ // Issue #76: in degraded mode any message that reached this point (i.e.
321
+ // not handled by a degraded-aware branch above) gets a per-call error
322
+ // rather than being queued forever waiting for a transport that will
323
+ // never arrive.
324
+ if (this.degraded) {
325
+ try {
326
+ const msg = JSON.parse(line);
327
+ if (msg.id !== undefined) {
328
+ this.sendToClient(JSON.stringify({
329
+ jsonrpc: '2.0', id: msg.id,
330
+ error: {
331
+ code: -32603,
332
+ message: `Bridge running in degraded mode — no site transport available. ` +
333
+ this._degradedSummary(),
334
+ },
335
+ }));
336
+ }
337
+ } catch (e) { /* non-JSON, drop */ }
338
+ return;
339
+ }
340
+ if (this.earlyQueue.length >= this.MAX_EARLY_QUEUE) {
341
+ this.log('Early queue full — rejecting message');
342
+ try {
343
+ const msg = JSON.parse(line);
344
+ if (msg.id !== undefined) {
345
+ this.sendToClient(JSON.stringify({
346
+ jsonrpc: '2.0', id: msg.id,
347
+ error: { code: -32603, message: 'Server not ready — queue full' }
348
+ }));
349
+ }
350
+ } catch (e) { /* non-JSON, drop */ }
351
+ return;
352
+ }
353
+ this.earlyQueue.push(line);
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Internal — degraded mode (Issue #76)
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /**
361
+ * Synthesize a valid MCP InitializeResult so the client's MCP validator
362
+ * receives `protocolVersion`, `capabilities`, and `serverInfo` instead of
363
+ * EOF (the failure mode pinned in #76 — bridge died before any response).
364
+ *
365
+ * Echoes the client's `protocolVersion` when present (per MCP spec — the
366
+ * server returns the negotiated version, defaulting to its own when no
367
+ * client-side version was provided).
368
+ */
369
+ _sendSynthesizedInitializeResult(msg) {
370
+ const clientProtocol = msg.params && msg.params.protocolVersion;
371
+ const result = {
372
+ jsonrpc: '2.0',
373
+ id: msg.id,
374
+ result: {
375
+ protocolVersion: clientProtocol || '2025-06-18',
376
+ capabilities: { tools: {}, resources: {} },
377
+ serverInfo: {
378
+ name: 'abilities-mcp (degraded)',
379
+ version: this._bridgeVersion(),
380
+ },
381
+ },
382
+ };
383
+ this.sendToClient(JSON.stringify(result));
384
+ }
385
+
386
+ /**
387
+ * Synthesize a tools/list response with only the bridge's local tools.
388
+ * In degraded mode there is no WordPress adapter to answer the real list,
389
+ * so wp_bridge_health / wp_browse_tools / wp_load_tools are still callable
390
+ * for diagnostics.
391
+ */
392
+ _sendSynthesizedToolsListResult(msg) {
393
+ const result = { jsonrpc: '2.0', id: msg.id, result: { tools: [] } };
394
+ injectBridgeTools(result);
395
+ this.sendToClient(JSON.stringify(result));
396
+ }
397
+
398
+ _sendDegradedToolsCallError(msg) {
399
+ this.sendToClient(JSON.stringify({
400
+ jsonrpc: '2.0', id: msg.id,
401
+ error: {
402
+ code: -32603,
403
+ message: `Tool call cannot be served — bridge is running in degraded mode. ` +
404
+ this._degradedSummary() +
405
+ ` Run: abilities-mcp reauth <site> to recover, or use the bridge tools ` +
406
+ `(wp_bridge_health, wp_browse_tools, wp_load_tools) for diagnostics.`,
407
+ },
408
+ }));
409
+ }
410
+
411
+ _degradedSummary() {
412
+ if (!this.degradedSites || this.degradedSites.length === 0) {
413
+ return 'No degraded-site details available.';
414
+ }
415
+ const parts = this.degradedSites.map((s) => `${s.siteId}: ${s.reason}`);
416
+ return `Degraded sites — ${parts.join('; ')}.`;
417
+ }
418
+
419
+ _bridgeVersion() {
420
+ try {
421
+ const pkg = require('../package.json');
422
+ return (pkg && pkg.version) || 'unknown';
423
+ } catch { return 'unknown'; }
253
424
  }
254
425
 
255
426
  // ---------------------------------------------------------------------------
package/lib/sanitizer.js CHANGED
@@ -65,8 +65,14 @@ function sanitizeToolsList(msg, log) {
65
65
  delete tool.type;
66
66
  delete tool.outputSchema;
67
67
 
68
- // Validate schema and log warnings
69
- if (tool.inputSchema) {
68
+ // Normalize broken or non-object inputSchema (defensive — broken upstream
69
+ // schemas would otherwise 400 the API and break the entire tool list).
70
+ if (!tool.inputSchema || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema)) {
71
+ if (tool.inputSchema !== undefined) {
72
+ _log(`SCHEMA NORMALIZE [${tool.name || '(unnamed)'}]: inputSchema not a valid object — defaulted to {type:'object'}`);
73
+ }
74
+ tool.inputSchema = { type: 'object' };
75
+ } else {
70
76
  validateToolSchema(tool.name || '(unnamed)', tool.inputSchema, _log);
71
77
  }
72
78
 
package/package.json CHANGED
@@ -1,13 +1,29 @@
1
1
  {
2
2
  "name": "@wickedevolutions/abilities-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Open-source MCP bridge connecting AI clients to WordPress through the Abilities API — multi-site routing, zero dependencies",
5
5
  "main": "abilities-mcp.js",
6
6
  "bin": {
7
7
  "abilities-mcp": "./abilities-mcp.js"
8
8
  },
9
- "files": ["abilities-mcp.js", "lib/", "wp-sites.example.json", "LICENSE", "README.md", "CHANGELOG.md"],
10
- "keywords": ["mcp", "wordpress", "bridge", "abilities", "ai", "open-source", "multi-site", "model-context-protocol"],
9
+ "files": [
10
+ "abilities-mcp.js",
11
+ "lib/",
12
+ "wp-sites.example.json",
13
+ "LICENSE",
14
+ "README.md",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "keywords": [
18
+ "mcp",
19
+ "wordpress",
20
+ "bridge",
21
+ "abilities",
22
+ "ai",
23
+ "open-source",
24
+ "multi-site",
25
+ "model-context-protocol"
26
+ ],
11
27
  "license": "GPL-2.0-or-later",
12
28
  "author": "Wicked Evolutions",
13
29
  "repository": {