@wickedevolutions/abilities-mcp 1.5.3 → 1.5.4
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 +25 -0
- package/lib/auth/oauth-client.js +11 -1
- package/lib/cli/commands/add-site.js +65 -2
- package/lib/cli/index.js +6 -1
- package/lib/cli/multisite-probe.js +392 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Abilities MCP are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.5.4] - 2026-05-04
|
|
6
|
+
|
|
7
|
+
This release lands the bridge-side foundations for the multisite UX promised in [#43](https://github.com/Wicked-Evolutions/abilities-mcp/issues/43). `add-site` now requests multisite OAuth scopes during DCR (so super-admin operators consent through the standard consent flow), runs a one-shot `multisite/list-sites` probe after OAuth completes, and on success writes a slug→subsite-URL `multisite` block to `wp-sites.json` so the bridge's existing dot-notation routing serves multi-site OAuth in any AI client without operator JSON editing. End-to-end dot-notation routing validated on darwin-arm64 against a 4-subsite multisite by manually populating the block from the verified `multisite/list-sites` response.
|
|
8
|
+
|
|
9
|
+
Bridge-only release — no companion adapter or ai release this release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`add-site` auto-populate: probes `multisite/list-sites` after OAuth on the freshly-authenticated bridge connection** (PR [#44](https://github.com/Wicked-Evolutions/abilities-mcp/pull/44), closes [#43](https://github.com/Wicked-Evolutions/abilities-mcp/issues/43)). Builds the slug→subsite-URL block from the response and attaches it to the new site entry before persisting `wp-sites.json`. Schema verified against the existing dot-notation routing implementation (`lib/config.js:resolveSiteKey` + `lib/connection-pool.js:_findExistingHttpTransport`) — slug→URL string map, no schema migration. Single-site / non-multisite / permission-denied / network-error all degrade gracefully, with stderr advisory naming the failure where appropriate (silent for the expected single-site case). New `lib/cli/multisite-probe.js` houses a one-shot bearer JSON-RPC client (minimal MCP handshake + `tools/call`, distinct from the runtime `OAuthHttpTransport` so the probe runs once with the freshly-minted in-memory access token without the queue/batch machinery) plus pure schema-mapping helpers (`buildMultisiteBlock`, `deriveSubsiteSlug`) covering subdomain mode, path-based mode, slug-collision disambiguation by `blog_id`, and `www.` parent stripping.
|
|
14
|
+
- **`add-site` DCR scope request now includes `abilities:multisite:read` and `abilities:multisite:write`** (PR [#46](https://github.com/Wicked-Evolutions/abilities-mcp/pull/46), closes [#45](https://github.com/Wicked-Evolutions/abilities-mcp/issues/45)). The adapter's `ScopeRegistry` classifies multisite scopes as `SENSITIVE_SCOPES` and intentionally excludes them from the `abilities:read` / `abilities:write` umbrella expansion, so explicit DCR requests are the only way the consent screen surfaces them for super-admin operators on a Multisite Network root. Single source of truth in `DEFAULT_SCOPE` (`lib/auth/oauth-client.js`), picked up by `add-site` / `reauth` / `upgrade-auth` automatically. Single-site WP installs unaffected — the adapter declines to grant scopes the OAuth user lacks WP capability for, so single-site operator UX is preserved.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Permission-denied advisory wording in `add-site` rewritten to surface BOTH possible failure causes** so operators don't chase the wrong layer: (1) the OAuth user lacks the `manage_network_options` WP capability, or (2) the OAuth token lacks the `abilities:multisite:read` scope (granted on the consent screen). Both gates can produce the same observable rejection from `multisite/list-sites`; the advisory now names both explicitly with re-run guidance.
|
|
19
|
+
|
|
20
|
+
### Internal
|
|
21
|
+
|
|
22
|
+
- **Test count:** `275 → 293` (+18 across PR #44 +14 and PR #46 +4). Node CI matrix unchanged: 18, 20, 22.
|
|
23
|
+
- **Bundle size unchanged** (~413 kB packed / ~1.2 MB unpacked — no binary or dependency changes this release).
|
|
24
|
+
- **Run-contract extension:** `lib/cli/index.js` now forwards `errLines` from successful subcommand returns so non-fatal stderr advisories can surface without changing exit semantics. Backwards-compatible — subcommands that don't set `errLines` get the previous empty-array behavior. Internal CLI surface only; the bin entrypoint already writes `errLines` to stderr (`abilities-mcp.js:62`).
|
|
25
|
+
|
|
26
|
+
### Known issue (linked to adapter follow-up)
|
|
27
|
+
|
|
28
|
+
- **The auto-populate's happy path does not currently fire end-to-end** due to an adapter-side bearer-auth quirk that rejects `multisite/list-sites` from the bridge's fresh-token one-shot probe — even when the OAuth user is a super admin and the token carries the required scopes. The same operation against the same tokens succeeds when invoked from an established MCP runtime session in any AI client. Tracked at [abilities-mcp-adapter#87](https://github.com/Wicked-Evolutions/abilities-mcp-adapter/issues/87) — adapter-side fix; bridge code is correct in isolation. Operators following the documented v1.5.4 flow today will hit the bridge's documented graceful-degrade path: site entry written without the `multisite` block, advisory printed, manual block edit OR an immediate `multisite/list-sites` call from any already-connected MCP client (which writes nothing — operator copies the response into `wp-sites.json`) lets dot-notation routing work end-to-end. End-to-end dot-notation routing validated on darwin-arm64 against `wickedevolutions.com` multisite (4 subsites: `main`, `community`, `knowledge`, `test1`) by manually populating the block — 106 published posts returned correctly from `wickedevolutions.community` through the bridge's existing routing.
|
|
29
|
+
|
|
5
30
|
## [1.5.3] - 2026-05-04
|
|
6
31
|
|
|
7
32
|
**macOS hotfix: OAuth in Claude Desktop's `.mcpb` runtime now works.** Hotfix to v1.5.2's `.mcpb` operator UX on macOS. v1.5.2 shipped with keytar prebuilds bundled, but Claude Desktop's hardened-runtime host process on macOS rejects native modules with mismatched code-signing Team IDs — a system-level macOS protection that applies to every hardened app, not a Claude Desktop quirk. This blocked OAuth inside Claude Desktop's `.mcpb` runtime even though the bundle itself is structurally correct (loads cleanly via system Node from the extracted `.mcpb`). The hotfix adds a darwin-only shell-out to the macOS `security` CLI when keytar fails to load. Validated end-to-end on darwin-arm64 by an operator running the documented progression (install `.mcpb` → `upgrade-auth` → `add-site` → multi-site OAuth in the same Claude Desktop entry, read and write confirmed live on two production WordPress sites via OAuth bearer) before release.
|
package/lib/auth/oauth-client.js
CHANGED
|
@@ -46,7 +46,17 @@ const {
|
|
|
46
46
|
* @license GPL-2.0-or-later
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// `abilities:multisite:read` / `abilities:multisite:write` are SENSITIVE_SCOPES
|
|
50
|
+
// in the adapter's ScopeRegistry (Auth/OAuth/ScopeRegistry.php) — never implied
|
|
51
|
+
// by the `abilities:read` / `abilities:write` umbrella expansion. Requesting
|
|
52
|
+
// them explicitly during DCR is the only way the consent screen can surface
|
|
53
|
+
// them for super-admin operators on a Multisite Network root, which in turn
|
|
54
|
+
// is the only way `add-site`'s post-OAuth multisite/list-sites probe (#43)
|
|
55
|
+
// can fire end-to-end. Single-site WP installs accept the request — the
|
|
56
|
+
// adapter simply won't grant scopes the OAuth user lacks WP capability for,
|
|
57
|
+
// so single-site UX is preserved.
|
|
58
|
+
const DEFAULT_SCOPE =
|
|
59
|
+
'abilities:read abilities:write abilities:multisite:read abilities:multisite:write';
|
|
50
60
|
const DEFAULT_LOOPBACK_TIMEOUT_MS = 5 * 60_000;
|
|
51
61
|
|
|
52
62
|
class OAuthClient extends EventEmitter {
|
|
@@ -12,6 +12,7 @@ const { makeRef } = require('../../auth/secret-store');
|
|
|
12
12
|
const { CliError, EXIT_USAGE, EXIT_CONFIG, fromAuthError } = require('../errors');
|
|
13
13
|
const { subscribeProgress } = require('../output');
|
|
14
14
|
const { readConfig, writeConfig, freshConfig } = require('../config-store');
|
|
15
|
+
const { probeMultisite: defaultProbeMultisite } = require('../multisite-probe');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* `add-site <url>` — register a new site with the bridge using OAuth (default)
|
|
@@ -108,6 +109,7 @@ async function run(args, ctx) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const out = [];
|
|
112
|
+
const errLines = [];
|
|
111
113
|
let exitCode = 0;
|
|
112
114
|
|
|
113
115
|
if (args.apppassword) {
|
|
@@ -139,7 +141,7 @@ async function run(args, ctx) {
|
|
|
139
141
|
await writeConfig(ctx.configPath, config);
|
|
140
142
|
out.push(`✓ Site "${siteId}" configured with App Password authentication.`);
|
|
141
143
|
out.push(` Config: ${ctx.configPath}`);
|
|
142
|
-
return { exitCode, lines: out };
|
|
144
|
+
return { exitCode, lines: out, errLines };
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
// OAuth path — full authorization-code + PKCE flow via OAuthClient.
|
|
@@ -199,12 +201,73 @@ async function run(args, ctx) {
|
|
|
199
201
|
if (result.prMetadata && result.prMetadata.resource) {
|
|
200
202
|
config.sites[siteId].mcp_resource = result.prMetadata.resource;
|
|
201
203
|
}
|
|
204
|
+
|
|
205
|
+
// Multisite Network root probe. If the freshly authenticated bridge can
|
|
206
|
+
// resolve `multisite/list-sites`, populate the multisite block so dot-
|
|
207
|
+
// notation routing works without operator JSON editing. Single-site,
|
|
208
|
+
// permission-denied, and network errors all degrade gracefully — the
|
|
209
|
+
// site entry is still written without a multisite block.
|
|
210
|
+
const probeEndpoint = result.prMetadata && result.prMetadata.resource;
|
|
211
|
+
if (probeEndpoint && result.tokens && result.tokens.access_token) {
|
|
212
|
+
const probe = (ctx.deps && ctx.deps.probeMultisite) || defaultProbeMultisite;
|
|
213
|
+
try {
|
|
214
|
+
const probeResult = await probe({
|
|
215
|
+
endpoint: probeEndpoint,
|
|
216
|
+
accessToken: result.tokens.access_token,
|
|
217
|
+
siteUrl: parsedUrl.origin,
|
|
218
|
+
log: typeof ctx.log === 'function' ? ctx.log : null,
|
|
219
|
+
deps: ctx.deps && ctx.deps.probeMultisiteDeps,
|
|
220
|
+
});
|
|
221
|
+
if (probeResult && probeResult.block && Object.keys(probeResult.block).length > 0) {
|
|
222
|
+
config.sites[siteId].multisite = probeResult.block;
|
|
223
|
+
const slugs = Object.keys(probeResult.block).join(', ');
|
|
224
|
+
out.push(` Multisite: discovered ${Object.keys(probeResult.block).length} subsite(s) → ${slugs}`);
|
|
225
|
+
}
|
|
226
|
+
} catch (probeErr) {
|
|
227
|
+
_appendProbeAdvisory(probeErr, siteId, errLines);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
202
231
|
if (!config.defaultSite) config.defaultSite = siteId;
|
|
203
232
|
await writeConfig(ctx.configPath, config);
|
|
204
233
|
|
|
205
234
|
out.push(`✓ Site "${siteId}" configured. Granted scopes: ${result.scopes.join(', ')}.`);
|
|
206
235
|
out.push(` Config: ${ctx.configPath}`);
|
|
207
|
-
return { exitCode, lines: out };
|
|
236
|
+
return { exitCode, lines: out, errLines };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Append a stderr advisory naming the multisite-probe failure so operators
|
|
241
|
+
* who expected dot-notation routing know why it isn't wired and can either
|
|
242
|
+
* fix the underlying issue or hand-add the block per existing docs.
|
|
243
|
+
*
|
|
244
|
+
* Silent for `tool_not_registered` (single-site is the expected case for
|
|
245
|
+
* the vast majority of `add-site` invocations).
|
|
246
|
+
*/
|
|
247
|
+
function _appendProbeAdvisory(probeErr, siteId, errLines) {
|
|
248
|
+
const code = probeErr && probeErr.code;
|
|
249
|
+
if (code === 'tool_not_registered') return;
|
|
250
|
+
|
|
251
|
+
if (code === 'permission_denied' || code === 'unauthorized') {
|
|
252
|
+
errLines.push(
|
|
253
|
+
`Multisite discovery skipped for "${siteId}": multisite/list-sites was rejected ` +
|
|
254
|
+
`by the adapter. Two possible causes — verify both before manually adding the block: ` +
|
|
255
|
+
`(1) the OAuth user lacks the manage_network_options WP capability (super-admin ` +
|
|
256
|
+
`required on a Multisite Network root), or (2) the OAuth token lacks the ` +
|
|
257
|
+
`abilities:multisite:read scope (it must be granted on the consent screen — ` +
|
|
258
|
+
`re-run add-site and confirm the multisite scope checkbox if it was unchecked). ` +
|
|
259
|
+
`Site entry written without multisite block — dot-notation subsite routing ` +
|
|
260
|
+
`will not be available until the block is added manually or add-site is re-run.`
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const detail = (probeErr && probeErr.message) || String(probeErr);
|
|
266
|
+
errLines.push(
|
|
267
|
+
`Multisite discovery failed for "${siteId}": ${detail}. ` +
|
|
268
|
+
`Site entry written without multisite block — dot-notation subsite routing ` +
|
|
269
|
+
`will not be available until the block is added manually or add-site is re-run.`
|
|
270
|
+
);
|
|
208
271
|
}
|
|
209
272
|
|
|
210
273
|
/**
|
package/lib/cli/index.js
CHANGED
|
@@ -100,7 +100,12 @@ async function runCommand(opts) {
|
|
|
100
100
|
const lines = preLines.length
|
|
101
101
|
? preLines.concat(r.lines || [])
|
|
102
102
|
: (r.lines || []);
|
|
103
|
-
|
|
103
|
+
// Commands may return non-fatal advisories alongside a success exit
|
|
104
|
+
// code (e.g. add-site emits one when the multisite-discovery probe
|
|
105
|
+
// degrades gracefully). Surface them on stderr without changing
|
|
106
|
+
// exit semantics.
|
|
107
|
+
const errLines = Array.isArray(r.errLines) ? r.errLines : [];
|
|
108
|
+
return { exitCode: r.exitCode || EXIT_OK, lines, errLines };
|
|
104
109
|
} catch (err) {
|
|
105
110
|
const cliErr = err instanceof CliError ? err : fromAuthError(err);
|
|
106
111
|
const errLines = renderNextAction(cliErr);
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('node:https');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const { URL } = require('node:url');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Multisite Network root probe for `abilities-mcp add-site`.
|
|
9
|
+
*
|
|
10
|
+
* After OAuth completes, call `multisite/list-sites` against the freshly
|
|
11
|
+
* authenticated bridge connection. If the URL points to a Multisite Network
|
|
12
|
+
* root, build the `multisite` block (slug → subsite-URL map) the bridge's
|
|
13
|
+
* dot-notation routing already expects (see `lib/config.js:resolveSiteKey`,
|
|
14
|
+
* `lib/connection-pool.js:_findExistingHttpTransport`). If the URL is a
|
|
15
|
+
* single-site install, the OAuth user lacks `manage_network_options`, or the
|
|
16
|
+
* call fails for any other reason, the probe returns null / a structured
|
|
17
|
+
* error so `add-site` can degrade gracefully without writing the block.
|
|
18
|
+
*
|
|
19
|
+
* Schema (verified against routing read paths):
|
|
20
|
+
* site.multisite = { [slug]: subsiteUrlString }
|
|
21
|
+
*
|
|
22
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
23
|
+
* @license GPL-2.0-or-later
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const PROBE_PROTOCOL_VERSION = '2025-06-18';
|
|
27
|
+
const PROBE_PER_PAGE = 100;
|
|
28
|
+
const PROBE_TIMEOUT_MS = 30000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} ProbeResult
|
|
32
|
+
* @property {object|null} block Multisite block, or null when no block
|
|
33
|
+
* should be written (single-site / empty).
|
|
34
|
+
* @property {string} reason One of: 'multisite-root', 'single-site',
|
|
35
|
+
* 'tool-not-registered', 'empty-list'.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.endpoint MCP resource URL (from prMetadata.resource).
|
|
41
|
+
* @param {string} opts.accessToken Freshly minted OAuth access token.
|
|
42
|
+
* @param {string} opts.siteUrl Parent network-root URL (parsedUrl.origin).
|
|
43
|
+
* @param {function} [opts.log] Logger.
|
|
44
|
+
* @param {object} [opts.deps]
|
|
45
|
+
* @param {function} [opts.deps.request] Inject for tests; replaces the inline
|
|
46
|
+
* bearer JSON-RPC client. Receives the
|
|
47
|
+
* full message and resolves the parsed
|
|
48
|
+
* JSON-RPC response (or rejects with a
|
|
49
|
+
* structured error).
|
|
50
|
+
* @returns {Promise<ProbeResult>}
|
|
51
|
+
*/
|
|
52
|
+
async function probeMultisite(opts) {
|
|
53
|
+
const { endpoint, accessToken, siteUrl, log } = opts;
|
|
54
|
+
const logger = typeof log === 'function' ? log : function noop() {};
|
|
55
|
+
|
|
56
|
+
if (!endpoint) {
|
|
57
|
+
const e = new Error('multisite probe: no MCP endpoint available');
|
|
58
|
+
e.code = 'no_endpoint';
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
if (!accessToken) {
|
|
62
|
+
const e = new Error('multisite probe: no access token available');
|
|
63
|
+
e.code = 'no_access_token';
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const client = (opts.deps && opts.deps.request)
|
|
68
|
+
? new InjectedClient(opts.deps.request)
|
|
69
|
+
: new BearerJsonRpcClient(endpoint, accessToken, logger);
|
|
70
|
+
|
|
71
|
+
await client.initialize();
|
|
72
|
+
const toolResp = await client.callTool('multisite/list-sites', { per_page: PROBE_PER_PAGE });
|
|
73
|
+
const payload = parseToolResponse(toolResp);
|
|
74
|
+
const items = extractSites(payload);
|
|
75
|
+
|
|
76
|
+
if (items === null) {
|
|
77
|
+
return { block: null, reason: 'empty-list' };
|
|
78
|
+
}
|
|
79
|
+
if (items.length === 0) {
|
|
80
|
+
return { block: null, reason: 'empty-list' };
|
|
81
|
+
}
|
|
82
|
+
if (items.length === 1) {
|
|
83
|
+
// Only the network root came back — treat as single-site for routing
|
|
84
|
+
// purposes. Operators can still call `multisite/*` abilities at the
|
|
85
|
+
// network level; dot-notation routing isn't useful with no subsites.
|
|
86
|
+
return { block: null, reason: 'single-site' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const block = buildMultisiteBlock(siteUrl, items);
|
|
90
|
+
if (!block || Object.keys(block).length === 0) {
|
|
91
|
+
return { block: null, reason: 'empty-list' };
|
|
92
|
+
}
|
|
93
|
+
return { block, reason: 'multisite-root' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the multisite block (slug → subsite URL) from a `multisite/list-sites`
|
|
98
|
+
* response. Pure function — no I/O, no logging. Exported so `add-site.js`
|
|
99
|
+
* tests can verify the schema-mapping logic in isolation.
|
|
100
|
+
*/
|
|
101
|
+
function buildMultisiteBlock(parentSiteUrl, items) {
|
|
102
|
+
let parentHost;
|
|
103
|
+
try { parentHost = new URL(parentSiteUrl).hostname.toLowerCase().replace(/^www\./, ''); }
|
|
104
|
+
catch { return null; }
|
|
105
|
+
|
|
106
|
+
const block = {};
|
|
107
|
+
const used = new Set();
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
if (!item || typeof item.url !== 'string' || item.url.length === 0) continue;
|
|
110
|
+
const baseSlug = deriveSubsiteSlug(parentHost, item);
|
|
111
|
+
if (!baseSlug) continue;
|
|
112
|
+
const slug = uniqueSlug(baseSlug, used, item);
|
|
113
|
+
used.add(slug);
|
|
114
|
+
block[slug] = item.url;
|
|
115
|
+
}
|
|
116
|
+
return block;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map a `multisite/list-sites` item to a slug usable for dot-notation
|
|
121
|
+
* routing. Subdomain mode → first label of the subdomain. Path mode →
|
|
122
|
+
* first segment of the path. Network root → 'main'. Mapped-domain
|
|
123
|
+
* subsites → first label of the domain.
|
|
124
|
+
*/
|
|
125
|
+
function deriveSubsiteSlug(parentHost, item) {
|
|
126
|
+
const itemDomain = String(item.domain || '').toLowerCase().replace(/^www\./, '');
|
|
127
|
+
const itemPath = String(item.path || '/').replace(/^\/+|\/+$/g, '');
|
|
128
|
+
|
|
129
|
+
if (itemDomain === parentHost) {
|
|
130
|
+
return itemPath === '' ? 'main' : itemPath.split('/')[0];
|
|
131
|
+
}
|
|
132
|
+
if (itemDomain.endsWith('.' + parentHost)) {
|
|
133
|
+
const prefix = itemDomain.slice(0, itemDomain.length - parentHost.length - 1);
|
|
134
|
+
const first = prefix.split('.')[0];
|
|
135
|
+
return first || null;
|
|
136
|
+
}
|
|
137
|
+
// Mapped / different domain — fall back to first label
|
|
138
|
+
const first = itemDomain.split('.')[0];
|
|
139
|
+
return first || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function uniqueSlug(base, used, item) {
|
|
143
|
+
if (!used.has(base)) return base;
|
|
144
|
+
// Disambiguate with blog_id when slugs collide (e.g. two subsites whose
|
|
145
|
+
// first path segments match). Never silently overwrite a previously
|
|
146
|
+
// mapped subsite.
|
|
147
|
+
const blogId = item && item.blog_id;
|
|
148
|
+
if (blogId !== undefined && blogId !== null) {
|
|
149
|
+
const candidate = `${base}-${blogId}`;
|
|
150
|
+
if (!used.has(candidate)) return candidate;
|
|
151
|
+
}
|
|
152
|
+
let n = 2;
|
|
153
|
+
while (used.has(`${base}-${n}`)) n += 1;
|
|
154
|
+
return `${base}-${n}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseToolResponse(resp) {
|
|
158
|
+
if (resp && resp.error) {
|
|
159
|
+
const e = new Error(resp.error.message || 'JSON-RPC error');
|
|
160
|
+
e.code = mapJsonRpcErrorCode(resp.error.code, resp.error.message);
|
|
161
|
+
e.jsonrpcCode = resp.error.code;
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
const result = resp && resp.result;
|
|
165
|
+
if (!result) {
|
|
166
|
+
const e = new Error('multisite/list-sites: empty result');
|
|
167
|
+
e.code = 'empty_result';
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
171
|
+
const first = content[0];
|
|
172
|
+
let payload = null;
|
|
173
|
+
if (first && first.type === 'text' && typeof first.text === 'string') {
|
|
174
|
+
try { payload = JSON.parse(first.text); }
|
|
175
|
+
catch { payload = { _raw: first.text }; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (result.isError) {
|
|
179
|
+
const errMsg = (payload && (payload.error || payload._raw))
|
|
180
|
+
|| (first && first.text)
|
|
181
|
+
|| 'tool error';
|
|
182
|
+
const errCode = (payload && payload.error_code) || 'tool_error';
|
|
183
|
+
const e = new Error(errMsg);
|
|
184
|
+
e.code = mapAbilityErrorCode(errCode, errMsg);
|
|
185
|
+
e.abilityCode = errCode;
|
|
186
|
+
e.data = payload && payload.error_data;
|
|
187
|
+
throw e;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return payload || result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function extractSites(payload) {
|
|
194
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
195
|
+
if (Array.isArray(payload.sites)) return payload.sites;
|
|
196
|
+
if (payload.data && Array.isArray(payload.data.sites)) return payload.data.sites;
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function mapJsonRpcErrorCode(code, message) {
|
|
201
|
+
// -32601 = Method not found → tool not registered (single-site install)
|
|
202
|
+
if (code === -32601) return 'tool_not_registered';
|
|
203
|
+
if (typeof message === 'string' && /unknown\s+tool/i.test(message)) return 'tool_not_registered';
|
|
204
|
+
return 'jsonrpc_error';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function mapAbilityErrorCode(abilityCode, message) {
|
|
208
|
+
const code = String(abilityCode || '').toLowerCase();
|
|
209
|
+
if (code === 'rest_forbidden_context'
|
|
210
|
+
|| code === 'rest_forbidden'
|
|
211
|
+
|| code === 'permission_denied'
|
|
212
|
+
|| code === 'forbidden_context'
|
|
213
|
+
|| code.indexOf('forbidden') !== -1) {
|
|
214
|
+
return 'permission_denied';
|
|
215
|
+
}
|
|
216
|
+
if (code === 'rest_no_route' || code === 'not_multisite') {
|
|
217
|
+
return 'tool_not_registered';
|
|
218
|
+
}
|
|
219
|
+
if (typeof message === 'string' && /manage_network_options|insufficient.*capabilit|forbidden/i.test(message)) {
|
|
220
|
+
return 'permission_denied';
|
|
221
|
+
}
|
|
222
|
+
return 'tool_error';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Bearer JSON-RPC client — minimal MCP handshake + tools/call over HTTP.
|
|
227
|
+
// Distinct from OAuthHttpTransport: this runs once during add-site with a
|
|
228
|
+
// fresh in-memory access token, so it skips TokenManager + queue/batch.
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
class BearerJsonRpcClient {
|
|
232
|
+
constructor(endpoint, accessToken, log) {
|
|
233
|
+
this.url = new URL(endpoint);
|
|
234
|
+
this.accessToken = accessToken;
|
|
235
|
+
this.log = log;
|
|
236
|
+
this.module = this.url.protocol === 'https:' ? https : http;
|
|
237
|
+
this.sessionId = null;
|
|
238
|
+
this.cookies = new Map();
|
|
239
|
+
this._idCounter = 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async initialize() {
|
|
243
|
+
const initResp = await this._post({
|
|
244
|
+
jsonrpc: '2.0',
|
|
245
|
+
id: this._idCounter++,
|
|
246
|
+
method: 'initialize',
|
|
247
|
+
params: {
|
|
248
|
+
protocolVersion: PROBE_PROTOCOL_VERSION,
|
|
249
|
+
capabilities: {},
|
|
250
|
+
clientInfo: { name: 'abilities-mcp-add-site', version: '1.5.4' },
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
if (initResp && initResp.error) {
|
|
254
|
+
const e = new Error(initResp.error.message || 'initialize failed');
|
|
255
|
+
e.code = 'initialize_failed';
|
|
256
|
+
e.jsonrpcCode = initResp.error.code;
|
|
257
|
+
throw e;
|
|
258
|
+
}
|
|
259
|
+
await this._post({
|
|
260
|
+
jsonrpc: '2.0',
|
|
261
|
+
method: 'notifications/initialized',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
callTool(name, args) {
|
|
266
|
+
return this._post({
|
|
267
|
+
jsonrpc: '2.0',
|
|
268
|
+
id: this._idCounter++,
|
|
269
|
+
method: 'tools/call',
|
|
270
|
+
params: { name, arguments: args || {} },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_post(message) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
const body = JSON.stringify(message);
|
|
277
|
+
const headers = {
|
|
278
|
+
'Content-Type': 'application/json',
|
|
279
|
+
'Accept': 'application/json',
|
|
280
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
281
|
+
'Content-Length': Buffer.byteLength(body),
|
|
282
|
+
};
|
|
283
|
+
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
|
|
284
|
+
if (this.cookies.size > 0) {
|
|
285
|
+
headers['Cookie'] = Array.from(this.cookies.entries())
|
|
286
|
+
.map(([k, v]) => `${k}=${v}`).join('; ');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const req = this.module.request({
|
|
290
|
+
hostname: this.url.hostname,
|
|
291
|
+
port: this.url.port || (this.url.protocol === 'https:' ? 443 : 80),
|
|
292
|
+
path: this.url.pathname + this.url.search,
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers,
|
|
295
|
+
}, (res) => {
|
|
296
|
+
const chunks = [];
|
|
297
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
298
|
+
res.on('end', () => {
|
|
299
|
+
const newSession = res.headers['mcp-session-id'];
|
|
300
|
+
if (newSession) this.sessionId = newSession;
|
|
301
|
+
const setCookie = res.headers['set-cookie'];
|
|
302
|
+
if (setCookie) {
|
|
303
|
+
const list = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
304
|
+
for (const raw of list) {
|
|
305
|
+
const nv = raw.split(';')[0].trim();
|
|
306
|
+
const eq = nv.indexOf('=');
|
|
307
|
+
if (eq > 0) this.cookies.set(nv.slice(0, eq), nv.slice(eq + 1));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (res.statusCode === 401) {
|
|
311
|
+
const e = new Error('multisite probe: HTTP 401 (token rejected)');
|
|
312
|
+
e.code = 'unauthorized';
|
|
313
|
+
return reject(e);
|
|
314
|
+
}
|
|
315
|
+
if (res.statusCode === 403) {
|
|
316
|
+
const e = new Error('multisite probe: HTTP 403 (forbidden)');
|
|
317
|
+
e.code = 'permission_denied';
|
|
318
|
+
return reject(e);
|
|
319
|
+
}
|
|
320
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
321
|
+
if (!text.trim()) return resolve(null);
|
|
322
|
+
let parsed;
|
|
323
|
+
try { parsed = JSON.parse(text); }
|
|
324
|
+
catch (err) {
|
|
325
|
+
const e = new Error(`multisite probe: response parse error: ${err.message}`);
|
|
326
|
+
e.code = 'parse_error';
|
|
327
|
+
return reject(e);
|
|
328
|
+
}
|
|
329
|
+
// Tolerate single-element JSON-RPC batch responses (some servers
|
|
330
|
+
// wrap a single response in an array).
|
|
331
|
+
if (Array.isArray(parsed) && parsed.length === 1) parsed = parsed[0];
|
|
332
|
+
resolve(parsed);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
req.setTimeout(PROBE_TIMEOUT_MS, () => {
|
|
337
|
+
req.destroy(new Error('multisite probe: request timeout'));
|
|
338
|
+
});
|
|
339
|
+
req.on('error', (err) => {
|
|
340
|
+
const e = new Error(`multisite probe: ${err.message}`);
|
|
341
|
+
e.code = 'network_error';
|
|
342
|
+
e.cause = err;
|
|
343
|
+
reject(e);
|
|
344
|
+
});
|
|
345
|
+
req.write(body);
|
|
346
|
+
req.end();
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Test injection seam — wraps a function `(message) => Promise<jsonrpcResp>`
|
|
353
|
+
* and presents the same surface (`initialize`, `callTool`) the inline client
|
|
354
|
+
* does so the probe code path is identical.
|
|
355
|
+
*/
|
|
356
|
+
class InjectedClient {
|
|
357
|
+
constructor(requestFn) {
|
|
358
|
+
this._request = requestFn;
|
|
359
|
+
this._idCounter = 1;
|
|
360
|
+
}
|
|
361
|
+
async initialize() {
|
|
362
|
+
const resp = await this._request({
|
|
363
|
+
jsonrpc: '2.0',
|
|
364
|
+
id: this._idCounter++,
|
|
365
|
+
method: 'initialize',
|
|
366
|
+
params: { protocolVersion: PROBE_PROTOCOL_VERSION, capabilities: {} },
|
|
367
|
+
});
|
|
368
|
+
if (resp && resp.error) {
|
|
369
|
+
const e = new Error(resp.error.message || 'initialize failed');
|
|
370
|
+
e.code = 'initialize_failed';
|
|
371
|
+
e.jsonrpcCode = resp.error.code;
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
await this._request({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
375
|
+
}
|
|
376
|
+
callTool(name, args) {
|
|
377
|
+
return this._request({
|
|
378
|
+
jsonrpc: '2.0',
|
|
379
|
+
id: this._idCounter++,
|
|
380
|
+
method: 'tools/call',
|
|
381
|
+
params: { name, arguments: args || {} },
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module.exports = {
|
|
387
|
+
probeMultisite,
|
|
388
|
+
buildMultisiteBlock,
|
|
389
|
+
deriveSubsiteSlug,
|
|
390
|
+
parseToolResponse,
|
|
391
|
+
PROBE_PROTOCOL_VERSION,
|
|
392
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wickedevolutions/abilities-mcp",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
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": {
|