@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +191 -114
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +265 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +328 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config-source-line.js +85 -0
  37. package/lib/config.js +282 -22
  38. package/lib/connection-pool.js +214 -11
  39. package/lib/router.js +29 -11
  40. package/lib/transports/oauth-http-transport.js +601 -0
  41. 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.1",
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
  }