@wickedevolutions/abilities-mcp 1.5.1 → 1.5.3

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,56 @@
2
2
 
3
3
  All notable changes to Abilities MCP are documented here.
4
4
 
5
+ ## [1.5.3] - 2026-05-04
6
+
7
+ **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.
8
+
9
+ Bridge-only release — no companion adapter or ai release this hotfix.
10
+
11
+ ### Fixed
12
+
13
+ - **`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.
14
+
15
+ ### Internal
16
+
17
+ - **Test count:** `265 → 275` (+10 in `keychain-secret-store-darwin-fallback.test.js`).
18
+ - **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).
19
+
20
+ ### Known unverified — research outstanding for a follow-up release
21
+
22
+ - **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.
23
+ - **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.
24
+
25
+ ## [1.5.2] - 2026-05-03
26
+
27
+ **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.
28
+
29
+ Bridge-only release — no companion adapter or ai release this sprint.
30
+
31
+ ### Fixed
32
+
33
+ - **`.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.
34
+
35
+ - **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:
36
+ ```
37
+ Config source: ABILITIES_MCP_URL env var (single-site basic auth: example.com as wp_user)
38
+ Config source: [home-dir] ~/.abilities-mcp/wp-sites.json (3 sites: helena oauth, wicked oauth, tnn apppassword)
39
+ ```
40
+ No secrets — only IDs, methods, hostnames, paths, counts. Always-on, not gated by `--debug`.
41
+
42
+ - **`.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.
43
+
44
+ ### Changed
45
+
46
+ - **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).
47
+ - **`_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).
48
+
49
+ ### Internal
50
+
51
+ - **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`.
52
+ - **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.
53
+ - 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).
54
+
5
55
  ## [1.5.1] - 2026-05-02
6
56
 
7
57
  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 };
@@ -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
  };
@@ -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.3",
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
  }