@wickedevolutions/abilities-mcp 1.5.4 → 1.6.1

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.
@@ -1,9 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  const { execFile } = require('node:child_process');
4
+ const { existsSync } = require('node:fs');
4
5
 
5
6
  const { SecretStoreError } = require('./errors');
6
7
 
8
+ const DEFAULT_SECURITY_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_KEYCHAIN_BACKEND = 'auto';
10
+ const KEYCHAIN_BACKENDS = new Set(['auto', 'keytar', 'security-cli']);
11
+ const SECURITY_CLI_PATH = '/usr/bin/security';
12
+
7
13
  /**
8
14
  * KeychainSecretStore — keytar-backed SecretStore with a darwin-only
9
15
  * `security` CLI fallback for Claude Desktop's hardened-runtime barrier.
@@ -13,19 +19,32 @@ const { SecretStoreError } = require('./errors');
13
19
  * `optionalDependency` so a failed native build does not break `npm install`
14
20
  * for env-var-only operators.
15
21
  *
16
- * **The darwin fallback (issue #39).** When the bundled keytar binary fails
17
- * to dlopen inside Claude Desktop's hardened-runtime process the macOS
18
- * code-signing rejects native binaries with mismatched Team IDs, Anthropic-
19
- * signed Claude Desktop refuses to load npm-distribution-signed keytar.node
20
- * we fall back to shelling out to the macOS `security` CLI via
21
- * `child_process.execFile`. `security` is always installed on macOS, doesn't
22
- * require dynamic native loading, operates against the same macOS Keychain,
23
- * and runs as a child process out from under Claude Desktop's hardened-
24
- * runtime restrictions. The fallback fires only on darwin; on linux/win32 a
25
- * keytar load failure still throws `keytar_unavailable` (current behavior).
22
+ * **Darwin default: security-CLI (issue #61).** On darwin under the default
23
+ * `auto` backend, the store engages the `/usr/bin/security` CLI directly
24
+ * without attempting to load keytar. This is the alpha-gate fix for the
25
+ * multi-client macOS keychain ACL identity split: every runtime that spawns
26
+ * the bridge Claude Desktop's `.mcpb`, Claude Code via npm/node, Codex,
27
+ * terminal CLI, etc. issues `SecKeychainItem*` calls through the same
28
+ * caller binary (`/usr/bin/security`), so macOS's per-binary ACL trusted-
29
+ * application list contains exactly one entry. After the operator's first
30
+ * "Always Allow" the entry is silently readable from every runtime.
31
+ *
32
+ * Issue #58's `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` env var was the
33
+ * opt-in shape of this fix; #61 promotes it to the default. Operators who
34
+ * need keytar on darwin (uncommon — debugging, custom build) can opt back in
35
+ * with `ABILITIES_MCP_KEYCHAIN_BACKEND=keytar`.
36
+ *
37
+ * **The original darwin fallback (issue #39).** When the bundled keytar
38
+ * binary failed to dlopen inside Claude Desktop's hardened-runtime process,
39
+ * the store fell back to `/usr/bin/security` automatically. With the #61
40
+ * default in place, darwin auto never attempts keytar in the first place, so
41
+ * the dlopen-rejection branch is no longer reachable from `auto`. The
42
+ * security-CLI implementation itself is the same code path that's been
43
+ * shipping inside the .mcpb runtime since v1.5.3.
26
44
  *
27
- * Outside the .mcpb path system Node, CLI install, npx, source clone —
28
- * keytar loads normally and the fallback never engages.
45
+ * Linux / win32 behavior unchanged: keytar via libsecret / Credential
46
+ * Manager. There is no `/usr/bin/security` equivalent and no analogous ACL
47
+ * prompt, so the per-platform identity-split bug doesn't apply.
29
48
  *
30
49
  * If keytar is unavailable at runtime AND the darwin fallback also can't run,
31
50
  * every method throws `SecretStoreError` with code `keytar_unavailable` —
@@ -53,24 +72,45 @@ class KeychainSecretStore {
53
72
  * fallback-eligibility decision. Test seam.
54
73
  * @param {Function} [opts.exec] Override `child_process.execFile`. Test
55
74
  * seam for the security-CLI fallback path.
75
+ * @param {number} [opts.securityTimeoutMs] Max time to wait for the darwin
76
+ * `security` CLI before surfacing a typed
77
+ * timeout error. Defaults to 30s.
78
+ * @param {string} [opts.backend] Secret backend: auto (default), keytar,
79
+ * or security-cli. When omitted, reads
80
+ * ABILITIES_MCP_KEYCHAIN_BACKEND.
81
+ * @param {object} [opts.env] Env object for backend selection tests.
82
+ * @param {Function} [opts.fsExistsSync] Override `fs.existsSync` for the
83
+ * `/usr/bin/security` existence probe.
84
+ * Test seam.
56
85
  */
57
86
  constructor(opts = {}) {
58
87
  this._injected = opts.keytar || null;
59
88
  this._keytar = null;
60
89
  this._loadAttempted = false;
61
90
  this._loadError = null;
91
+ this._loadErrorCode = 'keytar_unavailable';
62
92
  this._fallbackMode = null; // null | 'security-cli'
63
93
 
64
94
  this._requireKeytar = opts.requireKeytar || ((id) => require(id));
65
95
  this._platform = opts.platform || process.platform;
66
96
  this._exec = opts.exec || execFile;
97
+ this._fsExistsSync = opts.fsExistsSync || existsSync;
98
+ const env = opts.env || process.env;
99
+ this._backend = _normalizeBackend(opts.backend || env.ABILITIES_MCP_KEYCHAIN_BACKEND);
100
+ this._securityTimeoutMs = Number.isFinite(opts.securityTimeoutMs) && opts.securityTimeoutMs > 0
101
+ ? opts.securityTimeoutMs
102
+ : DEFAULT_SECURITY_TIMEOUT_MS;
67
103
  }
68
104
 
69
105
  /**
70
106
  * Lazy load. Sets one of three terminal states:
71
- * - `this._keytar` populated (keytar loaded normally; primary path)
72
- * - `this._fallbackMode === 'security-cli'` (darwin fallback engaged)
73
- * - `this._loadError` set + throws (non-darwin keytar failure)
107
+ * - `this._fallbackMode === 'security-cli'` (darwin auto default + explicit
108
+ * security-cli backend)
109
+ * - `this._keytar` populated (keytar loaded normally; non-darwin auto, or
110
+ * explicit `backend: 'keytar'` anywhere)
111
+ * - `this._loadError` set + throws (security-cli engagement on a host
112
+ * without `/usr/bin/security`, keytar load failure when keytar is the
113
+ * selected backend, invalid backend value)
74
114
  */
75
115
  _load() {
76
116
  if (this._keytar || this._fallbackMode) {
@@ -80,11 +120,50 @@ class KeychainSecretStore {
80
120
  // Previously failed and we cached the error.
81
121
  throw new SecretStoreError(
82
122
  `OS keychain unavailable: ${this._loadError.message}`,
83
- { code: 'keytar_unavailable', cause: this._loadError }
123
+ { code: this._loadErrorCode, cause: this._loadError }
84
124
  );
85
125
  }
86
126
  this._loadAttempted = true;
87
127
 
128
+ if (!KEYCHAIN_BACKENDS.has(this._backend)) {
129
+ const err = new Error(
130
+ `Unsupported ABILITIES_MCP_KEYCHAIN_BACKEND="${this._backend}". ` +
131
+ `Use one of: auto, keytar, security-cli.`
132
+ );
133
+ this._loadError = err;
134
+ this._loadErrorCode = 'invalid_keychain_backend';
135
+ throw new SecretStoreError(
136
+ `OS keychain unavailable: ${err.message}`,
137
+ { code: this._loadErrorCode, cause: err }
138
+ );
139
+ }
140
+
141
+ if (this._backend === 'security-cli') {
142
+ if (this._platform !== 'darwin') {
143
+ const err = new Error(
144
+ `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli is only supported on macOS.`
145
+ );
146
+ this._loadError = err;
147
+ this._loadErrorCode = 'security_cli_unavailable';
148
+ throw new SecretStoreError(
149
+ `OS keychain unavailable: ${err.message}`,
150
+ { code: this._loadErrorCode, cause: err }
151
+ );
152
+ }
153
+ this._engageSecurityCliMode();
154
+ return;
155
+ }
156
+
157
+ // Issue #61: darwin default = security-CLI for cross-runtime ACL identity.
158
+ // All bridge spawn paths (Claude Desktop .mcpb, Claude Code, Codex,
159
+ // terminal CLI) issue keychain syscalls through the same `/usr/bin/security`
160
+ // caller binary, so macOS sees one ACL identity instead of N. Operators
161
+ // who want keytar on darwin can opt back in with backend=keytar.
162
+ if (this._backend === 'auto' && this._platform === 'darwin') {
163
+ this._engageSecurityCliMode();
164
+ return;
165
+ }
166
+
88
167
  if (this._injected) {
89
168
  this._keytar = this._injected;
90
169
  return;
@@ -94,22 +173,44 @@ class KeychainSecretStore {
94
173
  this._keytar = this._requireKeytar('keytar');
95
174
  return;
96
175
  } catch (err) {
97
- // darwin: Claude Desktop's hardened-runtime rejects bundled keytar.node
98
- // with a Team ID mismatch (issue #39). Fall back to the `security` CLI
99
- // rather than throwing the bridge keeps working against the same
100
- // macOS Keychain, just via shell-out.
101
- if (this._platform === 'darwin') {
102
- this._fallbackMode = 'security-cli';
103
- return;
104
- }
176
+ // backend === 'keytar' (any platform), or backend === 'auto' on
177
+ // linux/win32. No fallback: the security-CLI is darwin-only, and the
178
+ // darwin auto path above already engaged it before we got here.
105
179
  this._loadError = err;
180
+ this._loadErrorCode = 'keytar_unavailable';
106
181
  throw new SecretStoreError(
107
182
  `OS keychain unavailable: ${err.message}`,
108
- { code: 'keytar_unavailable', cause: err }
183
+ { code: this._loadErrorCode, cause: err }
109
184
  );
110
185
  }
111
186
  }
112
187
 
188
+ /**
189
+ * Probe `/usr/bin/security` and engage security-CLI mode. Surfaces a typed
190
+ * error early (at first `_load()`) on hosts where the binary is missing —
191
+ * the corporate-locked-macOS edge case — instead of waiting for the first
192
+ * keychain operation to fail at execFile spawn time.
193
+ *
194
+ * Sets `_fallbackMode = 'security-cli'` on success; throws
195
+ * SecretStoreError code `security_cli_unavailable` on failure.
196
+ */
197
+ _engageSecurityCliMode() {
198
+ if (!this._fsExistsSync(SECURITY_CLI_PATH)) {
199
+ const err = new Error(
200
+ `${SECURITY_CLI_PATH} not found. macOS Keychain access requires the ` +
201
+ `security CLI (standard at ${SECURITY_CLI_PATH} on a normal macOS ` +
202
+ `install). This may indicate a corporate-locked or non-standard host.`
203
+ );
204
+ this._loadError = err;
205
+ this._loadErrorCode = 'security_cli_unavailable';
206
+ throw new SecretStoreError(
207
+ `OS keychain unavailable: ${err.message}`,
208
+ { code: this._loadErrorCode, cause: err }
209
+ );
210
+ }
211
+ this._fallbackMode = 'security-cli';
212
+ }
213
+
113
214
  /**
114
215
  * @returns {Promise<boolean>} true if keytar can be loaded on this host OR
115
216
  * the darwin security-CLI fallback is engaged.
@@ -150,14 +251,14 @@ class KeychainSecretStore {
150
251
  return this._keytar.deletePassword(service, account);
151
252
  }
152
253
 
254
+ // findAll() is unavailable on the Darwin security-cli backend; current
255
+ // bridge flows do not rely on it. The macOS `security` CLI has no clean
256
+ // enumerate-by-service mode, and with #61 making security-cli the darwin
257
+ // default this method returns [] for every darwin caller. Linux/Windows
258
+ // (keytar) continue to enumerate normally.
153
259
  async findAll(service) {
154
260
  this._load();
155
261
  if (this._fallbackMode === 'security-cli') {
156
- // The macOS `security` CLI has no clean enumerate-by-service mode.
157
- // Returning [] here is safe because the bridge runtime path never
158
- // calls findAll — only the CLI subcommand `list-sites` does, and that
159
- // runs in system Node where keytar loads normally and this branch
160
- // is never taken. Documented in the issue body's "findAll" note.
161
262
  return [];
162
263
  }
163
264
  return this._keytar.findCredentials(service);
@@ -176,7 +277,16 @@ class KeychainSecretStore {
176
277
  */
177
278
  _execSecurity(args) {
178
279
  return new Promise((resolve, reject) => {
179
- this._exec('security', args, {}, (err, stdout, stderr) => {
280
+ const opts = {
281
+ timeout: this._securityTimeoutMs,
282
+ killSignal: 'SIGTERM',
283
+ };
284
+ // Issue #66: pass the absolute path explicitly so execFile bypasses
285
+ // PATH resolution. Bare `'security'` would resolve through the
286
+ // operator's PATH and could route the syscall through a shadowing
287
+ // binary (brew, nvm, ~/bin, etc.), breaking #61's "trusted caller
288
+ // binary at syscall time = /usr/bin/security" guarantee.
289
+ this._exec(SECURITY_CLI_PATH, args, opts, (err, stdout, stderr) => {
180
290
  const stdoutStr = typeof stdout === 'string'
181
291
  ? stdout
182
292
  : (stdout ? stdout.toString() : '');
@@ -190,6 +300,9 @@ class KeychainSecretStore {
190
300
  // and don't pass via cb args); fall back to the cb args otherwise.
191
301
  if (typeof err.stderr !== 'string') err.stderr = stderrStr;
192
302
  if (typeof err.stdout !== 'string') err.stdout = stdoutStr;
303
+ if (_isTimeout(err) && typeof err.timeoutMs !== 'number') {
304
+ err.timeoutMs = this._securityTimeoutMs;
305
+ }
193
306
  return reject(err);
194
307
  }
195
308
  resolve({ stdout: stdoutStr, stderr: stderrStr });
@@ -206,6 +319,12 @@ class KeychainSecretStore {
206
319
  return stdout.replace(/\n$/, '');
207
320
  } catch (err) {
208
321
  if (_isNotFound(err)) return null;
322
+ if (_isTimeout(err)) {
323
+ throw new SecretStoreError(
324
+ `security find-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
325
+ { code: 'security_cli_timeout', cause: err }
326
+ );
327
+ }
209
328
  throw new SecretStoreError(
210
329
  `security find-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
211
330
  { code: 'security_cli_failed', cause: err }
@@ -225,6 +344,12 @@ class KeychainSecretStore {
225
344
  'add-generic-password', '-U', '-s', service, '-a', account, '-w', secret,
226
345
  ]);
227
346
  } catch (err) {
347
+ if (_isTimeout(err)) {
348
+ throw new SecretStoreError(
349
+ `security add-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
350
+ { code: 'security_cli_timeout', cause: err }
351
+ );
352
+ }
228
353
  throw new SecretStoreError(
229
354
  `security add-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
230
355
  { code: 'security_cli_failed', cause: err }
@@ -240,6 +365,12 @@ class KeychainSecretStore {
240
365
  return true;
241
366
  } catch (err) {
242
367
  if (_isNotFound(err)) return false;
368
+ if (_isTimeout(err)) {
369
+ throw new SecretStoreError(
370
+ `security delete-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
371
+ { code: 'security_cli_timeout', cause: err }
372
+ );
373
+ }
243
374
  throw new SecretStoreError(
244
375
  `security delete-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
245
376
  { code: 'security_cli_failed', cause: err }
@@ -262,4 +393,16 @@ function _isNotFound(err) {
262
393
  return /could not be found/i.test(stderr);
263
394
  }
264
395
 
396
+ function _isTimeout(err) {
397
+ if (!err) return false;
398
+ if (err.code === 'ETIMEDOUT') return true;
399
+ if (err.killed && err.signal === 'SIGTERM') return true;
400
+ return /timed out/i.test(err.message || '');
401
+ }
402
+
403
+ function _normalizeBackend(value) {
404
+ if (!value) return DEFAULT_KEYCHAIN_BACKEND;
405
+ return String(value).trim().toLowerCase();
406
+ }
407
+
265
408
  module.exports = { KeychainSecretStore };
@@ -9,6 +9,7 @@ const {
9
9
  const { CliError, EXIT_USAGE, fromAuthError } = require('../errors');
10
10
  const { subscribeProgress } = require('../output');
11
11
  const { readConfig, writeConfig } = require('../config-store');
12
+ const { computeScopeMutation } = require('../scope-mutation');
12
13
 
13
14
  /**
14
15
  * `reauth <site_id>` — re-run the OAuth flow for an existing site.
@@ -49,7 +50,28 @@ async function run(args, ctx) {
49
50
  });
50
51
  }
51
52
 
53
+ // Resolve the requested scope set from the three mutually-exclusive flags
54
+ // (--scope replaces, --add-scope merges, --remove-scope drops). See
55
+ // lib/cli/scope-mutation.js for the full design (Issue #50). Empty
56
+ // existing → DEFAULT_SCOPE fallback to preserve the prior bare-reauth
57
+ // contract.
58
+ const persistedScopes = Array.isArray(site.auth.scopes) ? site.auth.scopes : null;
59
+ const mutation = computeScopeMutation({
60
+ scope: args.scope,
61
+ addScope: args['add-scope'],
62
+ removeScope: args['remove-scope'],
63
+ existing: persistedScopes,
64
+ });
65
+ if (mutation.errorCode) {
66
+ throw new CliError(mutation.errorMessage, {
67
+ exitCode: EXIT_USAGE,
68
+ nextAction: 'Run: abilities-mcp reauth <site_id> --add-scope=<scopes> | --remove-scope=<scopes> | --scope=<scopes>',
69
+ });
70
+ }
71
+ const requestedScope = mutation.scopes.length > 0 ? mutation.scopes : (persistedScopes || DEFAULT_SCOPE);
72
+
52
73
  const out = [];
74
+ const errLines = mutation.warnings.slice();
53
75
  out.push(`Re-running OAuth flow for site "${siteId}" (${site.url})…`);
54
76
 
55
77
  const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
@@ -58,7 +80,7 @@ async function run(args, ctx) {
58
80
  siteUrl: site.url,
59
81
  clientName,
60
82
  softwareVersion: ctx.softwareVersion,
61
- scope: args.scope || (Array.isArray(site.auth.scopes) ? site.auth.scopes : DEFAULT_SCOPE),
83
+ scope: requestedScope,
62
84
  identityProvider: ctx.identityProvider,
63
85
  allowInsecure: ctx.allowInsecure,
64
86
  capabilityPin: site.oauth_capability_pinned ? {
@@ -102,7 +124,7 @@ async function run(args, ctx) {
102
124
  await writeConfig(ctx.configPath, config);
103
125
 
104
126
  out.push(`✓ Site "${siteId}" re-authorized. Granted scopes: ${result.scopes.join(', ')}.`);
105
- return { exitCode: 0, lines: out };
127
+ return { exitCode: 0, lines: out, errLines };
106
128
  }
107
129
 
108
130
  module.exports = { run };
@@ -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
@@ -140,6 +140,11 @@ const HELP_TEXT = [
140
140
  ' --label=<text> Human-readable label',
141
141
  ' --force Overwrite an existing site_id',
142
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)',
143
148
  ' revoke <site_id> Revoke OAuth tokens (local + remote)',
144
149
  ' list-sites Show configured sites + auth status',
145
150
  ' test <site_id> Ping the adapter and report scopes',
@@ -156,6 +161,11 @@ const HELP_TEXT = [
156
161
  ' --debug Include cause stack on errors',
157
162
  ' --allow-insecure Allow plain HTTP (localhost dev only)',
158
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
+ '',
159
169
  'Exit codes:',
160
170
  ' 0 success',
161
171
  ' 1 unexpected error',
@@ -26,6 +26,10 @@ const { URL } = require('node:url');
26
26
  const PROBE_PROTOCOL_VERSION = '2025-06-18';
27
27
  const PROBE_PER_PAGE = 100;
28
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;
29
33
 
30
34
  /**
31
35
  * @typedef {object} ProbeResult
@@ -69,9 +73,63 @@ async function probeMultisite(opts) {
69
73
  : new BearerJsonRpcClient(endpoint, accessToken, logger);
70
74
 
71
75
  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);
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
+ }
75
133
 
76
134
  if (items === null) {
77
135
  return { block: null, reason: 'empty-list' };
@@ -119,15 +177,28 @@ function buildMultisiteBlock(parentSiteUrl, items) {
119
177
  /**
120
178
  * Map a `multisite/list-sites` item to a slug usable for dot-notation
121
179
  * 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.
180
+ * first segment of the path. Mapped-domain subsitesfirst 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.
124
194
  */
125
195
  function deriveSubsiteSlug(parentHost, item) {
126
196
  const itemDomain = String(item.domain || '').toLowerCase().replace(/^www\./, '');
127
197
  const itemPath = String(item.path || '/').replace(/^\/+|\/+$/g, '');
128
198
 
129
199
  if (itemDomain === parentHost) {
130
- return itemPath === '' ? 'main' : itemPath.split('/')[0];
200
+ if (itemPath === '') return null;
201
+ return itemPath.split('/')[0];
131
202
  }
132
203
  if (itemDomain.endsWith('.' + parentHost)) {
133
204
  const prefix = itemDomain.slice(0, itemDomain.length - parentHost.length - 1);
@@ -197,6 +268,26 @@ function extractSites(payload) {
197
268
  return null;
198
269
  }
199
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
+
200
291
  function mapJsonRpcErrorCode(code, message) {
201
292
  // -32601 = Method not found → tool not registered (single-site install)
202
293
  if (code === -32601) return 'tool_not_registered';
@@ -235,6 +326,7 @@ class BearerJsonRpcClient {
235
326
  this.log = log;
236
327
  this.module = this.url.protocol === 'https:' ? https : http;
237
328
  this.sessionId = null;
329
+ this.sessionToken = null; // Mcp-Session-Token (HMAC, echoed back on every request)
238
330
  this.cookies = new Map();
239
331
  this._idCounter = 1;
240
332
  }
@@ -281,6 +373,11 @@ class BearerJsonRpcClient {
281
373
  'Content-Length': Buffer.byteLength(body),
282
374
  };
283
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;
284
381
  if (this.cookies.size > 0) {
285
382
  headers['Cookie'] = Array.from(this.cookies.entries())
286
383
  .map(([k, v]) => `${k}=${v}`).join('; ');
@@ -298,6 +395,8 @@ class BearerJsonRpcClient {
298
395
  res.on('end', () => {
299
396
  const newSession = res.headers['mcp-session-id'];
300
397
  if (newSession) this.sessionId = newSession;
398
+ const newSessionToken = res.headers['mcp-session-token'];
399
+ if (newSessionToken) this.sessionToken = newSessionToken;
301
400
  const setCookie = res.headers['set-cookie'];
302
401
  if (setCookie) {
303
402
  const list = Array.isArray(setCookie) ? setCookie : [setCookie];
@@ -388,5 +487,12 @@ module.exports = {
388
487
  buildMultisiteBlock,
389
488
  deriveSubsiteSlug,
390
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,
391
495
  PROBE_PROTOCOL_VERSION,
496
+ PROBE_PER_PAGE,
497
+ PROBE_PAGE_CAP,
392
498
  };