@wickedevolutions/abilities-mcp 1.3.1 → 1.5.3
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 +111 -0
- package/README.md +88 -17
- package/abilities-mcp.js +191 -114
- package/lib/auth/bridge-identity-provider.js +34 -0
- package/lib/auth/browser-launcher.js +67 -0
- package/lib/auth/config-migration.js +322 -0
- package/lib/auth/dcr-client.js +123 -0
- package/lib/auth/discovery-client.js +273 -0
- package/lib/auth/errors.js +114 -0
- package/lib/auth/events.js +55 -0
- package/lib/auth/fresh-each-time-identity.js +101 -0
- package/lib/auth/http-json.js +151 -0
- package/lib/auth/index.js +88 -0
- package/lib/auth/keychain-secret-store.js +265 -0
- package/lib/auth/loopback-server.js +249 -0
- package/lib/auth/memory-secret-store.js +0 -0
- package/lib/auth/oauth-client.js +357 -0
- package/lib/auth/pkce.js +93 -0
- package/lib/auth/schema-v2.js +110 -0
- package/lib/auth/secret-store.js +78 -0
- package/lib/auth/token-manager.js +378 -0
- package/lib/cli/commands/add-site.js +226 -0
- package/lib/cli/commands/force-downgrade.js +93 -0
- package/lib/cli/commands/list-sites.js +93 -0
- package/lib/cli/commands/reauth.js +108 -0
- package/lib/cli/commands/revoke.js +127 -0
- package/lib/cli/commands/self-check.js +158 -0
- package/lib/cli/commands/test.js +174 -0
- package/lib/cli/commands/upgrade-auth.js +259 -0
- package/lib/cli/config-store.js +328 -0
- package/lib/cli/context.js +102 -0
- package/lib/cli/errors.js +227 -0
- package/lib/cli/index.js +173 -0
- package/lib/cli/output.js +175 -0
- package/lib/cli/parse-args.js +80 -0
- package/lib/config-source-line.js +85 -0
- package/lib/config.js +282 -22
- package/lib/connection-pool.js +214 -11
- package/lib/router.js +29 -11
- package/lib/transports/oauth-http-transport.js +601 -0
- package/package.json +8 -2
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { URL } = require('url');
|
|
6
|
+
|
|
7
|
+
const MAX_QUEUE = 100;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* OAuthHttpTransport — HTTP transport for sites authenticated with
|
|
11
|
+
* OAuth 2.1 bearer tokens.
|
|
12
|
+
*
|
|
13
|
+
* Sibling to HttpTransport (which handles Basic / App-Password). Selected by
|
|
14
|
+
* ConnectionPool when `siteConfig.auth.method === 'oauth'`.
|
|
15
|
+
*
|
|
16
|
+
* Surface compatible with HttpTransport: connect / send / isReady /
|
|
17
|
+
* performHandshake / shutdown / onMessage / endpoint, so the rest of the
|
|
18
|
+
* bridge (router, connection-pool reuse logic, healthcheck) cannot tell
|
|
19
|
+
* the two apart.
|
|
20
|
+
*
|
|
21
|
+
* Auth:
|
|
22
|
+
* - Before each POST, resolve a usable access token via TokenManager.
|
|
23
|
+
* TokenManager handles the 300-second pre-expiry refresh per H.2.1.
|
|
24
|
+
* - Build `Authorization: Bearer ${accessToken}`.
|
|
25
|
+
* - On 401 from the resource, call TokenManager with `forceRefresh: true`
|
|
26
|
+
* and retry the request once. If the retry also returns 401, emit an
|
|
27
|
+
* auth_failed event (caller updates auth_status to "expired") and
|
|
28
|
+
* surface a structured error to the request — no third attempt.
|
|
29
|
+
*
|
|
30
|
+
* Queue / batch / cookie / session-token semantics mirror HttpTransport.
|
|
31
|
+
* The duplication is intentional for this hotfix: extracting a shared base
|
|
32
|
+
* across both transports would touch HttpTransport mid-hotfix and risk
|
|
33
|
+
* regressing the App-Password runtime path. A future refactor can pull a
|
|
34
|
+
* BaseHttpTransport once both implementations have stabilised.
|
|
35
|
+
*
|
|
36
|
+
* Out of scope for this transport (per Appendix H.2.3):
|
|
37
|
+
* - Capability pinning enforcement. The pin lives on `siteConfig` and is
|
|
38
|
+
* written by the OAuth flow (oauth-client.js) during add-site / reauth.
|
|
39
|
+
* Pinned-then-404 fail-loud belongs to the discovery layer, not to
|
|
40
|
+
* runtime MCP traffic. The transport never probes .well-known/*.
|
|
41
|
+
* - apppassword_fallback at runtime. When `auth.method === 'oauth'`,
|
|
42
|
+
* OAuth always wins — see Appendix F.5 "Precedence rule". The fallback
|
|
43
|
+
* block exists for the operator-driven `upgrade-auth` workflow only;
|
|
44
|
+
* it is NOT a runtime "if OAuth fails, try Basic" branch.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {string} opts.endpoint MCP resource URL (https)
|
|
48
|
+
* @param {object} opts.tokenManager TokenManager instance
|
|
49
|
+
* @param {object} opts.siteAuth SiteAuthState (see TokenManager)
|
|
50
|
+
* @param {function} opts.onAuthStatusChange Optional. Called with
|
|
51
|
+
* (newStatus, { reason, siteId })
|
|
52
|
+
* when transport detects a terminal
|
|
53
|
+
* auth failure. Caller persists.
|
|
54
|
+
* @param {function} opts.logger Logger function
|
|
55
|
+
*
|
|
56
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
57
|
+
* @license GPL-2.0-or-later
|
|
58
|
+
*/
|
|
59
|
+
class OAuthHttpTransport {
|
|
60
|
+
|
|
61
|
+
constructor(opts) {
|
|
62
|
+
if (!opts || !opts.endpoint) {
|
|
63
|
+
throw new Error('OAuthHttpTransport requires an endpoint');
|
|
64
|
+
}
|
|
65
|
+
if (!opts.tokenManager) {
|
|
66
|
+
throw new Error('OAuthHttpTransport requires a tokenManager');
|
|
67
|
+
}
|
|
68
|
+
if (!opts.siteAuth) {
|
|
69
|
+
throw new Error('OAuthHttpTransport requires siteAuth');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.endpoint = opts.endpoint;
|
|
73
|
+
this._tokenManager = opts.tokenManager;
|
|
74
|
+
this._siteAuth = opts.siteAuth;
|
|
75
|
+
this._onAuthStatusChange = opts.onAuthStatusChange || null;
|
|
76
|
+
this.log = opts.logger || function noop() {};
|
|
77
|
+
|
|
78
|
+
const parsedUrl = new URL(this.endpoint);
|
|
79
|
+
this.parsedUrl = parsedUrl;
|
|
80
|
+
this.isHttps = parsedUrl.protocol === 'https:';
|
|
81
|
+
this.httpModule = this.isHttps ? https : http;
|
|
82
|
+
|
|
83
|
+
// State
|
|
84
|
+
this.sessionId = null;
|
|
85
|
+
this.sessionToken = null;
|
|
86
|
+
this.clientProtocolVersion = null;
|
|
87
|
+
this.ready = false;
|
|
88
|
+
this.onMessage = null;
|
|
89
|
+
|
|
90
|
+
// Message queue — serialized processing
|
|
91
|
+
this.messageQueue = [];
|
|
92
|
+
this.processing = false;
|
|
93
|
+
|
|
94
|
+
// Batch coalescing
|
|
95
|
+
this._coalesceTimer = null;
|
|
96
|
+
this._coalesceWindowMs = 10;
|
|
97
|
+
|
|
98
|
+
// Healthcheck
|
|
99
|
+
this.healthcheckTimer = null;
|
|
100
|
+
|
|
101
|
+
// 5xx retry config (separate from the 401-once-refresh path)
|
|
102
|
+
this.maxRetries = 3;
|
|
103
|
+
this.baseRetryDelay = 1000;
|
|
104
|
+
|
|
105
|
+
// Handshake cache for session recovery
|
|
106
|
+
this.cachedInitRequest = null;
|
|
107
|
+
this.cachedInitNotification = null;
|
|
108
|
+
|
|
109
|
+
// Cookie jar — per-host
|
|
110
|
+
this._cookies = new Map();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Public surface (mirrors HttpTransport)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
async connect() {
|
|
118
|
+
this.ready = true;
|
|
119
|
+
this.log(`OAuth HTTP transport ready: ${this.parsedUrl.hostname}`);
|
|
120
|
+
this._startHealthcheck();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
send(line) {
|
|
124
|
+
if (this.messageQueue.length >= MAX_QUEUE) {
|
|
125
|
+
this.log('OAuth HTTP queue full — rejecting');
|
|
126
|
+
try {
|
|
127
|
+
const msg = JSON.parse(line);
|
|
128
|
+
if (msg.id !== undefined && this.onMessage) {
|
|
129
|
+
this.onMessage({
|
|
130
|
+
jsonrpc: '2.0', id: msg.id,
|
|
131
|
+
error: { code: -32603, message: 'OAuth HTTP transport queue full' }
|
|
132
|
+
}, null);
|
|
133
|
+
}
|
|
134
|
+
} catch (e) { /* ignore */ }
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.messageQueue.push(line);
|
|
138
|
+
this._drainQueue();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
isReady() {
|
|
142
|
+
return this.ready;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async performHandshake(initRequest, initNotification) {
|
|
146
|
+
this.cachedInitRequest = initRequest;
|
|
147
|
+
this.cachedInitNotification = initNotification;
|
|
148
|
+
|
|
149
|
+
if (initRequest.params && initRequest.params.protocolVersion) {
|
|
150
|
+
this.clientProtocolVersion = initRequest.params.protocolVersion;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const initLine = JSON.stringify(initRequest);
|
|
154
|
+
const initResult = await this._postWithRetry(initLine);
|
|
155
|
+
if (initResult && initResult.body && initResult.body.trim()) {
|
|
156
|
+
this.log(`OAuth HTTP handshake init response: ${initResult.statusCode}`);
|
|
157
|
+
}
|
|
158
|
+
if (initResult && initResult.sessionId) {
|
|
159
|
+
this.sessionId = initResult.sessionId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (initNotification) {
|
|
163
|
+
const notifLine = JSON.stringify(initNotification);
|
|
164
|
+
await this._postWithRetry(notifLine);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async shutdown() {
|
|
169
|
+
this._stopHealthcheck();
|
|
170
|
+
this.ready = false;
|
|
171
|
+
this.log(`OAuth HTTP transport shutdown: ${this.parsedUrl.hostname}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Internal — queue / batch (mirrors HttpTransport)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
_drainQueue() {
|
|
179
|
+
if (this.processing || this._coalesceTimer) return;
|
|
180
|
+
|
|
181
|
+
this._coalesceTimer = setTimeout(async () => {
|
|
182
|
+
this._coalesceTimer = null;
|
|
183
|
+
if (this.processing || this.messageQueue.length === 0) return;
|
|
184
|
+
|
|
185
|
+
this.processing = true;
|
|
186
|
+
const batch = this.messageQueue.splice(0);
|
|
187
|
+
|
|
188
|
+
if (batch.length === 1) {
|
|
189
|
+
await this._processMessage(batch[0]);
|
|
190
|
+
} else {
|
|
191
|
+
await this._processBatch(batch);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.processing = false;
|
|
195
|
+
|
|
196
|
+
if (this.messageQueue.length > 0) {
|
|
197
|
+
this._drainQueue();
|
|
198
|
+
}
|
|
199
|
+
}, this._coalesceWindowMs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _processBatch(lines) {
|
|
203
|
+
const parsed = [];
|
|
204
|
+
const fallback = [];
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
try {
|
|
207
|
+
parsed.push({ line, msg: JSON.parse(line) });
|
|
208
|
+
} catch {
|
|
209
|
+
fallback.push(line);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const { msg } of parsed) {
|
|
214
|
+
if (msg.method === 'initialize') {
|
|
215
|
+
this.cachedInitRequest = msg;
|
|
216
|
+
if (msg.params && msg.params.protocolVersion) {
|
|
217
|
+
this.clientProtocolVersion = msg.params.protocolVersion;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (msg.method === 'initialized' || msg.method === 'notifications/initialized') {
|
|
221
|
+
this.cachedInitNotification = msg;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const requests = parsed.filter(({ msg }) => 'id' in msg);
|
|
226
|
+
const notifications = parsed.filter(({ msg }) => !('id' in msg));
|
|
227
|
+
|
|
228
|
+
for (const { line } of notifications) {
|
|
229
|
+
try { await this._postWithRetry(line); } catch { /* ignore */ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (requests.length === 0) {
|
|
233
|
+
for (const line of fallback) await this._processMessage(line);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const batchBody = JSON.stringify(requests.map(({ msg }) => msg));
|
|
238
|
+
|
|
239
|
+
const pending = new Map();
|
|
240
|
+
const resultPromises = requests.map(({ msg }) => {
|
|
241
|
+
return new Promise((resolve) => { pending.set(String(msg.id), resolve); });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const result = await this._postWithRetry(batchBody);
|
|
246
|
+
|
|
247
|
+
if (result.body && result.body.trim()) {
|
|
248
|
+
let batchResponse;
|
|
249
|
+
try {
|
|
250
|
+
batchResponse = JSON.parse(result.body.trim());
|
|
251
|
+
} catch {
|
|
252
|
+
for (const { msg } of requests) {
|
|
253
|
+
pending.get(String(msg.id))?.({
|
|
254
|
+
jsonrpc: '2.0', id: msg.id,
|
|
255
|
+
error: { code: -32700, message: 'Batch response parse error' },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (Array.isArray(batchResponse)) {
|
|
262
|
+
for (let resp of batchResponse) {
|
|
263
|
+
const resolver = pending.get(String(resp.id));
|
|
264
|
+
if (resolver) {
|
|
265
|
+
resolver(resp);
|
|
266
|
+
pending.delete(String(resp.id));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
const resp = batchResponse;
|
|
271
|
+
const resolver = pending.get(String(resp.id));
|
|
272
|
+
if (resolver) {
|
|
273
|
+
resolver(resp);
|
|
274
|
+
pending.delete(String(resp.id));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
this.log(`OAuth HTTP batch error: ${err.message}`);
|
|
280
|
+
for (const { msg } of requests) {
|
|
281
|
+
pending.get(String(msg.id))?.({
|
|
282
|
+
jsonrpc: '2.0', id: msg.id,
|
|
283
|
+
error: { code: -32000, message: `OAuth HTTP batch error: ${err.message}` },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const [id, resolve] of pending.entries()) {
|
|
289
|
+
resolve({ jsonrpc: '2.0', id, error: { code: -32000, message: 'No response in batch' } });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const responses = await Promise.all(resultPromises);
|
|
293
|
+
for (const resp of responses) {
|
|
294
|
+
if (this.onMessage) this.onMessage(resp, JSON.stringify(resp));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const line of fallback) {
|
|
298
|
+
await this._processMessage(line);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async _processMessage(line) {
|
|
303
|
+
let msg;
|
|
304
|
+
try {
|
|
305
|
+
msg = JSON.parse(line);
|
|
306
|
+
} catch {
|
|
307
|
+
if (this.onMessage) {
|
|
308
|
+
this.onMessage({
|
|
309
|
+
jsonrpc: '2.0', id: null,
|
|
310
|
+
error: { code: -32700, message: 'Parse error' },
|
|
311
|
+
}, null);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (msg.method === 'initialize') {
|
|
317
|
+
this.cachedInitRequest = msg;
|
|
318
|
+
if (msg.params && msg.params.protocolVersion) {
|
|
319
|
+
this.clientProtocolVersion = msg.params.protocolVersion;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (msg.method === 'initialized' || msg.method === 'notifications/initialized') {
|
|
323
|
+
this.cachedInitNotification = msg;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const isNotification = msg.method && !('id' in msg);
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const result = await this._postWithRetry(line);
|
|
330
|
+
|
|
331
|
+
if (result.body && result.body.trim()) {
|
|
332
|
+
const rawLine = result.body.trim();
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(rawLine);
|
|
336
|
+
} catch {
|
|
337
|
+
if (this.onMessage) this.onMessage(null, rawLine);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (parsed.result && parsed.result.isError && parsed.result._metadata && parsed.result._metadata.input_schema) {
|
|
342
|
+
const content = parsed.result.content;
|
|
343
|
+
if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
|
|
344
|
+
const schema = parsed.result._metadata.input_schema;
|
|
345
|
+
const required = schema.required || [];
|
|
346
|
+
const props = schema.properties || {};
|
|
347
|
+
const paramList = Object.entries(props).map(([k, v]) => {
|
|
348
|
+
const req = required.includes(k) ? ' (required)' : '';
|
|
349
|
+
return ` ${k}: ${v.type || 'any'}${req} — ${v.description || ''}`;
|
|
350
|
+
}).join('\n');
|
|
351
|
+
content[0].text += `\n\nExpected parameters:\n${paramList}`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (this.onMessage) this.onMessage(parsed, JSON.stringify(parsed));
|
|
356
|
+
}
|
|
357
|
+
} catch (err) {
|
|
358
|
+
this.log(`OAuth HTTP error for ${msg.method || 'unknown'}: ${err.message}`);
|
|
359
|
+
if (!isNotification && this.onMessage) {
|
|
360
|
+
this.onMessage({
|
|
361
|
+
jsonrpc: '2.0', id: msg.id,
|
|
362
|
+
error: { code: -32000, message: `OAuth HTTP bridge error: ${err.message}` },
|
|
363
|
+
}, null);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Internal — POST with retry
|
|
370
|
+
//
|
|
371
|
+
// Two distinct retry concerns layered on top of each other:
|
|
372
|
+
// 1. Bearer freshness: TokenManager refreshes within 300s of expiry on
|
|
373
|
+
// the way in. On a 401 from the resource we do exactly one
|
|
374
|
+
// forceRefresh + retry — second 401 is terminal and fails the request.
|
|
375
|
+
// 2. Transport-level retries on 5xx and network errors, mirroring the
|
|
376
|
+
// App-Password transport so basic robustness matches.
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
async _postWithRetry(body, attempt = 0, retryAfterRefresh = false) {
|
|
380
|
+
let bearer;
|
|
381
|
+
try {
|
|
382
|
+
const tok = await this._tokenManager.getAccessToken(this._siteAuth, {
|
|
383
|
+
forceRefresh: retryAfterRefresh,
|
|
384
|
+
});
|
|
385
|
+
bearer = tok.accessToken;
|
|
386
|
+
if (tok.refreshed && tok.updatedAuth) {
|
|
387
|
+
// Adopt the rotated refs / new expiry locally so subsequent calls
|
|
388
|
+
// skip the refresh window check until next pre-expiry. The on-disk
|
|
389
|
+
// config is updated by the auth_status_change consumer if wired.
|
|
390
|
+
this._siteAuth = {
|
|
391
|
+
...this._siteAuth,
|
|
392
|
+
accessTokenRef: tok.updatedAuth.accessTokenRef,
|
|
393
|
+
refreshTokenRef: tok.updatedAuth.refreshTokenRef,
|
|
394
|
+
accessTokenExpiresAt: tok.updatedAuth.accessTokenExpiresAt,
|
|
395
|
+
authStatus: tok.updatedAuth.authStatus,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
// Refresh failure (e.g. RefreshError 4xx → expired). Emit auth status
|
|
400
|
+
// change so caller can update wp-sites.json, then surface to caller.
|
|
401
|
+
if (err && err.updatedAuth && this._onAuthStatusChange) {
|
|
402
|
+
try {
|
|
403
|
+
this._onAuthStatusChange(err.updatedAuth.authStatus, {
|
|
404
|
+
reason: 'refresh_failed',
|
|
405
|
+
siteId: this._siteAuth.siteId,
|
|
406
|
+
cause: err,
|
|
407
|
+
});
|
|
408
|
+
} catch { /* observer must not break the request path */ }
|
|
409
|
+
}
|
|
410
|
+
const reauthHint = err && err.reauthHint
|
|
411
|
+
? ` (run: ${err.reauthHint.command})`
|
|
412
|
+
: '';
|
|
413
|
+
const wrapped = new Error(`OAuth refresh failed${reauthHint}: ${err && err.message || err}`);
|
|
414
|
+
wrapped.cause = err;
|
|
415
|
+
wrapped.code = (err && err.code) || 'oauth_refresh_failed';
|
|
416
|
+
wrapped.reauth = true;
|
|
417
|
+
throw wrapped;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let result;
|
|
421
|
+
try {
|
|
422
|
+
result = await this._post(body, bearer);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
// Network error — retry with backoff (mirror HttpTransport).
|
|
425
|
+
if (attempt < this.maxRetries) {
|
|
426
|
+
const delay = this.baseRetryDelay * Math.pow(2, attempt);
|
|
427
|
+
this.log(`OAuth HTTP network error — retrying in ${delay}ms: ${err.message}`);
|
|
428
|
+
await this._sleep(delay);
|
|
429
|
+
return this._postWithRetry(body, attempt + 1, false);
|
|
430
|
+
}
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 401 from resource — bearer is stale. Force-refresh once and retry.
|
|
435
|
+
// Per Appendix H.2.1 + Issue #17 acceptance: do this exactly once. A
|
|
436
|
+
// second 401 marks the site expired and surfaces a reauth hint.
|
|
437
|
+
if (result.statusCode === 401 && !retryAfterRefresh) {
|
|
438
|
+
this.log('OAuth HTTP 401 from resource — forcing token refresh and retrying once');
|
|
439
|
+
return this._postWithRetry(body, attempt, true);
|
|
440
|
+
}
|
|
441
|
+
if (result.statusCode === 401 && retryAfterRefresh) {
|
|
442
|
+
// Two 401s in a row with a freshly-minted token — token is dead or the
|
|
443
|
+
// server has revoked. Treat as terminal, mark expired, fail the request.
|
|
444
|
+
const expiredErr = new Error(
|
|
445
|
+
`OAuth bearer rejected after refresh (HTTP 401). ` +
|
|
446
|
+
`Run: abilities-mcp reauth ${this._siteAuth.siteId}`
|
|
447
|
+
);
|
|
448
|
+
expiredErr.code = 'oauth_bearer_rejected';
|
|
449
|
+
expiredErr.reauth = true;
|
|
450
|
+
expiredErr.statusCode = 401;
|
|
451
|
+
if (this._onAuthStatusChange) {
|
|
452
|
+
try {
|
|
453
|
+
this._onAuthStatusChange('expired', {
|
|
454
|
+
reason: 'bearer_rejected_after_refresh',
|
|
455
|
+
siteId: this._siteAuth.siteId,
|
|
456
|
+
});
|
|
457
|
+
} catch { /* observer must not break the request path */ }
|
|
458
|
+
}
|
|
459
|
+
throw expiredErr;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Session recovery — same triggers as HttpTransport, except 401/403 with
|
|
463
|
+
// an active session is now AT MOST a stale-session signal. Bearer staleness
|
|
464
|
+
// is already handled above, so a 401 reaching this branch means the token
|
|
465
|
+
// is fresh but the MCP session expired. We still allow one re-handshake.
|
|
466
|
+
const isExplicitExpiry = result.statusCode === 404 || result.statusCode === 410;
|
|
467
|
+
const isStaleSession = result.statusCode === 403 && this.sessionId !== null;
|
|
468
|
+
if ((isExplicitExpiry || isStaleSession) && attempt === 0) {
|
|
469
|
+
this.log(`OAuth HTTP session expired (HTTP ${result.statusCode}) — attempting recovery`);
|
|
470
|
+
this.sessionId = null;
|
|
471
|
+
this.sessionToken = null;
|
|
472
|
+
if (this.cachedInitRequest) {
|
|
473
|
+
await this.performHandshake(this.cachedInitRequest, this.cachedInitNotification);
|
|
474
|
+
return this._postWithRetry(body, attempt + 1, false);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 5xx retry with backoff
|
|
479
|
+
if (result.statusCode >= 500 && attempt < this.maxRetries) {
|
|
480
|
+
const delay = this.baseRetryDelay * Math.pow(2, attempt);
|
|
481
|
+
this.log(`OAuth HTTP ${result.statusCode} — retrying in ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`);
|
|
482
|
+
await this._sleep(delay);
|
|
483
|
+
return this._postWithRetry(body, attempt + 1, false);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_post(body, bearer) {
|
|
490
|
+
return new Promise((resolve, reject) => {
|
|
491
|
+
const headers = {
|
|
492
|
+
'Content-Type': 'application/json',
|
|
493
|
+
'Authorization': `Bearer ${bearer}`,
|
|
494
|
+
'Accept': 'application/json',
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
|
|
498
|
+
if (this.sessionToken) headers['Mcp-Session-Token'] = this.sessionToken;
|
|
499
|
+
|
|
500
|
+
const hostCookies = this._cookies.get(this.parsedUrl.hostname);
|
|
501
|
+
if (hostCookies && hostCookies.size > 0) {
|
|
502
|
+
headers['Cookie'] = Array.from(hostCookies.entries())
|
|
503
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
504
|
+
.join('; ');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const options = {
|
|
508
|
+
hostname: this.parsedUrl.hostname,
|
|
509
|
+
port: this.parsedUrl.port || (this.isHttps ? 443 : 80),
|
|
510
|
+
path: this.parsedUrl.pathname + this.parsedUrl.search,
|
|
511
|
+
method: 'POST',
|
|
512
|
+
headers,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const req = this.httpModule.request(options, (res) => {
|
|
516
|
+
const chunks = [];
|
|
517
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
518
|
+
res.on('end', () => {
|
|
519
|
+
const newSessionId = res.headers['mcp-session-id'];
|
|
520
|
+
if (newSessionId) this.sessionId = newSessionId;
|
|
521
|
+
const newSessionToken = res.headers['mcp-session-token'];
|
|
522
|
+
if (newSessionToken) this.sessionToken = newSessionToken;
|
|
523
|
+
|
|
524
|
+
const setCookieHeader = res.headers['set-cookie'];
|
|
525
|
+
if (setCookieHeader) {
|
|
526
|
+
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
|
527
|
+
if (!this._cookies.has(this.parsedUrl.hostname)) {
|
|
528
|
+
this._cookies.set(this.parsedUrl.hostname, new Map());
|
|
529
|
+
}
|
|
530
|
+
const jar = this._cookies.get(this.parsedUrl.hostname);
|
|
531
|
+
for (const raw of cookies) {
|
|
532
|
+
const nameValue = raw.split(';')[0].trim();
|
|
533
|
+
const eqIdx = nameValue.indexOf('=');
|
|
534
|
+
if (eqIdx > 0) {
|
|
535
|
+
jar.set(nameValue.slice(0, eqIdx), nameValue.slice(eqIdx + 1));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
resolve({
|
|
541
|
+
statusCode: res.statusCode,
|
|
542
|
+
headers: res.headers,
|
|
543
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
544
|
+
sessionId: newSessionId || this.sessionId,
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
req.on('error', (err) => reject(err));
|
|
550
|
+
req.setTimeout(120000, () => {
|
|
551
|
+
req.destroy(new Error('Request timeout (120s)'));
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
req.write(body);
|
|
555
|
+
req.end();
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// Internal — healthcheck (matches HttpTransport cadence)
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
_startHealthcheck() {
|
|
564
|
+
this._stopHealthcheck();
|
|
565
|
+
this.healthcheckTimer = setInterval(() => {
|
|
566
|
+
this._sendPing();
|
|
567
|
+
}, 45000);
|
|
568
|
+
if (this.healthcheckTimer.unref) this.healthcheckTimer.unref();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_stopHealthcheck() {
|
|
572
|
+
if (this.healthcheckTimer) {
|
|
573
|
+
clearInterval(this.healthcheckTimer);
|
|
574
|
+
this.healthcheckTimer = null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async _sendPing() {
|
|
579
|
+
const pingMsg = JSON.stringify({
|
|
580
|
+
jsonrpc: '2.0',
|
|
581
|
+
method: 'ping',
|
|
582
|
+
id: `__healthcheck_${Date.now()}`,
|
|
583
|
+
});
|
|
584
|
+
try {
|
|
585
|
+
const result = await this._postWithRetry(pingMsg);
|
|
586
|
+
this.log(`OAuth HTTP healthcheck: ${result.statusCode}`);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
this.log(`OAuth HTTP healthcheck failed: ${err.message}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// Internal — utils
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
_sleep(ms) {
|
|
597
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
module.exports = { OAuthHttpTransport };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wickedevolutions/abilities-mcp",
|
|
3
|
-
"version": "1.3
|
|
3
|
+
"version": "1.5.3",
|
|
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": {
|
|
@@ -15,9 +15,15 @@
|
|
|
15
15
|
"url": "https://github.com/Wicked-Evolutions/abilities-mcp.git"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "node --test test/*.test.js"
|
|
18
|
+
"test": "node --test test/*.test.js test/auth/*.test.js test/cli/*.test.js test/transports/*.test.js",
|
|
19
|
+
"validate:mcpb": "npx --yes @anthropic-ai/mcpb validate manifest.json",
|
|
20
|
+
"pack:mcpb": "node scripts/pack-mcpb.js",
|
|
21
|
+
"verify:pack-isolation": "node scripts/verify-pack-isolation.js"
|
|
19
22
|
},
|
|
20
23
|
"engines": {
|
|
21
24
|
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"optionalDependencies": {
|
|
27
|
+
"keytar": "~7.9.0"
|
|
22
28
|
}
|
|
23
29
|
}
|