@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,595 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn, execSync, execFileSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function shellQuote(s) {
|
|
6
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MAX_PENDING = 100;
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Constants (matching mcp-ssh-bridge.js v2.3.0)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const RECONNECT_MAX_RETRIES = 10;
|
|
16
|
+
const RECONNECT_BASE_DELAY = 1000; // 1 second
|
|
17
|
+
const RECONNECT_MAX_DELAY = 30000; // 30 seconds
|
|
18
|
+
const HEALTHCHECK_INTERVAL = 45000; // 45 seconds
|
|
19
|
+
const HEALTHCHECK_TIMEOUT = 10000; // 10 seconds to respond to ping
|
|
20
|
+
const REQUEST_TIMEOUT = 120000; // 2 minutes per request
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* SSH Transport — connects to a remote WordPress site via SSH + WP-CLI STDIO.
|
|
24
|
+
*
|
|
25
|
+
* Refactored from mcp-ssh-bridge.js v2.3.0. Same resilience features:
|
|
26
|
+
* auto-reconnect, handshake replay, request queuing, healthcheck pings,
|
|
27
|
+
* orphan cleanup, exponential backoff.
|
|
28
|
+
*
|
|
29
|
+
* @fires onMessage(parsedMsg) — when a JSON-RPC response arrives from server
|
|
30
|
+
*
|
|
31
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
32
|
+
* @license GPL-2.0-or-later
|
|
33
|
+
*/
|
|
34
|
+
class SshTransport {
|
|
35
|
+
|
|
36
|
+
constructor({ host, path, user, mcpServer, subsiteUrl, logger }) {
|
|
37
|
+
this.host = host;
|
|
38
|
+
this.wpPath = path;
|
|
39
|
+
this.wpUser = user || '';
|
|
40
|
+
this.mcpServer = mcpServer || 'mcp-adapter-default-server';
|
|
41
|
+
this.subsiteUrl = subsiteUrl || null;
|
|
42
|
+
this.log = logger || function noop() {};
|
|
43
|
+
|
|
44
|
+
// Build WP-CLI command
|
|
45
|
+
this.wpCmd = this._buildWpCmd();
|
|
46
|
+
|
|
47
|
+
// SSH child process
|
|
48
|
+
this.child = null;
|
|
49
|
+
this.childReady = false;
|
|
50
|
+
this.reconnecting = false;
|
|
51
|
+
this.reconnectAttempts = 0;
|
|
52
|
+
this.outputBuffer = '';
|
|
53
|
+
|
|
54
|
+
// Handshake cache (set externally by connection pool)
|
|
55
|
+
this.cachedInitializeRequest = null;
|
|
56
|
+
this.cachedInitializedNotification = null;
|
|
57
|
+
this.clientProtocolVersion = null;
|
|
58
|
+
|
|
59
|
+
// Request queue + in-flight tracking
|
|
60
|
+
this.pendingRequests = [];
|
|
61
|
+
this.inflightRequests = new Map();
|
|
62
|
+
this.reconnectWaiters = {};
|
|
63
|
+
|
|
64
|
+
// Healthcheck
|
|
65
|
+
this.healthcheckTimer = null;
|
|
66
|
+
this.healthcheckPendingId = null;
|
|
67
|
+
this.healthcheckTimeout = null;
|
|
68
|
+
|
|
69
|
+
// Shutdown flag
|
|
70
|
+
this.shuttingDown = false;
|
|
71
|
+
|
|
72
|
+
// Callback: (parsedMsg: object) => void
|
|
73
|
+
this.onMessage = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Public API
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Spawn SSH child and optionally replay handshake.
|
|
82
|
+
*/
|
|
83
|
+
async connect() {
|
|
84
|
+
this._remoteCleanup();
|
|
85
|
+
this.child = this._spawnChild();
|
|
86
|
+
this.log(`[${this.host}] SSH child spawned`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Send a JSON-RPC line to this transport's SSH child.
|
|
91
|
+
*/
|
|
92
|
+
send(line) {
|
|
93
|
+
if (this.reconnecting) {
|
|
94
|
+
// Queue during reconnection
|
|
95
|
+
try {
|
|
96
|
+
const msg = JSON.parse(line);
|
|
97
|
+
if (msg.id !== undefined && msg.method) {
|
|
98
|
+
this.log(`[${this.host}] Queuing request ${msg.id} (${msg.method}) — reconnecting`);
|
|
99
|
+
this._queueOrReject(line);
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
this._queueOrReject(line);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (this.child && this.childReady && !this.child.killed) {
|
|
108
|
+
try {
|
|
109
|
+
this.child.stdin.write(line + '\n');
|
|
110
|
+
this._trackInflight(line);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
this.log(`[${this.host}] Write failed: ${e.message}`);
|
|
113
|
+
this._queueOrReject(line);
|
|
114
|
+
}
|
|
115
|
+
} else if (this.child && !this.child.killed && !this.childReady) {
|
|
116
|
+
// Child exists but not ready — queue non-init messages
|
|
117
|
+
try {
|
|
118
|
+
const msg = JSON.parse(line);
|
|
119
|
+
if (msg.method === 'initialize') {
|
|
120
|
+
this.child.stdin.write(line + '\n');
|
|
121
|
+
} else if (msg.method === 'initialized' || msg.method === 'notifications/initialized') {
|
|
122
|
+
this.child.stdin.write(line + '\n');
|
|
123
|
+
this.childReady = true;
|
|
124
|
+
this.log(`[${this.host}] childReady set via client-driven handshake`);
|
|
125
|
+
this._drainPendingRequests();
|
|
126
|
+
this._startHealthcheck();
|
|
127
|
+
} else if (msg.id !== undefined && msg.method) {
|
|
128
|
+
this._queueOrReject(line);
|
|
129
|
+
}
|
|
130
|
+
} catch (e) {
|
|
131
|
+
this._queueOrReject(line);
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
// No child — queue
|
|
135
|
+
this._queueOrReject(line);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Graceful shutdown — kill child, remote cleanup.
|
|
141
|
+
*/
|
|
142
|
+
async shutdown() {
|
|
143
|
+
this.shuttingDown = true;
|
|
144
|
+
this._stopHealthcheck();
|
|
145
|
+
this._safeKillChild();
|
|
146
|
+
this._remoteCleanup();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
isReady() {
|
|
150
|
+
return this.childReady && !this.reconnecting && !this.shuttingDown;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Perform MCP initialize handshake on this transport.
|
|
155
|
+
* Called by the connection pool for lazy-connected sites.
|
|
156
|
+
* Returns the initialize response (parsed JSON-RPC object).
|
|
157
|
+
*/
|
|
158
|
+
async performHandshake(initReq, initializedNotif) {
|
|
159
|
+
this.cachedInitializeRequest = initReq;
|
|
160
|
+
this.cachedInitializedNotification = initializedNotif;
|
|
161
|
+
if (initReq.params && initReq.params.protocolVersion) {
|
|
162
|
+
this.clientProtocolVersion = initReq.params.protocolVersion;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Send initialize request
|
|
166
|
+
const initLine = JSON.stringify(initReq) + '\n';
|
|
167
|
+
this.child.stdin.write(initLine);
|
|
168
|
+
this.log(`[${this.host}] HANDSHAKE > SERVER: initialize (id=${initReq.id})`);
|
|
169
|
+
|
|
170
|
+
// Wait for response
|
|
171
|
+
const response = await this._waitForResponse(initReq.id, 15000);
|
|
172
|
+
if (!response) {
|
|
173
|
+
throw new Error(`No initialize response from ${this.host} within 15s`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Protocol version negotiation is handled server-side (InitializeHandler v1.0.7+).
|
|
177
|
+
|
|
178
|
+
// Send initialized notification
|
|
179
|
+
if (initializedNotif) {
|
|
180
|
+
const notifLine = JSON.stringify(initializedNotif) + '\n';
|
|
181
|
+
this.child.stdin.write(notifLine);
|
|
182
|
+
this.log(`[${this.host}] HANDSHAKE > SERVER: initialized`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.childReady = true;
|
|
186
|
+
this._drainPendingRequests();
|
|
187
|
+
this._startHealthcheck();
|
|
188
|
+
|
|
189
|
+
return response;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// SSH child process management
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
_buildWpCmd() {
|
|
197
|
+
let cmd = `cd ${this.wpPath} && timeout 2h wp mcp-adapter serve --server=${this.mcpServer}`;
|
|
198
|
+
if (this.subsiteUrl) {
|
|
199
|
+
cmd += ` --url=${this.subsiteUrl}`;
|
|
200
|
+
}
|
|
201
|
+
if (this.wpUser) {
|
|
202
|
+
cmd += ` --user=${this.wpUser}`;
|
|
203
|
+
}
|
|
204
|
+
cmd += ' 2>/dev/null';
|
|
205
|
+
return cmd;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_spawnChild() {
|
|
209
|
+
this.log(`[${this.host}] Spawning: ssh -T ${this.host} '${this.wpCmd}'`);
|
|
210
|
+
|
|
211
|
+
const proc = spawn('ssh', [
|
|
212
|
+
'-T',
|
|
213
|
+
'-o', 'BatchMode=yes',
|
|
214
|
+
'-o', 'ServerAliveInterval=30',
|
|
215
|
+
'-o', 'ServerAliveCountMax=3',
|
|
216
|
+
this.host,
|
|
217
|
+
this.wpCmd,
|
|
218
|
+
], {
|
|
219
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
proc.stderr.on('data', (chunk) => {
|
|
223
|
+
this.log(`[${this.host}] SSH STDERR: ${chunk.toString().trim()}`);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
proc.on('error', (err) => {
|
|
227
|
+
this.log(`[${this.host}] Child spawn error: ${err.message}`);
|
|
228
|
+
this._handleChildDeath(-1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
proc.on('close', (code) => {
|
|
232
|
+
this.log(`[${this.host}] Child exited with code ${code}`);
|
|
233
|
+
if (proc === this.child) {
|
|
234
|
+
this._handleChildDeath(code);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
proc.stdout.on('data', (chunk) => {
|
|
239
|
+
this._handleChildStdout(chunk);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return proc;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_handleChildDeath(code) {
|
|
246
|
+
this.childReady = false;
|
|
247
|
+
this._stopHealthcheck();
|
|
248
|
+
|
|
249
|
+
// Fail any in-flight requests
|
|
250
|
+
for (const [id, timer] of this.inflightRequests) {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
this._sendError(id, -32603, 'Server connection lost, reconnecting...');
|
|
253
|
+
}
|
|
254
|
+
this.inflightRequests.clear();
|
|
255
|
+
|
|
256
|
+
if (this.shuttingDown) return;
|
|
257
|
+
if (this.reconnecting) return;
|
|
258
|
+
|
|
259
|
+
this.log(`[${this.host}] Child died (code ${code}), reconnecting`);
|
|
260
|
+
this._attemptReconnect();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Reconnection with exponential backoff
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async _attemptReconnect() {
|
|
268
|
+
this.reconnecting = true;
|
|
269
|
+
|
|
270
|
+
while (this.reconnectAttempts < RECONNECT_MAX_RETRIES && !this.shuttingDown) {
|
|
271
|
+
this.reconnectAttempts++;
|
|
272
|
+
const delay = Math.min(
|
|
273
|
+
RECONNECT_BASE_DELAY * Math.pow(2, this.reconnectAttempts - 1),
|
|
274
|
+
RECONNECT_MAX_DELAY
|
|
275
|
+
);
|
|
276
|
+
this.log(`[${this.host}] Reconnect ${this.reconnectAttempts}/${RECONNECT_MAX_RETRIES} after ${delay}ms`);
|
|
277
|
+
|
|
278
|
+
await this._sleep(delay);
|
|
279
|
+
if (this.shuttingDown) break;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
this.child = this._spawnChild();
|
|
283
|
+
this.outputBuffer = '';
|
|
284
|
+
|
|
285
|
+
if (this.cachedInitializeRequest) {
|
|
286
|
+
this.log(`[${this.host}] Replaying handshake`);
|
|
287
|
+
|
|
288
|
+
const initReq = JSON.stringify(this.cachedInitializeRequest) + '\n';
|
|
289
|
+
this.child.stdin.write(initReq);
|
|
290
|
+
|
|
291
|
+
const initResponse = await this._waitForResponse(this.cachedInitializeRequest.id, 15000);
|
|
292
|
+
if (!initResponse) {
|
|
293
|
+
this.log(`[${this.host}] No init response during reconnect`);
|
|
294
|
+
this._safeKillChild();
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Protocol version negotiation is handled server-side (InitializeHandler v1.0.7+).
|
|
299
|
+
|
|
300
|
+
if (this.cachedInitializedNotification) {
|
|
301
|
+
const notif = JSON.stringify(this.cachedInitializedNotification) + '\n';
|
|
302
|
+
this.child.stdin.write(notif);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.childReady = true;
|
|
306
|
+
this.reconnectAttempts = 0;
|
|
307
|
+
this.reconnecting = false;
|
|
308
|
+
this.log(`[${this.host}] Reconnect successful`);
|
|
309
|
+
|
|
310
|
+
this._drainPendingRequests();
|
|
311
|
+
this._startHealthcheck();
|
|
312
|
+
return;
|
|
313
|
+
} else {
|
|
314
|
+
this.childReady = true;
|
|
315
|
+
this.reconnectAttempts = 0;
|
|
316
|
+
this.reconnecting = false;
|
|
317
|
+
this.log(`[${this.host}] Reconnect successful (pre-init)`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
this.log(`[${this.host}] Reconnect attempt failed: ${err.message}`);
|
|
322
|
+
this._safeKillChild();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!this.shuttingDown) {
|
|
327
|
+
this.log(`[${this.host}] All reconnect attempts exhausted`);
|
|
328
|
+
// Fail pending requests
|
|
329
|
+
for (const req of this.pendingRequests) {
|
|
330
|
+
try {
|
|
331
|
+
const msg = JSON.parse(req.raw);
|
|
332
|
+
if (msg.id !== undefined) {
|
|
333
|
+
this._sendError(msg.id, -32603, 'Server connection lost after all reconnect attempts');
|
|
334
|
+
}
|
|
335
|
+
} catch (e) { /* ignore */ }
|
|
336
|
+
}
|
|
337
|
+
this.pendingRequests = [];
|
|
338
|
+
this.reconnecting = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_waitForResponse(id, timeoutMs) {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
const timer = setTimeout(() => {
|
|
345
|
+
delete this.reconnectWaiters[id];
|
|
346
|
+
resolve(null);
|
|
347
|
+
}, timeoutMs);
|
|
348
|
+
|
|
349
|
+
this.reconnectWaiters[id] = (msg) => {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
delete this.reconnectWaiters[id];
|
|
352
|
+
resolve(msg);
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_safeKillChild() {
|
|
358
|
+
try {
|
|
359
|
+
if (this.child && !this.child.killed) {
|
|
360
|
+
this.child.kill('SIGTERM');
|
|
361
|
+
}
|
|
362
|
+
} catch (e) {
|
|
363
|
+
this.log(`[${this.host}] Error killing child: ${e.message}`);
|
|
364
|
+
}
|
|
365
|
+
this.child = null;
|
|
366
|
+
this.childReady = false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_remoteCleanup() {
|
|
370
|
+
try {
|
|
371
|
+
let killPattern = this.wpPath + '.*mcp-adapter serve --server=' + this.mcpServer;
|
|
372
|
+
if (this.subsiteUrl) {
|
|
373
|
+
killPattern += '.*--url=' + this.subsiteUrl;
|
|
374
|
+
}
|
|
375
|
+
const remoteCmd = `pkill -f ${shellQuote(killPattern)}`;
|
|
376
|
+
this.log(`[${this.host}] Remote cleanup: ${remoteCmd}`);
|
|
377
|
+
execFileSync('ssh', [
|
|
378
|
+
'-o', 'BatchMode=yes',
|
|
379
|
+
'-o', 'ConnectTimeout=5',
|
|
380
|
+
this.host,
|
|
381
|
+
remoteCmd,
|
|
382
|
+
], {
|
|
383
|
+
timeout: 10000,
|
|
384
|
+
stdio: 'ignore',
|
|
385
|
+
});
|
|
386
|
+
} catch (e) {
|
|
387
|
+
this.log(`[${this.host}] Remote cleanup finished (exit: ${e.status || 'unknown'})`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
_sleep(ms) {
|
|
392
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Pending request queue
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
_queueOrReject(line) {
|
|
400
|
+
if (this.pendingRequests.length >= MAX_PENDING) {
|
|
401
|
+
this.log(`[${this.host}] Pending queue full — rejecting`);
|
|
402
|
+
try {
|
|
403
|
+
const msg = JSON.parse(line);
|
|
404
|
+
if (msg.id !== undefined && this.onMessage) {
|
|
405
|
+
const errorMsg = {
|
|
406
|
+
jsonrpc: '2.0', id: msg.id,
|
|
407
|
+
error: { code: -32603, message: 'Transport queue full' }
|
|
408
|
+
};
|
|
409
|
+
this.onMessage(errorMsg, JSON.stringify(errorMsg));
|
|
410
|
+
}
|
|
411
|
+
} catch (e) { /* ignore */ }
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.pendingRequests.push({ raw: line });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_drainPendingRequests() {
|
|
418
|
+
if (this.pendingRequests.length === 0) return;
|
|
419
|
+
this.log(`[${this.host}] Draining ${this.pendingRequests.length} pending request(s)`);
|
|
420
|
+
|
|
421
|
+
const queued = this.pendingRequests.slice();
|
|
422
|
+
this.pendingRequests = [];
|
|
423
|
+
|
|
424
|
+
for (const req of queued) {
|
|
425
|
+
if (this.child && this.childReady) {
|
|
426
|
+
this.child.stdin.write(req.raw + '\n');
|
|
427
|
+
this._trackInflight(req.raw);
|
|
428
|
+
} else {
|
|
429
|
+
this.pendingRequests.push(req);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// In-flight request tracking
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
_trackInflight(rawLine) {
|
|
439
|
+
try {
|
|
440
|
+
const msg = JSON.parse(rawLine);
|
|
441
|
+
if (msg.id !== undefined && msg.method) {
|
|
442
|
+
const timer = setTimeout(() => {
|
|
443
|
+
this.log(`[${this.host}] Request ${msg.id} timed out after ${REQUEST_TIMEOUT}ms`);
|
|
444
|
+
this.inflightRequests.delete(msg.id);
|
|
445
|
+
this._sendError(msg.id, -32603, `Request timed out after ${REQUEST_TIMEOUT / 1000}s`);
|
|
446
|
+
}, REQUEST_TIMEOUT);
|
|
447
|
+
this.inflightRequests.set(msg.id, timer);
|
|
448
|
+
}
|
|
449
|
+
} catch (e) { /* not JSON */ }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
_resolveInflight(id) {
|
|
453
|
+
const timer = this.inflightRequests.get(id);
|
|
454
|
+
if (timer) {
|
|
455
|
+
clearTimeout(timer);
|
|
456
|
+
this.inflightRequests.delete(id);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Healthcheck
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
_startHealthcheck() {
|
|
465
|
+
this._stopHealthcheck();
|
|
466
|
+
this.healthcheckTimer = setInterval(() => {
|
|
467
|
+
if (!this.child || !this.childReady || this.reconnecting) return;
|
|
468
|
+
this._sendPing();
|
|
469
|
+
}, HEALTHCHECK_INTERVAL);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
_stopHealthcheck() {
|
|
473
|
+
if (this.healthcheckTimer) { clearInterval(this.healthcheckTimer); this.healthcheckTimer = null; }
|
|
474
|
+
if (this.healthcheckTimeout) { clearTimeout(this.healthcheckTimeout); this.healthcheckTimeout = null; }
|
|
475
|
+
this.healthcheckPendingId = null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
_sendPing() {
|
|
479
|
+
this.healthcheckPendingId = `__bridge_ping_${Date.now()}`;
|
|
480
|
+
const ping = JSON.stringify({ jsonrpc: '2.0', id: this.healthcheckPendingId, method: 'ping' }) + '\n';
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
this.child.stdin.write(ping);
|
|
484
|
+
} catch (e) {
|
|
485
|
+
this.log(`[${this.host}] Ping write failed: ${e.message}`);
|
|
486
|
+
this._handleChildDeath(-1);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.healthcheckTimeout = setTimeout(() => {
|
|
491
|
+
this.log(`[${this.host}] Healthcheck timed out`);
|
|
492
|
+
this.healthcheckPendingId = null;
|
|
493
|
+
this._safeKillChild();
|
|
494
|
+
this._handleChildDeath(-1);
|
|
495
|
+
}, HEALTHCHECK_TIMEOUT);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Child stdout processing
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
_handleChildStdout(chunk) {
|
|
503
|
+
this.outputBuffer += chunk.toString();
|
|
504
|
+
|
|
505
|
+
let newlineIdx;
|
|
506
|
+
while ((newlineIdx = this.outputBuffer.indexOf('\n')) !== -1) {
|
|
507
|
+
const line = this.outputBuffer.slice(0, newlineIdx);
|
|
508
|
+
this.outputBuffer = this.outputBuffer.slice(newlineIdx + 1);
|
|
509
|
+
|
|
510
|
+
if (!line.trim()) continue;
|
|
511
|
+
|
|
512
|
+
let msg = null;
|
|
513
|
+
try {
|
|
514
|
+
msg = JSON.parse(line);
|
|
515
|
+
} catch (e) {
|
|
516
|
+
// Not valid JSON — forward as-is
|
|
517
|
+
if (this.onMessage) this.onMessage(null, line);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Internal: healthcheck pong
|
|
522
|
+
if (msg.id && msg.id === this.healthcheckPendingId) {
|
|
523
|
+
this.log(`[${this.host}] Healthcheck pong received`);
|
|
524
|
+
if (this.healthcheckTimeout) { clearTimeout(this.healthcheckTimeout); this.healthcheckTimeout = null; }
|
|
525
|
+
this.healthcheckPendingId = null;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Internal: reconnect handshake waiter
|
|
530
|
+
if (msg.id !== undefined && this.reconnectWaiters[msg.id]) {
|
|
531
|
+
this.reconnectWaiters[msg.id](msg);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Resolve in-flight tracking
|
|
536
|
+
if (msg.id !== undefined && !msg.method) {
|
|
537
|
+
this._resolveInflight(msg.id);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Protocol version negotiation is handled server-side (InitializeHandler v1.0.7+).
|
|
541
|
+
|
|
542
|
+
// Fold _metadata.input_schema into error text content for client visibility
|
|
543
|
+
if (msg.result && msg.result.isError && msg.result._metadata && msg.result._metadata.input_schema) {
|
|
544
|
+
const content = msg.result.content;
|
|
545
|
+
if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
|
|
546
|
+
const schema = msg.result._metadata.input_schema;
|
|
547
|
+
const required = schema.required || [];
|
|
548
|
+
const props = schema.properties || {};
|
|
549
|
+
const paramList = Object.entries(props).map(([k, v]) => {
|
|
550
|
+
const req = required.includes(k) ? ' (required)' : '';
|
|
551
|
+
return ` ${k}: ${v.type || 'any'}${req} — ${v.description || ''}`;
|
|
552
|
+
}).join('\n');
|
|
553
|
+
content[0].text += `\n\nExpected parameters:\n${paramList}`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Forward to callback
|
|
558
|
+
if (this.onMessage) {
|
|
559
|
+
this.onMessage(msg, JSON.stringify(msg));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Error helper
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
_sendError(id, code, message) {
|
|
569
|
+
if (this.onMessage) {
|
|
570
|
+
this.onMessage({
|
|
571
|
+
jsonrpc: '2.0',
|
|
572
|
+
id: id,
|
|
573
|
+
error: { code, message },
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// Static: SSH_AUTH_SOCK discovery (call once from main)
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
static ensureSshAuthSock() {
|
|
583
|
+
if (process.env.SSH_AUTH_SOCK) return;
|
|
584
|
+
try {
|
|
585
|
+
const sock = execSync('launchctl getenv SSH_AUTH_SOCK', { encoding: 'utf8' }).trim();
|
|
586
|
+
if (sock) {
|
|
587
|
+
process.env.SSH_AUTH_SOCK = sock;
|
|
588
|
+
}
|
|
589
|
+
} catch (e) {
|
|
590
|
+
// Not on macOS or launchd not available — fine
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
module.exports = { SshTransport };
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wickedevolutions/abilities-mcp",
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Open-source MCP bridge connecting AI clients to WordPress through the Abilities API — multi-site routing, zero dependencies",
|
|
5
|
+
"main": "abilities-mcp.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"abilities-mcp": "./abilities-mcp.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["abilities-mcp.js", "lib/", "wp-sites.example.json", "LICENSE", "README.md", "CHANGELOG.md"],
|
|
10
|
+
"keywords": ["mcp", "wordpress", "bridge", "abilities", "ai", "open-source", "multi-site", "model-context-protocol"],
|
|
11
|
+
"license": "GPL-2.0-or-later",
|
|
12
|
+
"author": "Wicked Evolutions",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/Wicked-Evolutions/abilities-mcp.git"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.js"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"defaultSite": "mysite",
|
|
3
|
+
"sites": {
|
|
4
|
+
"mysite": {
|
|
5
|
+
"label": "My WordPress Site",
|
|
6
|
+
"url": "https://example.com",
|
|
7
|
+
"transport": "http",
|
|
8
|
+
"http": {
|
|
9
|
+
"endpoint": "https://example.com/wp-json/mcp/mcp-adapter-default-server",
|
|
10
|
+
"username": "mcp-agent",
|
|
11
|
+
"passwordCommand": "security find-generic-password -a mcp-agent -s example.com -w"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"staging": {
|
|
15
|
+
"label": "Staging Site",
|
|
16
|
+
"url": "https://staging.example.com",
|
|
17
|
+
"transport": "http",
|
|
18
|
+
"http": {
|
|
19
|
+
"endpoint": "https://staging.example.com/wp-json/mcp/mcp-adapter-default-server",
|
|
20
|
+
"username": "mcp-agent",
|
|
21
|
+
"password": "xxxx xxxx xxxx xxxx"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"legacy-ssh": {
|
|
25
|
+
"label": "SSH Site (legacy transport)",
|
|
26
|
+
"transport": "ssh",
|
|
27
|
+
"ssh": {
|
|
28
|
+
"host": "my-ssh-host",
|
|
29
|
+
"path": "~/public_html",
|
|
30
|
+
"user": "wpaiagent"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"network": {
|
|
34
|
+
"label": "WordPress Multisite",
|
|
35
|
+
"url": "https://network.example.com",
|
|
36
|
+
"transport": "http",
|
|
37
|
+
"http": {
|
|
38
|
+
"endpoint": "https://network.example.com/wp-json/mcp/mcp-adapter-default-server",
|
|
39
|
+
"username": "mcp-agent",
|
|
40
|
+
"passwordEnv": "WP_NETWORK_PASSWORD"
|
|
41
|
+
},
|
|
42
|
+
"multisite": {
|
|
43
|
+
"main": "https://network.example.com/",
|
|
44
|
+
"blog": "https://blog.network.example.com/",
|
|
45
|
+
"shop": "https://shop.network.example.com/"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|