@wickedevolutions/abilities-mcp 1.3.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 +81 -0
- package/LICENSE +12 -0
- package/README.md +321 -0
- package/abilities-mcp.js +169 -0
- package/lib/bridge-tools.js +67 -0
- package/lib/config.js +210 -0
- package/lib/connection-pool.js +272 -0
- package/lib/logger.js +43 -0
- package/lib/register.js +65 -0
- package/lib/router.js +436 -0
- package/lib/sanitizer.js +111 -0
- package/lib/tool-catalog.js +157 -0
- package/lib/tool-injector.js +51 -0
- package/lib/transports/http-transport.js +558 -0
- package/lib/transports/ssh-transport.js +595 -0
- package/package.json +23 -0
- package/wp-sites.example.json +49 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inject a `site` parameter into every tool in a tools/list response.
|
|
5
|
+
* This is the core of multi-site routing — the LLM sees `site` as an optional
|
|
6
|
+
* enum parameter on every tool and specifies which WordPress site to target.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} msg - The full JSON-RPC tools/list response (mutated in place)
|
|
9
|
+
* @param {string[]} siteKeys - All available site keys (including multisite composites)
|
|
10
|
+
* @param {string} defaultSite - The default site key
|
|
11
|
+
* @returns {object} The modified response
|
|
12
|
+
*
|
|
13
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
14
|
+
* @license GPL-2.0-or-later
|
|
15
|
+
*/
|
|
16
|
+
function injectSiteParam(msg, siteKeys, defaultSite) {
|
|
17
|
+
if (!msg.result || !msg.result.tools || !Array.isArray(msg.result.tools)) return msg;
|
|
18
|
+
|
|
19
|
+
const siteDesc = `Target WordPress site. Available: ${siteKeys.join(', ')}. Default: ${defaultSite}`;
|
|
20
|
+
|
|
21
|
+
for (const tool of msg.result.tools) {
|
|
22
|
+
if (!tool.inputSchema) {
|
|
23
|
+
tool.inputSchema = { type: 'object', properties: {} };
|
|
24
|
+
}
|
|
25
|
+
if (!tool.inputSchema.properties) {
|
|
26
|
+
tool.inputSchema.properties = {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
tool.inputSchema.properties.site = {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: siteDesc,
|
|
32
|
+
enum: siteKeys,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Do NOT add 'site' to required — it's optional, defaults to defaultSite
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return msg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract and remove the `site` parameter from a tools/call arguments object.
|
|
43
|
+
* Returns the site key and the cleaned arguments (without `site`).
|
|
44
|
+
*/
|
|
45
|
+
function extractSiteParam(args, defaultSite) {
|
|
46
|
+
if (!args || typeof args !== 'object') return { site: defaultSite, cleanArgs: args || {} };
|
|
47
|
+
const { site, ...cleanArgs } = args;
|
|
48
|
+
return { site: site || defaultSite, cleanArgs };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { injectSiteParam, extractSiteParam };
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { URL } = require('url');
|
|
6
|
+
const MAX_QUEUE = 100;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HTTP Transport — connects to WordPress abilities via HTTP POST.
|
|
10
|
+
*
|
|
11
|
+
* Translates MCP STDIO ↔ HTTP POST with Basic Auth and session management.
|
|
12
|
+
* Enhancements over mcp-http-bridge v1.0.0:
|
|
13
|
+
* - Retry with exponential backoff on network error / 5xx
|
|
14
|
+
* - Session recovery on 404/410 (re-handshake)
|
|
15
|
+
* - Healthcheck ping every 45s
|
|
16
|
+
* - Tool sanitization via shared sanitizer
|
|
17
|
+
*
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {string} opts.endpoint - Full URL to the MCP endpoint
|
|
20
|
+
* @param {string} opts.username - WordPress username
|
|
21
|
+
* @param {string} opts.password - Application Password
|
|
22
|
+
* @param {function} opts.logger - Logger function
|
|
23
|
+
*
|
|
24
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
25
|
+
* @license GPL-2.0-or-later
|
|
26
|
+
*/
|
|
27
|
+
class HttpTransport {
|
|
28
|
+
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.endpoint = opts.endpoint;
|
|
31
|
+
this.username = opts.username;
|
|
32
|
+
this.password = opts.password;
|
|
33
|
+
this.log = opts.logger || function noop() {};
|
|
34
|
+
|
|
35
|
+
const parsedUrl = new URL(this.endpoint);
|
|
36
|
+
this.parsedUrl = parsedUrl;
|
|
37
|
+
this.isHttps = parsedUrl.protocol === 'https:';
|
|
38
|
+
this.httpModule = this.isHttps ? https : http;
|
|
39
|
+
this.authHeader = 'Basic ' + Buffer.from(`${this.username}:${this.password}`).toString('base64');
|
|
40
|
+
|
|
41
|
+
// State
|
|
42
|
+
this.sessionId = null;
|
|
43
|
+
this.sessionToken = null; // Mcp-Session-Token (HMAC, echoed back on every request)
|
|
44
|
+
this.clientProtocolVersion = null; // Captured from initialize request for version rewriting
|
|
45
|
+
this.ready = false;
|
|
46
|
+
this.onMessage = null; // Callback: (parsedMsg, rawLine) => void
|
|
47
|
+
|
|
48
|
+
// Message queue — serialized processing
|
|
49
|
+
this.messageQueue = [];
|
|
50
|
+
this.processing = false;
|
|
51
|
+
|
|
52
|
+
// Batch coalescing — accumulate messages within a short window before dispatch
|
|
53
|
+
this._coalesceTimer = null;
|
|
54
|
+
this._coalesceWindowMs = 10;
|
|
55
|
+
|
|
56
|
+
// Healthcheck
|
|
57
|
+
this.healthcheckTimer = null;
|
|
58
|
+
|
|
59
|
+
// Retry config
|
|
60
|
+
this.maxRetries = 3;
|
|
61
|
+
this.baseRetryDelay = 1000;
|
|
62
|
+
|
|
63
|
+
// Handshake cache for session recovery
|
|
64
|
+
this.cachedInitRequest = null;
|
|
65
|
+
this.cachedInitNotification = null;
|
|
66
|
+
|
|
67
|
+
// Cookie jar — per-host, scoped to this transport instance so multi-site
|
|
68
|
+
// doesn't bleed cookies across sites. Keys are hostnames.
|
|
69
|
+
this._cookies = new Map(); // hostname -> Map<name, value>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Connect — for HTTP transport this just marks ready and starts healthcheck.
|
|
74
|
+
* The actual connection happens on first request.
|
|
75
|
+
*/
|
|
76
|
+
async connect() {
|
|
77
|
+
this.ready = true;
|
|
78
|
+
this.log(`HTTP transport ready: ${this.parsedUrl.hostname}`);
|
|
79
|
+
this._startHealthcheck();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Send a line (JSON string) to the remote WordPress site.
|
|
84
|
+
*/
|
|
85
|
+
send(line) {
|
|
86
|
+
if (this.messageQueue.length >= MAX_QUEUE) {
|
|
87
|
+
this.log('HTTP queue full — rejecting');
|
|
88
|
+
try {
|
|
89
|
+
const msg = JSON.parse(line);
|
|
90
|
+
if (msg.id !== undefined && this.onMessage) {
|
|
91
|
+
this.onMessage({
|
|
92
|
+
jsonrpc: '2.0', id: msg.id,
|
|
93
|
+
error: { code: -32603, message: 'HTTP transport queue full' }
|
|
94
|
+
}, null);
|
|
95
|
+
}
|
|
96
|
+
} catch (e) { /* ignore */ }
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.messageQueue.push(line);
|
|
100
|
+
this._drainQueue();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if transport is ready.
|
|
105
|
+
*/
|
|
106
|
+
isReady() {
|
|
107
|
+
return this.ready;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Perform MCP handshake (initialize + initialized).
|
|
112
|
+
* Used by connection pool for lazy-connected transports.
|
|
113
|
+
*/
|
|
114
|
+
async performHandshake(initRequest, initNotification) {
|
|
115
|
+
this.cachedInitRequest = initRequest;
|
|
116
|
+
this.cachedInitNotification = initNotification;
|
|
117
|
+
|
|
118
|
+
// Capture client protocol version for rewriting
|
|
119
|
+
if (initRequest.params && initRequest.params.protocolVersion) {
|
|
120
|
+
this.clientProtocolVersion = initRequest.params.protocolVersion;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Send initialize request
|
|
124
|
+
const initLine = JSON.stringify(initRequest);
|
|
125
|
+
const initResult = await this._postWithRetry(initLine);
|
|
126
|
+
if (initResult && initResult.body && initResult.body.trim()) {
|
|
127
|
+
// Parse and forward the init response (but don't send to client — pool handles this)
|
|
128
|
+
this.log(`HTTP handshake init response: ${initResult.statusCode}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Capture session ID from init
|
|
132
|
+
if (initResult && initResult.sessionId) {
|
|
133
|
+
this.sessionId = initResult.sessionId;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Send initialized notification
|
|
137
|
+
if (initNotification) {
|
|
138
|
+
const notifLine = JSON.stringify(initNotification);
|
|
139
|
+
await this._postWithRetry(notifLine);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Graceful shutdown.
|
|
145
|
+
*/
|
|
146
|
+
async shutdown() {
|
|
147
|
+
this._stopHealthcheck();
|
|
148
|
+
this.ready = false;
|
|
149
|
+
this.log(`HTTP transport shutdown: ${this.parsedUrl.hostname}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Internal — message queue
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
_drainQueue() {
|
|
157
|
+
// If already draining or a coalesce timer is pending, nothing to do —
|
|
158
|
+
// the in-progress drain or the pending timer will pick up new arrivals.
|
|
159
|
+
if (this.processing || this._coalesceTimer) return;
|
|
160
|
+
|
|
161
|
+
this._coalesceTimer = setTimeout(async () => {
|
|
162
|
+
this._coalesceTimer = null;
|
|
163
|
+
if (this.processing || this.messageQueue.length === 0) return;
|
|
164
|
+
|
|
165
|
+
this.processing = true;
|
|
166
|
+
|
|
167
|
+
// Snapshot all pending messages in one batch.
|
|
168
|
+
const batch = this.messageQueue.splice(0);
|
|
169
|
+
|
|
170
|
+
if (batch.length === 1) {
|
|
171
|
+
// Single message — use normal path (no batch overhead).
|
|
172
|
+
await this._processMessage(batch[0]);
|
|
173
|
+
} else {
|
|
174
|
+
// Multiple messages — coalesce into a JSON-RPC batch POST.
|
|
175
|
+
await this._processBatch(batch);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.processing = false;
|
|
179
|
+
|
|
180
|
+
// If more messages arrived while we were processing, schedule another drain.
|
|
181
|
+
if (this.messageQueue.length > 0) {
|
|
182
|
+
this._drainQueue();
|
|
183
|
+
}
|
|
184
|
+
}, this._coalesceWindowMs);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Send multiple messages as a JSON-RPC batch array in a single HTTP POST.
|
|
189
|
+
* Routes responses back to callers by matching on `id`.
|
|
190
|
+
*
|
|
191
|
+
* @param {string[]} lines - Array of raw JSON strings.
|
|
192
|
+
*/
|
|
193
|
+
async _processBatch(lines) {
|
|
194
|
+
// Parse all messages. Invalid JSON falls back to individual processing.
|
|
195
|
+
const parsed = [];
|
|
196
|
+
const fallback = [];
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
try {
|
|
199
|
+
parsed.push({ line, msg: JSON.parse(line) });
|
|
200
|
+
} catch {
|
|
201
|
+
fallback.push(line);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Cache handshake messages (same as _processMessage).
|
|
206
|
+
for (const { msg } of parsed) {
|
|
207
|
+
if (msg.method === 'initialize') {
|
|
208
|
+
this.cachedInitRequest = msg;
|
|
209
|
+
if (msg.params && msg.params.protocolVersion) {
|
|
210
|
+
this.clientProtocolVersion = msg.params.protocolVersion;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (msg.method === 'initialized' || msg.method === 'notifications/initialized') {
|
|
214
|
+
this.cachedInitNotification = msg;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Separate notifications (no id) from requests — notifications don't get responses.
|
|
219
|
+
const requests = parsed.filter(({ msg }) => 'id' in msg);
|
|
220
|
+
const notifications = parsed.filter(({ msg }) => !('id' in msg));
|
|
221
|
+
|
|
222
|
+
// Send notifications individually (they expect no response, keep things clean).
|
|
223
|
+
for (const { line } of notifications) {
|
|
224
|
+
try { await this._postWithRetry(line); } catch { /* ignore notification errors */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// If no actual requests, nothing more to do.
|
|
228
|
+
if (requests.length === 0) {
|
|
229
|
+
for (const line of fallback) await this._processMessage(line);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Build the batch array body.
|
|
234
|
+
const batchBody = JSON.stringify(requests.map(({ msg }) => msg));
|
|
235
|
+
|
|
236
|
+
// Build an id→resolve map so we can route responses back.
|
|
237
|
+
const pending = new Map();
|
|
238
|
+
const resultPromises = requests.map(({ msg }) => {
|
|
239
|
+
return new Promise((resolve) => { pending.set(String(msg.id), resolve); });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const result = await this._postWithRetry(batchBody);
|
|
244
|
+
|
|
245
|
+
if (result.body && result.body.trim()) {
|
|
246
|
+
let batchResponse;
|
|
247
|
+
try {
|
|
248
|
+
batchResponse = JSON.parse(result.body.trim());
|
|
249
|
+
} catch {
|
|
250
|
+
// Unparseable — forward raw to all callers as error.
|
|
251
|
+
for (const { msg } of requests) {
|
|
252
|
+
pending.get(String(msg.id))?.({
|
|
253
|
+
jsonrpc: '2.0', id: msg.id,
|
|
254
|
+
error: { code: -32700, message: 'Batch response parse error' },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// PHP returns an array of results — route each back by id.
|
|
261
|
+
if (Array.isArray(batchResponse)) {
|
|
262
|
+
for (let resp of batchResponse) {
|
|
263
|
+
// Protocol version negotiation is handled server-side (InitializeHandler v1.0.7+).
|
|
264
|
+
const resolver = pending.get(String(resp.id));
|
|
265
|
+
if (resolver) {
|
|
266
|
+
resolver(resp);
|
|
267
|
+
pending.delete(String(resp.id));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
// Single-object response (shouldn't happen for batch, but guard it).
|
|
272
|
+
const resp = batchResponse;
|
|
273
|
+
// Protocol version negotiation is handled server-side (InitializeHandler v1.0.7+).
|
|
274
|
+
const resolver = pending.get(String(resp.id));
|
|
275
|
+
if (resolver) {
|
|
276
|
+
resolver(resp);
|
|
277
|
+
pending.delete(String(resp.id));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
this.log(`HTTP batch error: ${err.message}`);
|
|
283
|
+
// Resolve all pending with an error.
|
|
284
|
+
for (const { msg } of requests) {
|
|
285
|
+
pending.get(String(msg.id))?.({
|
|
286
|
+
jsonrpc: '2.0', id: msg.id,
|
|
287
|
+
error: { code: -32000, message: `HTTP batch error: ${err.message}` },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Any requests that didn't get a response — resolve with error.
|
|
293
|
+
for (const [id, resolve] of pending.entries()) {
|
|
294
|
+
resolve({ jsonrpc: '2.0', id, error: { code: -32000, message: 'No response in batch' } });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Forward all resolved responses to the message callback.
|
|
298
|
+
const responses = await Promise.all(resultPromises);
|
|
299
|
+
for (const resp of responses) {
|
|
300
|
+
if (this.onMessage) this.onMessage(resp, JSON.stringify(resp));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Process any fallback (parse-failed) messages individually.
|
|
304
|
+
for (const line of fallback) {
|
|
305
|
+
await this._processMessage(line);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async _processMessage(line) {
|
|
310
|
+
let msg;
|
|
311
|
+
try {
|
|
312
|
+
msg = JSON.parse(line);
|
|
313
|
+
} catch {
|
|
314
|
+
// Not JSON — send error response
|
|
315
|
+
if (this.onMessage) {
|
|
316
|
+
this.onMessage({
|
|
317
|
+
jsonrpc: '2.0',
|
|
318
|
+
id: null,
|
|
319
|
+
error: { code: -32700, message: 'Parse error' },
|
|
320
|
+
}, null);
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Cache handshake messages for session recovery
|
|
326
|
+
if (msg.method === 'initialize') {
|
|
327
|
+
this.cachedInitRequest = msg;
|
|
328
|
+
if (msg.params && msg.params.protocolVersion) {
|
|
329
|
+
this.clientProtocolVersion = msg.params.protocolVersion;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (msg.method === 'initialized' || msg.method === 'notifications/initialized') {
|
|
333
|
+
this.cachedInitNotification = msg;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const isNotification = msg.method && !('id' in msg);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const result = await this._postWithRetry(line);
|
|
340
|
+
|
|
341
|
+
if (result.body && result.body.trim()) {
|
|
342
|
+
const rawLine = result.body.trim();
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = JSON.parse(rawLine);
|
|
346
|
+
} catch {
|
|
347
|
+
// Non-JSON response — forward raw
|
|
348
|
+
if (this.onMessage) this.onMessage(null, rawLine);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Protocol version negotiation is handled server-side (InitializeHandler v1.0.7+).
|
|
353
|
+
|
|
354
|
+
// Fold _metadata.input_schema into error text content for client visibility
|
|
355
|
+
if (parsed.result && parsed.result.isError && parsed.result._metadata && parsed.result._metadata.input_schema) {
|
|
356
|
+
const content = parsed.result.content;
|
|
357
|
+
if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
|
|
358
|
+
const schema = parsed.result._metadata.input_schema;
|
|
359
|
+
const required = schema.required || [];
|
|
360
|
+
const props = schema.properties || {};
|
|
361
|
+
const paramList = Object.entries(props).map(([k, v]) => {
|
|
362
|
+
const req = required.includes(k) ? ' (required)' : '';
|
|
363
|
+
return ` ${k}: ${v.type || 'any'}${req} — ${v.description || ''}`;
|
|
364
|
+
}).join('\n');
|
|
365
|
+
content[0].text += `\n\nExpected parameters:\n${paramList}`;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Sanitization is handled by the router (McpRouter.handleTransportMessage)
|
|
370
|
+
// to avoid double-sanitization. Transport just forwards the parsed message.
|
|
371
|
+
|
|
372
|
+
if (this.onMessage) this.onMessage(parsed, JSON.stringify(parsed));
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
this.log(`HTTP error for ${msg.method || 'unknown'}: ${err.message}`);
|
|
376
|
+
|
|
377
|
+
// Only send error response for requests (not notifications)
|
|
378
|
+
if (!isNotification && this.onMessage) {
|
|
379
|
+
this.onMessage({
|
|
380
|
+
jsonrpc: '2.0',
|
|
381
|
+
id: msg.id,
|
|
382
|
+
error: { code: -32000, message: `HTTP bridge error: ${err.message}` },
|
|
383
|
+
}, null);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Internal — HTTP POST with retry
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
async _postWithRetry(body, attempt = 0) {
|
|
393
|
+
try {
|
|
394
|
+
const result = await this._post(body);
|
|
395
|
+
|
|
396
|
+
// Session expired — re-handshake and retry.
|
|
397
|
+
// 404/410: explicit session-not-found signals.
|
|
398
|
+
// 401/403: some WordPress configs return these for stale session tokens,
|
|
399
|
+
// but only treat as expiry if we had an active session — otherwise
|
|
400
|
+
// it's a genuine auth failure (wrong credentials, capability denied).
|
|
401
|
+
const isExplicitExpiry = result.statusCode === 404 || result.statusCode === 410;
|
|
402
|
+
const isStaleSession = (result.statusCode === 401 || result.statusCode === 403) && this.sessionId !== null;
|
|
403
|
+
if ((isExplicitExpiry || isStaleSession) && attempt === 0) {
|
|
404
|
+
this.log(`Session expired (HTTP ${result.statusCode}) — attempting recovery`);
|
|
405
|
+
this.sessionId = null;
|
|
406
|
+
this.sessionToken = null;
|
|
407
|
+
if (this.cachedInitRequest) {
|
|
408
|
+
await this.performHandshake(this.cachedInitRequest, this.cachedInitNotification);
|
|
409
|
+
return this._postWithRetry(body, attempt + 1);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 5xx — retry with backoff
|
|
414
|
+
if (result.statusCode >= 500 && attempt < this.maxRetries) {
|
|
415
|
+
const delay = this.baseRetryDelay * Math.pow(2, attempt);
|
|
416
|
+
this.log(`HTTP ${result.statusCode} — retrying in ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`);
|
|
417
|
+
await this._sleep(delay);
|
|
418
|
+
return this._postWithRetry(body, attempt + 1);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return result;
|
|
422
|
+
} catch (err) {
|
|
423
|
+
// Network error — retry with backoff
|
|
424
|
+
if (attempt < this.maxRetries) {
|
|
425
|
+
const delay = this.baseRetryDelay * Math.pow(2, attempt);
|
|
426
|
+
this.log(`Network error — retrying in ${delay}ms: ${err.message}`);
|
|
427
|
+
await this._sleep(delay);
|
|
428
|
+
return this._postWithRetry(body, attempt + 1);
|
|
429
|
+
}
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_post(body) {
|
|
435
|
+
return new Promise((resolve, reject) => {
|
|
436
|
+
const headers = {
|
|
437
|
+
'Content-Type': 'application/json',
|
|
438
|
+
'Authorization': this.authHeader,
|
|
439
|
+
'Accept': 'application/json',
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
if (this.sessionId) {
|
|
443
|
+
headers['Mcp-Session-Id'] = this.sessionId;
|
|
444
|
+
}
|
|
445
|
+
if (this.sessionToken) {
|
|
446
|
+
headers['Mcp-Session-Token'] = this.sessionToken;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Send cookies for this host
|
|
450
|
+
const hostCookies = this._cookies.get(this.parsedUrl.hostname);
|
|
451
|
+
if (hostCookies && hostCookies.size > 0) {
|
|
452
|
+
headers['Cookie'] = Array.from(hostCookies.entries())
|
|
453
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
454
|
+
.join('; ');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const options = {
|
|
458
|
+
hostname: this.parsedUrl.hostname,
|
|
459
|
+
port: this.parsedUrl.port || (this.isHttps ? 443 : 80),
|
|
460
|
+
path: this.parsedUrl.pathname + this.parsedUrl.search,
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers,
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const req = this.httpModule.request(options, (res) => {
|
|
466
|
+
const chunks = [];
|
|
467
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
468
|
+
res.on('end', () => {
|
|
469
|
+
// Capture session ID and token from response headers.
|
|
470
|
+
const newSessionId = res.headers['mcp-session-id'];
|
|
471
|
+
if (newSessionId) {
|
|
472
|
+
this.sessionId = newSessionId;
|
|
473
|
+
}
|
|
474
|
+
const newSessionToken = res.headers['mcp-session-token'];
|
|
475
|
+
if (newSessionToken) {
|
|
476
|
+
this.sessionToken = newSessionToken;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Parse Set-Cookie headers and store in cookie jar
|
|
480
|
+
const setCookieHeader = res.headers['set-cookie'];
|
|
481
|
+
if (setCookieHeader) {
|
|
482
|
+
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
|
483
|
+
if (!this._cookies.has(this.parsedUrl.hostname)) {
|
|
484
|
+
this._cookies.set(this.parsedUrl.hostname, new Map());
|
|
485
|
+
}
|
|
486
|
+
const jar = this._cookies.get(this.parsedUrl.hostname);
|
|
487
|
+
for (const raw of cookies) {
|
|
488
|
+
// Only store name=value — strip attributes (Path, HttpOnly, etc.)
|
|
489
|
+
const nameValue = raw.split(';')[0].trim();
|
|
490
|
+
const eqIdx = nameValue.indexOf('=');
|
|
491
|
+
if (eqIdx > 0) {
|
|
492
|
+
jar.set(nameValue.slice(0, eqIdx), nameValue.slice(eqIdx + 1));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
resolve({
|
|
498
|
+
statusCode: res.statusCode,
|
|
499
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
500
|
+
sessionId: newSessionId || this.sessionId,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
req.on('error', (err) => reject(err));
|
|
506
|
+
req.setTimeout(120000, () => {
|
|
507
|
+
req.destroy(new Error('Request timeout (120s)'));
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
req.write(body);
|
|
511
|
+
req.end();
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// Internal — healthcheck
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
_startHealthcheck() {
|
|
520
|
+
this._stopHealthcheck();
|
|
521
|
+
this.healthcheckTimer = setInterval(() => {
|
|
522
|
+
this._sendPing();
|
|
523
|
+
}, 45000);
|
|
524
|
+
// Unref so it doesn't keep the process alive
|
|
525
|
+
if (this.healthcheckTimer.unref) this.healthcheckTimer.unref();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
_stopHealthcheck() {
|
|
529
|
+
if (this.healthcheckTimer) {
|
|
530
|
+
clearInterval(this.healthcheckTimer);
|
|
531
|
+
this.healthcheckTimer = null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
_sendPing() {
|
|
536
|
+
const pingMsg = JSON.stringify({
|
|
537
|
+
jsonrpc: '2.0',
|
|
538
|
+
method: 'ping',
|
|
539
|
+
id: `__healthcheck_${Date.now()}`,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
this._post(pingMsg).then((result) => {
|
|
543
|
+
this.log(`HTTP healthcheck: ${result.statusCode}`);
|
|
544
|
+
}).catch((err) => {
|
|
545
|
+
this.log(`HTTP healthcheck failed: ${err.message}`);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// Internal — utils
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
_sleep(ms) {
|
|
554
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
module.exports = { HttpTransport };
|