@wickedevolutions/abilities-mcp 1.6.1 → 1.6.3
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 +43 -0
- package/abilities-mcp.js +30 -3
- package/lib/bridge-tools.js +6 -3
- package/lib/cli/commands/add-site.js +17 -0
- package/lib/cli/multisite-probe.js +72 -2
- package/lib/config.js +93 -0
- package/lib/connection-pool.js +62 -6
- package/lib/router.js +220 -19
- package/lib/sanitizer.js +21 -3
- package/lib/tool-catalog.js +20 -10
- package/package.json +19 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Abilities MCP are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.6.3] - 2026-05-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Sanitizer normalizes array-shaped `inputSchema.properties` (Issue [#83](https://github.com/Wicked-Evolutions/abilities-mcp/issues/83), Sprint C 2026-05-11).** Extends `validateToolSchema` in `lib/sanitizer.js` so that when a tool's `inputSchema.properties` is an array (PHP `'properties' => array()` JSON-encodes to `[]`), `null`, a string, or any other non-plain-object value, it is normalized to `{}` before client emission. The previous v1.6.2 fix (#78/#79) normalized broken top-level `inputSchema` but left malformed nested `properties` untouched — the value passed `typeof === 'object'` (arrays are objects in JS) and reached the `Object.entries()` loop unchanged. Anthropic's draft 2020-12 validator rejects the entire `tools/list` payload on the first invalid schema (`/properties must be object`), so a single vendor-registered `properties: []` shape breaks the whole catalog. Defends against the entire class of vendor-registered ability schema slips that share the `properties: []` shape; SureCart's `surecart/get-store-info` was the trigger case (live capture from helenawillow.com 2026-05-11, 1 of 789 tools failing) but the fix is name-agnostic. Boundary discipline binding: `inputSchema: { type: "object" }` shapes that omit the `properties` key entirely remain byte-unchanged (early-return on `schema.properties === undefined` before the malformed-shape normalize). Already-valid object-shaped `properties` pass through byte-identical (regression-guarded with reference-equality assertion in `test/sanitizer.test.js`).
|
|
10
|
+
|
|
11
|
+
- **Bridge orientation: `mcp-adapter-*` reliably visible as the cross-site discovery path when filtering is enabled; `wp_browse_tools` / `wp_load_tools` describe themselves honestly (Issue [#85](https://github.com/Wicked-Evolutions/abilities-mcp/issues/85), Sprint C-follow-up 2026-05-12).** Surfaced during v1.6.3 release validation: AI clients were orienting around the bridge-local meta-tools first, treating their default-site catalog as the full ability surface and missing abilities registered only on non-default sites (e.g., `surecart/get-store-info` on helenawillow when `defaultSite=wickedevolutions`). The cross-site execution path via `mcp-adapter-discover-abilities` / `mcp-adapter-get-ability-info` / `mcp-adapter-execute-ability` already worked correctly — it just wasn't the path clients reached for. Three minimal changes restore orientation without changing architecture: (1) `lib/tool-catalog.js` — when `toolFilter.enabled === true` and the operator has not set their own `alwaysIncludeCategories`, default to `['mcp-adapter']` so the five `mcp-adapter-*` tools are always callable without a prior `wp_load_tools` step. Explicit operator override (including `[]`) is honored — guarded against the `[] || ['mcp-adapter']` truthy-array pitfall with an explicit `undefined`/`null` check. (2) `lib/bridge-tools.js` — `wp_browse_tools` and `wp_load_tools` descriptions now explicitly state they operate on the direct-tool catalog scoped to the default site, not full cross-site ability discovery. (3) `lib/router.js` — `wp_browse_tools` response leads with a `Scope:` line; `wp_load_tools` computes which requested categories are missing from the catalog and returns a structured pointer ("Not in the direct-tool catalog (scoped to defaultSite `<site>`): `<category>`. To call abilities in this category on another site, use `mcp-adapter-discover-abilities` with `{site, category}` then `mcp-adapter-execute-ability` with `{site, ability_name, parameters}`") instead of the prior silent "No changes — categories may already be active" zero-activation. No union-catalog architecture introduced; no `defaultSite`-switching workaround required; adapter unchanged. Acceptance pin: with `defaultSite = wickedevolutions` and `toolFilter.enabled = true`, a fresh session can discover and execute `surecart/get-store-info` on `helenawillow` through the adapter meta-tool path without changing `defaultSite` (empirically verified, MD5-pinned behavior-reversal cycle, 415/415 tests passing).
|
|
12
|
+
|
|
13
|
+
### Compatibility & operator notes
|
|
14
|
+
|
|
15
|
+
- **No protocol semantics change** for valid schemas. Healthy operators see no behavioral difference. The fix activates only on the malformed-`properties` shape it closes.
|
|
16
|
+
- **No operator action required** — passive defensive normalization on next bridge restart.
|
|
17
|
+
- **Coordinated release wave** — held for joint release with abilities-mcp-adapter v1.4.8 per the cross-sprint coupling decision.
|
|
18
|
+
|
|
19
|
+
## [1.6.2] - 2026-05-10
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **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`).
|
|
26
|
+
|
|
27
|
+
- **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:
|
|
28
|
+
- **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.
|
|
29
|
+
- **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.
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
- **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.
|
|
35
|
+
|
|
36
|
+
### Compatibility & operator notes
|
|
37
|
+
|
|
38
|
+
- **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.
|
|
39
|
+
- **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.
|
|
40
|
+
- **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.
|
|
41
|
+
|
|
42
|
+
### Out of scope (deferred)
|
|
43
|
+
|
|
44
|
+
- 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.
|
|
45
|
+
|
|
46
|
+
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).
|
|
47
|
+
|
|
5
48
|
## [1.6.1] - 2026-05-08
|
|
6
49
|
|
|
7
50
|
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.
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/bridge-tools.js
CHANGED
|
@@ -23,7 +23,8 @@ const BRIDGE_TOOLS = [
|
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
name: 'wp_browse_tools',
|
|
26
|
-
description:
|
|
26
|
+
description:
|
|
27
|
+
'List categories in the bridge\'s direct-tool catalog (scoped to defaultSite) with tool counts and load-state. This is the local-catalog control surface — not full cross-site ability discovery. For abilities on another site, or in a category not listed here, use mcp-adapter-discover-abilities / mcp-adapter-execute-ability with { site }.',
|
|
27
28
|
inputSchema: {
|
|
28
29
|
type: 'object',
|
|
29
30
|
properties: {},
|
|
@@ -31,14 +32,16 @@ const BRIDGE_TOOLS = [
|
|
|
31
32
|
},
|
|
32
33
|
{
|
|
33
34
|
name: 'wp_load_tools',
|
|
34
|
-
description:
|
|
35
|
+
description:
|
|
36
|
+
'Activate categories in the bridge\'s direct-tool catalog (scoped to defaultSite) to expose their tools in tools/list. This is the local-catalog control surface — not full cross-site ability discovery. If a requested category is not in the local catalog, the response points to mcp-adapter-discover-abilities + mcp-adapter-execute-ability for the cross-site path.',
|
|
35
37
|
inputSchema: {
|
|
36
38
|
type: 'object',
|
|
37
39
|
properties: {
|
|
38
40
|
categories: {
|
|
39
41
|
type: 'array',
|
|
40
42
|
items: { type: 'string' },
|
|
41
|
-
description:
|
|
43
|
+
description:
|
|
44
|
+
'Category names to activate from the direct-tool catalog (e.g. ["fluent-crm", "content", "media"]). Use wp_browse_tools to see available categories. Categories not in the local catalog return a pointer to mcp-adapter-discover-abilities instead of silently activating nothing.',
|
|
42
45
|
},
|
|
43
46
|
deactivate: {
|
|
44
47
|
type: 'array',
|
|
@@ -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
|
};
|
package/lib/connection-pool.js
CHANGED
|
@@ -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
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
318
|
+
return;
|
|
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.';
|
|
252
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
|
// ---------------------------------------------------------------------------
|
|
@@ -292,14 +463,26 @@ class McpRouter {
|
|
|
292
463
|
}
|
|
293
464
|
|
|
294
465
|
const summary = this.catalog.getCategorySummary();
|
|
295
|
-
const lines =
|
|
296
|
-
|
|
466
|
+
const lines = [];
|
|
467
|
+
lines.push(
|
|
468
|
+
`Scope: direct-tool catalog scoped to defaultSite (${this.config.defaultSite}). ` +
|
|
469
|
+
`Not full cross-site ability discovery.`
|
|
297
470
|
);
|
|
298
471
|
lines.push('');
|
|
472
|
+
for (const c of summary) {
|
|
473
|
+
lines.push(`${c.active ? '[LOADED]' : ' '} ${c.name} (${c.toolCount} tools)`);
|
|
474
|
+
}
|
|
475
|
+
lines.push('');
|
|
299
476
|
lines.push(`Total: ${this.catalog.fullTools.length} tools in ${summary.length} categories`);
|
|
300
477
|
lines.push(`Loaded: ${summary.filter(c => c.active).reduce((n, c) => n + c.toolCount, 0)} tools`);
|
|
301
478
|
lines.push('');
|
|
302
479
|
lines.push('Use wp_load_tools with categories array to activate.');
|
|
480
|
+
lines.push('');
|
|
481
|
+
lines.push(
|
|
482
|
+
'For abilities on another site, or in a category not listed here, use ' +
|
|
483
|
+
'mcp-adapter-discover-abilities { site, category } and ' +
|
|
484
|
+
'mcp-adapter-execute-ability { site, ability_name, parameters }.'
|
|
485
|
+
);
|
|
303
486
|
|
|
304
487
|
this.sendToClient(JSON.stringify({
|
|
305
488
|
jsonrpc: '2.0', id: msg.id,
|
|
@@ -325,6 +508,7 @@ class McpRouter {
|
|
|
325
508
|
}
|
|
326
509
|
|
|
327
510
|
const activated = this.catalog.activateCategories(toActivate);
|
|
511
|
+
const missing = toActivate.filter((c) => !this.catalog.categories[c]);
|
|
328
512
|
const lines = [];
|
|
329
513
|
|
|
330
514
|
if (activated.length > 0) {
|
|
@@ -333,8 +517,25 @@ class McpRouter {
|
|
|
333
517
|
if (toDeactivate.length > 0) {
|
|
334
518
|
lines.push(`Deactivated: ${toDeactivate.join(', ')}`);
|
|
335
519
|
}
|
|
336
|
-
if (
|
|
337
|
-
lines.push(
|
|
520
|
+
if (missing.length > 0) {
|
|
521
|
+
lines.push(
|
|
522
|
+
`Not in the direct-tool catalog (scoped to defaultSite "${this.config.defaultSite}"): ` +
|
|
523
|
+
missing.join(', ') +
|
|
524
|
+
'.'
|
|
525
|
+
);
|
|
526
|
+
lines.push(
|
|
527
|
+
'For abilities on another site, or in a category not in this catalog, use the ' +
|
|
528
|
+
'adapter meta-tools instead:'
|
|
529
|
+
);
|
|
530
|
+
for (const cat of missing) {
|
|
531
|
+
lines.push(` mcp-adapter-discover-abilities { site: "<site>", category: "${cat}" }`);
|
|
532
|
+
}
|
|
533
|
+
lines.push(
|
|
534
|
+
' mcp-adapter-execute-ability { site: "<site>", ability_name: "<ability>", parameters: { ... } }'
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (activated.length === 0 && toDeactivate.length === 0 && missing.length === 0) {
|
|
538
|
+
lines.push('No changes — categories may already be active.');
|
|
338
539
|
}
|
|
339
540
|
|
|
340
541
|
const filtered = this.catalog.getFilteredTools();
|
package/lib/sanitizer.js
CHANGED
|
@@ -29,7 +29,19 @@ function validateToolSchema(toolName, schema, log) {
|
|
|
29
29
|
log(`SCHEMA WARN [${toolName}]: invalid top-level type "${schema.type}"`);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// Boundary: properties key absent — leave inputSchema byte-unchanged.
|
|
33
|
+
if (schema.properties === undefined) return;
|
|
34
|
+
|
|
35
|
+
// Normalize malformed `properties` shapes (PHP `array()` JSON-encodes to `[]`,
|
|
36
|
+
// strings, null, primitives) to `{}` before downstream validation. Anthropic's
|
|
37
|
+
// draft 2020-12 validator rejects the entire tools/list payload on the first
|
|
38
|
+
// invalid schema, so a single vendor-registered `properties: []` breaks the
|
|
39
|
+
// whole catalog. Mirror of the top-level inputSchema normalize one frame up.
|
|
40
|
+
if (schema.properties === null || typeof schema.properties !== 'object' || Array.isArray(schema.properties)) {
|
|
41
|
+
log(`SCHEMA NORMALIZE [${toolName}]: inputSchema.properties not a plain object — normalized to {}`);
|
|
42
|
+
schema.properties = {};
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
33
45
|
|
|
34
46
|
for (const [prop, def] of Object.entries(schema.properties)) {
|
|
35
47
|
if (!def || typeof def !== 'object') {
|
|
@@ -65,8 +77,14 @@ function sanitizeToolsList(msg, log) {
|
|
|
65
77
|
delete tool.type;
|
|
66
78
|
delete tool.outputSchema;
|
|
67
79
|
|
|
68
|
-
//
|
|
69
|
-
|
|
80
|
+
// Normalize broken or non-object inputSchema (defensive — broken upstream
|
|
81
|
+
// schemas would otherwise 400 the API and break the entire tool list).
|
|
82
|
+
if (!tool.inputSchema || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema)) {
|
|
83
|
+
if (tool.inputSchema !== undefined) {
|
|
84
|
+
_log(`SCHEMA NORMALIZE [${tool.name || '(unnamed)'}]: inputSchema not a valid object — defaulted to {type:'object'}`);
|
|
85
|
+
}
|
|
86
|
+
tool.inputSchema = { type: 'object' };
|
|
87
|
+
} else {
|
|
70
88
|
validateToolSchema(tool.name || '(unnamed)', tool.inputSchema, _log);
|
|
71
89
|
}
|
|
72
90
|
|
package/lib/tool-catalog.js
CHANGED
|
@@ -32,11 +32,22 @@ class ToolCatalog {
|
|
|
32
32
|
// Active (loaded) categories
|
|
33
33
|
this.activeCategories = new Set();
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
// Resolve "always-include" categories once. Explicit operator config wins
|
|
36
|
+
// (including an explicit empty list, which preserves the operator's choice).
|
|
37
|
+
// When filtering is enabled and the operator hasn't set their own list,
|
|
38
|
+
// default to ['mcp-adapter'] so the cross-site adapter meta-tools
|
|
39
|
+
// (mcp-adapter-discover-abilities / -get-ability-info / -execute-ability)
|
|
40
|
+
// are always visible without requiring a prior wp_load_tools call.
|
|
41
|
+
const explicit = this.filterConfig && this.filterConfig.alwaysIncludeCategories;
|
|
42
|
+
const resolved =
|
|
43
|
+
explicit !== undefined && explicit !== null
|
|
44
|
+
? explicit
|
|
45
|
+
: this.isEnabled()
|
|
46
|
+
? ['mcp-adapter']
|
|
47
|
+
: [];
|
|
48
|
+
this.effectiveAlwaysInclude = new Set(resolved);
|
|
49
|
+
for (const cat of this.effectiveAlwaysInclude) {
|
|
50
|
+
this.activeCategories.add(cat);
|
|
40
51
|
}
|
|
41
52
|
}
|
|
42
53
|
|
|
@@ -104,12 +115,11 @@ class ToolCatalog {
|
|
|
104
115
|
* Deactivate categories (return to compact mode).
|
|
105
116
|
*/
|
|
106
117
|
deactivateCategories(categoryNames) {
|
|
107
|
-
// Don't deactivate always-included categories
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
118
|
+
// Don't deactivate always-included categories (single source of truth:
|
|
119
|
+
// the set resolved at construction, so the default mcp-adapter inclusion
|
|
120
|
+
// is treated the same as an explicit operator-configured entry).
|
|
111
121
|
for (const name of categoryNames) {
|
|
112
|
-
if (!
|
|
122
|
+
if (!this.effectiveAlwaysInclude.has(name)) {
|
|
113
123
|
this.activeCategories.delete(name);
|
|
114
124
|
}
|
|
115
125
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wickedevolutions/abilities-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
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": [
|
|
10
|
-
|
|
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": {
|