@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 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.
@@ -46,7 +46,17 @@ const {
46
46
  * @license GPL-2.0-or-later
47
47
  */
48
48
 
49
- const DEFAULT_SCOPE = 'abilities:read abilities:write';
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
- return { exitCode: r.exitCode || EXIT_OK, lines, errLines: [] };
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",
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": {