@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 +75 -0
- package/abilities-mcp.js +8 -0
- package/lib/auth/keychain-secret-store.js +198 -31
- package/lib/auth/oauth-client.js +11 -1
- package/lib/cli/commands/add-site.js +65 -2
- package/lib/cli/config-store.js +167 -0
- package/lib/cli/index.js +6 -1
- package/lib/cli/multisite-probe.js +392 -0
- package/lib/config-source-line.js +85 -0
- package/lib/config.js +36 -5
- package/package.json +4 -3
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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]
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
76
|
+
if (this._keytar || this._fallbackMode) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
37
79
|
if (this._loadAttempted) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
90
|
+
return;
|
|
50
91
|
}
|
|
92
|
+
|
|
51
93
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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 };
|
package/lib/auth/oauth-client.js
CHANGED
|
@@ -46,7 +46,17 @@ const {
|
|
|
46
46
|
* @license GPL-2.0-or-later
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// `abilities:multisite:read` / `abilities:multisite:write` are SENSITIVE_SCOPES
|
|
50
|
+
// in the adapter's ScopeRegistry (Auth/OAuth/ScopeRegistry.php) — never implied
|
|
51
|
+
// by the `abilities:read` / `abilities:write` umbrella expansion. Requesting
|
|
52
|
+
// them explicitly during DCR is the only way the consent screen can surface
|
|
53
|
+
// them for super-admin operators on a Multisite Network root, which in turn
|
|
54
|
+
// is the only way `add-site`'s post-OAuth multisite/list-sites probe (#43)
|
|
55
|
+
// can fire end-to-end. Single-site WP installs accept the request — the
|
|
56
|
+
// adapter simply won't grant scopes the OAuth user lacks WP capability for,
|
|
57
|
+
// so single-site UX is preserved.
|
|
58
|
+
const DEFAULT_SCOPE =
|
|
59
|
+
'abilities:read abilities:write abilities:multisite:read abilities:multisite:write';
|
|
50
60
|
const DEFAULT_LOOPBACK_TIMEOUT_MS = 5 * 60_000;
|
|
51
61
|
|
|
52
62
|
class OAuthClient extends EventEmitter {
|
|
@@ -12,6 +12,7 @@ const { makeRef } = require('../../auth/secret-store');
|
|
|
12
12
|
const { CliError, EXIT_USAGE, EXIT_CONFIG, fromAuthError } = require('../errors');
|
|
13
13
|
const { subscribeProgress } = require('../output');
|
|
14
14
|
const { readConfig, writeConfig, freshConfig } = require('../config-store');
|
|
15
|
+
const { probeMultisite: defaultProbeMultisite } = require('../multisite-probe');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* `add-site <url>` — register a new site with the bridge using OAuth (default)
|
|
@@ -108,6 +109,7 @@ async function run(args, ctx) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const out = [];
|
|
112
|
+
const errLines = [];
|
|
111
113
|
let exitCode = 0;
|
|
112
114
|
|
|
113
115
|
if (args.apppassword) {
|
|
@@ -139,7 +141,7 @@ async function run(args, ctx) {
|
|
|
139
141
|
await writeConfig(ctx.configPath, config);
|
|
140
142
|
out.push(`✓ Site "${siteId}" configured with App Password authentication.`);
|
|
141
143
|
out.push(` Config: ${ctx.configPath}`);
|
|
142
|
-
return { exitCode, lines: out };
|
|
144
|
+
return { exitCode, lines: out, errLines };
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
// OAuth path — full authorization-code + PKCE flow via OAuthClient.
|
|
@@ -199,12 +201,73 @@ async function run(args, ctx) {
|
|
|
199
201
|
if (result.prMetadata && result.prMetadata.resource) {
|
|
200
202
|
config.sites[siteId].mcp_resource = result.prMetadata.resource;
|
|
201
203
|
}
|
|
204
|
+
|
|
205
|
+
// Multisite Network root probe. If the freshly authenticated bridge can
|
|
206
|
+
// resolve `multisite/list-sites`, populate the multisite block so dot-
|
|
207
|
+
// notation routing works without operator JSON editing. Single-site,
|
|
208
|
+
// permission-denied, and network errors all degrade gracefully — the
|
|
209
|
+
// site entry is still written without a multisite block.
|
|
210
|
+
const probeEndpoint = result.prMetadata && result.prMetadata.resource;
|
|
211
|
+
if (probeEndpoint && result.tokens && result.tokens.access_token) {
|
|
212
|
+
const probe = (ctx.deps && ctx.deps.probeMultisite) || defaultProbeMultisite;
|
|
213
|
+
try {
|
|
214
|
+
const probeResult = await probe({
|
|
215
|
+
endpoint: probeEndpoint,
|
|
216
|
+
accessToken: result.tokens.access_token,
|
|
217
|
+
siteUrl: parsedUrl.origin,
|
|
218
|
+
log: typeof ctx.log === 'function' ? ctx.log : null,
|
|
219
|
+
deps: ctx.deps && ctx.deps.probeMultisiteDeps,
|
|
220
|
+
});
|
|
221
|
+
if (probeResult && probeResult.block && Object.keys(probeResult.block).length > 0) {
|
|
222
|
+
config.sites[siteId].multisite = probeResult.block;
|
|
223
|
+
const slugs = Object.keys(probeResult.block).join(', ');
|
|
224
|
+
out.push(` Multisite: discovered ${Object.keys(probeResult.block).length} subsite(s) → ${slugs}`);
|
|
225
|
+
}
|
|
226
|
+
} catch (probeErr) {
|
|
227
|
+
_appendProbeAdvisory(probeErr, siteId, errLines);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
202
231
|
if (!config.defaultSite) config.defaultSite = siteId;
|
|
203
232
|
await writeConfig(ctx.configPath, config);
|
|
204
233
|
|
|
205
234
|
out.push(`✓ Site "${siteId}" configured. Granted scopes: ${result.scopes.join(', ')}.`);
|
|
206
235
|
out.push(` Config: ${ctx.configPath}`);
|
|
207
|
-
return { exitCode, lines: out };
|
|
236
|
+
return { exitCode, lines: out, errLines };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Append a stderr advisory naming the multisite-probe failure so operators
|
|
241
|
+
* who expected dot-notation routing know why it isn't wired and can either
|
|
242
|
+
* fix the underlying issue or hand-add the block per existing docs.
|
|
243
|
+
*
|
|
244
|
+
* Silent for `tool_not_registered` (single-site is the expected case for
|
|
245
|
+
* the vast majority of `add-site` invocations).
|
|
246
|
+
*/
|
|
247
|
+
function _appendProbeAdvisory(probeErr, siteId, errLines) {
|
|
248
|
+
const code = probeErr && probeErr.code;
|
|
249
|
+
if (code === 'tool_not_registered') return;
|
|
250
|
+
|
|
251
|
+
if (code === 'permission_denied' || code === 'unauthorized') {
|
|
252
|
+
errLines.push(
|
|
253
|
+
`Multisite discovery skipped for "${siteId}": multisite/list-sites was rejected ` +
|
|
254
|
+
`by the adapter. Two possible causes — verify both before manually adding the block: ` +
|
|
255
|
+
`(1) the OAuth user lacks the manage_network_options WP capability (super-admin ` +
|
|
256
|
+
`required on a Multisite Network root), or (2) the OAuth token lacks the ` +
|
|
257
|
+
`abilities:multisite:read scope (it must be granted on the consent screen — ` +
|
|
258
|
+
`re-run add-site and confirm the multisite scope checkbox if it was unchecked). ` +
|
|
259
|
+
`Site entry written without multisite block — dot-notation subsite routing ` +
|
|
260
|
+
`will not be available until the block is added manually or add-site is re-run.`
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const detail = (probeErr && probeErr.message) || String(probeErr);
|
|
266
|
+
errLines.push(
|
|
267
|
+
`Multisite discovery failed for "${siteId}": ${detail}. ` +
|
|
268
|
+
`Site entry written without multisite block — dot-notation subsite routing ` +
|
|
269
|
+
`will not be available until the block is added manually or add-site is re-run.`
|
|
270
|
+
);
|
|
208
271
|
}
|
|
209
272
|
|
|
210
273
|
/**
|
package/lib/cli/config-store.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
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": "
|
|
27
|
+
"keytar": "~7.9.0"
|
|
27
28
|
}
|
|
28
29
|
}
|