@wickedevolutions/abilities-mcp 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,85 @@
2
2
 
3
3
  All notable changes to Abilities MCP are documented here.
4
4
 
5
+ ## [1.6.0] - 2026-05-07
6
+
7
+ ### Fixed
8
+
9
+ - **macOS keychain backend defaults to security-CLI for cross-runtime ACL identity (Issue [#61](https://github.com/Wicked-Evolutions/abilities-mcp/issues/61), Alpha Release Gate Phase B.2).** v1.5.5's [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58) shipped `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` as an opt-in alignment, but the default `auto` backend still picked keytar in terminal / Claude Code contexts and security-CLI only in Claude Desktop's `.mcpb` runtime. Multi-client operator setups (Claude Desktop + Claude Code + Codex on the same workstation — the standard alpha install reality) saw a fresh macOS Keychain ACL prompt on every cross-runtime read, because each runtime's keychain syscall caller binary was a different ACL identity (system `node`, Codex's bundled `node`, `/usr/bin/security`). On darwin under the default `auto` backend, `KeychainSecretStore` now engages `/usr/bin/security` directly without attempting to load keytar. Every bridge spawn — Claude Desktop, Claude Code, Codex, terminal CLI — issues `SecKeychainItem*` calls through the same caller binary, so macOS's per-binary ACL trusted-application list contains exactly one entry. After the operator's first "Always Allow" the entry is silently readable from every runtime. The fix preserves least-privilege ACL semantics: no `-A` all-apps flag, no `-T` consumer-path additions, no broadening of the trusted-application set; the trusted set is *narrower* than today (one binary instead of N). Linux / win32 behavior unchanged (keytar via libsecret / Credential Manager — no analogous identity-split bug on those platforms).
10
+ - **Security CLI existence probe.** `_load()` now probes `/usr/bin/security` on first use and surfaces a typed `SecretStoreError` (`code: 'security_cli_unavailable'`) if absent, so corporate-locked or non-standard macOS hosts get a clear early diagnostic instead of an opaque execFile spawn failure on the first ability call.
11
+ - **`findAll()` darwin behavior:** `findAll()` is unavailable on the Darwin security-cli backend; current bridge flows do not rely on it. Linux / Windows (keytar) continue to enumerate normally.
12
+ - **Operator opt-out:** operators who need keytar on darwin (uncommon — debugging, custom build) can opt back in with `ABILITIES_MCP_KEYCHAIN_BACKEND=keytar`. Behavior matches v1.5.5: keytar is attempted, throws `SecretStoreError` (`code: 'keytar_unavailable'`) if it can't load.
13
+
14
+ ### Known limitations
15
+
16
+ - **Existing macOS keychain entries written under v1.5.5 keytar may prompt once on first read under the new default (Issue [#61](https://github.com/Wicked-Evolutions/abilities-mcp/issues/61)).** v1.5.5 wrote OAuth and App Password entries via keytar in terminal / Claude Code contexts, so the entry's macOS Keychain ACL trusted-application list contains the system `node` binary, not `/usr/bin/security`. After upgrading, the first read by `/usr/bin/security` against an existing entry triggers a one-time ACL prompt — operators have **two recovery paths** (same shape as the v1.5.5 [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58) opt-in note): (1) click **Always Allow** at the prompt — the ACL is updated to add `/usr/bin/security` and no further prompts appear from any runtime, OR (2) re-run `abilities-mcp add-site --force <site>` / `abilities-mcp reauth <site>` to write a fresh entry under the new default backend. New entries written from this release forward never carry the legacy ACL state. The remaining one-time prompt is UX, not security: existing keychain ACLs continue to protect secrets correctly throughout.
17
+
18
+ - **Multisite OAuth dot-notation routing is discovery-only in alpha (carried forward from v1.5.5; tracked on [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60)).** **Multisite discovery is available in alpha.** For OAuth subsite execution, add each subsite as a separate site entry using `add-site --site-id=<subsite-id>`. Use the explicit subsite IDs in your MCP client. Dot-notation subsite routing from a single network-root OAuth token is a known alpha limitation; a first-class fix is post-alpha design work tracked on [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60).
19
+
20
+ - **Default OAuth grants are intentionally baseline-scoped.** `add-site` / `upgrade-auth` request a baseline scope set (`abilities:read`, `abilities:write`, `abilities:multisite:read`, `abilities:multisite:write`) that expands to common read/write categories. Operators who need delete-tier scopes, sensitive WordPress core scopes (`users`, `settings`, `filesystem`, `plugins`, `cron`, `themes`, `rewrite`), suite scopes (`astra`, `spectra`, `surecart`, `surecart-ecommerce`, `presto-player`), or Fluent suite scopes must request them explicitly with `add-site --scope="<list>"` at registration time or `reauth <site> --add-scope="<list>"` after OAuth. Scope expansion through `reauth --add-scope` is supported; an effective-permissions diagnostic feed is post-alpha.
21
+
22
+ - **Full OAuth grant does not imply full execution. Abilities for AI module permissions remain an independent per-blog gate.** A granted OAuth scope is necessary but not sufficient to execute an ability. Abilities for AI's module permission settings (per-blog on multisite, configured at *Abilities for AI → Permissions* in wp-admin) gate execution independently — if a module's read/write/delete tier is disabled there, the OAuth-scope-bearing token will receive `[ability_disabled]` 403 errors with explicit operator remediation guidance, regardless of the granted scope set. The two gates apply together by design (Principle 5 — Permissions Stay Layered) to prevent AI sessions from escalating their own permissions via the OAuth surface.
23
+
24
+ - **Pre-1.6.0 add-site runs may have written stale `main` aliases into multisite blocks of `wp-sites.json` (Issue [#70](https://github.com/Wicked-Evolutions/abilities-mcp/issues/70)).** v1.6.0 includes the fix that no longer generates `main` slugs, but existing `wp-sites.json` files written by earlier versions retain those keys and would silently route `<site>.main` to the source subsite instead of the network root. Operators upgrading from earlier versions: either re-run `add-site` against any affected subsite to regenerate its multisite block fresh under v1.6.0+ logic, or manually edit `~/.abilities-mcp/wp-sites.json` to remove the `"main": ...` entry from each affected `multisite` block. The `<site>.<network-root-domain-label>` slug (e.g., `wicked-community.wickedevolutions`) continues to route to the network root and remains the supported alias.
25
+
26
+
27
+ ## [1.5.5] - 2026-05-05
28
+
29
+ ### Fixed
30
+
31
+ - **OAuth subsite routing actually targets the subsite host (Issue [#48](https://github.com/Wicked-Evolutions/abilities-mcp/issues/48), Public Alpha Hardening Phase A.1).** v1.5.4 wrote the `multisite` block on add-site but the OAuth dispatch path ignored the resolved subsite endpoint — every `<network-id>.<subsite-slug>` ability call was POSTed to the network root, so WordPress booted blog 1 regardless of which subsite was named. Three connected fixes:
32
+ - `lib/config.js:resolveSiteKey` now derives `resolvedEndpoint` for OAuth subsites (was HTTP/App-Password only). OAuth sites carry their endpoint on `mcp_resource`, not `http.endpoint`, so the substitution branch never fired for the OAuth case.
33
+ - `lib/connection-pool.js:_createTransport` passes `{ resolvedEndpoint, subsiteUrl: finalSubsiteUrl }` through to the OAuth branch, mirroring the HTTP branch's pattern. `_createOAuthHttpTransport` uses `resolvedEndpoint || siteConfig.mcp_resource` and forwards `subsiteUrl` to the transport.
34
+ - `lib/connection-pool.js:_findExistingHttpTransport` dedupes OAuth subsites by `resolvedEndpoint || mcp_resource` (was always `mcp_resource`). Without this, the cached network-root transport was returned for every subsite key, making the endpoint fix functionally inert behind the cache lookup.
35
+ - `lib/transports/oauth-http-transport.js` accepts `subsiteUrl` and forwards it on every POST as `X-Abilities-MCP-Subsite-URL`. Subdomain-style multisite (the alpha-locked scope) routes by host in the endpoint URL and does not need the header; it's forward-looking infrastructure for path-style multisite (Phase B) so the adapter can `switch_to_blog()` per request without re-parsing the request URL.
36
+ - **Multisite probe pages through `multisite-list-sites` (Issue [#49](https://github.com/Wicked-Evolutions/abilities-mcp/issues/49), Public Alpha Hardening Phase A.2).** v1.5.4's `add-site` probe issued a single `multisite-list-sites` call with `per_page=100`, so networks with more than 100 sites silently got a truncated `multisite` block while `add-site` reported success. `lib/cli/multisite-probe.js` now loops `page=1..N` accumulating items across pages, terminating on the first of: empty page, partial page (length < `PROBE_PER_PAGE`), body-level `total` / `total_pages` reached, or the 50-page cap (`PROBE_PAGE_CAP × PROBE_PER_PAGE = 5,000` sites). When the cap is reached with a still-full final page, the probe throws a typed `Error` with `code === 'probe_cap_exceeded'` and `data: { count, cap }` so an operator hitting it gets a clean diagnostic rather than a silently-truncated block. The cap is a hard constant — no env shim — because >5,000-site networks are an exceptional case that should engage maintainers. Body-level `total` / `total_pages` (when the adapter exposes them) take precedence over the partial-page fallback to avoid one redundant page request when the last page is exactly full.
37
+ - **Multisite auto-populate via `add-site` is now functional end-to-end on multisite-network OAuth setups (Issue [#54](https://github.com/Wicked-Evolutions/abilities-mcp/issues/54)).** Two bugs in `BearerJsonRpcClient` (the one-shot probe client distinct from the long-running MCP runtime transports) blocked auto-populate since v1.5.4: (a) the client did not echo the adapter's `Mcp-Session-Token` HMAC on requests after `initialize`, so the adapter's `HttpSessionValidator::validate_session()` correctly rejected the probe call as session-fixation defense; and (b) the probe sent the tool name `multisite/list-sites` (slash-case) but the adapter registers it as `multisite-list-sites` (kebab-case) — the session-token rejection masked the name mismatch until (a) was fixed. Both fixes required to deliver the v1.5.4 multisite-UX promise; the v1.5.4 auto-populate flow has shipped non-functional in production until this release. `BearerJsonRpcClient` now mirrors the long-running runtime transports' established session-token capture/echo pattern (see `lib/transports/http-transport.js:42-46, 442-447, 470-477`); the kebab-case tool name is verified against `tools/list` from the live adapter.
38
+ - **`upgrade-auth` no longer corrupts config when the legacy keychain copy fails (Issue [#56](https://github.com/Wicked-Evolutions/abilities-mcp/issues/56)).** The App Password → OAuth migration's dual-write step swallowed keychain read/write errors but still rewrote the fallback `password_ref` to `<siteId>/apppassword-legacy`. If the keychain read was denied, hung, or failed (common during macOS cross-process ACL prompts), config could end up pointing at a legacy fallback secret that was never created. `lib/cli/commands/upgrade-auth.js` now only switches the fallback ref to `apppassword-legacy` after the legacy keychain copy actually succeeds; otherwise it preserves the original `password_ref` as the fallback and surfaces a stderr advisory naming what happened. Operators see a clean diagnostic rather than silent config corruption.
39
+ - **macOS `security` CLI fallback has a 30s timeout (Issue [#57](https://github.com/Wicked-Evolutions/abilities-mcp/issues/57)).** The darwin-only fallback (introduced in v1.5.3 to bypass the hardened-runtime keytar barrier inside Claude Desktop's `.mcpb` host) shelled out to `security find-generic-password -w` with no timeout. Stuck macOS ACL prompts (common when multiple bridge consumer processes — Claude Desktop, Claude Code, terminal CLI — race for the same keychain entry) caused `upgrade-auth` and other CLI flows to hang indefinitely while additional bridge processes piled up more prompts. `lib/auth/keychain-secret-store.js` now wraps every `security` shell-out (`find`, `add`, `delete`) with a 30-second default timeout (configurable via constructor option). On timeout, surfaces a typed `SecretStoreError` with `code: 'security_cli_timeout'` and a message naming the situation, kills the child process, and lets the caller decide how to recover. CLI flows fail clean instead of hanging silently.
40
+ - **macOS operators can opt into security-CLI keychain backend alignment (Issue [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58)).** Terminal `add-site` / `reauth` normally uses keytar when it loads successfully, while Claude Desktop's `.mcpb` runtime falls back to `/usr/bin/security` after macOS hardened-runtime rejects the native keytar module. That split can make Terminal write OAuth token entries through one macOS Keychain app identity and Claude Desktop read them through another, producing repeated cross-process ACL prompts. `KeychainSecretStore` now accepts `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` (or constructor `backend: 'security-cli'`) on darwin so terminal setup can write tokens through the same backend Claude Desktop reads through. The setting is opt-in, macOS-only, and does **not** use the insecure `security -A` all-apps trust flag. Defaults remain unchanged (`auto` prefers keytar and falls back to security-CLI only when keytar fails on darwin).
41
+
42
+ ### Changed
43
+
44
+ - **`reauth` scope-mutation flag triad replaces the bare `--scope` footgun (Issue [#50](https://github.com/Wicked-Evolutions/abilities-mcp/issues/50), Public Alpha Hardening Phase A.3).** The previous `reauth --scope=<list>` behavior *replaced* the persisted scope array — an operator (or AI assistant) adding new scopes via `--scope='<new only>'` would silently drop every existing scope. The locked design adds two new flags and keeps `--scope` as the explicit-replace escape hatch:
45
+ - `reauth <site> --add-scope=<scopes>` — merge into the existing scope set, deduped (preserves order: existing first, additions appended). The recommended way to add scopes.
46
+ - `reauth <site> --remove-scope=<scopes>` — drop scopes by exact match. Removals that aren't in the existing set are reported on stderr as no-op warnings, not errors.
47
+ - `reauth <site> --scope=<scopes>` — replace the entire scope set. Emits a stderr warning when the supplied set is a strict subset of the existing scopes (i.e., the replace would drop scopes), naming the count and recommending `--add-scope` instead.
48
+ - All three flags are **mutually exclusive** — passing more than one is a typed `EXIT_USAGE` error before the OAuth flow runs at all. Mirrors `git remote add` / `git remote remove` conventions; each flag has one job.
49
+ - Scope lists accept comma- or whitespace-separated tokens.
50
+ - Bare `reauth <site>` (no flag) continues to use the persisted scope set unchanged — no behavior change for the common refresh case.
51
+ - New pure module `lib/cli/scope-mutation.js` houses the parse / merge / remove / subset-detect logic so the reauth command stays a thin wrapper. CLI help output (`abilities-mcp --help`) documents all three flags + the mutual-exclusion rule.
52
+
53
+ ### Known limitations
54
+
55
+ - **Existing macOS keychain entries may still carry old ACL state (Issue [#58](https://github.com/Wicked-Evolutions/abilities-mcp/issues/58)).** The new `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` escape hatch aligns future Terminal OAuth writes with Claude Desktop's `.mcpb` reader backend, but it does not rewrite ACLs on entries that already exist in the operator's Keychain. If macOS is already showing a prompt, operators may still need to click **Always Allow**, kill a stale `SecurityAgent`, or rerun `add-site --force` / `reauth` with the env var so fresh token entries are written through the aligned backend. A fuller long-term design (explicit `-T` consumer-path list at write time, or canonical helper identity) remains a post-alpha improvement. The remaining cross-process gap is UX, not security: existing keychain ACLs continue to protect secrets correctly.
56
+ - **Multisite OAuth dot-notation routing is discovery-only in alpha (Issue [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60)).** `add-site` against a Multisite Network root probes `multisite-list-sites` and writes the `multisite` slug→URL block to `wp-sites.json` (the v1.5.4 promise, end-to-end functional after the v1.5.5 fixes for [#48](https://github.com/Wicked-Evolutions/abilities-mcp/issues/48), [#49](https://github.com/Wicked-Evolutions/abilities-mcp/issues/49), [#54](https://github.com/Wicked-Evolutions/abilities-mcp/issues/54)). However, dot-notation execution against a subsite (e.g., `<network-id>.<subsite-slug>.<ability>`) routed by a single network-root OAuth token is rejected by the adapter at token validation: the adapter's `mcp_resource` check binds tokens to a specific blog URL, so a token minted against the network root cannot satisfy a subsite resource match even when the bridge has correctly switched the request endpoint and forwarded `X-Abilities-MCP-Subsite-URL`. Subsite execution **does** work today by adding each subsite as a separate site entry: `abilities-mcp add-site <subsite-id> https://<subsite-host>` (or, for path-style, `--site-id=<subsite-id>` against the subsite URL) provisions an OAuth token bound to that subsite's resource, and AI clients can then call abilities by the explicit subsite site_id. **Operator guidance:** Multisite discovery is available in alpha. For OAuth subsite execution, add each subsite that you want to operate on as a separate site entry using `--site-id`. Use the explicit subsite IDs in your MCP client. Dot-notation subsite routing from a single network-root OAuth token is a known alpha limitation. A first-class fix (network-root token + adapter-side per-blog `switch_to_blog()` validation, or first-class per-subsite token discovery) is tracked as a post-alpha design item on [#60](https://github.com/Wicked-Evolutions/abilities-mcp/issues/60).
57
+
58
+
59
+ ## [1.5.4] - 2026-05-04
60
+
61
+ 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.
62
+
63
+ Bridge-only release — no companion adapter or ai release this release.
64
+
65
+ ### Added
66
+
67
+ - **`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.
68
+ - **`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.
69
+
70
+ ### Changed
71
+
72
+ - **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.
73
+
74
+ ### Internal
75
+
76
+ - **Test count:** `275 → 293` (+18 across PR #44 +14 and PR #46 +4). Node CI matrix unchanged: 18, 20, 22.
77
+ - **Bundle size unchanged** (~413 kB packed / ~1.2 MB unpacked — no binary or dependency changes this release).
78
+ - **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`).
79
+
80
+ ### Known issue (linked to adapter follow-up)
81
+
82
+ - **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.
83
+
5
84
  ## [1.5.3] - 2026-05-04
6
85
 
7
86
  **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.
package/README.md CHANGED
@@ -84,6 +84,14 @@ Single-click install for Claude Desktop on macOS and Windows. The Application Pa
84
84
 
85
85
  The bundle covers the single-site case. For multi-site (one bridge connected to several WordPress sites at once), use Path 3.
86
86
 
87
+ **macOS + Claude Desktop keychain note.** Claude Desktop's `.mcpb` runtime may use Apple's `/usr/bin/security` keychain path when macOS rejects native keytar loading. If you set up OAuth from Terminal and then Claude Desktop repeatedly asks for Keychain access, rerun the terminal setup with the same backend Claude Desktop uses:
88
+
89
+ ```bash
90
+ ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli abilities-mcp add-site --force https://example.com
91
+ ```
92
+
93
+ This is macOS-only and opt-in. It does not loosen Keychain permissions; it just writes tokens through the same Keychain doorway Claude Desktop reads through.
94
+
87
95
  ---
88
96
 
89
97
  ### Path 2 — Env vars (Claude Code, Cursor, Docker, any MCP client)
@@ -1,9 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  const { execFile } = require('node:child_process');
4
+ const { existsSync } = require('node:fs');
4
5
 
5
6
  const { SecretStoreError } = require('./errors');
6
7
 
8
+ const DEFAULT_SECURITY_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_KEYCHAIN_BACKEND = 'auto';
10
+ const KEYCHAIN_BACKENDS = new Set(['auto', 'keytar', 'security-cli']);
11
+ const SECURITY_CLI_PATH = '/usr/bin/security';
12
+
7
13
  /**
8
14
  * KeychainSecretStore — keytar-backed SecretStore with a darwin-only
9
15
  * `security` CLI fallback for Claude Desktop's hardened-runtime barrier.
@@ -13,19 +19,32 @@ const { SecretStoreError } = require('./errors');
13
19
  * `optionalDependency` so a failed native build does not break `npm install`
14
20
  * for env-var-only operators.
15
21
  *
16
- * **The darwin fallback (issue #39).** When the bundled keytar binary fails
17
- * to dlopen inside Claude Desktop's hardened-runtime process the macOS
18
- * code-signing rejects native binaries with mismatched Team IDs, Anthropic-
19
- * signed Claude Desktop refuses to load npm-distribution-signed keytar.node
20
- * we fall back to shelling out to the macOS `security` CLI via
21
- * `child_process.execFile`. `security` is always installed on macOS, doesn't
22
- * require dynamic native loading, operates against the same macOS Keychain,
23
- * and runs as a child process out from under Claude Desktop's hardened-
24
- * runtime restrictions. The fallback fires only on darwin; on linux/win32 a
25
- * keytar load failure still throws `keytar_unavailable` (current behavior).
22
+ * **Darwin default: security-CLI (issue #61).** On darwin under the default
23
+ * `auto` backend, the store engages the `/usr/bin/security` CLI directly
24
+ * without attempting to load keytar. This is the alpha-gate fix for the
25
+ * multi-client macOS keychain ACL identity split: every runtime that spawns
26
+ * the bridge Claude Desktop's `.mcpb`, Claude Code via npm/node, Codex,
27
+ * terminal CLI, etc. issues `SecKeychainItem*` calls through the same
28
+ * caller binary (`/usr/bin/security`), so macOS's per-binary ACL trusted-
29
+ * application list contains exactly one entry. After the operator's first
30
+ * "Always Allow" the entry is silently readable from every runtime.
31
+ *
32
+ * Issue #58's `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli` env var was the
33
+ * opt-in shape of this fix; #61 promotes it to the default. Operators who
34
+ * need keytar on darwin (uncommon — debugging, custom build) can opt back in
35
+ * with `ABILITIES_MCP_KEYCHAIN_BACKEND=keytar`.
36
+ *
37
+ * **The original darwin fallback (issue #39).** When the bundled keytar
38
+ * binary failed to dlopen inside Claude Desktop's hardened-runtime process,
39
+ * the store fell back to `/usr/bin/security` automatically. With the #61
40
+ * default in place, darwin auto never attempts keytar in the first place, so
41
+ * the dlopen-rejection branch is no longer reachable from `auto`. The
42
+ * security-CLI implementation itself is the same code path that's been
43
+ * shipping inside the .mcpb runtime since v1.5.3.
26
44
  *
27
- * Outside the .mcpb path system Node, CLI install, npx, source clone —
28
- * keytar loads normally and the fallback never engages.
45
+ * Linux / win32 behavior unchanged: keytar via libsecret / Credential
46
+ * Manager. There is no `/usr/bin/security` equivalent and no analogous ACL
47
+ * prompt, so the per-platform identity-split bug doesn't apply.
29
48
  *
30
49
  * If keytar is unavailable at runtime AND the darwin fallback also can't run,
31
50
  * every method throws `SecretStoreError` with code `keytar_unavailable` —
@@ -53,24 +72,45 @@ class KeychainSecretStore {
53
72
  * fallback-eligibility decision. Test seam.
54
73
  * @param {Function} [opts.exec] Override `child_process.execFile`. Test
55
74
  * seam for the security-CLI fallback path.
75
+ * @param {number} [opts.securityTimeoutMs] Max time to wait for the darwin
76
+ * `security` CLI before surfacing a typed
77
+ * timeout error. Defaults to 30s.
78
+ * @param {string} [opts.backend] Secret backend: auto (default), keytar,
79
+ * or security-cli. When omitted, reads
80
+ * ABILITIES_MCP_KEYCHAIN_BACKEND.
81
+ * @param {object} [opts.env] Env object for backend selection tests.
82
+ * @param {Function} [opts.fsExistsSync] Override `fs.existsSync` for the
83
+ * `/usr/bin/security` existence probe.
84
+ * Test seam.
56
85
  */
57
86
  constructor(opts = {}) {
58
87
  this._injected = opts.keytar || null;
59
88
  this._keytar = null;
60
89
  this._loadAttempted = false;
61
90
  this._loadError = null;
91
+ this._loadErrorCode = 'keytar_unavailable';
62
92
  this._fallbackMode = null; // null | 'security-cli'
63
93
 
64
94
  this._requireKeytar = opts.requireKeytar || ((id) => require(id));
65
95
  this._platform = opts.platform || process.platform;
66
96
  this._exec = opts.exec || execFile;
97
+ this._fsExistsSync = opts.fsExistsSync || existsSync;
98
+ const env = opts.env || process.env;
99
+ this._backend = _normalizeBackend(opts.backend || env.ABILITIES_MCP_KEYCHAIN_BACKEND);
100
+ this._securityTimeoutMs = Number.isFinite(opts.securityTimeoutMs) && opts.securityTimeoutMs > 0
101
+ ? opts.securityTimeoutMs
102
+ : DEFAULT_SECURITY_TIMEOUT_MS;
67
103
  }
68
104
 
69
105
  /**
70
106
  * Lazy load. Sets one of three terminal states:
71
- * - `this._keytar` populated (keytar loaded normally; primary path)
72
- * - `this._fallbackMode === 'security-cli'` (darwin fallback engaged)
73
- * - `this._loadError` set + throws (non-darwin keytar failure)
107
+ * - `this._fallbackMode === 'security-cli'` (darwin auto default + explicit
108
+ * security-cli backend)
109
+ * - `this._keytar` populated (keytar loaded normally; non-darwin auto, or
110
+ * explicit `backend: 'keytar'` anywhere)
111
+ * - `this._loadError` set + throws (security-cli engagement on a host
112
+ * without `/usr/bin/security`, keytar load failure when keytar is the
113
+ * selected backend, invalid backend value)
74
114
  */
75
115
  _load() {
76
116
  if (this._keytar || this._fallbackMode) {
@@ -80,11 +120,50 @@ class KeychainSecretStore {
80
120
  // Previously failed and we cached the error.
81
121
  throw new SecretStoreError(
82
122
  `OS keychain unavailable: ${this._loadError.message}`,
83
- { code: 'keytar_unavailable', cause: this._loadError }
123
+ { code: this._loadErrorCode, cause: this._loadError }
84
124
  );
85
125
  }
86
126
  this._loadAttempted = true;
87
127
 
128
+ if (!KEYCHAIN_BACKENDS.has(this._backend)) {
129
+ const err = new Error(
130
+ `Unsupported ABILITIES_MCP_KEYCHAIN_BACKEND="${this._backend}". ` +
131
+ `Use one of: auto, keytar, security-cli.`
132
+ );
133
+ this._loadError = err;
134
+ this._loadErrorCode = 'invalid_keychain_backend';
135
+ throw new SecretStoreError(
136
+ `OS keychain unavailable: ${err.message}`,
137
+ { code: this._loadErrorCode, cause: err }
138
+ );
139
+ }
140
+
141
+ if (this._backend === 'security-cli') {
142
+ if (this._platform !== 'darwin') {
143
+ const err = new Error(
144
+ `ABILITIES_MCP_KEYCHAIN_BACKEND=security-cli is only supported on macOS.`
145
+ );
146
+ this._loadError = err;
147
+ this._loadErrorCode = 'security_cli_unavailable';
148
+ throw new SecretStoreError(
149
+ `OS keychain unavailable: ${err.message}`,
150
+ { code: this._loadErrorCode, cause: err }
151
+ );
152
+ }
153
+ this._engageSecurityCliMode();
154
+ return;
155
+ }
156
+
157
+ // Issue #61: darwin default = security-CLI for cross-runtime ACL identity.
158
+ // All bridge spawn paths (Claude Desktop .mcpb, Claude Code, Codex,
159
+ // terminal CLI) issue keychain syscalls through the same `/usr/bin/security`
160
+ // caller binary, so macOS sees one ACL identity instead of N. Operators
161
+ // who want keytar on darwin can opt back in with backend=keytar.
162
+ if (this._backend === 'auto' && this._platform === 'darwin') {
163
+ this._engageSecurityCliMode();
164
+ return;
165
+ }
166
+
88
167
  if (this._injected) {
89
168
  this._keytar = this._injected;
90
169
  return;
@@ -94,22 +173,44 @@ class KeychainSecretStore {
94
173
  this._keytar = this._requireKeytar('keytar');
95
174
  return;
96
175
  } catch (err) {
97
- // darwin: Claude Desktop's hardened-runtime rejects bundled keytar.node
98
- // with a Team ID mismatch (issue #39). Fall back to the `security` CLI
99
- // rather than throwing the bridge keeps working against the same
100
- // macOS Keychain, just via shell-out.
101
- if (this._platform === 'darwin') {
102
- this._fallbackMode = 'security-cli';
103
- return;
104
- }
176
+ // backend === 'keytar' (any platform), or backend === 'auto' on
177
+ // linux/win32. No fallback: the security-CLI is darwin-only, and the
178
+ // darwin auto path above already engaged it before we got here.
105
179
  this._loadError = err;
180
+ this._loadErrorCode = 'keytar_unavailable';
106
181
  throw new SecretStoreError(
107
182
  `OS keychain unavailable: ${err.message}`,
108
- { code: 'keytar_unavailable', cause: err }
183
+ { code: this._loadErrorCode, cause: err }
109
184
  );
110
185
  }
111
186
  }
112
187
 
188
+ /**
189
+ * Probe `/usr/bin/security` and engage security-CLI mode. Surfaces a typed
190
+ * error early (at first `_load()`) on hosts where the binary is missing —
191
+ * the corporate-locked-macOS edge case — instead of waiting for the first
192
+ * keychain operation to fail at execFile spawn time.
193
+ *
194
+ * Sets `_fallbackMode = 'security-cli'` on success; throws
195
+ * SecretStoreError code `security_cli_unavailable` on failure.
196
+ */
197
+ _engageSecurityCliMode() {
198
+ if (!this._fsExistsSync(SECURITY_CLI_PATH)) {
199
+ const err = new Error(
200
+ `${SECURITY_CLI_PATH} not found. macOS Keychain access requires the ` +
201
+ `security CLI (standard at ${SECURITY_CLI_PATH} on a normal macOS ` +
202
+ `install). This may indicate a corporate-locked or non-standard host.`
203
+ );
204
+ this._loadError = err;
205
+ this._loadErrorCode = 'security_cli_unavailable';
206
+ throw new SecretStoreError(
207
+ `OS keychain unavailable: ${err.message}`,
208
+ { code: this._loadErrorCode, cause: err }
209
+ );
210
+ }
211
+ this._fallbackMode = 'security-cli';
212
+ }
213
+
113
214
  /**
114
215
  * @returns {Promise<boolean>} true if keytar can be loaded on this host OR
115
216
  * the darwin security-CLI fallback is engaged.
@@ -150,14 +251,14 @@ class KeychainSecretStore {
150
251
  return this._keytar.deletePassword(service, account);
151
252
  }
152
253
 
254
+ // findAll() is unavailable on the Darwin security-cli backend; current
255
+ // bridge flows do not rely on it. The macOS `security` CLI has no clean
256
+ // enumerate-by-service mode, and with #61 making security-cli the darwin
257
+ // default this method returns [] for every darwin caller. Linux/Windows
258
+ // (keytar) continue to enumerate normally.
153
259
  async findAll(service) {
154
260
  this._load();
155
261
  if (this._fallbackMode === 'security-cli') {
156
- // The macOS `security` CLI has no clean enumerate-by-service mode.
157
- // Returning [] here is safe because the bridge runtime path never
158
- // calls findAll — only the CLI subcommand `list-sites` does, and that
159
- // runs in system Node where keytar loads normally and this branch
160
- // is never taken. Documented in the issue body's "findAll" note.
161
262
  return [];
162
263
  }
163
264
  return this._keytar.findCredentials(service);
@@ -176,7 +277,16 @@ class KeychainSecretStore {
176
277
  */
177
278
  _execSecurity(args) {
178
279
  return new Promise((resolve, reject) => {
179
- this._exec('security', args, {}, (err, stdout, stderr) => {
280
+ const opts = {
281
+ timeout: this._securityTimeoutMs,
282
+ killSignal: 'SIGTERM',
283
+ };
284
+ // Issue #66: pass the absolute path explicitly so execFile bypasses
285
+ // PATH resolution. Bare `'security'` would resolve through the
286
+ // operator's PATH and could route the syscall through a shadowing
287
+ // binary (brew, nvm, ~/bin, etc.), breaking #61's "trusted caller
288
+ // binary at syscall time = /usr/bin/security" guarantee.
289
+ this._exec(SECURITY_CLI_PATH, args, opts, (err, stdout, stderr) => {
180
290
  const stdoutStr = typeof stdout === 'string'
181
291
  ? stdout
182
292
  : (stdout ? stdout.toString() : '');
@@ -190,6 +300,9 @@ class KeychainSecretStore {
190
300
  // and don't pass via cb args); fall back to the cb args otherwise.
191
301
  if (typeof err.stderr !== 'string') err.stderr = stderrStr;
192
302
  if (typeof err.stdout !== 'string') err.stdout = stdoutStr;
303
+ if (_isTimeout(err) && typeof err.timeoutMs !== 'number') {
304
+ err.timeoutMs = this._securityTimeoutMs;
305
+ }
193
306
  return reject(err);
194
307
  }
195
308
  resolve({ stdout: stdoutStr, stderr: stderrStr });
@@ -206,6 +319,12 @@ class KeychainSecretStore {
206
319
  return stdout.replace(/\n$/, '');
207
320
  } catch (err) {
208
321
  if (_isNotFound(err)) return null;
322
+ if (_isTimeout(err)) {
323
+ throw new SecretStoreError(
324
+ `security find-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
325
+ { code: 'security_cli_timeout', cause: err }
326
+ );
327
+ }
209
328
  throw new SecretStoreError(
210
329
  `security find-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
211
330
  { code: 'security_cli_failed', cause: err }
@@ -225,6 +344,12 @@ class KeychainSecretStore {
225
344
  'add-generic-password', '-U', '-s', service, '-a', account, '-w', secret,
226
345
  ]);
227
346
  } catch (err) {
347
+ if (_isTimeout(err)) {
348
+ throw new SecretStoreError(
349
+ `security add-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
350
+ { code: 'security_cli_timeout', cause: err }
351
+ );
352
+ }
228
353
  throw new SecretStoreError(
229
354
  `security add-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
230
355
  { code: 'security_cli_failed', cause: err }
@@ -240,6 +365,12 @@ class KeychainSecretStore {
240
365
  return true;
241
366
  } catch (err) {
242
367
  if (_isNotFound(err)) return false;
368
+ if (_isTimeout(err)) {
369
+ throw new SecretStoreError(
370
+ `security delete-generic-password timed out after ${err.timeoutMs || this._securityTimeoutMs}ms; a macOS Keychain prompt may be waiting for approval`,
371
+ { code: 'security_cli_timeout', cause: err }
372
+ );
373
+ }
243
374
  throw new SecretStoreError(
244
375
  `security delete-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
245
376
  { code: 'security_cli_failed', cause: err }
@@ -262,4 +393,16 @@ function _isNotFound(err) {
262
393
  return /could not be found/i.test(stderr);
263
394
  }
264
395
 
396
+ function _isTimeout(err) {
397
+ if (!err) return false;
398
+ if (err.code === 'ETIMEDOUT') return true;
399
+ if (err.killed && err.signal === 'SIGTERM') return true;
400
+ return /timed out/i.test(err.message || '');
401
+ }
402
+
403
+ function _normalizeBackend(value) {
404
+ if (!value) return DEFAULT_KEYCHAIN_BACKEND;
405
+ return String(value).trim().toLowerCase();
406
+ }
407
+
265
408
  module.exports = { KeychainSecretStore };
@@ -46,7 +46,17 @@ const {
46
46
  * @license GPL-2.0-or-later
47
47
  */
48
48
 
49
- const DEFAULT_SCOPE = 'abilities:read abilities:write';
49
+ // `abilities:multisite:read` / `abilities:multisite:write` are SENSITIVE_SCOPES
50
+ // in the adapter's ScopeRegistry (Auth/OAuth/ScopeRegistry.php) — never implied
51
+ // by the `abilities:read` / `abilities:write` umbrella expansion. Requesting
52
+ // them explicitly during DCR is the only way the consent screen can surface
53
+ // them for super-admin operators on a Multisite Network root, which in turn
54
+ // is the only way `add-site`'s post-OAuth multisite/list-sites probe (#43)
55
+ // can fire end-to-end. Single-site WP installs accept the request — the
56
+ // adapter simply won't grant scopes the OAuth user lacks WP capability for,
57
+ // so single-site UX is preserved.
58
+ const DEFAULT_SCOPE =
59
+ 'abilities:read abilities:write abilities:multisite:read abilities:multisite:write';
50
60
  const DEFAULT_LOOPBACK_TIMEOUT_MS = 5 * 60_000;
51
61
 
52
62
  class OAuthClient extends EventEmitter {
@@ -12,6 +12,7 @@ const { makeRef } = require('../../auth/secret-store');
12
12
  const { CliError, EXIT_USAGE, EXIT_CONFIG, fromAuthError } = require('../errors');
13
13
  const { subscribeProgress } = require('../output');
14
14
  const { readConfig, writeConfig, freshConfig } = require('../config-store');
15
+ const { probeMultisite: defaultProbeMultisite } = require('../multisite-probe');
15
16
 
16
17
  /**
17
18
  * `add-site <url>` — register a new site with the bridge using OAuth (default)
@@ -108,6 +109,7 @@ async function run(args, ctx) {
108
109
  }
109
110
 
110
111
  const out = [];
112
+ const errLines = [];
111
113
  let exitCode = 0;
112
114
 
113
115
  if (args.apppassword) {
@@ -139,7 +141,7 @@ async function run(args, ctx) {
139
141
  await writeConfig(ctx.configPath, config);
140
142
  out.push(`✓ Site "${siteId}" configured with App Password authentication.`);
141
143
  out.push(` Config: ${ctx.configPath}`);
142
- return { exitCode, lines: out };
144
+ return { exitCode, lines: out, errLines };
143
145
  }
144
146
 
145
147
  // OAuth path — full authorization-code + PKCE flow via OAuthClient.
@@ -199,12 +201,73 @@ async function run(args, ctx) {
199
201
  if (result.prMetadata && result.prMetadata.resource) {
200
202
  config.sites[siteId].mcp_resource = result.prMetadata.resource;
201
203
  }
204
+
205
+ // Multisite Network root probe. If the freshly authenticated bridge can
206
+ // resolve `multisite/list-sites`, populate the multisite block so dot-
207
+ // notation routing works without operator JSON editing. Single-site,
208
+ // permission-denied, and network errors all degrade gracefully — the
209
+ // site entry is still written without a multisite block.
210
+ const probeEndpoint = result.prMetadata && result.prMetadata.resource;
211
+ if (probeEndpoint && result.tokens && result.tokens.access_token) {
212
+ const probe = (ctx.deps && ctx.deps.probeMultisite) || defaultProbeMultisite;
213
+ try {
214
+ const probeResult = await probe({
215
+ endpoint: probeEndpoint,
216
+ accessToken: result.tokens.access_token,
217
+ siteUrl: parsedUrl.origin,
218
+ log: typeof ctx.log === 'function' ? ctx.log : null,
219
+ deps: ctx.deps && ctx.deps.probeMultisiteDeps,
220
+ });
221
+ if (probeResult && probeResult.block && Object.keys(probeResult.block).length > 0) {
222
+ config.sites[siteId].multisite = probeResult.block;
223
+ const slugs = Object.keys(probeResult.block).join(', ');
224
+ out.push(` Multisite: discovered ${Object.keys(probeResult.block).length} subsite(s) → ${slugs}`);
225
+ }
226
+ } catch (probeErr) {
227
+ _appendProbeAdvisory(probeErr, siteId, errLines);
228
+ }
229
+ }
230
+
202
231
  if (!config.defaultSite) config.defaultSite = siteId;
203
232
  await writeConfig(ctx.configPath, config);
204
233
 
205
234
  out.push(`✓ Site "${siteId}" configured. Granted scopes: ${result.scopes.join(', ')}.`);
206
235
  out.push(` Config: ${ctx.configPath}`);
207
- return { exitCode, lines: out };
236
+ return { exitCode, lines: out, errLines };
237
+ }
238
+
239
+ /**
240
+ * Append a stderr advisory naming the multisite-probe failure so operators
241
+ * who expected dot-notation routing know why it isn't wired and can either
242
+ * fix the underlying issue or hand-add the block per existing docs.
243
+ *
244
+ * Silent for `tool_not_registered` (single-site is the expected case for
245
+ * the vast majority of `add-site` invocations).
246
+ */
247
+ function _appendProbeAdvisory(probeErr, siteId, errLines) {
248
+ const code = probeErr && probeErr.code;
249
+ if (code === 'tool_not_registered') return;
250
+
251
+ if (code === 'permission_denied' || code === 'unauthorized') {
252
+ errLines.push(
253
+ `Multisite discovery skipped for "${siteId}": multisite/list-sites was rejected ` +
254
+ `by the adapter. Two possible causes — verify both before manually adding the block: ` +
255
+ `(1) the OAuth user lacks the manage_network_options WP capability (super-admin ` +
256
+ `required on a Multisite Network root), or (2) the OAuth token lacks the ` +
257
+ `abilities:multisite:read scope (it must be granted on the consent screen — ` +
258
+ `re-run add-site and confirm the multisite scope checkbox if it was unchecked). ` +
259
+ `Site entry written without multisite block — dot-notation subsite routing ` +
260
+ `will not be available until the block is added manually or add-site is re-run.`
261
+ );
262
+ return;
263
+ }
264
+
265
+ const detail = (probeErr && probeErr.message) || String(probeErr);
266
+ errLines.push(
267
+ `Multisite discovery failed for "${siteId}": ${detail}. ` +
268
+ `Site entry written without multisite block — dot-notation subsite routing ` +
269
+ `will not be available until the block is added manually or add-site is re-run.`
270
+ );
208
271
  }
209
272
 
210
273
  /**
@@ -9,6 +9,7 @@ const {
9
9
  const { CliError, EXIT_USAGE, fromAuthError } = require('../errors');
10
10
  const { subscribeProgress } = require('../output');
11
11
  const { readConfig, writeConfig } = require('../config-store');
12
+ const { computeScopeMutation } = require('../scope-mutation');
12
13
 
13
14
  /**
14
15
  * `reauth <site_id>` — re-run the OAuth flow for an existing site.
@@ -49,7 +50,28 @@ async function run(args, ctx) {
49
50
  });
50
51
  }
51
52
 
53
+ // Resolve the requested scope set from the three mutually-exclusive flags
54
+ // (--scope replaces, --add-scope merges, --remove-scope drops). See
55
+ // lib/cli/scope-mutation.js for the full design (Issue #50). Empty
56
+ // existing → DEFAULT_SCOPE fallback to preserve the prior bare-reauth
57
+ // contract.
58
+ const persistedScopes = Array.isArray(site.auth.scopes) ? site.auth.scopes : null;
59
+ const mutation = computeScopeMutation({
60
+ scope: args.scope,
61
+ addScope: args['add-scope'],
62
+ removeScope: args['remove-scope'],
63
+ existing: persistedScopes,
64
+ });
65
+ if (mutation.errorCode) {
66
+ throw new CliError(mutation.errorMessage, {
67
+ exitCode: EXIT_USAGE,
68
+ nextAction: 'Run: abilities-mcp reauth <site_id> --add-scope=<scopes> | --remove-scope=<scopes> | --scope=<scopes>',
69
+ });
70
+ }
71
+ const requestedScope = mutation.scopes.length > 0 ? mutation.scopes : (persistedScopes || DEFAULT_SCOPE);
72
+
52
73
  const out = [];
74
+ const errLines = mutation.warnings.slice();
53
75
  out.push(`Re-running OAuth flow for site "${siteId}" (${site.url})…`);
54
76
 
55
77
  const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
@@ -58,7 +80,7 @@ async function run(args, ctx) {
58
80
  siteUrl: site.url,
59
81
  clientName,
60
82
  softwareVersion: ctx.softwareVersion,
61
- scope: args.scope || (Array.isArray(site.auth.scopes) ? site.auth.scopes : DEFAULT_SCOPE),
83
+ scope: requestedScope,
62
84
  identityProvider: ctx.identityProvider,
63
85
  allowInsecure: ctx.allowInsecure,
64
86
  capabilityPin: site.oauth_capability_pinned ? {
@@ -102,7 +124,7 @@ async function run(args, ctx) {
102
124
  await writeConfig(ctx.configPath, config);
103
125
 
104
126
  out.push(`✓ Site "${siteId}" re-authorized. Granted scopes: ${result.scopes.join(', ')}.`);
105
- return { exitCode: 0, lines: out };
127
+ return { exitCode: 0, lines: out, errLines };
106
128
  }
107
129
 
108
130
  module.exports = { run };