@wickedevolutions/abilities-mcp 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to Abilities MCP are documented here.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [1.6.4] - 2026-05-17
8
+
9
+ ### Added
10
+
11
+ - **Opt-in silent sliding-renewal OAuth (Issue [#90](https://github.com/Wicked-Evolutions/abilities-mcp/issues/90); depends on [#89](https://github.com/Wicked-Evolutions/abilities-mcp/issues/89)).** A new per-site `auth.sliding_renewal` flag in `wp-sites.json` (default **off**). When **off / absent / any value that is not strictly `true`**, behavior is byte-identical to today: the refresh token stays bounded (~90 days from initial authorization) and `reauth` is required after that, regardless of use. When **on**, every successful token refresh advances the on-disk `refresh_token_expires_at` to mirror the fresh TTL the adapter re-issues on rotation (`abilities-mcp-adapter` `TokenStore::REFRESH_TTL` = 90d), so an **actively-used** site renews indefinitely and never forces reauth; it still lapses normally if left untouched past the window (no refresh within ~90d → next use hits the #89 reauth path). The slide is gated end-to-end: `token-manager.js` only advances the expiry when `siteAuth.slidingRenewal === true`; the transport only invokes the new `onTokensRenewed` persist callback for opt-in sites — **flag-off sites get no new write path** (no new disk writes vs. today). `list-sites` already surfaces the live `refresh_token_expires_at` (added in #89) so the window is operator-visible. Bridge-only; adapter untouched. The bridge mirrors the adapter's `REFRESH_TTL` constant in code (documented as a contract mirror); a split, non-blocking follow-up to have the adapter emit `refresh_expires_in` so the bridge reads the server's effective TTL instead of mirroring is tracked at [abilities-mcp-adapter#120](https://github.com/Wicked-Evolutions/abilities-mcp-adapter/issues/120). No released ability contract change (Principle 10 — opt-in flag + additive callback; default path unchanged).
12
+ - **⚠️ Security trade-off (explicit per-site operator choice, documented at enable-time + in the README).** Sliding renewal extends the refresh window indefinitely for actively-used sites, so a leaked/exfiltrated refresh token has a **longer blast radius** than the default bounded credential (which self-expires ≤90 days after initial authorization no matter what). It is never enabled implicitly, by migration, or by `add-site`/`reauth` — the operator must set the flag deliberately. Chosen over a very-long static TTL / app-password mode precisely because only *actively-used* sites stay alive (idle sites still lapse → smaller exposure).
13
+ - Proven against the bridge's own harness (`test/auth/token-manager.test.js` + `test/transports/oauth-http-transport.test.js` + `test/manual/repro-90.js`), not the MCP path: flag-on slides indefinitely while used; flag-off `refresh_token_expires_at` byte-identical across refreshes (default-preserved guard); idle-past-window lapses to reauth (#89 path); REVOKED / terminal-4xx unaffected (sliding acts only on a successful refresh); flag-off never reaches the persist callback. Full `node --test`: 427/427.
14
+
15
+ ### Fixed
16
+
17
+ - **Sticky `auth_status="expired"` no longer blocks refresh when the refresh token is still valid (Issue [#89](https://github.com/Wicked-Evolutions/abilities-mcp/issues/89); structural root deferred from [#76](https://github.com/Wicked-Evolutions/abilities-mcp/issues/76)).** A single transient 4xx during token refresh flipped `auth_status` to `expired` on disk; from then on `refresh()` short-circuited on the cached enum and never called the token endpoint again — even though the refresh token was valid for months — so sites got stuck "expired" until manual reauth (the wickedevolutions / helenawillow incident; root-caused and Path-A live-validated in #76, 2026-05-09). Two coupled defects in `lib/auth/token-manager.js`, fixed minimally: **(1)** `refresh()` now consults the on-disk `refreshTokenExpiresAt` — it only short-circuits a cached `authStatus === "expired"` when the refresh token is *genuinely* past/missing expiry; otherwise it lets the token endpoint be the authority (new `_refreshTokenActuallyExpired()` helper). **(2)** the 4xx→`expired` persist is gated on **strong evidence** (B-orchestrator-approved minimal gate): an explicitly-terminal OAuth error (`invalid_client` / `unauthorized_client` / `revoked`) **or** a genuinely past/missing `refresh_token_expires_at`. A lone transient `invalid_grant` while the refresh token is still valid is now surfaced as a **retryable** error with no `auth_status` write, so the next use/boot re-attempts instead of staying stuck. The genuine-expiry terminal path (real 4xx with a past refresh-token expiry → `expired` + reauth hint) is preserved and explicitly regression-guarded; `REVOKED` stays terminal. `list-sites` now annotates each OAuth site with the actual `refresh_token_expires_at` so the discrepancy between a stale cached badge and underlying validity is operator-visible and self-recoverable. No cross-boot failure counter and no schema-v2 change (composes later via #90 if wanted). Adapter untouched; bridge-only. No released ability contract change (Principle 10 — internal refresh behavior + additive CLI annotation). Proven against the bridge's own units (`test/auth/token-manager.test.js` incl. Case 1 / Case 4 / REVOKED / transient-gate; `test/cli/list-sites.test.js`; `test/manual/repro-89.js`), not the MCP path; full `node --test` suite green (421/421).
18
+
19
+ - **Single (default) site's request-time refresh expiry no longer blanket-degrades the whole bridge; degraded mode is no longer sticky for the recoverable single-site case (Issue [#87](https://github.com/Wicked-Evolutions/abilities-mcp/issues/87) S1/S2).** The #76 request-time follow-up (v1.6.2, PR #82) intercepted a failed cached-`initialize` forward and called `enterDegradedMode([defaultSite])` — flipping the **entire router** to degraded for a single default-site refresh-token expiry, with no per-site fallback and no recovery path. Because OAuth `transport.connect()` does not pre-validate refresh tokens, `connectDefault`'s boot-time per-site fallback never fired (the default site "connects" cleanly and only fails when the cached `initialize` is forwarded), so a single expired default-site token took down healthy non-default sites too: every `tools/call` — including `mcp-adapter-execute-ability {site: <healthy-site>}`, which routes through an independent per-site lazy transport — was blanket-refused `-32603 "bridge is running in degraded mode"` (S1, blast-radius coupling). The whole-router `degraded` flag was set once and never cleared, so the running bridge process never recovered after reauth — only a brand-new client conversation (new process) did (S2, sticky per-session stale catalog). `lib/router.js` now mirrors the boot path at the request-time boundary: on a cached-`initialize` forward failure it attempts a healthy fallback site (`lib/connection-pool.js#connectFallback` — tears down the failed site's stale transport, marks it degraded in-memory, connects the first healthy *other* site and promotes it to runtime default), re-forwards the cached `initialize` through it so the client gets a real `InitializeResult` and the tool catalog populates normally, and enters degraded mode **only** when no site can serve. The genuine all-sites-down case still synthesizes a valid `InitializeResult` and enters degraded mode — the #76 init-death gate is preserved and explicitly regression-guarded. **Convergence (PR #88 reviewer blocker):** because `transport.connect()` does not validate refresh tokens, a freshly-connected fallback can itself fail the re-forwarded `initialize`; the router now accumulates every site that has failed the *cached initialize* (`McpRouter._initFailedSites`) and `connectFallback` excludes that whole set, so the candidate pool strictly shrinks and the chain provably terminates — once every configured site has failed the initialize the #76 all-sites-down gate fires instead of two bad-token OAuth sites alternating forever. No released ability contract changes (Principle 10): this is internal bridge recovery behavior only. Proven against the bridge's own units (`test/router-degraded-mode.test.js` incl. the two-OAuth-sites convergence guard, `test/manual/repro-87.js`), not the MCP tool path; full `node --test` suite green (417/417).
20
+
21
+ ## [1.6.3] - 2026-05-12
22
+
23
+ ### Fixed
24
+
25
+ - **Sanitizer normalizes array-shaped `inputSchema.properties` (Issue [#83](https://github.com/Wicked-Evolutions/abilities-mcp/issues/83), Sprint C 2026-05-11).** Extends `validateToolSchema` in `lib/sanitizer.js` so that when a tool's `inputSchema.properties` is an array (PHP `'properties' => array()` JSON-encodes to `[]`), `null`, a string, or any other non-plain-object value, it is normalized to `{}` before client emission. The previous v1.6.2 fix (#78/#79) normalized broken top-level `inputSchema` but left malformed nested `properties` untouched — the value passed `typeof === 'object'` (arrays are objects in JS) and reached the `Object.entries()` loop unchanged. Anthropic's draft 2020-12 validator rejects the entire `tools/list` payload on the first invalid schema (`/properties must be object`), so a single vendor-registered `properties: []` shape breaks the whole catalog. Defends against the entire class of vendor-registered ability schema slips that share the `properties: []` shape; SureCart's `surecart/get-store-info` was the trigger case (live capture from helenawillow.com 2026-05-11, 1 of 789 tools failing) but the fix is name-agnostic. Boundary discipline binding: `inputSchema: { type: "object" }` shapes that omit the `properties` key entirely remain byte-unchanged (early-return on `schema.properties === undefined` before the malformed-shape normalize). Already-valid object-shaped `properties` pass through byte-identical (regression-guarded with reference-equality assertion in `test/sanitizer.test.js`).
26
+
27
+ - **Bridge orientation: `mcp-adapter-*` reliably visible as the cross-site discovery path when filtering is enabled; `wp_browse_tools` / `wp_load_tools` describe themselves honestly (Issue [#85](https://github.com/Wicked-Evolutions/abilities-mcp/issues/85), Sprint C-follow-up 2026-05-12).** Surfaced during v1.6.3 release validation: AI clients were orienting around the bridge-local meta-tools first, treating their default-site catalog as the full ability surface and missing abilities registered only on non-default sites (e.g., `surecart/get-store-info` on helenawillow when `defaultSite=wickedevolutions`). The cross-site execution path via `mcp-adapter-discover-abilities` / `mcp-adapter-get-ability-info` / `mcp-adapter-execute-ability` already worked correctly — it just wasn't the path clients reached for. Three minimal changes restore orientation without changing architecture: (1) `lib/tool-catalog.js` — when `toolFilter.enabled === true` and the operator has not set their own `alwaysIncludeCategories`, default to `['mcp-adapter']` so the five `mcp-adapter-*` tools are always callable without a prior `wp_load_tools` step. Explicit operator override (including `[]`) is honored — guarded against the `[] || ['mcp-adapter']` truthy-array pitfall with an explicit `undefined`/`null` check. (2) `lib/bridge-tools.js` — `wp_browse_tools` and `wp_load_tools` descriptions now explicitly state they operate on the direct-tool catalog scoped to the default site, not full cross-site ability discovery. (3) `lib/router.js` — `wp_browse_tools` response leads with a `Scope:` line; `wp_load_tools` computes which requested categories are missing from the catalog and returns a structured pointer ("Not in the direct-tool catalog (scoped to defaultSite `<site>`): `<category>`. To call abilities in this category on another site, use `mcp-adapter-discover-abilities` with `{site, category}` then `mcp-adapter-execute-ability` with `{site, ability_name, parameters}`") instead of the prior silent "No changes — categories may already be active" zero-activation. No union-catalog architecture introduced; no `defaultSite`-switching workaround required; adapter unchanged. Acceptance pin: with `defaultSite = wickedevolutions` and `toolFilter.enabled = true`, a fresh session can discover and execute `surecart/get-store-info` on `helenawillow` through the adapter meta-tool path without changing `defaultSite` (empirically verified, MD5-pinned behavior-reversal cycle, 415/415 tests passing).
28
+
29
+ ### Compatibility & operator notes
30
+
31
+ - **No protocol semantics change** for valid schemas. Healthy operators see no behavioral difference. The fix activates only on the malformed-`properties` shape it closes.
32
+ - **No operator action required** — passive defensive normalization on next bridge restart.
33
+ - **Coordinated release wave** — held for joint release with abilities-mcp-adapter v1.4.8 per the cross-sprint coupling decision.
34
+
5
35
  ## [1.6.2] - 2026-05-10
6
36
 
7
37
  Schema validity polish + boot fragility MVP + multisite topology gate. Four Bridge fixes ship together as a bundled release closing the operator-side schema-400 + the silent-bridge-death-on-expired-refresh-token (both connect-time and request-time refresh boundaries) + the misplaced-multisite-block-cross-product issues.
package/README.md CHANGED
@@ -272,6 +272,27 @@ Bare `abilities-mcp` (no subcommand) starts the MCP STDIO server — the mode ev
272
272
 
273
273
  OAuth-managed sites added through `abilities-mcp add-site` are written to `~/.abilities-mcp/wp-sites.json` automatically — they carry an `auth.method: "oauth"` block with keychain references rather than inline secrets.
274
274
 
275
+ ### Sliding-renewal OAuth (opt-in, off by default)
276
+
277
+ By default an OAuth credential is bounded: the refresh token expires roughly **90 days after the initial authorization**, regardless of how often the site is used, after which `reauth` is required. This is unchanged and remains the default for every site.
278
+
279
+ You can opt a site into **silent sliding renewal** by setting `"sliding_renewal": true` inside that site's `auth` block in `wp-sites.json`:
280
+
281
+ ```json
282
+ "sites": {
283
+ "mysite": {
284
+ "url": "https://example.com",
285
+ "auth": { "method": "oauth", "sliding_renewal": true, "...": "..." }
286
+ }
287
+ }
288
+ ```
289
+
290
+ With it enabled, every successful token refresh advances the refresh-token expiry, so a site that is **actively used keeps renewing indefinitely and never forces reauth**. It still lapses normally if the site is left untouched past the window (no refresh within ~90 days → next use requires `reauth`).
291
+
292
+ > ⚠️ **Security trade-off — explicit operator choice.** Sliding renewal extends the refresh window indefinitely for actively-used sites. A leaked or exfiltrated refresh token therefore has a **longer blast radius** than the default bounded credential (which self-expires ≤90 days after initial authorization no matter what). Enable it only per-site, deliberately, for sites where uninterrupted automation outweighs that exposure. It is never enabled implicitly, never by migration, and never by `add-site`/`reauth` — you must set the flag yourself.
293
+
294
+ It is per-site: sites without the flag keep the default bounded behavior, byte-for-byte. `abilities-mcp list-sites` shows each OAuth site's actual `refresh_token_expires_at` so you can see the live window.
295
+
275
296
  ### Config search order
276
297
 
277
298
  1. `--config=/path/to/wp-sites.json` (explicit)
@@ -55,6 +55,12 @@ const { AUTH_STATUS } = require('./events');
55
55
  * @property {string} accessTokenExpiresAt ISO 8601
56
56
  * @property {string} refreshTokenExpiresAt ISO 8601
57
57
  * @property {string} authStatus 'active' | 'expired' | 'revoked' | 'pending-reauth'
58
+ * @property {boolean} [slidingRenewal] Issue #90: opt-in. When true,
59
+ * a successful refresh advances
60
+ * refreshTokenExpiresAt to mirror
61
+ * the adapter's re-issued TTL
62
+ * (sliding window). Absent/false
63
+ * = default bounded behavior.
58
64
  *
59
65
  * Copyright (C) 2026 Influencentricity | Wicked Evolutions
60
66
  * @license GPL-2.0-or-later
@@ -65,6 +71,35 @@ const HTTP_TIMEOUT_MS = 30_000;
65
71
  const MAX_RETRIES = 2;
66
72
  const SECRET_SERVICE = 'abilities-mcp';
67
73
 
74
+ // Issue #89: OAuth token-endpoint `error` codes that are STRONG terminal
75
+ // evidence — the grant is really gone, reauth is genuinely required. A bare
76
+ // `invalid_grant` is intentionally NOT here: it can occur on a transient
77
+ // server-state hiccup, and treating it as terminal while the refresh token is
78
+ // still valid for months is exactly the sticky-expired trap (#76/#89). Such a
79
+ // transient is gated by actual on-disk refresh-token expiry instead.
80
+ const TERMINAL_OAUTH_ERRORS = new Set([
81
+ 'invalid_client',
82
+ 'unauthorized_client',
83
+ 'revoked',
84
+ ]);
85
+
86
+ // Issue #90 (opt-in sliding renewal): explicit ADAPTER-CONTRACT MIRROR, not a
87
+ // bare bridge constant. The adapter (abilities-mcp-adapter,
88
+ // src/Auth/OAuth/TokenStore.php) defines `REFRESH_TTL = 7776000` (90d) and
89
+ // re-issues a brand-new refresh token with a fresh REFRESH_TTL on EVERY
90
+ // `refresh_token` grant (TokenStore::rotate() → issue(... REFRESH_TTL ...)).
91
+ // So while a site is actively used the server-side credential already slides;
92
+ // the bridge just needs to persist that slid expiry instead of the frozen
93
+ // issuance value — but ONLY when the operator opts in per-site.
94
+ //
95
+ // The adapter does not yet emit `refresh_expires_in` in the token response, so
96
+ // the bridge cannot read the server's effective TTL and mirrors the constant
97
+ // here. Tracked as a split, non-blocking follow-up:
98
+ // https://github.com/Wicked-Evolutions/abilities-mcp-adapter/issues/120
99
+ // Keep this value in sync with the adapter's TokenStore::REFRESH_TTL until
100
+ // that follow-up lands and the bridge can compute the slide from the response.
101
+ const ADAPTER_REFRESH_TTL_SECONDS = 90 * 24 * 3600; // mirror: TokenStore::REFRESH_TTL (7776000)
102
+
68
103
  class TokenManager {
69
104
  /**
70
105
  * @param {object} args
@@ -126,6 +161,27 @@ class TokenManager {
126
161
  return msUntilExpiry <= REFRESH_WINDOW_SECONDS * 1000;
127
162
  }
128
163
 
164
+ /**
165
+ * Issue #89: is the refresh token genuinely past its on-disk expiry (or has
166
+ * no usable expiry recorded)? This is the trust signal for deciding whether
167
+ * a cached `authStatus === "expired"` enum is believable, and whether a 4xx
168
+ * from the token endpoint is strong terminal evidence.
169
+ *
170
+ * A missing or unparseable `refreshTokenExpiresAt` is treated as expired
171
+ * (conservative — preserves the genuine-expiry terminal path when the field
172
+ * is absent; never makes a stuck site stickier than before).
173
+ *
174
+ * @param {SiteAuthState} siteAuth
175
+ * @returns {boolean} true when the refresh token is past/has-no expiry.
176
+ */
177
+ _refreshTokenActuallyExpired(siteAuth) {
178
+ const raw = siteAuth && siteAuth.refreshTokenExpiresAt;
179
+ if (!raw) return true;
180
+ const expiresAt = Date.parse(raw);
181
+ if (Number.isNaN(expiresAt)) return true;
182
+ return expiresAt <= this._now();
183
+ }
184
+
129
185
  // ---------------------------------------------------------------------
130
186
  // Refresh
131
187
  // ---------------------------------------------------------------------
@@ -144,7 +200,16 @@ class TokenManager {
144
200
  * @returns {Promise<{tokens: TokenSet, updatedAuth: SiteAuthState}>}
145
201
  */
146
202
  async refresh(siteAuth) {
147
- if (siteAuth.authStatus === AUTH_STATUS.EXPIRED) {
203
+ // Issue #76/#89: do NOT trust a cached `authStatus === "expired"` enum on
204
+ // its own. A single transient 4xx can have flipped it on disk while the
205
+ // refresh token is still valid for months; short-circuiting here is what
206
+ // made the state sticky (only manual reauth recovered). Consult the
207
+ // on-disk `refreshTokenExpiresAt`: only short-circuit when the refresh
208
+ // token is genuinely past/missing expiry. Otherwise fall through and let
209
+ // the token endpoint be the authority — it returns a real 4xx if the
210
+ // token is actually dead (the genuine-expiry terminal path, preserved).
211
+ if (siteAuth.authStatus === AUTH_STATUS.EXPIRED &&
212
+ this._refreshTokenActuallyExpired(siteAuth)) {
148
213
  throw new RefreshError(
149
214
  `Refresh token expired for site "${siteAuth.siteId}". ` +
150
215
  `Run: abilities-mcp reauth ${siteAuth.siteId}`,
@@ -201,23 +266,45 @@ class TokenManager {
201
266
  { code: 'server_error', state: 'refreshing', cause: { statusCode: res.statusCode, body: res.body } }
202
267
  );
203
268
  }
204
- // 4xx → never retry.
269
+ // 4xx → never retry the HTTP call. But Issue #89: gate the *terminal*
270
+ // `authStatus="expired"` persist on STRONG evidence (minimal gate,
271
+ // B-orchestrator-approved option a):
272
+ // - an explicitly-terminal OAuth error (invalid_client /
273
+ // unauthorized_client / revoked), OR
274
+ // - the on-disk refresh_token_expires_at is genuinely past/missing.
275
+ // A lone transient `invalid_grant` while the refresh token is still
276
+ // valid for months must NOT flip the sticky flag — that evidence-free
277
+ // write is what armed the #76/#89 trap. In that case surface a
278
+ // retryable error and leave `auth_status` untouched so the next use /
279
+ // boot re-attempts against the token endpoint.
205
280
  if (res.statusCode >= 400 && res.statusCode <= 499) {
206
281
  const oauthError = res.json && res.json.error ? res.json.error : 'invalid_grant';
207
282
  const description = res.json && res.json.error_description;
208
- // Mark the site as expired so the caller can route the operator to reauth.
209
- const updatedAuth = { ...siteAuth, authStatus: AUTH_STATUS.EXPIRED };
283
+ const terminal =
284
+ TERMINAL_OAUTH_ERRORS.has(oauthError) ||
285
+ this._refreshTokenActuallyExpired(siteAuth);
286
+
210
287
  const err = new RefreshError(
211
- `Refresh rejected (${oauthError}${description ? ': ' + description : ''}). ` +
212
- `Run: abilities-mcp reauth ${siteAuth.siteId}`,
288
+ `Refresh rejected (${oauthError}${description ? ': ' + description : ''}).` +
289
+ (terminal
290
+ ? ` Run: abilities-mcp reauth ${siteAuth.siteId}`
291
+ : ' Transient server-state — refresh token still valid; will retry on next use.'),
213
292
  {
214
293
  code: oauthError,
215
294
  state: 'refreshing',
216
295
  cause: { statusCode: res.statusCode, body: res.body },
217
296
  }
218
297
  );
219
- err.updatedAuth = updatedAuth;
220
- err.reauthHint = { siteId: siteAuth.siteId, command: `abilities-mcp reauth ${siteAuth.siteId}` };
298
+ if (terminal) {
299
+ // Strong evidence route the operator to reauth and persist the
300
+ // terminal state (the genuine-expiry path, preserved).
301
+ err.updatedAuth = { ...siteAuth, authStatus: AUTH_STATUS.EXPIRED };
302
+ err.reauthHint = { siteId: siteAuth.siteId, command: `abilities-mcp reauth ${siteAuth.siteId}` };
303
+ } else {
304
+ // Transient — do NOT attach updatedAuth (the wp-sites.json persist
305
+ // trigger). Mark retryable so callers/logs can distinguish it.
306
+ err.retryable = true;
307
+ }
221
308
  throw err;
222
309
  }
223
310
  // 2xx
@@ -255,6 +342,20 @@ class TokenManager {
255
342
  authStatus: AUTH_STATUS.ACTIVE,
256
343
  };
257
344
 
345
+ // Issue #90: opt-in silent sliding renewal. Default (flag absent/false):
346
+ // `refreshTokenExpiresAt` is carried unchanged from `...siteAuth` above —
347
+ // byte-identical to today's bounded ~90-days-from-initial-auth behavior,
348
+ // and `slidingRenewal` is left on updatedAuth so the persistence layer
349
+ // can tell this rotation must NOT open a new write path. When the
350
+ // operator has enabled it for this site, advance the on-disk refresh-token
351
+ // expiry to mirror the fresh TTL the adapter just re-issued on rotation,
352
+ // so an actively-used site renews indefinitely (it still lapses if left
353
+ // untouched past the window — the next refresh hits the #89 expiry path).
354
+ if (siteAuth.slidingRenewal === true) {
355
+ updatedAuth.refreshTokenExpiresAt =
356
+ new Date(this._now() + ADAPTER_REFRESH_TTL_SECONDS * 1000).toISOString();
357
+ }
358
+
258
359
  return { tokens, updatedAuth };
259
360
  }
260
361
 
@@ -23,7 +23,8 @@ const BRIDGE_TOOLS = [
23
23
  },
24
24
  {
25
25
  name: 'wp_browse_tools',
26
- description: 'List available WordPress tool categories with tool counts. Shows which categories are currently loaded. Use wp_load_tools to activate categories.',
26
+ description:
27
+ 'List categories in the bridge\'s direct-tool catalog (scoped to defaultSite) with tool counts and load-state. This is the local-catalog control surface — not full cross-site ability discovery. For abilities on another site, or in a category not listed here, use mcp-adapter-discover-abilities / mcp-adapter-execute-ability with { site }.',
27
28
  inputSchema: {
28
29
  type: 'object',
29
30
  properties: {},
@@ -31,14 +32,16 @@ const BRIDGE_TOOLS = [
31
32
  },
32
33
  {
33
34
  name: 'wp_load_tools',
34
- description: 'Activate WordPress tool categories to make their tools available. After loading, the tools list is automatically refreshed.',
35
+ description:
36
+ 'Activate categories in the bridge\'s direct-tool catalog (scoped to defaultSite) to expose their tools in tools/list. This is the local-catalog control surface — not full cross-site ability discovery. If a requested category is not in the local catalog, the response points to mcp-adapter-discover-abilities + mcp-adapter-execute-ability for the cross-site path.',
35
37
  inputSchema: {
36
38
  type: 'object',
37
39
  properties: {
38
40
  categories: {
39
41
  type: 'array',
40
42
  items: { type: 'string' },
41
- description: 'Category names to activate (e.g. ["fluent-crm", "content", "media"]). Use wp_browse_tools to see available categories.',
43
+ description:
44
+ 'Category names to activate from the direct-tool catalog (e.g. ["fluent-crm", "content", "media"]). Use wp_browse_tools to see available categories. Categories not in the local catalog return a pointer to mcp-adapter-discover-abilities instead of silently activating nothing.',
42
45
  },
43
46
  deactivate: {
44
47
  type: 'array',
@@ -64,6 +64,18 @@ async function run(args, ctx) {
64
64
  statusBadge += ' +apppassword-fallback';
65
65
  }
66
66
 
67
+ // Issue #89: surface the ACTUAL refresh-token expiry so the discrepancy
68
+ // between a (possibly stale) cached status badge and the underlying
69
+ // refresh-token validity is operator-visible and self-recoverable. The
70
+ // EXPIRES column is the access token; this annotation is the refresh
71
+ // token (the 90-day "authorize once" window).
72
+ let refreshExpiresAnnotation = null;
73
+ if (isOAuth && auth.refresh_token_expires_at) {
74
+ refreshExpiresAnnotation =
75
+ `refresh token: valid until ${auth.refresh_token_expires_at} ` +
76
+ `(${expiresLabel(auth.refresh_token_expires_at, nowMs)})`;
77
+ }
78
+
67
79
  let downgradeAnnotation = null;
68
80
  if (site.force_downgrade) {
69
81
  const expiresAt = Date.parse(site.force_downgrade.expires_at);
@@ -82,6 +94,7 @@ async function run(args, ctx) {
82
94
  scopesShort,
83
95
  expires,
84
96
  statusBadge,
97
+ refreshExpiresAnnotation,
85
98
  downgradeAnnotation,
86
99
  });
87
100
  }
package/lib/cli/output.js CHANGED
@@ -114,6 +114,9 @@ function renderSiteTable(rows, opts = {}) {
114
114
  lines.push(fmt([
115
115
  r.siteId, r.url, r.authMethod, r.user, r.scopesShort, r.expires, r.statusBadge,
116
116
  ]));
117
+ if (r.refreshExpiresAnnotation) {
118
+ lines.push(` ${r.refreshExpiresAnnotation}`);
119
+ }
117
120
  if (r.downgradeAnnotation) {
118
121
  lines.push(` ${r.downgradeAnnotation}`);
119
122
  }
@@ -24,6 +24,16 @@ function _siteAuthFromConfig(siteId, siteConfig, asMetadata) {
24
24
  accessTokenExpiresAt: siteConfig.auth.access_token_expires_at,
25
25
  refreshTokenExpiresAt: siteConfig.auth.refresh_token_expires_at,
26
26
  authStatus: siteConfig.auth_status || 'active',
27
+ // Issue #90: opt-in sliding renewal. Strictly === true so any
28
+ // missing/false/truthy-but-not-true value is the default bounded path.
29
+ //
30
+ // SECURITY TRADE-OFF (operator chose this per-site by setting
31
+ // auth.sliding_renewal:true in wp-sites.json): an actively-used site's
32
+ // refresh window then extends indefinitely, so a leaked refresh token has
33
+ // a longer blast radius than the default ≤90-days-from-initial-auth cap.
34
+ // Never enabled implicitly / by migration / by add-site|reauth. See
35
+ // README "Sliding-renewal OAuth (opt-in, off by default)".
36
+ slidingRenewal: siteConfig.auth.sliding_renewal === true,
27
37
  };
28
38
  }
29
39
 
@@ -50,6 +60,11 @@ class ConnectionPool {
50
60
  * Persists to wp-sites.json. Defaults to
51
61
  * atomic write via config-migration._atomicWrite
52
62
  * when config._configPath is set.
63
+ * @param {function} [deps.persistSlidingRenewal] (siteId, updatedAuth) => void
64
+ * Issue #90. Persists the slid
65
+ * refresh expiry + rotated refs for
66
+ * opt-in sites only. Defaults to an
67
+ * atomic write when _configPath set.
53
68
  * @param {boolean} [deps.allowInsecure] For local-dev OAuth over HTTP
54
69
  */
55
70
  constructor(config, logger, deps = {}) {
@@ -66,6 +81,7 @@ class ConnectionPool {
66
81
  this._discover = deps.discover || null;
67
82
  this._allowInsecure = !!deps.allowInsecure;
68
83
  this._persistAuthStatus = deps.persistAuthStatus || null;
84
+ this._persistSlidingRenewal = deps.persistSlidingRenewal || null;
69
85
 
70
86
  // Cache of OAuth AS metadata per site URL — avoids re-probing .well-known
71
87
  // on every transport rebuild. Refreshed when the transport is recreated.
@@ -187,6 +203,69 @@ class ConnectionPool {
187
203
  return null;
188
204
  }
189
205
 
206
+ /**
207
+ * Issue #87 (S1/S2): request-time per-site fallback.
208
+ *
209
+ * OAuth `transport.connect()` does NOT pre-validate refresh tokens
210
+ * (lib/transports/oauth-http-transport.js — sets ready, returns). So a
211
+ * default site with an expired refresh token connects cleanly at boot and
212
+ * `connectDefault` never falls back; the failure only surfaces when the
213
+ * cached `initialize` is forwarded and the refresh throws. This mirrors
214
+ * `connectDefault`'s boot-time fallback at request time: tear down the
215
+ * known-failed sites' stale transports, then connect the first site NOT in
216
+ * the exclude list and promote it to runtime default. Returns the new
217
+ * transport, or `null` when no non-excluded site can serve (the genuine
218
+ * "all sites failed" condition — the caller then enters degraded mode per
219
+ * the #76 gate).
220
+ *
221
+ * The caller (router) accumulates every site that has failed the
222
+ * re-forwarded cached initialize and passes the full set here, because
223
+ * `transport.connect()` does not validate tokens — a freshly-connected
224
+ * fallback can itself fail the initialize. Excluding the accumulated set
225
+ * makes the candidate pool strictly shrink so the chain converges (reviewer
226
+ * blocker, PR #88).
227
+ *
228
+ * @param {string|string[]} excludeSiteIds Site id(s) already known to have
229
+ * failed the cached initialize/connect — never re-tried here.
230
+ * @param {function} onMessage Per-site message router callback.
231
+ * @returns {Promise<Transport|null>}
232
+ */
233
+ async connectFallback(excludeSiteIds, onMessage) {
234
+ const excluded = new Set(
235
+ Array.isArray(excludeSiteIds) ? excludeSiteIds : [excludeSiteIds]
236
+ );
237
+
238
+ for (const siteId of excluded) {
239
+ const stale = this.transports.get(siteId);
240
+ if (stale) {
241
+ try { await stale.shutdown(); } catch { /* best effort — stale anyway */ }
242
+ this.transports.delete(siteId);
243
+ }
244
+ }
245
+
246
+ const tryOrder = Object.keys(this.config.sites).filter((k) => !excluded.has(k));
247
+ for (const key of tryOrder) {
248
+ try {
249
+ const transport = await this._createTransport(key, null);
250
+ transport.onMessage = onMessage;
251
+ await transport.connect();
252
+ this.transports.set(key, transport);
253
+ this.config.defaultSite = key;
254
+ this.log(
255
+ `Default failed at request time (excluded: ${[...excluded].join(', ')}); ` +
256
+ `runtime default fell back to "${key}". Excluded sites are marked ` +
257
+ `degraded in-memory; reauth to restore.`
258
+ );
259
+ return transport;
260
+ } catch (err) {
261
+ this.log(`Fallback site "${key}" failed to connect: ${err.message}`);
262
+ this._markSiteDegraded(key, err);
263
+ }
264
+ }
265
+
266
+ return null;
267
+ }
268
+
190
269
  /**
191
270
  * Mark a site degraded in the in-memory config so other lookups (tools/list,
192
271
  * resources/read wp-abilities://sites, per-call routing) can surface it
@@ -480,6 +559,27 @@ class ConnectionPool {
480
559
  .catch((err) => this.log(`OAuth auth_status persist failed: ${err.message}`));
481
560
  }
482
561
  },
562
+ // Issue #90: opt-in sliding renewal. The transport invokes this ONLY
563
+ // after a successful refresh of a site whose siteAuth.slidingRenewal ===
564
+ // true — flag-off (default) sites never reach this callback, so no new
565
+ // write path is created for them (binding guardrail 1).
566
+ onTokensRenewed: (updatedAuth) => {
567
+ try {
568
+ siteConfig.auth.refresh_token_expires_at = updatedAuth.refreshTokenExpiresAt;
569
+ siteConfig.auth.access_token_expires_at = updatedAuth.accessTokenExpiresAt;
570
+ siteConfig.auth.access_token_ref = updatedAuth.accessTokenRef;
571
+ siteConfig.auth.refresh_token_ref = updatedAuth.refreshTokenRef;
572
+ } catch { /* siteConfig may be frozen in tests — ignore */ }
573
+ this.log(`OAuth sliding renewal: ${compositeKey} → refresh window slid to ${updatedAuth.refreshTokenExpiresAt}`);
574
+ if (this._persistSlidingRenewal) {
575
+ Promise.resolve()
576
+ .then(() => this._persistSlidingRenewal(compositeKey, updatedAuth))
577
+ .catch((err) => this.log(`OAuth sliding-renewal persist failed: ${err.message}`));
578
+ } else if (this.config && this.config._configPath) {
579
+ this._defaultPersistSlidingRenewal(compositeKey, updatedAuth)
580
+ .catch((err) => this.log(`OAuth sliding-renewal persist failed: ${err.message}`));
581
+ }
582
+ },
483
583
  logger: this.log,
484
584
  });
485
585
  }
@@ -510,6 +610,41 @@ class ConnectionPool {
510
610
  }
511
611
  }
512
612
 
613
+ /**
614
+ * Issue #90: default sliding-renewal persistor — atomic rewrite of the slid
615
+ * refresh-token expiry and rotated refs. Reached ONLY via the transport's
616
+ * onTokensRenewed callback, which fires solely for opt-in sites
617
+ * (siteAuth.slidingRenewal === true) after a successful refresh — flag-off
618
+ * sites never get here, so the default bounded behavior writes nothing new
619
+ * (binding guardrail 1 & 2). Best-effort: a write failure must not break the
620
+ * request path that triggered the renewal.
621
+ */
622
+ async _defaultPersistSlidingRenewal(siteId, updatedAuth) {
623
+ const { _atomicWrite } = require('./auth/config-migration');
624
+ const fs = require('node:fs');
625
+ const filePath = this.config._configPath;
626
+ if (!filePath) return;
627
+ let raw;
628
+ try {
629
+ raw = await fs.promises.readFile(filePath, 'utf8');
630
+ } catch (err) {
631
+ throw new Error(`read ${filePath}: ${err.message}`);
632
+ }
633
+ let parsed;
634
+ try { parsed = JSON.parse(raw); }
635
+ catch (err) {
636
+ throw new Error(`parse ${filePath}: ${err.message}`);
637
+ }
638
+ if (parsed && parsed.sites && parsed.sites[siteId] && parsed.sites[siteId].auth) {
639
+ const auth = parsed.sites[siteId].auth;
640
+ auth.refresh_token_expires_at = updatedAuth.refreshTokenExpiresAt;
641
+ auth.access_token_expires_at = updatedAuth.accessTokenExpiresAt;
642
+ auth.access_token_ref = updatedAuth.accessTokenRef;
643
+ auth.refresh_token_ref = updatedAuth.refreshTokenRef;
644
+ await _atomicWrite(filePath, parsed);
645
+ }
646
+ }
647
+
513
648
  /**
514
649
  * Check if a composite key resolves to the same HTTP endpoint as an
515
650
  * already-connected transport. Returns { key, transport } or null.
package/lib/router.js CHANGED
@@ -63,6 +63,14 @@ class McpRouter {
63
63
  // tools/call surfaces a per-call error naming the degraded sites.
64
64
  this.degraded = false;
65
65
  this.degradedSites = []; // [{ siteId, reason }]
66
+
67
+ // Issue #87: sites that have failed the *re-forwarded cached initialize*
68
+ // (not merely connect — OAuth connect() does not validate tokens). Used to
69
+ // exclude already-failed sites from request-time fallback so the chain
70
+ // strictly converges: when every configured site is in this map there is
71
+ // no untried site left, and the #76 all-sites-down gate fires instead of
72
+ // alternating between bad OAuth sites forever. siteId → failure reason.
73
+ this._initFailedSites = new Map();
66
74
  }
67
75
 
68
76
  /**
@@ -227,10 +235,15 @@ class McpRouter {
227
235
  const reason = (parsedMsg.error && parsedMsg.error.message) || 'unknown';
228
236
  this.log(
229
237
  `Initialize forward failed for "${failedSite || '(unknown)'}": ${reason} — ` +
230
- `synthesizing degraded-mode InitializeResult to satisfy MCP runtime`
238
+ `attempting per-site fallback before degrading (Issue #87 S1/S2)`
231
239
  );
232
- this._sendSynthesizedInitializeResult(this.cachedInitRequest);
233
- this.enterDegradedMode([{ siteId: failedSite || '(unknown)', reason }]);
240
+ // Issue #87 (S1/S2): a single (default) site's request-time refresh
241
+ // failure must NOT blanket-degrade the whole bridge. Mirror the boot
242
+ // path's per-site fallback; degrade only when no site can serve (the
243
+ // genuine "all sites failed" condition the #76 gate is about). Async,
244
+ // fire-and-forget like _handleToolsCall; the promise is exposed for
245
+ // deterministic test sequencing.
246
+ this._initFailurePromise = this._handleInitializeForwardFailure(failedSite, reason);
234
247
  return;
235
248
  }
236
249
 
@@ -353,6 +366,85 @@ class McpRouter {
353
366
  this.earlyQueue.push(line);
354
367
  }
355
368
 
369
+ // ---------------------------------------------------------------------------
370
+ // Internal — degraded mode (Issue #76) + request-time fallback (Issue #87)
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Issue #87 (S1/S2): the cached `initialize` forward failed for the runtime
375
+ * default site (request-time refresh-token expiry). Try a fallback site that
376
+ * has NOT already failed the cached initialize and re-forward the cached
377
+ * initialize through it so the client gets a real InitializeResult and the
378
+ * tool catalog populates normally — the bridge stays healthy and the failed
379
+ * site is marked degraded (visible via wp_bridge_health).
380
+ *
381
+ * Convergence (reviewer blocker, PR #88): OAuth `transport.connect()` does
382
+ * NOT validate refresh tokens, so a freshly-connected fallback can itself
383
+ * fail the re-forwarded initialize. Without bookkeeping, two bad-token OAuth
384
+ * sites alternate forever and the #76 gate never fires. We accumulate every
385
+ * site that has failed the *cached initialize* in `this._initFailedSites`
386
+ * and exclude all of them from the next fallback. Each round adds the
387
+ * just-failed runtime default to that set, so the candidate pool strictly
388
+ * shrinks; once every configured site has failed the initialize there is no
389
+ * untried site left and the #76 all-sites-down gate fires (synthesized
390
+ * InitializeResult + degraded mode). Termination is guaranteed.
391
+ *
392
+ * @param {string} failedSite Runtime default site whose initialize failed.
393
+ * @param {string} reason Error message from the failed forward.
394
+ */
395
+ async _handleInitializeForwardFailure(failedSite, reason) {
396
+ if (failedSite) this._initFailedSites.set(failedSite, reason);
397
+
398
+ let fallback = null;
399
+ try {
400
+ fallback = await this.pool.connectFallback(
401
+ Array.from(this._initFailedSites.keys()),
402
+ (parsedMsg, rawLine) => {
403
+ this.handleTransportMessage(parsedMsg, rawLine);
404
+ }
405
+ );
406
+ } catch (err) {
407
+ this.log(`Per-site fallback connect threw: ${err.message}`);
408
+ fallback = null;
409
+ }
410
+
411
+ if (fallback) {
412
+ this.setDefaultTransport(fallback);
413
+ this.log(
414
+ `Per-site fallback succeeded; re-forwarding cached initialize via ` +
415
+ `"${this.config.defaultSite}" (excluded: ` +
416
+ `${Array.from(this._initFailedSites.keys()).join(', ')}). ` +
417
+ `Bridge NOT degraded (Issue #87 S1).`
418
+ );
419
+ this._forwardToDefault(JSON.stringify(this.cachedInitRequest));
420
+ return;
421
+ }
422
+
423
+ // No untried site — every configured site has failed the cached
424
+ // initialize (or connect). Genuine all-sites-down: preserve the #76 gate
425
+ // — synthesize a valid InitializeResult locally and enter degraded mode so
426
+ // the MCP runtime stays connectable and wp_bridge_health can report
427
+ // per-site status. degradedSites carries the recorded per-site initialize
428
+ // failure reason; sites that only failed connect fall back to their
429
+ // in-memory `_degraded_reason`.
430
+ this.log(
431
+ `No untried fallback site — every configured site failed the cached ` +
432
+ `initialize. Entering degraded mode (all sites failed, #76 gate).`
433
+ );
434
+ const degradedSites = Object.entries((this.config && this.config.sites) || {}).map(
435
+ ([siteId, site]) => ({
436
+ siteId,
437
+ reason: this._initFailedSites.get(siteId) ||
438
+ (site && site._degraded_reason) || 'connect failed',
439
+ })
440
+ );
441
+ if (degradedSites.length === 0) {
442
+ degradedSites.push({ siteId: failedSite || '(unknown)', reason });
443
+ }
444
+ this._sendSynthesizedInitializeResult(this.cachedInitRequest);
445
+ this.enterDegradedMode(degradedSites);
446
+ }
447
+
356
448
  // ---------------------------------------------------------------------------
357
449
  // Internal — degraded mode (Issue #76)
358
450
  // ---------------------------------------------------------------------------
@@ -463,14 +555,26 @@ class McpRouter {
463
555
  }
464
556
 
465
557
  const summary = this.catalog.getCategorySummary();
466
- const lines = summary.map(c =>
467
- `${c.active ? '[LOADED]' : ' '} ${c.name} (${c.toolCount} tools)`
558
+ const lines = [];
559
+ lines.push(
560
+ `Scope: direct-tool catalog scoped to defaultSite (${this.config.defaultSite}). ` +
561
+ `Not full cross-site ability discovery.`
468
562
  );
469
563
  lines.push('');
564
+ for (const c of summary) {
565
+ lines.push(`${c.active ? '[LOADED]' : ' '} ${c.name} (${c.toolCount} tools)`);
566
+ }
567
+ lines.push('');
470
568
  lines.push(`Total: ${this.catalog.fullTools.length} tools in ${summary.length} categories`);
471
569
  lines.push(`Loaded: ${summary.filter(c => c.active).reduce((n, c) => n + c.toolCount, 0)} tools`);
472
570
  lines.push('');
473
571
  lines.push('Use wp_load_tools with categories array to activate.');
572
+ lines.push('');
573
+ lines.push(
574
+ 'For abilities on another site, or in a category not listed here, use ' +
575
+ 'mcp-adapter-discover-abilities { site, category } and ' +
576
+ 'mcp-adapter-execute-ability { site, ability_name, parameters }.'
577
+ );
474
578
 
475
579
  this.sendToClient(JSON.stringify({
476
580
  jsonrpc: '2.0', id: msg.id,
@@ -496,6 +600,7 @@ class McpRouter {
496
600
  }
497
601
 
498
602
  const activated = this.catalog.activateCategories(toActivate);
603
+ const missing = toActivate.filter((c) => !this.catalog.categories[c]);
499
604
  const lines = [];
500
605
 
501
606
  if (activated.length > 0) {
@@ -504,8 +609,25 @@ class McpRouter {
504
609
  if (toDeactivate.length > 0) {
505
610
  lines.push(`Deactivated: ${toDeactivate.join(', ')}`);
506
611
  }
507
- if (activated.length === 0 && toDeactivate.length === 0) {
508
- lines.push('No changes — categories may already be active or not found.');
612
+ if (missing.length > 0) {
613
+ lines.push(
614
+ `Not in the direct-tool catalog (scoped to defaultSite "${this.config.defaultSite}"): ` +
615
+ missing.join(', ') +
616
+ '.'
617
+ );
618
+ lines.push(
619
+ 'For abilities on another site, or in a category not in this catalog, use the ' +
620
+ 'adapter meta-tools instead:'
621
+ );
622
+ for (const cat of missing) {
623
+ lines.push(` mcp-adapter-discover-abilities { site: "<site>", category: "${cat}" }`);
624
+ }
625
+ lines.push(
626
+ ' mcp-adapter-execute-ability { site: "<site>", ability_name: "<ability>", parameters: { ... } }'
627
+ );
628
+ }
629
+ if (activated.length === 0 && toDeactivate.length === 0 && missing.length === 0) {
630
+ lines.push('No changes — categories may already be active.');
509
631
  }
510
632
 
511
633
  const filtered = this.catalog.getFilteredTools();
package/lib/sanitizer.js CHANGED
@@ -29,7 +29,19 @@ function validateToolSchema(toolName, schema, log) {
29
29
  log(`SCHEMA WARN [${toolName}]: invalid top-level type "${schema.type}"`);
30
30
  }
31
31
 
32
- if (!schema.properties || typeof schema.properties !== 'object') return;
32
+ // Boundary: properties key absent leave inputSchema byte-unchanged.
33
+ if (schema.properties === undefined) return;
34
+
35
+ // Normalize malformed `properties` shapes (PHP `array()` JSON-encodes to `[]`,
36
+ // strings, null, primitives) to `{}` before downstream validation. Anthropic's
37
+ // draft 2020-12 validator rejects the entire tools/list payload on the first
38
+ // invalid schema, so a single vendor-registered `properties: []` breaks the
39
+ // whole catalog. Mirror of the top-level inputSchema normalize one frame up.
40
+ if (schema.properties === null || typeof schema.properties !== 'object' || Array.isArray(schema.properties)) {
41
+ log(`SCHEMA NORMALIZE [${toolName}]: inputSchema.properties not a plain object — normalized to {}`);
42
+ schema.properties = {};
43
+ return;
44
+ }
33
45
 
34
46
  for (const [prop, def] of Object.entries(schema.properties)) {
35
47
  if (!def || typeof def !== 'object') {
@@ -32,11 +32,22 @@ class ToolCatalog {
32
32
  // Active (loaded) categories
33
33
  this.activeCategories = new Set();
34
34
 
35
- // Initialize always-included categories from config
36
- if (this.filterConfig && this.filterConfig.alwaysIncludeCategories) {
37
- for (const cat of this.filterConfig.alwaysIncludeCategories) {
38
- this.activeCategories.add(cat);
39
- }
35
+ // Resolve "always-include" categories once. Explicit operator config wins
36
+ // (including an explicit empty list, which preserves the operator's choice).
37
+ // When filtering is enabled and the operator hasn't set their own list,
38
+ // default to ['mcp-adapter'] so the cross-site adapter meta-tools
39
+ // (mcp-adapter-discover-abilities / -get-ability-info / -execute-ability)
40
+ // are always visible without requiring a prior wp_load_tools call.
41
+ const explicit = this.filterConfig && this.filterConfig.alwaysIncludeCategories;
42
+ const resolved =
43
+ explicit !== undefined && explicit !== null
44
+ ? explicit
45
+ : this.isEnabled()
46
+ ? ['mcp-adapter']
47
+ : [];
48
+ this.effectiveAlwaysInclude = new Set(resolved);
49
+ for (const cat of this.effectiveAlwaysInclude) {
50
+ this.activeCategories.add(cat);
40
51
  }
41
52
  }
42
53
 
@@ -104,12 +115,11 @@ class ToolCatalog {
104
115
  * Deactivate categories (return to compact mode).
105
116
  */
106
117
  deactivateCategories(categoryNames) {
107
- // Don't deactivate always-included categories
108
- const alwaysIncluded = new Set(
109
- (this.filterConfig && this.filterConfig.alwaysIncludeCategories) || []
110
- );
118
+ // Don't deactivate always-included categories (single source of truth:
119
+ // the set resolved at construction, so the default mcp-adapter inclusion
120
+ // is treated the same as an explicit operator-configured entry).
111
121
  for (const name of categoryNames) {
112
- if (!alwaysIncluded.has(name)) {
122
+ if (!this.effectiveAlwaysInclude.has(name)) {
113
123
  this.activeCategories.delete(name);
114
124
  }
115
125
  }
@@ -72,6 +72,15 @@ const MAX_QUEUE = 100;
72
72
  * (newStatus, { reason, siteId })
73
73
  * when transport detects a terminal
74
74
  * auth failure. Caller persists.
75
+ * @param {function} opts.onTokensRenewed Optional. Issue #90 (opt-in
76
+ * sliding renewal). Called with
77
+ * (updatedAuth) after a successful
78
+ * refresh ONLY when
79
+ * siteAuth.slidingRenewal === true.
80
+ * Caller persists the slid
81
+ * refresh_token_expires_at + rotated
82
+ * refs + access expiry. Never
83
+ * invoked for default sites.
75
84
  * @param {function} opts.logger Logger function
76
85
  *
77
86
  * Copyright (C) 2026 Influencentricity | Wicked Evolutions
@@ -95,6 +104,10 @@ class OAuthHttpTransport {
95
104
  this._tokenManager = opts.tokenManager;
96
105
  this._siteAuth = opts.siteAuth;
97
106
  this._onAuthStatusChange = opts.onAuthStatusChange || null;
107
+ // Issue #90: opt-in sliding renewal. Invoked ONLY on a successful refresh
108
+ // of a site whose siteAuth.slidingRenewal === true, so flag-off sites
109
+ // never reach a new write path. Caller persists the slid expiry + refs.
110
+ this._onTokensRenewed = opts.onTokensRenewed || null;
98
111
  this.log = opts.logger || function noop() {};
99
112
 
100
113
  const parsedUrl = new URL(this.endpoint);
@@ -416,6 +429,19 @@ class OAuthHttpTransport {
416
429
  accessTokenExpiresAt: tok.updatedAuth.accessTokenExpiresAt,
417
430
  authStatus: tok.updatedAuth.authStatus,
418
431
  };
432
+ // Issue #90: opt-in sliding renewal ONLY. For default (flag
433
+ // absent/false) sites this branch is skipped entirely — no extra
434
+ // in-memory expiry adoption, no persist callback, no new write path.
435
+ // For opt-in sites, adopt the slid refresh-token expiry in-memory and
436
+ // ask the caller to persist it (+ rotated refs + access expiry).
437
+ if (this._siteAuth.slidingRenewal === true) {
438
+ this._siteAuth.refreshTokenExpiresAt = tok.updatedAuth.refreshTokenExpiresAt;
439
+ if (this._onTokensRenewed) {
440
+ try {
441
+ this._onTokensRenewed(tok.updatedAuth);
442
+ } catch { /* observer must not break the request path */ }
443
+ }
444
+ }
419
445
  }
420
446
  } catch (err) {
421
447
  // Refresh failure (e.g. RefreshError 4xx → expired). Emit auth status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wickedevolutions/abilities-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.6.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": {