@wickedevolutions/abilities-mcp 1.5.1 → 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,81 @@
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
+
30
+ ## [1.5.3] - 2026-05-04
31
+
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.
33
+
34
+ Bridge-only release — no companion adapter or ai release this hotfix.
35
+
36
+ ### Fixed
37
+
38
+ - **`KeychainSecretStore` falls back to the macOS `security` CLI when keytar fails to load on darwin** (PR [#40](https://github.com/Wicked-Evolutions/abilities-mcp/pull/40), closes [#39](https://github.com/Wicked-Evolutions/abilities-mcp/issues/39)). When `require('keytar')` throws on darwin (the hardened-runtime Team ID mismatch), `_load()` sets `_fallbackMode = 'security-cli'` instead of throwing. `get` / `set` / `delete` then dispatch to `security find-generic-password -w` / `add-generic-password -U` / `delete-generic-password` via `child_process.execFile` (no shell — argv passes verbatim, no shell-injection surface). `findAll` returns `[]` in fallback mode (security CLI has no clean enumerate-by-service; the bridge runtime path doesn't depend on it — only the CLI subcommand `list-sites` does, which runs in system Node where keytar loads normally). Stderr matching `/could not be found/i` maps to keytar's null (get) / false (delete) return semantics; other stderr propagates as `SecretStoreError` code `security_cli_failed`. `isAvailable()` returns true in both keytar and fallback modes. **Linux/Windows behavior unchanged** — keytar load failures on those platforms still throw `keytar_unavailable` (no fallback engaged; the security CLI is darwin-only). Test seams (`requireKeytar`, `platform`, `exec` injection on the constructor) added for fallback-path unit testing without breaking the test runtime's real `require('keytar')`. New `test/auth/keychain-secret-store-darwin-fallback.test.js` covers keytar-success preservation, fallback engagement on darwin, "could not be found" mapping, error propagation, `isAvailable` in both modes, linux/win32 throw-not-fallback, and `findAll` in fallback mode. The first time the `.mcpb`-installed bridge accesses a keychain entry on darwin, macOS will prompt for keychain access — operator clicks "Always Allow" once per entry and the prompt persists thereafter. This is a macOS keychain ACL property; the same prompt would have appeared with keytar-native if it had loaded.
39
+
40
+ ### Internal
41
+
42
+ - **Test count:** `265 → 275` (+10 in `keychain-secret-store-darwin-fallback.test.js`).
43
+ - **Bundle size unchanged** (~420 kB packed, ~1.3 MB unpacked — the four platform-specific keytar binaries dominate; the secret-store code change is small relative to that).
44
+
45
+ ### Known unverified — research outstanding for a follow-up release
46
+
47
+ - **Linux:** whether keytar's libsecret native binding loads cleanly inside Linux Claude Desktop's `.mcpb` runtime is unverified. Likely works (Linux's runtime model differs from macOS — no Team ID matching), but no Linux Claude Desktop access during this hotfix to test empirically. Operators on Linux Claude Desktop should test and report.
48
+ - **Windows:** whether keytar's win32 native binding loads cleanly inside Windows Claude Desktop's hardened process is unverified. If it doesn't, a separate Windows-specific fix shape is needed (PowerShell credential cmdlets, or formally limiting Windows operators to the CLI install path). Tracked for a follow-up release.
49
+
50
+ ## [1.5.2] - 2026-05-03
51
+
52
+ **OAuth flow now works inside `.mcpb`.** This release makes the documented `.mcpb` operator UX work end-to-end: install the extension from Claude Desktop with an Application Password, then run `abilities-mcp upgrade-auth <site>` from a terminal to migrate that single connection to OAuth in place, then `abilities-mcp add-site https://other.com` to add more sites — all surfacing through the same Claude Desktop "Abilities MCP" entry. Before this release, the keytar binary wasn't bundled with the `.mcpb` and the `.mcpb` install never persisted to `~/.abilities-mcp/wp-sites.json`, so the documented progression failed at the moment OAuth touched the keychain.
53
+
54
+ Bridge-only release — no companion adapter or ai release this sprint.
55
+
56
+ ### Fixed
57
+
58
+ - **`.mcpb` bundle now ships keytar prebuilds for darwin x64, darwin arm64, win32 x64, and linux x64** (PR [#35](https://github.com/Wicked-Evolutions/abilities-mcp/pull/35), closes [#33](https://github.com/Wicked-Evolutions/abilities-mcp/issues/33)). Without these, `KeychainSecretStore` failed at first request with `Cannot find module 'keytar'` even on the host platform — verified empirically against the v1.5.1 bundle. The pack pipeline moves from a single-line `mcpb pack` invocation to a staging-directory build (`scripts/pack-mcpb.js`) that fetches each platform's prebuild via `prebuild-install` and patches keytar's hardcoded single-slot loader (`var keytar = require('../build/Release/keytar.node')` in keytar 7.9.0) with a multi-platform-aware loader keyed on `process.platform`-`process.arch`. The patch is staging-only — `node_modules/keytar/` in the project tree is byte-identical pre-pack and post-pack, pinned by the new `scripts/verify-pack-isolation.js` (run via `npm run verify:pack-isolation`). Pre-patch substring assertion on the upstream loader fails loud if a future keytar bump changes the loader shape.
59
+
60
+ - **Bridge emits one operator-visible `Config source:` line on startup to stderr** (PR [#36](https://github.com/Wicked-Evolutions/abilities-mcp/pull/36), closes [#32](https://github.com/Wicked-Evolutions/abilities-mcp/issues/32)). Captured in Claude Desktop's per-server MCP log so the operator can tell at a glance which `loadConfig` source won. Names the source (`env-var` / `[explicit-config]` / `[script-adjacent]` / `[home-dir]` / `legacy-cli`), the file path (tildified) or hostname, the site count, and the per-site auth method. Sample output:
61
+ ```
62
+ Config source: ABILITIES_MCP_URL env var (single-site basic auth: example.com as wp_user)
63
+ Config source: [home-dir] ~/.abilities-mcp/wp-sites.json (3 sites: helena oauth, wicked oauth, tnn apppassword)
64
+ ```
65
+ No secrets — only IDs, methods, hostnames, paths, counts. Always-on, not gated by `--debug`.
66
+
67
+ - **`.mcpb` install seeds `~/.abilities-mcp/wp-sites.json` on first launch** (PR [#37](https://github.com/Wicked-Evolutions/abilities-mcp/pull/37), closes [#34](https://github.com/Wicked-Evolutions/abilities-mcp/issues/34)). When the env-var-mode bridge boots and the home-dir config doesn't exist, `seedFromEnvIfMissing` writes a v2 apppassword entry derived from the `ABILITIES_MCP_*` env vars before serving the first MCP request. `list-sites`, `upgrade-auth`, and `add-site` now operate on a single source of truth that already includes the `.mcpb`-installed site. The site-id is derived from the URL hostname, matching `add-site`'s `deriveSiteId`. Guards: pre-existing `wp-sites.json` is **never overwritten**; missing env vars / malformed URL / keytar unavailable → graceful no-op (bridge falls back to env-var-only mode); file-write failure → keychain entry rolled back so operators don't accumulate orphan secrets.
68
+
69
+ ### Changed
70
+
71
+ - **keytar pinned `^7.9.0` → `~7.9.0`** (patch versions only) so the staging script's pre-patch loader-shape substring assertion has a stable target. CLI install behavior is unchanged — keytar stays in `optionalDependencies` (skips gracefully on platforms without prebuilds).
72
+ - **`_configSource` discriminant renamed `'env'` → `'env-var'`** to align with the documented set (`explicit-config`, `script-adjacent`, `home-dir`, `env-var`, `legacy-cli`). Internal field, prefixed with underscore; only one test was reading the prior value (updated).
73
+
74
+ ### Internal
75
+
76
+ - **Bundle size:** `~115 kB → ~413 kB` packed / `~1.2 MB` unpacked. The four platform-specific keytar prebuilds (darwin-x64 ~83 kB, darwin-arm64 ~99 kB, win32-x64 ~707 kB, linux-x64 ~76 kB) are embedded for the `.mcpb` install path. CLI install paths are unaffected — keytar stays in `optionalDependencies` and is host-only via the operator's `npm install`.
77
+ - **Test count:** `237 → 265` (+28 across the sprint: 0 new in PR #35, +15 in PR #36, +13 in PR #37). Node CI matrix unchanged: 18, 20, 22.
78
+ - New maintenance scripts: `npm run pack:mcpb` (staging build + multi-platform prebuild fetch), `npm run verify:pack-isolation` (asserts `node_modules/keytar/` byte-identity across pack runs).
79
+
5
80
  ## [1.5.1] - 2026-05-02
6
81
 
7
82
  Stretch-to-stable release. Closes the OAuth 2.1 alpha audit pass and the two integration-seam regressions surfaced during Helena's Phase B operator verification, plus the async-config tech-debt sweep that shares the bridge's startup path. No new features, no surface changes — the v1.5.x line is now stable for broader operator adoption.
package/abilities-mcp.js CHANGED
@@ -24,6 +24,7 @@
24
24
 
25
25
  const { createLogger } = require('./lib/logger');
26
26
  const { loadConfig, buildSiteKeyEnum, resolveConfigFilePath } = require('./lib/config');
27
+ const { formatConfigSourceLine } = require('./lib/config-source-line');
27
28
  const { ConnectionPool } = require('./lib/connection-pool');
28
29
  const { ToolCatalog } = require('./lib/tool-catalog');
29
30
  const { McpRouter } = require('./lib/router');
@@ -141,6 +142,13 @@ if (!isSubcommandInvocation) {
141
142
  process.exit(1);
142
143
  }
143
144
 
145
+ // Emit a single config-source line to stderr so operators can diagnose
146
+ // which mode the bridge is in at a glance (Claude Desktop's MCP log
147
+ // captures the server's stderr stream). Always-on, not gated by --debug:
148
+ // operator-visibility is the entire point of #32 and createLogger is a
149
+ // debug-only file logger that wouldn't reach Claude Desktop's log.
150
+ process.stderr.write(formatConfigSourceLine(config) + '\n');
151
+
144
152
  const isMultiSite = config._isMultiSite;
145
153
  const siteKeys = buildSiteKeyEnum(config);
146
154
  log(`Config loaded: ${siteKeys.length} site(s): ${siteKeys.join(', ')} (default: ${config.defaultSite})`);
@@ -1,16 +1,36 @@
1
1
  'use strict';
2
2
 
3
+ const { execFile } = require('node:child_process');
4
+
3
5
  const { SecretStoreError } = require('./errors');
4
6
 
5
7
  /**
6
- * KeychainSecretStore — keytar-backed SecretStore.
8
+ * KeychainSecretStore — keytar-backed SecretStore with a darwin-only
9
+ * `security` CLI fallback for Claude Desktop's hardened-runtime barrier.
10
+ *
11
+ * keytar wraps macOS Keychain, Windows Credential Manager, and Linux libsecret
12
+ * via a native binding (`build/Release/keytar.node`). It is declared as an
13
+ * `optionalDependency` so a failed native build does not break `npm install`
14
+ * for env-var-only operators.
15
+ *
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).
26
+ *
27
+ * Outside the .mcpb path — system Node, CLI install, npx, source clone —
28
+ * keytar loads normally and the fallback never engages.
7
29
  *
8
- * keytar wraps macOS Keychain, Windows Credential Manager, and Linux libsecret.
9
- * It is declared as an `optionalDependency` so a failed native build does not
10
- * break `npm install` for env-var-only operators. If keytar is unavailable at
11
- * runtime, every method on this store throws `SecretStoreError` with code
12
- * `keytar_unavailable` — callers can detect that and fall back to a different
13
- * store (e.g. MemorySecretStore for tests, or surface to the user).
30
+ * If keytar is unavailable at runtime AND the darwin fallback also can't run,
31
+ * every method throws `SecretStoreError` with code `keytar_unavailable`
32
+ * callers can detect that and fall back to a different store (e.g.
33
+ * MemorySecretStore for tests, or surface to the user).
14
34
  *
15
35
  * Implements the SecretStore interface defined in `secret-store.js`.
16
36
  *
@@ -21,38 +41,67 @@ const { SecretStoreError } = require('./errors');
21
41
  class KeychainSecretStore {
22
42
  /**
23
43
  * @param {object} [opts]
24
- * @param {object} [opts.keytar] Inject a keytar module — primarily for tests.
25
- * When omitted, keytar is required lazily on
26
- * first use.
44
+ * @param {object} [opts.keytar] Inject a keytar module — primarily for tests.
45
+ * When omitted, keytar is required lazily on
46
+ * first use.
47
+ * @param {Function} [opts.requireKeytar] Override the require call used to load
48
+ * keytar. Test seam: pass a function that
49
+ * throws to simulate the .mcpb-path dlopen
50
+ * rejection without breaking the real
51
+ * require('keytar') in the test runtime.
52
+ * @param {string} [opts.platform] Override `process.platform` for the
53
+ * fallback-eligibility decision. Test seam.
54
+ * @param {Function} [opts.exec] Override `child_process.execFile`. Test
55
+ * seam for the security-CLI fallback path.
27
56
  */
28
57
  constructor(opts = {}) {
29
58
  this._injected = opts.keytar || null;
30
59
  this._keytar = null;
31
60
  this._loadAttempted = false;
32
61
  this._loadError = null;
62
+ this._fallbackMode = null; // null | 'security-cli'
63
+
64
+ this._requireKeytar = opts.requireKeytar || ((id) => require(id));
65
+ this._platform = opts.platform || process.platform;
66
+ this._exec = opts.exec || execFile;
33
67
  }
34
68
 
69
+ /**
70
+ * 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)
74
+ */
35
75
  _load() {
36
- if (this._keytar) return this._keytar;
76
+ if (this._keytar || this._fallbackMode) {
77
+ return;
78
+ }
37
79
  if (this._loadAttempted) {
38
- if (this._loadError) {
39
- throw new SecretStoreError(
40
- `OS keychain unavailable: ${this._loadError.message}`,
41
- { code: 'keytar_unavailable', cause: this._loadError }
42
- );
43
- }
44
- return this._keytar;
80
+ // Previously failed and we cached the error.
81
+ throw new SecretStoreError(
82
+ `OS keychain unavailable: ${this._loadError.message}`,
83
+ { code: 'keytar_unavailable', cause: this._loadError }
84
+ );
45
85
  }
46
86
  this._loadAttempted = true;
87
+
47
88
  if (this._injected) {
48
89
  this._keytar = this._injected;
49
- return this._keytar;
90
+ return;
50
91
  }
92
+
51
93
  try {
52
- // eslint-disable-next-line global-require
53
- this._keytar = require('keytar');
54
- return this._keytar;
94
+ this._keytar = this._requireKeytar('keytar');
95
+ return;
55
96
  } 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
+ }
56
105
  this._loadError = err;
57
106
  throw new SecretStoreError(
58
107
  `OS keychain unavailable: ${err.message}`,
@@ -61,7 +110,10 @@ class KeychainSecretStore {
61
110
  }
62
111
  }
63
112
 
64
- /** @returns {Promise<boolean>} true if keytar can be loaded on this host. */
113
+ /**
114
+ * @returns {Promise<boolean>} true if keytar can be loaded on this host OR
115
+ * the darwin security-CLI fallback is engaged.
116
+ */
65
117
  async isAvailable() {
66
118
  try {
67
119
  this._load();
@@ -72,27 +124,142 @@ class KeychainSecretStore {
72
124
  }
73
125
 
74
126
  async get(service, account) {
75
- const keytar = this._load();
76
- return keytar.getPassword(service, account);
127
+ this._load();
128
+ if (this._fallbackMode === 'security-cli') {
129
+ return this._securityGet(service, account);
130
+ }
131
+ return this._keytar.getPassword(service, account);
77
132
  }
78
133
 
79
134
  async set(service, account, secret) {
80
135
  if (typeof secret !== 'string') {
81
136
  throw new TypeError('SecretStore.set: secret must be a string');
82
137
  }
83
- const keytar = this._load();
84
- await keytar.setPassword(service, account, secret);
138
+ this._load();
139
+ if (this._fallbackMode === 'security-cli') {
140
+ return this._securitySet(service, account, secret);
141
+ }
142
+ await this._keytar.setPassword(service, account, secret);
85
143
  }
86
144
 
87
145
  async delete(service, account) {
88
- const keytar = this._load();
89
- return keytar.deletePassword(service, account);
146
+ this._load();
147
+ if (this._fallbackMode === 'security-cli') {
148
+ return this._securityDelete(service, account);
149
+ }
150
+ return this._keytar.deletePassword(service, account);
90
151
  }
91
152
 
92
153
  async findAll(service) {
93
- const keytar = this._load();
94
- return keytar.findCredentials(service);
154
+ this._load();
155
+ 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
+ return [];
162
+ }
163
+ return this._keytar.findCredentials(service);
95
164
  }
165
+
166
+ // ---------------------------------------------------------------------
167
+ // darwin `security` CLI fallback — internal helpers.
168
+ // ---------------------------------------------------------------------
169
+
170
+ /**
171
+ * Run the `security` CLI with the given args. Returns { stdout, stderr }
172
+ * on success, rejects with an error carrying `.stderr` / `.stdout` /
173
+ * `.code` (exit code) on failure. Uses `execFile` (not `exec`) so args
174
+ * are not shell-interpreted — the password / account / service strings
175
+ * pass through verbatim, no shell injection surface.
176
+ */
177
+ _execSecurity(args) {
178
+ return new Promise((resolve, reject) => {
179
+ this._exec('security', args, {}, (err, stdout, stderr) => {
180
+ const stdoutStr = typeof stdout === 'string'
181
+ ? stdout
182
+ : (stdout ? stdout.toString() : '');
183
+ const stderrStr = typeof stderr === 'string'
184
+ ? stderr
185
+ : (stderr ? stderr.toString() : '');
186
+ if (err) {
187
+ // Real child_process.execFile populates err.stderr / err.stdout
188
+ // and ALSO passes them as cb args. Preserve whichever the caller
189
+ // already attached (some test doubles attach to the err object
190
+ // and don't pass via cb args); fall back to the cb args otherwise.
191
+ if (typeof err.stderr !== 'string') err.stderr = stderrStr;
192
+ if (typeof err.stdout !== 'string') err.stdout = stdoutStr;
193
+ return reject(err);
194
+ }
195
+ resolve({ stdout: stdoutStr, stderr: stderrStr });
196
+ });
197
+ });
198
+ }
199
+
200
+ async _securityGet(service, account) {
201
+ try {
202
+ const { stdout } = await this._execSecurity([
203
+ 'find-generic-password', '-s', service, '-a', account, '-w',
204
+ ]);
205
+ // -w prints just the password to stdout, terminated by a newline.
206
+ return stdout.replace(/\n$/, '');
207
+ } catch (err) {
208
+ if (_isNotFound(err)) return null;
209
+ throw new SecretStoreError(
210
+ `security find-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
211
+ { code: 'security_cli_failed', cause: err }
212
+ );
213
+ }
214
+ }
215
+
216
+ async _securitySet(service, account, secret) {
217
+ // -U updates the existing entry if present, adds it otherwise.
218
+ // Note: passing the password as the last argv element is the standard
219
+ // pattern for non-interactive `security` use; the macOS `security` CLI
220
+ // exposes no stdin-only password input mode for non-interactive callers.
221
+ // This is the same trade-off keytar's own native binding makes — the
222
+ // password lives in process memory until the syscall completes.
223
+ try {
224
+ await this._execSecurity([
225
+ 'add-generic-password', '-U', '-s', service, '-a', account, '-w', secret,
226
+ ]);
227
+ } catch (err) {
228
+ throw new SecretStoreError(
229
+ `security add-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
230
+ { code: 'security_cli_failed', cause: err }
231
+ );
232
+ }
233
+ }
234
+
235
+ async _securityDelete(service, account) {
236
+ try {
237
+ await this._execSecurity([
238
+ 'delete-generic-password', '-s', service, '-a', account,
239
+ ]);
240
+ return true;
241
+ } catch (err) {
242
+ if (_isNotFound(err)) return false;
243
+ throw new SecretStoreError(
244
+ `security delete-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
245
+ { code: 'security_cli_failed', cause: err }
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Detect the macOS `security` CLI's "entry not found" condition. Stderr from
253
+ * `find-generic-password` / `delete-generic-password` against a missing entry
254
+ * looks like:
255
+ * security: SecKeychainSearchCopyNext: The specified item could not be
256
+ * found in the keychain.
257
+ * Match on the substring "could not be found" (case-insensitive) to map it
258
+ * to keytar's null/false return semantics.
259
+ */
260
+ function _isNotFound(err) {
261
+ const stderr = (err && err.stderr) || '';
262
+ return /could not be found/i.test(stderr);
96
263
  }
97
264
 
98
265
  module.exports = { KeychainSecretStore };
@@ -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
  /**
@@ -6,8 +6,12 @@ const os = require('node:os');
6
6
 
7
7
  const { SCHEMA_VERSION, validate, emptyConfig } = require('../auth/schema-v2');
8
8
  const { _atomicWrite } = require('../auth/config-migration');
9
+ const { AUTH_STATUS } = require('../auth/events');
10
+ const { makeRef } = require('../auth/secret-store');
9
11
  const { CliError, EXIT_CONFIG } = require('./errors');
10
12
 
13
+ const SECRET_SERVICE = 'abilities-mcp';
14
+
11
15
  /**
12
16
  * Read / write the v2 wp-sites.json file from a CLI command.
13
17
  *
@@ -152,10 +156,173 @@ function freshConfig() {
152
156
  return emptyConfig();
153
157
  }
154
158
 
159
+ /**
160
+ * Derive a site-id from a URL hostname. Mirrors `add-site`'s deriveSiteId so a
161
+ * `.mcpb`-seeded site collides with a CLI-added entry for the same host (the
162
+ * file-absence guard in `seedFromEnvIfMissing` prevents the collision in
163
+ * practice; the parity matters for `upgrade-auth <site-id>` to be intuitive).
164
+ *
165
+ * @param {string} siteUrl
166
+ * @returns {string|null} Site-id or null if URL is unparseable.
167
+ */
168
+ function deriveSiteId(siteUrl) {
169
+ let host;
170
+ try { host = new URL(siteUrl).hostname; }
171
+ catch { return null; }
172
+ const trimmed = host.replace(/^www\./, '');
173
+ const dot = trimmed.indexOf('.');
174
+ return dot > 0 ? trimmed.slice(0, dot) : trimmed;
175
+ }
176
+
177
+ /**
178
+ * Seed wp-sites.json from env vars (`ABILITIES_MCP_URL/USERNAME/PASSWORD`)
179
+ * when the file doesn't yet exist. Used on first launch of the `.mcpb`
180
+ * extension so subsequent CLI commands (`list-sites`, `upgrade-auth`,
181
+ * `add-site`) operate on a single source of truth that already includes
182
+ * the site Claude Desktop is connected to.
183
+ *
184
+ * Behavior:
185
+ * - If `configPath` already exists → no-op. Operators who manage their own
186
+ * `wp-sites.json` are never overwritten.
187
+ * - If keytar isn't loadable on this host (e.g. the .mcpb is somehow
188
+ * running without the bundled keytar prebuild) → no-op. The bridge
189
+ * falls back to env-var-only mode. Graceful degradation.
190
+ * - If any of the three env vars is missing → no-op. Should not happen
191
+ * in the .mcpb path (manifest user_config marks all three required) but
192
+ * guards against partial env in other invocations.
193
+ * - Otherwise: writes the App Password to keychain via the shared
194
+ * SecretStore, builds a v2 apppassword entry shaped to pass both the
195
+ * schema-v2 validator and the bridge's runtime validateSiteConfig
196
+ * (matching the migration `_convertSite` pattern — preserves
197
+ * `transport: 'http'` and the legacy http block alongside the v2 auth
198
+ * block, with `password_ref` in both).
199
+ *
200
+ * If the keychain write succeeds but the file write fails the keychain
201
+ * entry is rolled back so the operator's keychain doesn't accumulate
202
+ * orphans on repeated failures.
203
+ *
204
+ * @param {string} configPath Absolute path of the wp-sites.json to seed.
205
+ * @param {object} env Environment shape — expects ABILITIES_MCP_URL,
206
+ * ABILITIES_MCP_USERNAME, ABILITIES_MCP_PASSWORD.
207
+ * Defaults to process.env.
208
+ * @param {object} [deps]
209
+ * @param {object} [deps.secretStore] Inject for tests (a MemorySecretStore).
210
+ * Defaults to a fresh KeychainSecretStore.
211
+ * @returns {Promise<{
212
+ * seeded: boolean,
213
+ * reason?: 'exists'|'missing-env-vars'|'keytar-unavailable'|'invalid-url'|'error',
214
+ * siteId?: string,
215
+ * configPath?: string,
216
+ * error?: Error,
217
+ * }>}
218
+ */
219
+ async function seedFromEnvIfMissing(configPath, env, deps = {}) {
220
+ if (!configPath) {
221
+ return { seeded: false, reason: 'missing-env-vars' };
222
+ }
223
+ if (fs.existsSync(configPath)) {
224
+ return { seeded: false, reason: 'exists' };
225
+ }
226
+
227
+ const url = env && env.ABILITIES_MCP_URL;
228
+ const username = env && env.ABILITIES_MCP_USERNAME;
229
+ const password = env && env.ABILITIES_MCP_PASSWORD;
230
+ if (!url || !username || !password) {
231
+ return { seeded: false, reason: 'missing-env-vars' };
232
+ }
233
+
234
+ let parsedUrl;
235
+ try { parsedUrl = new URL(url); }
236
+ catch { return { seeded: false, reason: 'invalid-url' }; }
237
+
238
+ const siteId = deriveSiteId(url);
239
+ if (!siteId) {
240
+ return { seeded: false, reason: 'invalid-url' };
241
+ }
242
+
243
+ // Lazily build a SecretStore so SSH-only / env-var-only setups never load
244
+ // keytar on the seed path — the no-op "missing env vars" exit above keeps
245
+ // them out, but keep the require deferred for symmetry with the runtime.
246
+ let secretStore = deps.secretStore;
247
+ if (!secretStore) {
248
+ const { KeychainSecretStore } = require('../auth/keychain-secret-store');
249
+ secretStore = new KeychainSecretStore();
250
+ }
251
+
252
+ // Probe keytar before writing. If it isn't loadable (e.g. the .mcpb
253
+ // somehow shipped without the bundled binary) we skip seeding — the
254
+ // bridge keeps working in env-var-only mode and the operator can run
255
+ // `abilities-mcp add-site` from a CLI install instead.
256
+ if (typeof secretStore.isAvailable === 'function') {
257
+ const available = await secretStore.isAvailable();
258
+ if (!available) {
259
+ return { seeded: false, reason: 'keytar-unavailable' };
260
+ }
261
+ }
262
+
263
+ // Build the endpoint the same way buildEnvConfig does — strip trailing
264
+ // slash, append the adapter route. This is the URL the runtime will hit
265
+ // for App-Password requests.
266
+ const base = (parsedUrl.origin + parsedUrl.pathname).replace(/\/+$/, '');
267
+ const endpoint = `${base}/wp-json/mcp/mcp-adapter-default-server`;
268
+ const account = `${siteId}/apppassword`;
269
+ const passwordRef = makeRef(SECRET_SERVICE, account);
270
+
271
+ // Write the secret to keychain first. If the file write below fails we
272
+ // roll this back so the keychain doesn't accumulate orphan entries on
273
+ // repeated seed attempts.
274
+ try {
275
+ await secretStore.set(SECRET_SERVICE, account, password);
276
+ } catch (err) {
277
+ return { seeded: false, reason: 'error', error: err };
278
+ }
279
+
280
+ const allowInsecure = parsedUrl.protocol === 'http:';
281
+ const site = {
282
+ label: parsedUrl.hostname,
283
+ url: parsedUrl.origin,
284
+ transport: 'http',
285
+ http: {
286
+ endpoint,
287
+ username,
288
+ password_ref: passwordRef,
289
+ },
290
+ auth: {
291
+ method: 'apppassword',
292
+ username,
293
+ password_ref: passwordRef,
294
+ },
295
+ auth_status: AUTH_STATUS.ACTIVE,
296
+ };
297
+ if (allowInsecure) site.allowInsecure = true;
298
+
299
+ const v2Config = {
300
+ $schema: 'https://wickedevolutions.com/schemas/abilities-mcp/wp-sites/v2.json',
301
+ schema_version: SCHEMA_VERSION,
302
+ defaultSite: siteId,
303
+ sites: { [siteId]: site },
304
+ };
305
+
306
+ try {
307
+ const dir = path.dirname(configPath);
308
+ await fs.promises.mkdir(dir, { recursive: true });
309
+ await _atomicWrite(configPath, v2Config);
310
+ } catch (err) {
311
+ // Roll back the keychain write so we don't leave an orphan secret.
312
+ try { await secretStore.delete(SECRET_SERVICE, account); }
313
+ catch { /* best-effort rollback */ }
314
+ return { seeded: false, reason: 'error', error: err };
315
+ }
316
+
317
+ return { seeded: true, siteId, configPath };
318
+ }
319
+
155
320
  module.exports = {
156
321
  resolveConfigPath,
157
322
  readConfig,
158
323
  writeConfig,
159
324
  freshConfig,
325
+ seedFromEnvIfMissing,
326
+ deriveSiteId,
160
327
  HOME_DIR_REL,
161
328
  };
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
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+
5
+ /**
6
+ * Format the operator-visible startup diagnostic line that names which config
7
+ * source `loadConfig` resolved to and what's in it.
8
+ *
9
+ * Output goes to stderr where Claude Desktop's MCP log captures it
10
+ * (visible in `mcp-server-WordPress (Abilities MCP).log` on macOS), so the
11
+ * operator can tell at a glance:
12
+ * - Whether the .mcpb extension is in env-var single-site mode or has
13
+ * handed off to a home-dir wp-sites.json.
14
+ * - How many sites are configured and what auth method each uses.
15
+ * - Which file path or env var is the source of truth right now.
16
+ *
17
+ * Discriminants set in `lib/config.js`:
18
+ * - 'explicit-config' — args.config / --config=<path>
19
+ * - 'script-adjacent' — wp-sites.json next to abilities-mcp.js
20
+ * - 'home-dir' — ~/.abilities-mcp/wp-sites.json
21
+ * - 'env-var' — ABILITIES_MCP_URL injected by Claude Desktop user_config
22
+ * - 'legacy-cli' — --host / --path (mcp-ssh-bridge backward compat)
23
+ *
24
+ * The line never includes secrets — only site IDs, auth methods, hostnames,
25
+ * tildified file paths, and counts.
26
+ *
27
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
28
+ * @license GPL-2.0-or-later
29
+ */
30
+
31
+ /**
32
+ * Replace a leading $HOME prefix with `~/` so logs don't leak the operator's
33
+ * full username path. No-op for paths outside $HOME.
34
+ */
35
+ function tildify(p) {
36
+ if (!p) return p;
37
+ const home = os.homedir();
38
+ if (home && (p === home || p.startsWith(home + '/'))) {
39
+ return '~' + p.slice(home.length);
40
+ }
41
+ return p;
42
+ }
43
+
44
+ /**
45
+ * Per-site short auth label: prefer auth.method (v2 schema), fall back to
46
+ * transport (v1 schema), 'unknown' if neither is set.
47
+ */
48
+ function siteAuthLabel(site) {
49
+ if (site && site.auth && site.auth.method) return site.auth.method;
50
+ if (site && site.transport) return site.transport;
51
+ return 'unknown';
52
+ }
53
+
54
+ /**
55
+ * Build the Config-source line.
56
+ *
57
+ * @param {object} config Output of `loadConfig` — must carry `_configSource`
58
+ * and `_configSourceLabel`.
59
+ * @returns {string} One-line operator diagnostic, no trailing newline.
60
+ */
61
+ function formatConfigSourceLine(config) {
62
+ const source = config && config._configSource;
63
+ const rawLabel = (config && config._configSourceLabel) || '';
64
+
65
+ if (source === 'env-var') {
66
+ const site = config.sites && config.sites[config.defaultSite];
67
+ const username = (site && site.http && site.http.username) || '?';
68
+ return `Config source: ABILITIES_MCP_URL env var (single-site basic auth: ${rawLabel} as ${username})`;
69
+ }
70
+
71
+ if (source === 'legacy-cli') {
72
+ return `Config source: --host/--path legacy CLI (single-site SSH: ${rawLabel})`;
73
+ }
74
+
75
+ // File-based: explicit-config / script-adjacent / home-dir
76
+ const label = tildify(rawLabel);
77
+ const siteEntries = Object.entries(config.sites || {}).map(
78
+ ([id, site]) => `${id} ${siteAuthLabel(site)}`
79
+ );
80
+ const sitesHeader = siteEntries.length === 1 ? '1 site' : `${siteEntries.length} sites`;
81
+ const sourcePrefix = source ? `[${source}] ` : '';
82
+ return `Config source: ${sourcePrefix}${label} (${sitesHeader}: ${siteEntries.join(', ')})`;
83
+ }
84
+
85
+ module.exports = { formatConfigSourceLine, tildify, siteAuthLabel };
package/lib/config.js CHANGED
@@ -75,25 +75,39 @@ async function resolveConfigFilePath(args) {
75
75
  async function loadConfig(args) {
76
76
  // Explicit config path
77
77
  if (args.config) {
78
- return loadConfigFile(args.config);
78
+ return loadConfigFile(args.config, 'explicit-config');
79
79
  }
80
80
 
81
81
  // Check alongside script (lib/ → package root)
82
82
  const scriptDir = path.resolve(__dirname, '..');
83
83
  const scriptConfig = path.join(scriptDir, 'wp-sites.json');
84
84
  if (await _exists(scriptConfig)) {
85
- return loadConfigFile(scriptConfig);
85
+ return loadConfigFile(scriptConfig, 'script-adjacent');
86
86
  }
87
87
 
88
88
  // Check home directory
89
89
  const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
90
90
  if (await _exists(homeConfig)) {
91
- return loadConfigFile(homeConfig);
91
+ return loadConfigFile(homeConfig, 'home-dir');
92
92
  }
93
93
 
94
94
  // Env-var single-site config — covers the .mcpb install path and any
95
95
  // env-var-based MCP client configuration (claude mcp add, Docker, etc.)
96
+ //
97
+ // First-launch seed (#34): if the .mcpb just installed and no home-dir
98
+ // wp-sites.json exists yet, seed one from the env vars so subsequent CLI
99
+ // commands (`list-sites`, `upgrade-auth`, `add-site`) operate on a single
100
+ // source of truth that already includes the site Claude Desktop is
101
+ // connected to. On success the bridge loads the freshly seeded file —
102
+ // same shape it would read on next restart — so the runtime path stays
103
+ // identical regardless of whether seeding just happened. On failure
104
+ // (keytar unavailable, file-already-exists, write error) the seed is a
105
+ // graceful no-op and we fall back to env-var-only mode below.
96
106
  if (process.env.ABILITIES_MCP_URL) {
107
+ const seedResult = await _seedFromEnvIfMissing(homeConfig, process.env);
108
+ if (seedResult && seedResult.seeded) {
109
+ return loadConfigFile(homeConfig, 'home-dir');
110
+ }
97
111
  return buildEnvConfig(process.env);
98
112
  }
99
113
 
@@ -173,14 +187,15 @@ function buildEnvConfig(env) {
173
187
  return {
174
188
  defaultSite: 'default',
175
189
  _isMultiSite: false,
176
- _configSource: 'env',
190
+ _configSource: 'env-var',
191
+ _configSourceLabel: parsedUrl.hostname,
177
192
  sites: {
178
193
  default: siteConfig,
179
194
  },
180
195
  };
181
196
  }
182
197
 
183
- async function loadConfigFile(filePath) {
198
+ async function loadConfigFile(filePath, source = 'explicit-config') {
184
199
  const raw = await fsp.readFile(filePath, 'utf8');
185
200
 
186
201
  // Warn if config file is readable by group or world
@@ -216,6 +231,8 @@ async function loadConfigFile(filePath) {
216
231
  config._isMultiSite = Object.keys(config.sites).length > 1 ||
217
232
  Object.values(config.sites).some(s => s.multisite);
218
233
  config._configPath = filePath;
234
+ config._configSource = source;
235
+ config._configSourceLabel = filePath;
219
236
 
220
237
  return config;
221
238
  }
@@ -301,6 +318,8 @@ function buildLegacyConfig(args) {
301
318
  return {
302
319
  defaultSite: 'default',
303
320
  _isMultiSite: false,
321
+ _configSource: 'legacy-cli',
322
+ _configSourceLabel: args.host,
304
323
  sites: {
305
324
  default: {
306
325
  label: args.host,
@@ -428,6 +447,17 @@ async function resolveSitePassword(site, secretStore) {
428
447
  throw new Error('No password source configured for site');
429
448
  }
430
449
 
450
+ /**
451
+ * Lazy wrapper around `lib/cli/config-store.js#seedFromEnvIfMissing`. Only
452
+ * loads the CLI config-store + KeychainSecretStore modules when the env-var
453
+ * branch of `loadConfig` actually fires — so SSH-only, explicit-config, and
454
+ * legacy-CLI install paths never pay the keytar import cost.
455
+ */
456
+ async function _seedFromEnvIfMissing(configPath, env) {
457
+ const { seedFromEnvIfMissing } = require('./cli/config-store');
458
+ return seedFromEnvIfMissing(configPath, env);
459
+ }
460
+
431
461
  module.exports = {
432
462
  loadConfig,
433
463
  resolveConfigFilePath,
@@ -436,4 +466,5 @@ module.exports = {
436
466
  resolveSiteKey,
437
467
  buildSiteKeyEnum,
438
468
  buildEnvConfig,
469
+ validateSiteConfig,
439
470
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wickedevolutions/abilities-mcp",
3
- "version": "1.5.1",
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": {
@@ -17,12 +17,13 @@
17
17
  "scripts": {
18
18
  "test": "node --test test/*.test.js test/auth/*.test.js test/cli/*.test.js test/transports/*.test.js",
19
19
  "validate:mcpb": "npx --yes @anthropic-ai/mcpb validate manifest.json",
20
- "pack:mcpb": "npx --yes @anthropic-ai/mcpb pack . abilities-mcp.mcpb"
20
+ "pack:mcpb": "node scripts/pack-mcpb.js",
21
+ "verify:pack-isolation": "node scripts/verify-pack-isolation.js"
21
22
  },
22
23
  "engines": {
23
24
  "node": ">=18.0.0"
24
25
  },
25
26
  "optionalDependencies": {
26
- "keytar": "^7.9.0"
27
+ "keytar": "~7.9.0"
27
28
  }
28
29
  }