@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 +30 -0
- package/README.md +21 -0
- package/lib/auth/token-manager.js +109 -8
- package/lib/bridge-tools.js +6 -3
- package/lib/cli/commands/list-sites.js +13 -0
- package/lib/cli/output.js +3 -0
- package/lib/connection-pool.js +135 -0
- package/lib/router.js +129 -7
- package/lib/sanitizer.js +13 -1
- package/lib/tool-catalog.js +20 -10
- package/lib/transports/oauth-http-transport.js +26 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
package/lib/bridge-tools.js
CHANGED
|
@@ -23,7 +23,8 @@ const BRIDGE_TOOLS = [
|
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
name: 'wp_browse_tools',
|
|
26
|
-
description:
|
|
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:
|
|
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:
|
|
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
|
}
|
package/lib/connection-pool.js
CHANGED
|
@@ -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
|
-
`
|
|
238
|
+
`attempting per-site fallback before degrading (Issue #87 S1/S2)`
|
|
231
239
|
);
|
|
232
|
-
|
|
233
|
-
|
|
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 =
|
|
467
|
-
|
|
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 (
|
|
508
|
-
lines.push(
|
|
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
|
-
|
|
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') {
|
package/lib/tool-catalog.js
CHANGED
|
@@ -32,11 +32,22 @@ class ToolCatalog {
|
|
|
32
32
|
// Active (loaded) categories
|
|
33
33
|
this.activeCategories = new Set();
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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 (!
|
|
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.
|
|
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": {
|