@wickedevolutions/abilities-mcp 1.3.1 → 1.5.1
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 +61 -0
- package/README.md +88 -17
- package/abilities-mcp.js +182 -113
- 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 +98 -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 +161 -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.js +248 -19
- 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 +7 -2
package/lib/router.js
CHANGED
|
@@ -11,7 +11,8 @@ const { isBridgeTool, injectBridgeTools } = require('./bridge-tools');
|
|
|
11
11
|
* - Route client messages to the correct transport
|
|
12
12
|
* - Handle bridge tools locally (health, browse, load)
|
|
13
13
|
* - Sanitize and enrich tools/list responses (annotations, site param, bridge tools)
|
|
14
|
-
* -
|
|
14
|
+
* - Forward resources/list to WordPress and inject bridge resources
|
|
15
|
+
* - Handle resources/read locally for bridge URIs, forward others to WordPress
|
|
15
16
|
* - Route tools/call to the correct site transport
|
|
16
17
|
*
|
|
17
18
|
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
@@ -44,6 +45,9 @@ class McpRouter {
|
|
|
44
45
|
this.clientProtocolVersion = null;
|
|
45
46
|
this.initHandshakeComplete = false;
|
|
46
47
|
|
|
48
|
+
// Track pending resources/list request IDs for response interception
|
|
49
|
+
this.pendingResourcesListIds = new Set();
|
|
50
|
+
|
|
47
51
|
// Default transport
|
|
48
52
|
this.defaultTransport = null;
|
|
49
53
|
|
|
@@ -115,9 +119,12 @@ class McpRouter {
|
|
|
115
119
|
return;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
// resources/list —
|
|
122
|
+
// resources/list — forward to WordPress, inject bridge resources on response
|
|
119
123
|
if (msg.method === 'resources/list') {
|
|
120
|
-
|
|
124
|
+
if (msg.id !== undefined) {
|
|
125
|
+
this.pendingResourcesListIds.add(msg.id);
|
|
126
|
+
}
|
|
127
|
+
this._forwardToDefault(line);
|
|
121
128
|
return;
|
|
122
129
|
}
|
|
123
130
|
|
|
@@ -166,6 +173,16 @@ class McpRouter {
|
|
|
166
173
|
return;
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
// Inject bridge resources into resources/list responses
|
|
177
|
+
if (parsedMsg.id !== undefined && this.pendingResourcesListIds.has(parsedMsg.id)) {
|
|
178
|
+
this.pendingResourcesListIds.delete(parsedMsg.id);
|
|
179
|
+
if (!parsedMsg.error) {
|
|
180
|
+
this._injectBridgeResources(parsedMsg);
|
|
181
|
+
}
|
|
182
|
+
this.sendToClient(JSON.stringify(parsedMsg));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
// Detect execute-ability error responses and convert to isError format
|
|
170
187
|
if (parsedMsg.result && parsedMsg.result.content) {
|
|
171
188
|
const content = parsedMsg.result.content;
|
|
@@ -388,22 +405,23 @@ class McpRouter {
|
|
|
388
405
|
// Internal — resources
|
|
389
406
|
// ---------------------------------------------------------------------------
|
|
390
407
|
|
|
391
|
-
|
|
392
|
-
|
|
408
|
+
/**
|
|
409
|
+
* Inject bridge-owned resources into a resources/list response from WordPress.
|
|
410
|
+
* @param {object} msg - Parsed JSON-RPC response
|
|
411
|
+
*/
|
|
412
|
+
_injectBridgeResources(msg) {
|
|
413
|
+
// Ensure result.resources array exists (WordPress may return empty or error)
|
|
414
|
+
if (!msg.result) msg.result = {};
|
|
415
|
+
if (!Array.isArray(msg.result.resources)) msg.result.resources = [];
|
|
393
416
|
|
|
394
417
|
if (this.isMultiSite) {
|
|
395
|
-
resources.push({
|
|
418
|
+
msg.result.resources.push({
|
|
396
419
|
uri: 'wp-abilities://sites',
|
|
397
420
|
name: 'WordPress Sites',
|
|
398
421
|
description: `Available WordPress sites: ${this.siteKeys.join(', ')}`,
|
|
399
422
|
mimeType: 'application/json',
|
|
400
423
|
});
|
|
401
424
|
}
|
|
402
|
-
|
|
403
|
-
this.sendToClient(JSON.stringify({
|
|
404
|
-
jsonrpc: '2.0', id: msg.id,
|
|
405
|
-
result: { resources },
|
|
406
|
-
}));
|
|
407
425
|
}
|
|
408
426
|
|
|
409
427
|
_handleResourcesReadSites(msg) {
|
|
@@ -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 };
|