@wickedevolutions/abilities-mcp 1.5.3 → 1.6.0

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.
@@ -119,24 +119,30 @@ async function run(args, ctx) {
119
119
  username: site.auth.username,
120
120
  password_ref: site.auth.password_ref,
121
121
  };
122
- // Move the keychain entry from <siteId>/apppassword to
123
- // <siteId>/apppassword-legacy per the F.5 example. We do this before
124
- // the OAuth flow so a mid-flow crash leaves us with a recoverable
125
- // state (operator can re-run upgrade-auth).
122
+ // Copy the keychain entry from <siteId>/apppassword to
123
+ // <siteId>/apppassword-legacy per the F.5 example. If macOS refuses or
124
+ // delays the secret read, keep the original password_ref as the fallback
125
+ // rather than writing config that points at a secret we did not create.
126
126
  const legacyAccount = `${siteId}/apppassword-legacy`;
127
+ let legacyCopied = false;
127
128
  try {
128
129
  const oldAccount = parseRef(site.auth.password_ref).account;
129
130
  const value = await ctx.secretStore.get(SECRET_SERVICE, oldAccount);
130
131
  if (typeof value === 'string') {
131
132
  await ctx.secretStore.set(SECRET_SERVICE, legacyAccount, value);
133
+ legacyCopied = true;
132
134
  }
133
135
  } catch {
134
- // Non-fatal — operator may have already deleted the original entry.
136
+ // Non-fatal — preserve the original fallback reference below.
137
+ }
138
+ if (legacyCopied) {
139
+ pendingFallback = {
140
+ username: site.auth.username,
141
+ password_ref: makeRef(SECRET_SERVICE, legacyAccount),
142
+ };
143
+ } else {
144
+ out.push(' ! Could not copy App Password fallback to a legacy keychain entry; keeping existing fallback reference.');
135
145
  }
136
- pendingFallback = {
137
- username: site.auth.username,
138
- password_ref: makeRef(SECRET_SERVICE, legacyAccount),
139
- };
140
146
  }
141
147
 
142
148
  const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
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);
@@ -135,6 +140,11 @@ const HELP_TEXT = [
135
140
  ' --label=<text> Human-readable label',
136
141
  ' --force Overwrite an existing site_id',
137
142
  ' reauth <site_id> Re-run OAuth flow for an existing site',
143
+ ' --add-scope="<scopes>" Merge scopes into the existing set (recommended)',
144
+ ' --remove-scope="<scopes>" Drop scopes by exact match (missing = no-op warning)',
145
+ ' --scope="<scopes>" Replace the entire scope set (warns if dropping any)',
146
+ ' (the three flags above are mutually exclusive;',
147
+ ' accept comma- or space-separated scope lists)',
138
148
  ' revoke <site_id> Revoke OAuth tokens (local + remote)',
139
149
  ' list-sites Show configured sites + auth status',
140
150
  ' test <site_id> Ping the adapter and report scopes',
@@ -151,6 +161,11 @@ const HELP_TEXT = [
151
161
  ' --debug Include cause stack on errors',
152
162
  ' --allow-insecure Allow plain HTTP (localhost dev only)',
153
163
  '',
164
+ 'Environment:',
165
+ ' ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli',
166
+ ' macOS-only opt-in: use the same /usr/bin/security',
167
+ ' keychain backend Claude Desktop .mcpb uses',
168
+ '',
154
169
  'Exit codes:',
155
170
  ' 0 success',
156
171
  ' 1 unexpected error',
@@ -0,0 +1,498 @@
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
+ // Page cap = 50 pages × 100/page = 5,000 sites. Networks larger than this
30
+ // are an exceptional case that should engage maintainers rather than silently
31
+ // override; not exposed as an env shim (Issue #49).
32
+ const PROBE_PAGE_CAP = 50;
33
+
34
+ /**
35
+ * @typedef {object} ProbeResult
36
+ * @property {object|null} block Multisite block, or null when no block
37
+ * should be written (single-site / empty).
38
+ * @property {string} reason One of: 'multisite-root', 'single-site',
39
+ * 'tool-not-registered', 'empty-list'.
40
+ */
41
+
42
+ /**
43
+ * @param {object} opts
44
+ * @param {string} opts.endpoint MCP resource URL (from prMetadata.resource).
45
+ * @param {string} opts.accessToken Freshly minted OAuth access token.
46
+ * @param {string} opts.siteUrl Parent network-root URL (parsedUrl.origin).
47
+ * @param {function} [opts.log] Logger.
48
+ * @param {object} [opts.deps]
49
+ * @param {function} [opts.deps.request] Inject for tests; replaces the inline
50
+ * bearer JSON-RPC client. Receives the
51
+ * full message and resolves the parsed
52
+ * JSON-RPC response (or rejects with a
53
+ * structured error).
54
+ * @returns {Promise<ProbeResult>}
55
+ */
56
+ async function probeMultisite(opts) {
57
+ const { endpoint, accessToken, siteUrl, log } = opts;
58
+ const logger = typeof log === 'function' ? log : function noop() {};
59
+
60
+ if (!endpoint) {
61
+ const e = new Error('multisite probe: no MCP endpoint available');
62
+ e.code = 'no_endpoint';
63
+ throw e;
64
+ }
65
+ if (!accessToken) {
66
+ const e = new Error('multisite probe: no access token available');
67
+ e.code = 'no_access_token';
68
+ throw e;
69
+ }
70
+
71
+ const client = (opts.deps && opts.deps.request)
72
+ ? new InjectedClient(opts.deps.request)
73
+ : new BearerJsonRpcClient(endpoint, accessToken, logger);
74
+
75
+ await client.initialize();
76
+
77
+ // Page through multisite/list-sites until the network is fully covered
78
+ // (Issue #49). Networks with >100 sites previously got a truncated block
79
+ // because the probe issued a single per_page=100 call.
80
+ //
81
+ // Termination order matters: when a page returns fewer than per_page items
82
+ // (or exposes a body-level total/total_pages we've reached), we still
83
+ // accumulate that page's items first, then exit the loop. Dropping the
84
+ // partial page's items would silently lose subsites.
85
+ let items = null;
86
+ let totalKnown = null;
87
+ let totalPagesKnown = null;
88
+ for (let page = 1; page <= PROBE_PAGE_CAP; page++) {
89
+ // Adapter exposes the ability as kebab-case `multisite-list-sites`
90
+ // (verified via tools/list against wickedevolutions). The slash form
91
+ // shipped in v1.5.4 (Issue #54 same-files extension) — masked by the
92
+ // session-token rejection until the bridge fix above surfaced it.
93
+ const toolResp = await client.callTool('multisite-list-sites', {
94
+ per_page: PROBE_PER_PAGE,
95
+ page,
96
+ });
97
+ const payload = parseToolResponse(toolResp);
98
+ const pageItems = extractSites(payload);
99
+ if (pageItems === null) {
100
+ // Malformed payload on page 1 → preserve the existing 'empty-list'
101
+ // contract that downstream callers (add-site) already handle.
102
+ // On a later page, treat as "no more items" and stop.
103
+ if (page === 1) { items = null; break; }
104
+ break;
105
+ }
106
+ if (items === null) items = [];
107
+ items.push(...pageItems);
108
+
109
+ // Body-level total / total_pages take precedence — they're authoritative
110
+ // when present. Without them we use the partial-page fallback.
111
+ const meta = extractMeta(payload);
112
+ if (meta.total != null) totalKnown = meta.total;
113
+ if (meta.totalPages != null) totalPagesKnown = meta.totalPages;
114
+
115
+ const fullPage = pageItems.length === PROBE_PER_PAGE;
116
+ const reachedKnownTotal = totalKnown != null && items.length >= totalKnown;
117
+ const reachedKnownTotalPages = totalPagesKnown != null && page >= totalPagesKnown;
118
+ if (!fullPage || reachedKnownTotal || reachedKnownTotalPages) break;
119
+
120
+ // Full page AND no metadata signal we're done AND we're at the cap.
121
+ // This is the "5,000+ site network" case — fail loud rather than write
122
+ // a silently truncated block.
123
+ if (page === PROBE_PAGE_CAP) {
124
+ const e = new Error(
125
+ `multisite probe: page cap exceeded (${PROBE_PAGE_CAP} pages × ${PROBE_PER_PAGE}/page = ${PROBE_PAGE_CAP * PROBE_PER_PAGE} sites). ` +
126
+ `This network exceeds the supported probe size; contact maintainers.`
127
+ );
128
+ e.code = 'probe_cap_exceeded';
129
+ e.data = { count: items.length, cap: PROBE_PAGE_CAP * PROBE_PER_PAGE };
130
+ throw e;
131
+ }
132
+ }
133
+
134
+ if (items === null) {
135
+ return { block: null, reason: 'empty-list' };
136
+ }
137
+ if (items.length === 0) {
138
+ return { block: null, reason: 'empty-list' };
139
+ }
140
+ if (items.length === 1) {
141
+ // Only the network root came back — treat as single-site for routing
142
+ // purposes. Operators can still call `multisite/*` abilities at the
143
+ // network level; dot-notation routing isn't useful with no subsites.
144
+ return { block: null, reason: 'single-site' };
145
+ }
146
+
147
+ const block = buildMultisiteBlock(siteUrl, items);
148
+ if (!block || Object.keys(block).length === 0) {
149
+ return { block: null, reason: 'empty-list' };
150
+ }
151
+ return { block, reason: 'multisite-root' };
152
+ }
153
+
154
+ /**
155
+ * Build the multisite block (slug → subsite URL) from a `multisite/list-sites`
156
+ * response. Pure function — no I/O, no logging. Exported so `add-site.js`
157
+ * tests can verify the schema-mapping logic in isolation.
158
+ */
159
+ function buildMultisiteBlock(parentSiteUrl, items) {
160
+ let parentHost;
161
+ try { parentHost = new URL(parentSiteUrl).hostname.toLowerCase().replace(/^www\./, ''); }
162
+ catch { return null; }
163
+
164
+ const block = {};
165
+ const used = new Set();
166
+ for (const item of items) {
167
+ if (!item || typeof item.url !== 'string' || item.url.length === 0) continue;
168
+ const baseSlug = deriveSubsiteSlug(parentHost, item);
169
+ if (!baseSlug) continue;
170
+ const slug = uniqueSlug(baseSlug, used, item);
171
+ used.add(slug);
172
+ block[slug] = item.url;
173
+ }
174
+ return block;
175
+ }
176
+
177
+ /**
178
+ * Map a `multisite/list-sites` item to a slug usable for dot-notation
179
+ * routing. Subdomain mode → first label of the subdomain. Path mode →
180
+ * first segment of the path. Mapped-domain subsites → first label of
181
+ * the domain.
182
+ *
183
+ * Issue #70: returns null when `itemDomain === parentHost && itemPath
184
+ * is empty`. That case used to synthesize a `'main'` slug intended to
185
+ * point at the network root, but in subdomain-style multisite the only
186
+ * blog matching parent host is the parent subsite itself — so the
187
+ * resulting `<site>.main` URL was the source subsite, not the root,
188
+ * silently routing dot-notation calls to the wrong context. Skipping
189
+ * the slug means: in subdomain-style the network root is reachable via
190
+ * its domain-label slug from the fall-through (`<site>.<root-label>`);
191
+ * in path-style the network root is the parent itself and reachable as
192
+ * `<site>` (no dot). A first-class `'main' → blog_id 1` alias is
193
+ * post-alpha work.
194
+ */
195
+ function deriveSubsiteSlug(parentHost, item) {
196
+ const itemDomain = String(item.domain || '').toLowerCase().replace(/^www\./, '');
197
+ const itemPath = String(item.path || '/').replace(/^\/+|\/+$/g, '');
198
+
199
+ if (itemDomain === parentHost) {
200
+ if (itemPath === '') return null;
201
+ return itemPath.split('/')[0];
202
+ }
203
+ if (itemDomain.endsWith('.' + parentHost)) {
204
+ const prefix = itemDomain.slice(0, itemDomain.length - parentHost.length - 1);
205
+ const first = prefix.split('.')[0];
206
+ return first || null;
207
+ }
208
+ // Mapped / different domain — fall back to first label
209
+ const first = itemDomain.split('.')[0];
210
+ return first || null;
211
+ }
212
+
213
+ function uniqueSlug(base, used, item) {
214
+ if (!used.has(base)) return base;
215
+ // Disambiguate with blog_id when slugs collide (e.g. two subsites whose
216
+ // first path segments match). Never silently overwrite a previously
217
+ // mapped subsite.
218
+ const blogId = item && item.blog_id;
219
+ if (blogId !== undefined && blogId !== null) {
220
+ const candidate = `${base}-${blogId}`;
221
+ if (!used.has(candidate)) return candidate;
222
+ }
223
+ let n = 2;
224
+ while (used.has(`${base}-${n}`)) n += 1;
225
+ return `${base}-${n}`;
226
+ }
227
+
228
+ function parseToolResponse(resp) {
229
+ if (resp && resp.error) {
230
+ const e = new Error(resp.error.message || 'JSON-RPC error');
231
+ e.code = mapJsonRpcErrorCode(resp.error.code, resp.error.message);
232
+ e.jsonrpcCode = resp.error.code;
233
+ throw e;
234
+ }
235
+ const result = resp && resp.result;
236
+ if (!result) {
237
+ const e = new Error('multisite/list-sites: empty result');
238
+ e.code = 'empty_result';
239
+ throw e;
240
+ }
241
+ const content = Array.isArray(result.content) ? result.content : [];
242
+ const first = content[0];
243
+ let payload = null;
244
+ if (first && first.type === 'text' && typeof first.text === 'string') {
245
+ try { payload = JSON.parse(first.text); }
246
+ catch { payload = { _raw: first.text }; }
247
+ }
248
+
249
+ if (result.isError) {
250
+ const errMsg = (payload && (payload.error || payload._raw))
251
+ || (first && first.text)
252
+ || 'tool error';
253
+ const errCode = (payload && payload.error_code) || 'tool_error';
254
+ const e = new Error(errMsg);
255
+ e.code = mapAbilityErrorCode(errCode, errMsg);
256
+ e.abilityCode = errCode;
257
+ e.data = payload && payload.error_data;
258
+ throw e;
259
+ }
260
+
261
+ return payload || result;
262
+ }
263
+
264
+ function extractSites(payload) {
265
+ if (!payload || typeof payload !== 'object') return null;
266
+ if (Array.isArray(payload.sites)) return payload.sites;
267
+ if (payload.data && Array.isArray(payload.data.sites)) return payload.data.sites;
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Extract pagination metadata from a multisite/list-sites payload, if the
273
+ * adapter exposes it on the body. The adapter may surface totals on HTTP
274
+ * headers instead — body-only is the supported channel for the probe loop;
275
+ * absence is fine because the partial-page fallback handles termination.
276
+ */
277
+ function extractMeta(payload) {
278
+ if (!payload || typeof payload !== 'object') return { total: null, totalPages: null };
279
+ const root = payload.data && typeof payload.data === 'object' ? payload.data : payload;
280
+ const total = numericOrNull(root.total);
281
+ const totalPages = numericOrNull(root.total_pages);
282
+ return { total, totalPages };
283
+ }
284
+
285
+ function numericOrNull(v) {
286
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
287
+ if (typeof v === 'string' && /^-?\d+$/.test(v)) return parseInt(v, 10);
288
+ return null;
289
+ }
290
+
291
+ function mapJsonRpcErrorCode(code, message) {
292
+ // -32601 = Method not found → tool not registered (single-site install)
293
+ if (code === -32601) return 'tool_not_registered';
294
+ if (typeof message === 'string' && /unknown\s+tool/i.test(message)) return 'tool_not_registered';
295
+ return 'jsonrpc_error';
296
+ }
297
+
298
+ function mapAbilityErrorCode(abilityCode, message) {
299
+ const code = String(abilityCode || '').toLowerCase();
300
+ if (code === 'rest_forbidden_context'
301
+ || code === 'rest_forbidden'
302
+ || code === 'permission_denied'
303
+ || code === 'forbidden_context'
304
+ || code.indexOf('forbidden') !== -1) {
305
+ return 'permission_denied';
306
+ }
307
+ if (code === 'rest_no_route' || code === 'not_multisite') {
308
+ return 'tool_not_registered';
309
+ }
310
+ if (typeof message === 'string' && /manage_network_options|insufficient.*capabilit|forbidden/i.test(message)) {
311
+ return 'permission_denied';
312
+ }
313
+ return 'tool_error';
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Bearer JSON-RPC client — minimal MCP handshake + tools/call over HTTP.
318
+ // Distinct from OAuthHttpTransport: this runs once during add-site with a
319
+ // fresh in-memory access token, so it skips TokenManager + queue/batch.
320
+ // ---------------------------------------------------------------------------
321
+
322
+ class BearerJsonRpcClient {
323
+ constructor(endpoint, accessToken, log) {
324
+ this.url = new URL(endpoint);
325
+ this.accessToken = accessToken;
326
+ this.log = log;
327
+ this.module = this.url.protocol === 'https:' ? https : http;
328
+ this.sessionId = null;
329
+ this.sessionToken = null; // Mcp-Session-Token (HMAC, echoed back on every request)
330
+ this.cookies = new Map();
331
+ this._idCounter = 1;
332
+ }
333
+
334
+ async initialize() {
335
+ const initResp = await this._post({
336
+ jsonrpc: '2.0',
337
+ id: this._idCounter++,
338
+ method: 'initialize',
339
+ params: {
340
+ protocolVersion: PROBE_PROTOCOL_VERSION,
341
+ capabilities: {},
342
+ clientInfo: { name: 'abilities-mcp-add-site', version: '1.5.4' },
343
+ },
344
+ });
345
+ if (initResp && initResp.error) {
346
+ const e = new Error(initResp.error.message || 'initialize failed');
347
+ e.code = 'initialize_failed';
348
+ e.jsonrpcCode = initResp.error.code;
349
+ throw e;
350
+ }
351
+ await this._post({
352
+ jsonrpc: '2.0',
353
+ method: 'notifications/initialized',
354
+ });
355
+ }
356
+
357
+ callTool(name, args) {
358
+ return this._post({
359
+ jsonrpc: '2.0',
360
+ id: this._idCounter++,
361
+ method: 'tools/call',
362
+ params: { name, arguments: args || {} },
363
+ });
364
+ }
365
+
366
+ _post(message) {
367
+ return new Promise((resolve, reject) => {
368
+ const body = JSON.stringify(message);
369
+ const headers = {
370
+ 'Content-Type': 'application/json',
371
+ 'Accept': 'application/json',
372
+ 'Authorization': `Bearer ${this.accessToken}`,
373
+ 'Content-Length': Buffer.byteLength(body),
374
+ };
375
+ if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
376
+ // Echo the per-session HMAC token captured from initialize. The
377
+ // adapter's HttpSessionValidator rejects any non-initialize request
378
+ // missing this header as session-fixation defense — see Issue #54
379
+ // and the equivalent handling in lib/transports/http-transport.js.
380
+ if (this.sessionToken) headers['Mcp-Session-Token'] = this.sessionToken;
381
+ if (this.cookies.size > 0) {
382
+ headers['Cookie'] = Array.from(this.cookies.entries())
383
+ .map(([k, v]) => `${k}=${v}`).join('; ');
384
+ }
385
+
386
+ const req = this.module.request({
387
+ hostname: this.url.hostname,
388
+ port: this.url.port || (this.url.protocol === 'https:' ? 443 : 80),
389
+ path: this.url.pathname + this.url.search,
390
+ method: 'POST',
391
+ headers,
392
+ }, (res) => {
393
+ const chunks = [];
394
+ res.on('data', (chunk) => chunks.push(chunk));
395
+ res.on('end', () => {
396
+ const newSession = res.headers['mcp-session-id'];
397
+ if (newSession) this.sessionId = newSession;
398
+ const newSessionToken = res.headers['mcp-session-token'];
399
+ if (newSessionToken) this.sessionToken = newSessionToken;
400
+ const setCookie = res.headers['set-cookie'];
401
+ if (setCookie) {
402
+ const list = Array.isArray(setCookie) ? setCookie : [setCookie];
403
+ for (const raw of list) {
404
+ const nv = raw.split(';')[0].trim();
405
+ const eq = nv.indexOf('=');
406
+ if (eq > 0) this.cookies.set(nv.slice(0, eq), nv.slice(eq + 1));
407
+ }
408
+ }
409
+ if (res.statusCode === 401) {
410
+ const e = new Error('multisite probe: HTTP 401 (token rejected)');
411
+ e.code = 'unauthorized';
412
+ return reject(e);
413
+ }
414
+ if (res.statusCode === 403) {
415
+ const e = new Error('multisite probe: HTTP 403 (forbidden)');
416
+ e.code = 'permission_denied';
417
+ return reject(e);
418
+ }
419
+ const text = Buffer.concat(chunks).toString('utf8');
420
+ if (!text.trim()) return resolve(null);
421
+ let parsed;
422
+ try { parsed = JSON.parse(text); }
423
+ catch (err) {
424
+ const e = new Error(`multisite probe: response parse error: ${err.message}`);
425
+ e.code = 'parse_error';
426
+ return reject(e);
427
+ }
428
+ // Tolerate single-element JSON-RPC batch responses (some servers
429
+ // wrap a single response in an array).
430
+ if (Array.isArray(parsed) && parsed.length === 1) parsed = parsed[0];
431
+ resolve(parsed);
432
+ });
433
+ });
434
+
435
+ req.setTimeout(PROBE_TIMEOUT_MS, () => {
436
+ req.destroy(new Error('multisite probe: request timeout'));
437
+ });
438
+ req.on('error', (err) => {
439
+ const e = new Error(`multisite probe: ${err.message}`);
440
+ e.code = 'network_error';
441
+ e.cause = err;
442
+ reject(e);
443
+ });
444
+ req.write(body);
445
+ req.end();
446
+ });
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Test injection seam — wraps a function `(message) => Promise<jsonrpcResp>`
452
+ * and presents the same surface (`initialize`, `callTool`) the inline client
453
+ * does so the probe code path is identical.
454
+ */
455
+ class InjectedClient {
456
+ constructor(requestFn) {
457
+ this._request = requestFn;
458
+ this._idCounter = 1;
459
+ }
460
+ async initialize() {
461
+ const resp = await this._request({
462
+ jsonrpc: '2.0',
463
+ id: this._idCounter++,
464
+ method: 'initialize',
465
+ params: { protocolVersion: PROBE_PROTOCOL_VERSION, capabilities: {} },
466
+ });
467
+ if (resp && resp.error) {
468
+ const e = new Error(resp.error.message || 'initialize failed');
469
+ e.code = 'initialize_failed';
470
+ e.jsonrpcCode = resp.error.code;
471
+ throw e;
472
+ }
473
+ await this._request({ jsonrpc: '2.0', method: 'notifications/initialized' });
474
+ }
475
+ callTool(name, args) {
476
+ return this._request({
477
+ jsonrpc: '2.0',
478
+ id: this._idCounter++,
479
+ method: 'tools/call',
480
+ params: { name, arguments: args || {} },
481
+ });
482
+ }
483
+ }
484
+
485
+ module.exports = {
486
+ probeMultisite,
487
+ buildMultisiteBlock,
488
+ deriveSubsiteSlug,
489
+ parseToolResponse,
490
+ // Exported for direct testing of the per-session HMAC echo contract
491
+ // (Issue #54). The runtime contract (capture Mcp-Session-Token from
492
+ // initialize and echo on every subsequent request) is observable
493
+ // protocol behavior, not implementation detail.
494
+ BearerJsonRpcClient,
495
+ PROBE_PROTOCOL_VERSION,
496
+ PROBE_PER_PAGE,
497
+ PROBE_PAGE_CAP,
498
+ };