@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
package/lib/router.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { sanitizeToolsList, isToolsListResponse } = require('./sanitizer');
|
|
4
|
+
const { injectSiteParam, extractSiteParam } = require('./tool-injector');
|
|
5
|
+
const { isBridgeTool, injectBridgeTools } = require('./bridge-tools');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* McpRouter — routes MCP messages between client, transports, and bridge tools.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Route client messages to the correct transport
|
|
12
|
+
* - Handle bridge tools locally (health, browse, load)
|
|
13
|
+
* - Sanitize and enrich tools/list responses (annotations, site param, bridge tools)
|
|
14
|
+
* - Handle resources/list and resources/read locally
|
|
15
|
+
* - Route tools/call to the correct site transport
|
|
16
|
+
*
|
|
17
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
18
|
+
* @license GPL-2.0-or-later
|
|
19
|
+
*/
|
|
20
|
+
class McpRouter {
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {object} opts.config - Loaded config object
|
|
25
|
+
* @param {string[]} opts.siteKeys - All available site keys
|
|
26
|
+
* @param {boolean} opts.isMultiSite - Whether multi-site mode is active
|
|
27
|
+
* @param {object} opts.pool - ConnectionPool instance
|
|
28
|
+
* @param {object} opts.catalog - ToolCatalog instance
|
|
29
|
+
* @param {function} opts.sendToClient - Function to send data to STDIO client
|
|
30
|
+
* @param {function} opts.log - Logger function
|
|
31
|
+
*/
|
|
32
|
+
constructor(opts) {
|
|
33
|
+
this.config = opts.config;
|
|
34
|
+
this.siteKeys = opts.siteKeys;
|
|
35
|
+
this.isMultiSite = opts.isMultiSite;
|
|
36
|
+
this.pool = opts.pool;
|
|
37
|
+
this.catalog = opts.catalog;
|
|
38
|
+
this.sendToClient = opts.sendToClient;
|
|
39
|
+
this.log = opts.log;
|
|
40
|
+
|
|
41
|
+
// Handshake state
|
|
42
|
+
this.cachedInitRequest = null;
|
|
43
|
+
this.cachedInitNotification = null;
|
|
44
|
+
this.clientProtocolVersion = null;
|
|
45
|
+
this.initHandshakeComplete = false;
|
|
46
|
+
|
|
47
|
+
// Default transport
|
|
48
|
+
this.defaultTransport = null;
|
|
49
|
+
|
|
50
|
+
// Early queue for messages before transport is ready
|
|
51
|
+
this.MAX_EARLY_QUEUE = 50;
|
|
52
|
+
this.earlyQueue = [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Set the default transport after connection.
|
|
57
|
+
*/
|
|
58
|
+
setDefaultTransport(transport) {
|
|
59
|
+
this.defaultTransport = transport;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Drain messages queued before the default transport was ready.
|
|
64
|
+
*/
|
|
65
|
+
drainEarlyQueue() {
|
|
66
|
+
if (this.earlyQueue.length === 0) return;
|
|
67
|
+
this.log(`Draining ${this.earlyQueue.length} early queued message(s)`);
|
|
68
|
+
const queued = this.earlyQueue.slice();
|
|
69
|
+
this.earlyQueue = [];
|
|
70
|
+
for (const line of queued) {
|
|
71
|
+
if (this.defaultTransport) this.defaultTransport.send(line);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handle a parsed client message.
|
|
77
|
+
* @param {object} msg - Parsed JSON-RPC message
|
|
78
|
+
* @param {string} line - Raw JSON line
|
|
79
|
+
*/
|
|
80
|
+
handleClientMessage(msg, line) {
|
|
81
|
+
this.log(`CLIENT > BRIDGE: ${msg.method || 'response'} (id=${msg.id})`);
|
|
82
|
+
|
|
83
|
+
// Cache initialize request
|
|
84
|
+
if (msg.method === 'initialize') {
|
|
85
|
+
this.cachedInitRequest = msg;
|
|
86
|
+
if (msg.params && msg.params.protocolVersion) {
|
|
87
|
+
this.clientProtocolVersion = msg.params.protocolVersion;
|
|
88
|
+
}
|
|
89
|
+
this._forwardToDefault(line);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Cache initialized notification
|
|
94
|
+
if (msg.method === 'initialized' || msg.method === 'notifications/initialized') {
|
|
95
|
+
this.cachedInitNotification = msg;
|
|
96
|
+
this.initHandshakeComplete = true;
|
|
97
|
+
this.pool.setHandshakeCache(this.cachedInitRequest, this.cachedInitNotification, this.clientProtocolVersion);
|
|
98
|
+
this._forwardToDefault(line);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// tools/list — route to default, then inject site param
|
|
103
|
+
if (msg.method === 'tools/list') {
|
|
104
|
+
this._forwardToDefault(line);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// tools/call — check for bridge tools first, then route
|
|
109
|
+
if (msg.method === 'tools/call') {
|
|
110
|
+
if (msg.params && isBridgeTool(msg.params.name)) {
|
|
111
|
+
this._handleBridgeToolCall(msg);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this._handleToolsCall(msg);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// resources/list — handle locally
|
|
119
|
+
if (msg.method === 'resources/list') {
|
|
120
|
+
this._handleResourcesList(msg);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// resources/read — handle locally for our URIs
|
|
125
|
+
if (msg.method === 'resources/read') {
|
|
126
|
+
if (msg.params && msg.params.uri === 'wp-abilities://sites') {
|
|
127
|
+
this._handleResourcesReadSites(msg);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this._forwardToDefault(line);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Everything else — forward to default
|
|
135
|
+
this._forwardToDefault(line);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handle a message received from any transport (default or non-default).
|
|
140
|
+
* @param {object|null} parsedMsg - Parsed message or null
|
|
141
|
+
* @param {string|null} rawLine - Raw line or null
|
|
142
|
+
*/
|
|
143
|
+
handleTransportMessage(parsedMsg, rawLine) {
|
|
144
|
+
if (!parsedMsg) {
|
|
145
|
+
if (rawLine) this.log(`Non-JSON from transport (dropped): ${rawLine.substring(0, 200)}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Sanitize tools/list responses
|
|
150
|
+
if (isToolsListResponse(parsedMsg)) {
|
|
151
|
+
sanitizeToolsList(parsedMsg, this.log);
|
|
152
|
+
|
|
153
|
+
// Cache full tools list in catalog (before filtering)
|
|
154
|
+
if (this.catalog.isEnabled() && parsedMsg.result && parsedMsg.result.tools) {
|
|
155
|
+
this.catalog.cacheTools(parsedMsg.result.tools);
|
|
156
|
+
parsedMsg.result.tools = this.catalog.getFilteredTools();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Inject bridge tools
|
|
160
|
+
injectBridgeTools(parsedMsg);
|
|
161
|
+
// Inject site param if multi-site mode
|
|
162
|
+
if (this.isMultiSite) {
|
|
163
|
+
injectSiteParam(parsedMsg, this.siteKeys, this.config.defaultSite);
|
|
164
|
+
}
|
|
165
|
+
this.sendToClient(JSON.stringify(parsedMsg));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Detect execute-ability error responses and convert to isError format
|
|
170
|
+
if (parsedMsg.result && parsedMsg.result.content) {
|
|
171
|
+
const content = parsedMsg.result.content;
|
|
172
|
+
if (Array.isArray(content) && content.length === 1 && content[0].type === 'text') {
|
|
173
|
+
try {
|
|
174
|
+
const payload = JSON.parse(content[0].text);
|
|
175
|
+
if (payload.success === false && payload.error) {
|
|
176
|
+
const errorParts = [];
|
|
177
|
+
if (payload.error_code) errorParts.push(`[${payload.error_code}]`);
|
|
178
|
+
errorParts.push(payload.error);
|
|
179
|
+
if (payload.error_data) errorParts.push(`Details: ${JSON.stringify(payload.error_data)}`);
|
|
180
|
+
|
|
181
|
+
parsedMsg.result.content[0].text = errorParts.join(' ');
|
|
182
|
+
parsedMsg.result.isError = true;
|
|
183
|
+
|
|
184
|
+
this.log(`Converted execute-ability error: ${errorParts.join(' ')}`);
|
|
185
|
+
this.sendToClient(JSON.stringify(parsedMsg));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
} catch { /* not JSON — pass through */ }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Convert JSON-RPC error objects to isError result format for client visibility
|
|
193
|
+
if (parsedMsg.error && parsedMsg.id !== undefined) {
|
|
194
|
+
const errMsg = parsedMsg.error.message || 'Unknown error';
|
|
195
|
+
const errCode = parsedMsg.error.code || -32603;
|
|
196
|
+
const converted = {
|
|
197
|
+
jsonrpc: '2.0',
|
|
198
|
+
id: parsedMsg.id,
|
|
199
|
+
result: {
|
|
200
|
+
content: [{ type: 'text', text: `[${errCode}] ${errMsg}` }],
|
|
201
|
+
isError: true,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
this.log(`Converted JSON-RPC error to isError: [${errCode}] ${errMsg}`);
|
|
205
|
+
this.sendToClient(JSON.stringify(converted));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Normal response — forward
|
|
210
|
+
this.sendToClient(rawLine || JSON.stringify(parsedMsg));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Internal — forwarding
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
_forwardToDefault(line) {
|
|
218
|
+
if (this.defaultTransport) {
|
|
219
|
+
this.defaultTransport.send(line);
|
|
220
|
+
} else {
|
|
221
|
+
if (this.earlyQueue.length >= this.MAX_EARLY_QUEUE) {
|
|
222
|
+
this.log('Early queue full — rejecting message');
|
|
223
|
+
try {
|
|
224
|
+
const msg = JSON.parse(line);
|
|
225
|
+
if (msg.id !== undefined) {
|
|
226
|
+
this.sendToClient(JSON.stringify({
|
|
227
|
+
jsonrpc: '2.0', id: msg.id,
|
|
228
|
+
error: { code: -32603, message: 'Server not ready — queue full' }
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
} catch (e) { /* non-JSON, drop */ }
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.earlyQueue.push(line);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Internal — bridge tools
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
async _handleBridgeToolCall(msg) {
|
|
243
|
+
const toolName = msg.params.name;
|
|
244
|
+
const toolArgs = msg.params.arguments || {};
|
|
245
|
+
|
|
246
|
+
if (toolName === 'wp_bridge_health') {
|
|
247
|
+
const keysToCheck = toolArgs.site ? [toolArgs.site] : this.siteKeys;
|
|
248
|
+
const lines = [];
|
|
249
|
+
|
|
250
|
+
for (const key of keysToCheck) {
|
|
251
|
+
try {
|
|
252
|
+
const result = await this.pool.healthCheck(key);
|
|
253
|
+
let line = `${key}: ${result.status} (${result.latencyMs}ms)`;
|
|
254
|
+
if (result.error) line += ` — ${result.error}`;
|
|
255
|
+
lines.push(line);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
lines.push(`${key}: error — ${err.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.sendToClient(JSON.stringify({
|
|
262
|
+
jsonrpc: '2.0', id: msg.id,
|
|
263
|
+
result: { content: [{ type: 'text', text: lines.join('\n') }] },
|
|
264
|
+
}));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (toolName === 'wp_browse_tools') {
|
|
269
|
+
if (!this.catalog.isEnabled() || !this.catalog.fullTools) {
|
|
270
|
+
this.sendToClient(JSON.stringify({
|
|
271
|
+
jsonrpc: '2.0', id: msg.id,
|
|
272
|
+
result: { content: [{ type: 'text', text: 'Tool filtering is not enabled or tools have not been loaded yet.' }] },
|
|
273
|
+
}));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const summary = this.catalog.getCategorySummary();
|
|
278
|
+
const lines = summary.map(c =>
|
|
279
|
+
`${c.active ? '[LOADED]' : ' '} ${c.name} (${c.toolCount} tools)`
|
|
280
|
+
);
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push(`Total: ${this.catalog.fullTools.length} tools in ${summary.length} categories`);
|
|
283
|
+
lines.push(`Loaded: ${summary.filter(c => c.active).reduce((n, c) => n + c.toolCount, 0)} tools`);
|
|
284
|
+
lines.push('');
|
|
285
|
+
lines.push('Use wp_load_tools with categories array to activate.');
|
|
286
|
+
|
|
287
|
+
this.sendToClient(JSON.stringify({
|
|
288
|
+
jsonrpc: '2.0', id: msg.id,
|
|
289
|
+
result: { content: [{ type: 'text', text: lines.join('\n') }] },
|
|
290
|
+
}));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (toolName === 'wp_load_tools') {
|
|
295
|
+
if (!this.catalog.isEnabled() || !this.catalog.fullTools) {
|
|
296
|
+
this.sendToClient(JSON.stringify({
|
|
297
|
+
jsonrpc: '2.0', id: msg.id,
|
|
298
|
+
result: { content: [{ type: 'text', text: 'Tool filtering is not enabled or tools have not been loaded yet.' }] },
|
|
299
|
+
}));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const toActivate = toolArgs.categories || [];
|
|
304
|
+
const toDeactivate = toolArgs.deactivate || [];
|
|
305
|
+
|
|
306
|
+
if (toDeactivate.length > 0) {
|
|
307
|
+
this.catalog.deactivateCategories(toDeactivate);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const activated = this.catalog.activateCategories(toActivate);
|
|
311
|
+
const lines = [];
|
|
312
|
+
|
|
313
|
+
if (activated.length > 0) {
|
|
314
|
+
lines.push(`Activated: ${activated.join(', ')}`);
|
|
315
|
+
}
|
|
316
|
+
if (toDeactivate.length > 0) {
|
|
317
|
+
lines.push(`Deactivated: ${toDeactivate.join(', ')}`);
|
|
318
|
+
}
|
|
319
|
+
if (activated.length === 0 && toDeactivate.length === 0) {
|
|
320
|
+
lines.push('No changes — categories may already be active or not found.');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const filtered = this.catalog.getFilteredTools();
|
|
324
|
+
lines.push(`Tools now available: ${filtered.length}/${this.catalog.fullTools.length}`);
|
|
325
|
+
|
|
326
|
+
this.sendToClient(JSON.stringify({
|
|
327
|
+
jsonrpc: '2.0', id: msg.id,
|
|
328
|
+
result: { content: [{ type: 'text', text: lines.join('\n') }] },
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
// Notify client that tools list has changed
|
|
332
|
+
if (activated.length > 0 || toDeactivate.length > 0) {
|
|
333
|
+
this.sendToClient(JSON.stringify({
|
|
334
|
+
jsonrpc: '2.0',
|
|
335
|
+
method: 'notifications/tools/list_changed',
|
|
336
|
+
}));
|
|
337
|
+
this.log(`Sent tools/list_changed notification (activated: ${activated.join(',')})`);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Unknown bridge tool
|
|
343
|
+
this.sendToClient(JSON.stringify({
|
|
344
|
+
jsonrpc: '2.0', id: msg.id,
|
|
345
|
+
error: { code: -32601, message: `Unknown bridge tool: ${toolName}` },
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Internal — tools/call routing
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
async _handleToolsCall(msg) {
|
|
354
|
+
const toolArgs = msg.params && msg.params.arguments ? msg.params.arguments : {};
|
|
355
|
+
const { site, cleanArgs } = extractSiteParam(toolArgs, this.config.defaultSite);
|
|
356
|
+
|
|
357
|
+
this.log(`tools/call: ${msg.params.name} → site=${site}`);
|
|
358
|
+
|
|
359
|
+
const modifiedMsg = {
|
|
360
|
+
...msg,
|
|
361
|
+
params: { ...msg.params, arguments: cleanArgs },
|
|
362
|
+
};
|
|
363
|
+
const modifiedLine = JSON.stringify(modifiedMsg);
|
|
364
|
+
|
|
365
|
+
if (site === this.config.defaultSite) {
|
|
366
|
+
this._forwardToDefault(modifiedLine);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const transport = await this.pool.getTransport(site);
|
|
372
|
+
if (!transport.onMessage) {
|
|
373
|
+
transport.onMessage = (parsedMsg, rawLine) => {
|
|
374
|
+
this.handleTransportMessage(parsedMsg, rawLine);
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
transport.send(modifiedLine);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
this.log(`Route to site "${site}" failed: ${err.message}`);
|
|
380
|
+
this.sendToClient(JSON.stringify({
|
|
381
|
+
jsonrpc: '2.0', id: msg.id,
|
|
382
|
+
error: { code: -32603, message: `Failed to connect to site "${site}": ${err.message}` }
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Internal — resources
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
_handleResourcesList(msg) {
|
|
392
|
+
const resources = [];
|
|
393
|
+
|
|
394
|
+
if (this.isMultiSite) {
|
|
395
|
+
resources.push({
|
|
396
|
+
uri: 'wp-abilities://sites',
|
|
397
|
+
name: 'WordPress Sites',
|
|
398
|
+
description: `Available WordPress sites: ${this.siteKeys.join(', ')}`,
|
|
399
|
+
mimeType: 'application/json',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.sendToClient(JSON.stringify({
|
|
404
|
+
jsonrpc: '2.0', id: msg.id,
|
|
405
|
+
result: { resources },
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
_handleResourcesReadSites(msg) {
|
|
410
|
+
const sitesInfo = {};
|
|
411
|
+
for (const [key, site] of Object.entries(this.config.sites)) {
|
|
412
|
+
sitesInfo[key] = {
|
|
413
|
+
label: site.label || key,
|
|
414
|
+
url: site.url || '',
|
|
415
|
+
transport: site.transport,
|
|
416
|
+
connected: this.pool.transports ? this.pool.transports.has(key) : false,
|
|
417
|
+
};
|
|
418
|
+
if (site.multisite) {
|
|
419
|
+
sitesInfo[key].subsites = site.multisite;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
this.sendToClient(JSON.stringify({
|
|
424
|
+
jsonrpc: '2.0', id: msg.id,
|
|
425
|
+
result: {
|
|
426
|
+
contents: [{
|
|
427
|
+
uri: 'wp-abilities://sites',
|
|
428
|
+
mimeType: 'application/json',
|
|
429
|
+
text: JSON.stringify(sitesInfo, null, 2),
|
|
430
|
+
}]
|
|
431
|
+
},
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = { McpRouter };
|
package/lib/sanitizer.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Annotation fields to preserve from WordPress tool definitions.
|
|
5
|
+
* These are read before the annotations object is stripped for protocol compliance.
|
|
6
|
+
*
|
|
7
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
8
|
+
* @license GPL-2.0-or-later
|
|
9
|
+
*/
|
|
10
|
+
const ANNOTATION_WHITELIST = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint', 'title', 'permission', 'enabled'];
|
|
11
|
+
|
|
12
|
+
const VALID_SCHEMA_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate a tool's inputSchema and log warnings for common issues.
|
|
16
|
+
* Does NOT block tools — warnings only (debug log).
|
|
17
|
+
*
|
|
18
|
+
* @param {string} toolName - Tool name for log context.
|
|
19
|
+
* @param {object} schema - The inputSchema object.
|
|
20
|
+
* @param {function} log - Logger function (noop when debug disabled).
|
|
21
|
+
*/
|
|
22
|
+
function validateToolSchema(toolName, schema, log) {
|
|
23
|
+
if (!schema || typeof schema !== 'object') {
|
|
24
|
+
log(`SCHEMA WARN [${toolName}]: inputSchema is not an object`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (schema.type && !VALID_SCHEMA_TYPES.includes(schema.type)) {
|
|
29
|
+
log(`SCHEMA WARN [${toolName}]: invalid top-level type "${schema.type}"`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!schema.properties || typeof schema.properties !== 'object') return;
|
|
33
|
+
|
|
34
|
+
for (const [prop, def] of Object.entries(schema.properties)) {
|
|
35
|
+
if (!def || typeof def !== 'object') {
|
|
36
|
+
log(`SCHEMA WARN [${toolName}]: property "${prop}" is not an object`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (def.type && !VALID_SCHEMA_TYPES.includes(def.type)) {
|
|
40
|
+
log(`SCHEMA WARN [${toolName}]: property "${prop}" has invalid type "${def.type}"`);
|
|
41
|
+
}
|
|
42
|
+
if (def.type === 'array' && !def.items) {
|
|
43
|
+
log(`SCHEMA WARN [${toolName}]: property "${prop}" is array type but missing "items"`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sanitize tools/list responses for protocol compliance.
|
|
50
|
+
*
|
|
51
|
+
* 1. Strips non-standard fields (type, outputSchema) that Claude Code rejects.
|
|
52
|
+
* 2. Validates schemas and logs warnings for common issues (debug mode).
|
|
53
|
+
* 3. Reads permission metadata from annotations before stripping:
|
|
54
|
+
* - Keeps whitelisted annotation fields (MCP spec + permission/enabled).
|
|
55
|
+
* - Appends [DISABLED — requires '{permission}' permission] to description
|
|
56
|
+
* when enabled: false, so the LLM sees the tool is gated.
|
|
57
|
+
* 4. Removes annotations entirely if no whitelisted fields remain.
|
|
58
|
+
*/
|
|
59
|
+
function sanitizeToolsList(msg, log) {
|
|
60
|
+
if (!msg.result || !msg.result.tools || !Array.isArray(msg.result.tools)) return msg;
|
|
61
|
+
|
|
62
|
+
const _log = typeof log === 'function' ? log : function noop() {};
|
|
63
|
+
|
|
64
|
+
for (const tool of msg.result.tools) {
|
|
65
|
+
delete tool.type;
|
|
66
|
+
delete tool.outputSchema;
|
|
67
|
+
|
|
68
|
+
// Validate schema and log warnings
|
|
69
|
+
if (tool.inputSchema) {
|
|
70
|
+
validateToolSchema(tool.name || '(unnamed)', tool.inputSchema, _log);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Process annotations: read before stripping
|
|
74
|
+
if (tool.annotations && typeof tool.annotations === 'object') {
|
|
75
|
+
// Inject [DISABLED] into description when enabled is explicitly false
|
|
76
|
+
if (tool.annotations.enabled === false) {
|
|
77
|
+
const permission = tool.annotations.permission || 'write';
|
|
78
|
+
const suffix = ` [DISABLED — requires '${permission}' permission]`;
|
|
79
|
+
if (tool.description && !tool.description.includes('[DISABLED')) {
|
|
80
|
+
tool.description += suffix;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Keep only whitelisted annotation fields
|
|
85
|
+
const filtered = {};
|
|
86
|
+
for (const key of ANNOTATION_WHITELIST) {
|
|
87
|
+
if (key in tool.annotations) {
|
|
88
|
+
filtered[key] = tool.annotations[key];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Set filtered annotations or remove entirely if empty
|
|
93
|
+
if (Object.keys(filtered).length > 0) {
|
|
94
|
+
tool.annotations = filtered;
|
|
95
|
+
} else {
|
|
96
|
+
delete tool.annotations;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return msg;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a parsed JSON-RPC message is a tools/list response.
|
|
106
|
+
*/
|
|
107
|
+
function isToolsListResponse(msg) {
|
|
108
|
+
return !!(msg.result && Array.isArray(msg.result.tools));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { sanitizeToolsList, isToolsListResponse };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tool Catalog — caches the full tools list and provides category-based filtering.
|
|
5
|
+
*
|
|
6
|
+
* When toolFilter is enabled, tools/list responses are filtered to only include
|
|
7
|
+
* essential tools + activated categories + bridge tools. The model uses
|
|
8
|
+
* wp_browse_tools and wp_load_tools to discover and activate categories on demand.
|
|
9
|
+
*
|
|
10
|
+
* Categories are extracted dynamically from tool names using prefix patterns:
|
|
11
|
+
* fluent-crm-* → fluent-crm
|
|
12
|
+
* fluent-community-* → fluent-community
|
|
13
|
+
* mcp-adapter-* → mcp-adapter
|
|
14
|
+
* content-* → content
|
|
15
|
+
* media-* → media
|
|
16
|
+
*
|
|
17
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
18
|
+
* @license GPL-2.0-or-later
|
|
19
|
+
*/
|
|
20
|
+
class ToolCatalog {
|
|
21
|
+
|
|
22
|
+
constructor(config, logger) {
|
|
23
|
+
this.log = logger || function noop() {};
|
|
24
|
+
this.filterConfig = config.toolFilter || null;
|
|
25
|
+
|
|
26
|
+
// Full tools list from WordPress (cached on first tools/list response)
|
|
27
|
+
this.fullTools = null;
|
|
28
|
+
|
|
29
|
+
// Category index: categoryName → [tool objects]
|
|
30
|
+
this.categories = {};
|
|
31
|
+
|
|
32
|
+
// Active (loaded) categories
|
|
33
|
+
this.activeCategories = new Set();
|
|
34
|
+
|
|
35
|
+
// Initialize always-included categories from config
|
|
36
|
+
if (this.filterConfig && this.filterConfig.alwaysIncludeCategories) {
|
|
37
|
+
for (const cat of this.filterConfig.alwaysIncludeCategories) {
|
|
38
|
+
this.activeCategories.add(cat);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether filtering is enabled.
|
|
45
|
+
*/
|
|
46
|
+
isEnabled() {
|
|
47
|
+
return !!(this.filterConfig && this.filterConfig.enabled);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cache the full tools list and build the category index.
|
|
52
|
+
* Called when the first tools/list response arrives from WordPress.
|
|
53
|
+
*/
|
|
54
|
+
cacheTools(tools) {
|
|
55
|
+
this.fullTools = tools;
|
|
56
|
+
this.categories = {};
|
|
57
|
+
|
|
58
|
+
for (const tool of tools) {
|
|
59
|
+
const cat = this._extractCategory(tool.name);
|
|
60
|
+
if (!this.categories[cat]) this.categories[cat] = [];
|
|
61
|
+
this.categories[cat].push(tool);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.log(`Tool catalog: ${tools.length} tools in ${Object.keys(this.categories).length} categories`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get a filtered tools list based on active categories + essential tools.
|
|
69
|
+
* Returns a new array (does not mutate the cached list).
|
|
70
|
+
*/
|
|
71
|
+
getFilteredTools() {
|
|
72
|
+
if (!this.fullTools) return [];
|
|
73
|
+
|
|
74
|
+
const essentialNames = new Set(
|
|
75
|
+
(this.filterConfig && this.filterConfig.essentialTools) || []
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const result = [];
|
|
79
|
+
for (const tool of this.fullTools) {
|
|
80
|
+
const cat = this._extractCategory(tool.name);
|
|
81
|
+
if (this.activeCategories.has(cat) || essentialNames.has(tool.name)) {
|
|
82
|
+
result.push(tool);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Activate one or more categories. Returns list of newly activated category names.
|
|
91
|
+
*/
|
|
92
|
+
activateCategories(categoryNames) {
|
|
93
|
+
const activated = [];
|
|
94
|
+
for (const name of categoryNames) {
|
|
95
|
+
if (this.categories[name] && !this.activeCategories.has(name)) {
|
|
96
|
+
this.activeCategories.add(name);
|
|
97
|
+
activated.push(name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return activated;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deactivate categories (return to compact mode).
|
|
105
|
+
*/
|
|
106
|
+
deactivateCategories(categoryNames) {
|
|
107
|
+
// Don't deactivate always-included categories
|
|
108
|
+
const alwaysIncluded = new Set(
|
|
109
|
+
(this.filterConfig && this.filterConfig.alwaysIncludeCategories) || []
|
|
110
|
+
);
|
|
111
|
+
for (const name of categoryNames) {
|
|
112
|
+
if (!alwaysIncluded.has(name)) {
|
|
113
|
+
this.activeCategories.delete(name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get category summary for wp_browse_tools.
|
|
120
|
+
* Returns array of { name, toolCount, active, tools[] }
|
|
121
|
+
*/
|
|
122
|
+
getCategorySummary() {
|
|
123
|
+
const summary = [];
|
|
124
|
+
for (const [name, tools] of Object.entries(this.categories)) {
|
|
125
|
+
summary.push({
|
|
126
|
+
name,
|
|
127
|
+
toolCount: tools.length,
|
|
128
|
+
active: this.activeCategories.has(name),
|
|
129
|
+
tools: tools.map(t => t.name),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return summary.sort((a, b) => b.toolCount - a.toolCount);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract category from a tool name.
|
|
137
|
+
* Handles compound prefixes: fluent-crm-*, fluent-community-*, mcp-adapter-*, event-bridge-*
|
|
138
|
+
*/
|
|
139
|
+
_extractCategory(name) {
|
|
140
|
+
const parts = name.split('-');
|
|
141
|
+
|
|
142
|
+
// Compound prefixes where first segment is ambiguous
|
|
143
|
+
if (parts.length >= 3) {
|
|
144
|
+
const twoWord = parts[0] + '-' + parts[1];
|
|
145
|
+
if (['fluent-crm', 'fluent-community', 'fluent-boards', 'fluent-support',
|
|
146
|
+
'fluent-forms', 'fluent-booking', 'fluent-messaging', 'fluent-smtp',
|
|
147
|
+
'fluent-auth', 'fluent-snippets', 'fluent-get', 'fluent-onboard',
|
|
148
|
+
'mcp-adapter', 'event-bridge'].includes(twoWord)) {
|
|
149
|
+
return twoWord;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return parts[0];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { ToolCatalog };
|