@wickedevolutions/abilities-mcp 1.6.3 → 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 +16 -0
- package/README.md +21 -0
- package/lib/auth/token-manager.js +109 -8
- 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 +95 -3
- package/lib/transports/oauth-http-transport.js +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
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
|
+
|
|
5
21
|
## [1.6.3] - 2026-05-12
|
|
6
22
|
|
|
7
23
|
### Fixed
|
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
|
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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": {
|