@mainwp/mcp 1.0.0-beta.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/LICENSE +674 -0
- package/README.md +1034 -0
- package/dist/abilities.d.ts +144 -0
- package/dist/abilities.d.ts.map +1 -0
- package/dist/abilities.js +529 -0
- package/dist/abilities.js.map +1 -0
- package/dist/config.d.ts +135 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +405 -0
- package/dist/config.js.map +1 -0
- package/dist/confirmation-responses.d.ts +44 -0
- package/dist/confirmation-responses.d.ts.map +1 -0
- package/dist/confirmation-responses.js +120 -0
- package/dist/confirmation-responses.js.map +1 -0
- package/dist/errors.d.ts +118 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +206 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +506 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +34 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +74 -0
- package/dist/logging.js.map +1 -0
- package/dist/naming.d.ts +23 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +37 -0
- package/dist/naming.js.map +1 -0
- package/dist/prompts.d.ts +22 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +414 -0
- package/dist/prompts.js.map +1 -0
- package/dist/retry.d.ts +77 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +206 -0
- package/dist/retry.js.map +1 -0
- package/dist/security.d.ts +41 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +154 -0
- package/dist/security.js.map +1 -0
- package/dist/tools.d.ts +82 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +861 -0
- package/dist/tools.js.map +1 -0
- package/package.json +73 -0
- package/settings.example.json +30 -0
- package/settings.schema.json +129 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Tool Conversion
|
|
3
|
+
*
|
|
4
|
+
* Converts MainWP Abilities to MCP Tool definitions and handles
|
|
5
|
+
* tool execution by forwarding to the Abilities API.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { fetchAbilities, executeAbility, getAbility, } from './abilities.js';
|
|
9
|
+
import { formatJson } from './config.js';
|
|
10
|
+
import { validateInput, sanitizeError } from './security.js';
|
|
11
|
+
import { McpErrorFactory, formatErrorResponse } from './errors.js';
|
|
12
|
+
import { withRequestId } from './logging.js';
|
|
13
|
+
import { abilityNameToToolName, toolNameToAbilityName } from './naming.js';
|
|
14
|
+
import { buildSafeModeBlockedResponse, buildInvalidParameterResponse, buildConflictingParametersResponse, buildConfirmationRequiredResponse, buildPreviewRequiredResponse, buildPreviewExpiredResponse, buildNoChangeResponse, } from './confirmation-responses.js';
|
|
15
|
+
/**
|
|
16
|
+
* Session-level cumulative data tracking.
|
|
17
|
+
* Tracks total bytes of tool responses returned during the server's lifetime.
|
|
18
|
+
*
|
|
19
|
+
* Concurrency note: This module-level counter assumes MCP tool executions are
|
|
20
|
+
* processed sequentially (stdio transport handles one request at a time).
|
|
21
|
+
* If the architecture is later updated to support parallel tool execution,
|
|
22
|
+
* this tracking should be moved to a per-session context or use synchronized
|
|
23
|
+
* updates to prevent race conditions.
|
|
24
|
+
*/
|
|
25
|
+
let sessionDataBytes = 0;
|
|
26
|
+
/**
|
|
27
|
+
* Preview tracking for two-phase confirmation flow.
|
|
28
|
+
* Maps preview keys to timestamps for validation and expiry.
|
|
29
|
+
*/
|
|
30
|
+
const pendingPreviews = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* Token index for confirmation flow.
|
|
33
|
+
* Maps confirmation tokens (UUIDs) to preview keys for secure token-based confirmation.
|
|
34
|
+
*/
|
|
35
|
+
const tokenIndex = new Map();
|
|
36
|
+
/** Preview expiry time: 5 minutes in milliseconds */
|
|
37
|
+
const PREVIEW_EXPIRY_MS = 5 * 60 * 1000;
|
|
38
|
+
/** Maximum number of pending previews to prevent memory exhaustion */
|
|
39
|
+
const MAX_PENDING_PREVIEWS = 100;
|
|
40
|
+
/**
|
|
41
|
+
* No-op error codes and their human-readable descriptions.
|
|
42
|
+
* Single source of truth: NOOP_ERROR_CODES is derived from this map's keys.
|
|
43
|
+
* When adding new idempotent abilities that return new error codes, add them here.
|
|
44
|
+
*/
|
|
45
|
+
const NOOP_DESCRIPTIONS = {
|
|
46
|
+
already_active: 'Already active — no action needed',
|
|
47
|
+
already_inactive: 'Already inactive — no action needed',
|
|
48
|
+
already_installed: 'Already installed — no action needed',
|
|
49
|
+
already_connected: 'Already connected — no action needed',
|
|
50
|
+
already_disconnected: 'Already disconnected — no action needed',
|
|
51
|
+
already_suspended: 'Already suspended — no action needed',
|
|
52
|
+
already_unsuspended: 'Already unsuspended — no action needed',
|
|
53
|
+
no_updates_available: 'No updates available',
|
|
54
|
+
nothing_to_update: 'Nothing to update',
|
|
55
|
+
};
|
|
56
|
+
const NOOP_ERROR_CODES = new Set(Object.keys(NOOP_DESCRIPTIONS));
|
|
57
|
+
/**
|
|
58
|
+
* Format byte counts as human-readable strings (e.g., "50.0 MB", "2.5 KB").
|
|
59
|
+
*/
|
|
60
|
+
function formatBytes(bytes) {
|
|
61
|
+
if (bytes >= 1048576)
|
|
62
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
63
|
+
if (bytes >= 1024)
|
|
64
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
65
|
+
return `${bytes} bytes`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check whether an error represents an idempotent no-op (already in desired state).
|
|
69
|
+
* Only matches 4xx HTTP errors with a recognized no-op error code.
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
export function isNoOpError(error) {
|
|
73
|
+
if (typeof error !== 'object' || error === null)
|
|
74
|
+
return false;
|
|
75
|
+
const { status, code } = error;
|
|
76
|
+
if (typeof status !== 'number' || status < 400 || status > 499)
|
|
77
|
+
return false;
|
|
78
|
+
if (typeof code !== 'string')
|
|
79
|
+
return false;
|
|
80
|
+
return NOOP_ERROR_CODES.has(code);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get the current cumulative session data usage in bytes and the configured limit.
|
|
84
|
+
*/
|
|
85
|
+
export function getSessionDataUsage(config) {
|
|
86
|
+
return { used: sessionDataBytes, limit: config.maxSessionData };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Reset the cumulative session data counter to zero.
|
|
90
|
+
*/
|
|
91
|
+
export function resetSessionData() {
|
|
92
|
+
sessionDataBytes = 0;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Clear pending previews (for testing only).
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
export function clearPendingPreviews() {
|
|
99
|
+
pendingPreviews.clear();
|
|
100
|
+
tokenIndex.clear();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Generate a unique preview key for a tool call.
|
|
104
|
+
* Excludes confirmation-related parameters (confirm, user_confirmed, dry_run)
|
|
105
|
+
* from the key to ensure preview and execution calls match.
|
|
106
|
+
*
|
|
107
|
+
* Note: Uses JSON.stringify with sorted top-level keys. Nested object key
|
|
108
|
+
* ordering is not normalized, which may cause different keys for semantically
|
|
109
|
+
* identical nested structures.
|
|
110
|
+
*/
|
|
111
|
+
function getPreviewKey(toolName, args) {
|
|
112
|
+
const { confirm: _confirm, user_confirmed: _user_confirmed, dry_run: _dry_run, confirmation_token: _confirmation_token, ...relevantArgs } = args;
|
|
113
|
+
const sortedKeys = Object.keys(relevantArgs).sort();
|
|
114
|
+
return `${toolName}:${JSON.stringify(relevantArgs, sortedKeys)}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Clean up expired preview keys and enforce maximum preview limit.
|
|
118
|
+
* Two-pass cleanup strategy:
|
|
119
|
+
* 1. Remove all expired entries (older than PREVIEW_EXPIRY_MS)
|
|
120
|
+
* 2. If still over MAX_PENDING_PREVIEWS, remove oldest entries until at limit
|
|
121
|
+
*
|
|
122
|
+
* This bounds memory without requiring periodic timers (suitable for stdio server).
|
|
123
|
+
*/
|
|
124
|
+
function cleanupExpiredPreviews() {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
// First pass: Remove expired entries
|
|
127
|
+
for (const [key, timestamp] of pendingPreviews.entries()) {
|
|
128
|
+
if (now - timestamp > PREVIEW_EXPIRY_MS) {
|
|
129
|
+
pendingPreviews.delete(key);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Second pass: Enforce max size limit by removing oldest entries
|
|
133
|
+
if (pendingPreviews.size > MAX_PENDING_PREVIEWS) {
|
|
134
|
+
// Sort by timestamp (oldest first)
|
|
135
|
+
const sortedEntries = Array.from(pendingPreviews.entries()).sort((a, b) => a[1] - b[1]);
|
|
136
|
+
// Remove oldest entries until at limit
|
|
137
|
+
const toRemove = sortedEntries.slice(0, pendingPreviews.size - MAX_PENDING_PREVIEWS);
|
|
138
|
+
for (const [key] of toRemove) {
|
|
139
|
+
pendingPreviews.delete(key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Third pass: Clean up orphaned tokens whose preview keys no longer exist
|
|
143
|
+
for (const [token, previewKey] of tokenIndex.entries()) {
|
|
144
|
+
if (!pendingPreviews.has(previewKey)) {
|
|
145
|
+
tokenIndex.delete(token);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Convert an Ability's JSON Schema to MCP tool input schema format
|
|
151
|
+
*
|
|
152
|
+
* The Abilities API uses standard JSON Schema, which maps directly to
|
|
153
|
+
* MCP's tool input schema (also JSON Schema based).
|
|
154
|
+
*/
|
|
155
|
+
function convertInputSchema(ability) {
|
|
156
|
+
const schema = ability.input_schema;
|
|
157
|
+
if (!schema) {
|
|
158
|
+
// No input required
|
|
159
|
+
return {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// The abilities API uses JSON Schema, which is compatible with MCP
|
|
165
|
+
// We just need to ensure it has the required structure
|
|
166
|
+
// Cast to the expected MCP SDK type
|
|
167
|
+
const properties = (schema.properties || {});
|
|
168
|
+
const required = schema.required || [];
|
|
169
|
+
// Backfill missing descriptions from parameter names.
|
|
170
|
+
// Some upstream abilities omit descriptions; LLMs need them for accurate tool use.
|
|
171
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
172
|
+
if (prop && (!prop.description || String(prop.description).trim() === '')) {
|
|
173
|
+
prop.description = paramNameToDescription(name);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties,
|
|
179
|
+
required,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Generate a human-readable description from a parameter name.
|
|
184
|
+
* "client_id_or_email" → "Client ID or email."
|
|
185
|
+
* "address_1" → "Address 1."
|
|
186
|
+
*/
|
|
187
|
+
function paramNameToDescription(name) {
|
|
188
|
+
const words = name.replace(/_/g, ' ').replace(/\bid\b/gi, 'ID');
|
|
189
|
+
return words.charAt(0).toUpperCase() + words.slice(1) + '.';
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Truncate a description to the first sentence or ~60 characters
|
|
193
|
+
*
|
|
194
|
+
* Strategy:
|
|
195
|
+
* - Preserve full description if already ≤60 characters
|
|
196
|
+
* - Return first sentence if it's within limit (≤65 chars, small tolerance)
|
|
197
|
+
* - Otherwise truncate to 57 characters with ellipsis
|
|
198
|
+
*/
|
|
199
|
+
function truncateDescription(description) {
|
|
200
|
+
if (!description) {
|
|
201
|
+
return '';
|
|
202
|
+
}
|
|
203
|
+
// If already short enough, return as-is
|
|
204
|
+
if (description.length <= 60) {
|
|
205
|
+
return description;
|
|
206
|
+
}
|
|
207
|
+
// Try to find first sentence boundary
|
|
208
|
+
const sentenceMatch = description.match(/^[^.!?]+[.!?](?:\s|$)/);
|
|
209
|
+
if (sentenceMatch) {
|
|
210
|
+
const sentence = sentenceMatch[0].trim();
|
|
211
|
+
// Only use sentence if it's within limit (allow small tolerance of 5 chars)
|
|
212
|
+
if (sentence.length <= 65) {
|
|
213
|
+
return sentence;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// No suitable sentence found, truncate to ~60 chars
|
|
217
|
+
return description.slice(0, 57) + '...';
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Recursively compress a JSON Schema by truncating descriptions
|
|
221
|
+
*
|
|
222
|
+
* Preserves critical fields: type, enum, items, default, minimum, maximum, required, format
|
|
223
|
+
* Removes: examples field
|
|
224
|
+
*/
|
|
225
|
+
function compressSchema(schema) {
|
|
226
|
+
// If no properties field, return schema as-is
|
|
227
|
+
if (!schema.properties || typeof schema.properties !== 'object') {
|
|
228
|
+
return schema;
|
|
229
|
+
}
|
|
230
|
+
const properties = schema.properties;
|
|
231
|
+
const compressedProperties = {};
|
|
232
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
233
|
+
if (!prop || typeof prop !== 'object') {
|
|
234
|
+
compressedProperties[key] = prop;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Create compressed property, preserving critical fields
|
|
238
|
+
const compressedProp = {};
|
|
239
|
+
// Always preserve these critical fields
|
|
240
|
+
const criticalFields = [
|
|
241
|
+
'type',
|
|
242
|
+
'enum',
|
|
243
|
+
'default',
|
|
244
|
+
'minimum',
|
|
245
|
+
'maximum',
|
|
246
|
+
'format',
|
|
247
|
+
'required',
|
|
248
|
+
'minItems',
|
|
249
|
+
'maxItems',
|
|
250
|
+
'minLength',
|
|
251
|
+
'maxLength',
|
|
252
|
+
'pattern',
|
|
253
|
+
];
|
|
254
|
+
for (const field of criticalFields) {
|
|
255
|
+
if (field in prop) {
|
|
256
|
+
compressedProp[field] = prop[field];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Truncate description
|
|
260
|
+
if (typeof prop.description === 'string') {
|
|
261
|
+
compressedProp.description = truncateDescription(prop.description);
|
|
262
|
+
}
|
|
263
|
+
// Handle items (for arrays) - recursively compress if it has properties
|
|
264
|
+
if (prop.items && typeof prop.items === 'object') {
|
|
265
|
+
const items = prop.items;
|
|
266
|
+
if (items.properties) {
|
|
267
|
+
compressedProp.items = compressSchema(items);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Simple items (e.g., { type: 'string' })
|
|
271
|
+
compressedProp.items = items;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Handle nested properties (for objects) - recursively compress
|
|
275
|
+
if (prop.properties && typeof prop.properties === 'object') {
|
|
276
|
+
const nested = compressSchema(prop);
|
|
277
|
+
compressedProp.properties = nested.properties;
|
|
278
|
+
if (nested.required) {
|
|
279
|
+
compressedProp.required = nested.required;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Note: 'examples' field is intentionally NOT copied (removed in compact mode)
|
|
283
|
+
compressedProperties[key] = compressedProp;
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
...schema,
|
|
287
|
+
properties: compressedProperties,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Generate contextual LLM instruction text from ability metadata.
|
|
292
|
+
*
|
|
293
|
+
* Produces safety guidance that tells the AI how to use a tool correctly:
|
|
294
|
+
* preview-first workflows, dry-run suggestions, or read-only assurance.
|
|
295
|
+
* API-provided instructions are prepended (they take priority).
|
|
296
|
+
* @internal
|
|
297
|
+
*/
|
|
298
|
+
export function generateInstructions(meta, hasDryRun, hasConfirm) {
|
|
299
|
+
const parts = [];
|
|
300
|
+
// API-provided instructions take priority (ensure trailing punctuation for clean concatenation)
|
|
301
|
+
if (meta?.instructions) {
|
|
302
|
+
const instr = meta.instructions;
|
|
303
|
+
parts.push(/[.!?]$/.test(instr) ? instr : `${instr}.`);
|
|
304
|
+
}
|
|
305
|
+
if (meta?.destructive) {
|
|
306
|
+
if (hasConfirm && hasDryRun) {
|
|
307
|
+
parts.push('Always preview with dry_run or confirm before executing. Show preview to user.');
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
parts.push('This is destructive. Confirm intent with user first.');
|
|
311
|
+
}
|
|
312
|
+
if (!meta.idempotent) {
|
|
313
|
+
parts.push('Not idempotent — repeating may cause different results.');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else if (meta?.readonly) {
|
|
317
|
+
parts.push('Read-only. Safe to call without confirmation.');
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
parts.push('Write operation.');
|
|
321
|
+
}
|
|
322
|
+
return parts.join(' ');
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Build safety tag string for tool descriptions.
|
|
326
|
+
*
|
|
327
|
+
* Standard mode: verbose tags like `[DESTRUCTIVE, Requires two-step confirmation]`
|
|
328
|
+
* Compact mode: short tags like `[destructive, confirm]`
|
|
329
|
+
* @internal
|
|
330
|
+
*/
|
|
331
|
+
export function buildSafetyTags(meta, hasDryRun, hasConfirm, verbosity) {
|
|
332
|
+
if (verbosity === 'standard') {
|
|
333
|
+
if (meta?.destructive) {
|
|
334
|
+
const hints = [];
|
|
335
|
+
if (hasConfirm)
|
|
336
|
+
hints.push('Requires two-step confirmation');
|
|
337
|
+
if (hasDryRun)
|
|
338
|
+
hints.push('Supports dry_run');
|
|
339
|
+
if (!meta.idempotent)
|
|
340
|
+
hints.push('Not idempotent');
|
|
341
|
+
return hints.length > 0 ? `[DESTRUCTIVE, ${hints.join(', ')}]` : '[DESTRUCTIVE]';
|
|
342
|
+
}
|
|
343
|
+
const notes = [];
|
|
344
|
+
if (hasDryRun)
|
|
345
|
+
notes.push('Supports dry_run');
|
|
346
|
+
if (meta?.readonly)
|
|
347
|
+
notes.push('Read-only');
|
|
348
|
+
return notes.length > 0 ? `[${notes.join(', ')}]` : '';
|
|
349
|
+
}
|
|
350
|
+
// Compact mode — short tags
|
|
351
|
+
const tags = [];
|
|
352
|
+
if (meta?.destructive)
|
|
353
|
+
tags.push('destructive');
|
|
354
|
+
if (hasConfirm)
|
|
355
|
+
tags.push('confirm');
|
|
356
|
+
if (hasDryRun)
|
|
357
|
+
tags.push('dry_run');
|
|
358
|
+
return tags.length > 0 ? `[${tags.join(', ')}]` : '';
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Convert a MainWP Ability to an MCP Tool definition
|
|
362
|
+
*
|
|
363
|
+
* Enhances tool metadata with:
|
|
364
|
+
* - MCP semantic annotations (readOnlyHint, destructiveHint, idempotentHint, title, openWorldHint)
|
|
365
|
+
* - Contextual LLM instructions for safe tool usage
|
|
366
|
+
* - Highlighted safety parameters (dry_run, confirm) in descriptions
|
|
367
|
+
*
|
|
368
|
+
* @param ability - The MainWP ability to convert
|
|
369
|
+
* @param verbosity - Schema verbosity level ('compact' or 'standard')
|
|
370
|
+
*/
|
|
371
|
+
function abilityToTool(ability, verbosity = 'standard') {
|
|
372
|
+
// Create a tool name from the ability name
|
|
373
|
+
// e.g., "mainwp/list-sites-v1" -> "mainwp_list_sites_v1"
|
|
374
|
+
const toolName = abilityNameToToolName(ability.name);
|
|
375
|
+
const meta = ability.meta?.annotations;
|
|
376
|
+
let inputSchema = convertInputSchema(ability);
|
|
377
|
+
// Detect safety parameters in schema (before compression, needed for user_confirmed injection)
|
|
378
|
+
const props = (inputSchema.properties || {});
|
|
379
|
+
const hasDryRun = 'dry_run' in props;
|
|
380
|
+
const hasConfirm = 'confirm' in props;
|
|
381
|
+
const isDestructive = meta?.destructive ?? false;
|
|
382
|
+
// Add user_confirmed parameter for destructive tools with confirm parameter
|
|
383
|
+
// This must happen BEFORE schema compression so it applies to all verbosity modes
|
|
384
|
+
if (isDestructive && hasConfirm) {
|
|
385
|
+
const mutableProps = inputSchema.properties;
|
|
386
|
+
mutableProps['user_confirmed'] = {
|
|
387
|
+
type: 'boolean',
|
|
388
|
+
description: 'Confirm execution after reviewing preview. ' +
|
|
389
|
+
'FLOW: 1) confirm:true for preview, 2) show user, 3) user_confirmed:true if approved.',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
// Apply schema compression in compact mode (after user_confirmed injection)
|
|
393
|
+
if (verbosity === 'compact') {
|
|
394
|
+
inputSchema = compressSchema(inputSchema);
|
|
395
|
+
}
|
|
396
|
+
// Build description with safety context
|
|
397
|
+
let description;
|
|
398
|
+
if (verbosity === 'standard') {
|
|
399
|
+
// Category prefix for standard mode (e.g., "[sites] ...")
|
|
400
|
+
const categoryLabel = ability.category?.replace(/^mainwp-/, '').replace(/-/g, ' ');
|
|
401
|
+
description = categoryLabel ? `[${categoryLabel}] ${ability.description}` : ability.description;
|
|
402
|
+
// Append contextual LLM instructions
|
|
403
|
+
const instructions = generateInstructions(meta, hasDryRun, hasConfirm);
|
|
404
|
+
if (instructions) {
|
|
405
|
+
description += ` ${instructions}`;
|
|
406
|
+
}
|
|
407
|
+
// Append safety tags
|
|
408
|
+
const tags = buildSafetyTags(meta, hasDryRun, hasConfirm, 'standard');
|
|
409
|
+
if (tags) {
|
|
410
|
+
description += ` ${tags}`;
|
|
411
|
+
}
|
|
412
|
+
// Append confirmation workflow for destructive tools with confirm parameter
|
|
413
|
+
if (isDestructive && hasConfirm) {
|
|
414
|
+
description +=
|
|
415
|
+
'\n\nCONFIRMATION FLOW: ' +
|
|
416
|
+
'1) Call with confirm:true to preview what will be affected. ' +
|
|
417
|
+
'2) Show preview to user and ask for confirmation. ' +
|
|
418
|
+
'3) If confirmed, call again with user_confirmed:true to execute. ' +
|
|
419
|
+
'Do NOT set user_confirmed:true without explicit user consent.';
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
// Compact mode: truncated description + short safety tags
|
|
424
|
+
description = truncateDescription(ability.description);
|
|
425
|
+
const tags = buildSafetyTags(meta, hasDryRun, hasConfirm, 'compact');
|
|
426
|
+
if (tags) {
|
|
427
|
+
description += ` ${tags}`;
|
|
428
|
+
}
|
|
429
|
+
if (isDestructive && hasConfirm) {
|
|
430
|
+
description += ' FLOW: confirm:true -> preview -> user_confirmed:true + confirmation_token';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
name: toolName,
|
|
435
|
+
description,
|
|
436
|
+
inputSchema,
|
|
437
|
+
// MCP semantic annotations for client UI hints (always included regardless of verbosity)
|
|
438
|
+
annotations: meta
|
|
439
|
+
? {
|
|
440
|
+
title: ability.label || undefined,
|
|
441
|
+
readOnlyHint: meta.readonly,
|
|
442
|
+
destructiveHint: meta.destructive,
|
|
443
|
+
idempotentHint: meta.idempotent,
|
|
444
|
+
openWorldHint: true,
|
|
445
|
+
}
|
|
446
|
+
: undefined,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Cached tool list to avoid re-converting abilities on every ListTools call.
|
|
451
|
+
* Invalidated by abilities array reference change or config fingerprint change.
|
|
452
|
+
*/
|
|
453
|
+
let cachedTools = null;
|
|
454
|
+
let cachedToolsAbilitiesRef = null;
|
|
455
|
+
let cachedToolsFingerprint = null;
|
|
456
|
+
/**
|
|
457
|
+
* Clear the cached tool list (for testing).
|
|
458
|
+
* @internal
|
|
459
|
+
*/
|
|
460
|
+
export function clearToolsCache() {
|
|
461
|
+
cachedTools = null;
|
|
462
|
+
cachedToolsAbilitiesRef = null;
|
|
463
|
+
cachedToolsFingerprint = null;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Fetch all MainWP abilities and convert them to MCP tools
|
|
467
|
+
*
|
|
468
|
+
* Applies optional filtering based on config:
|
|
469
|
+
* - allowedTools: If set, only include tools in this list
|
|
470
|
+
* - blockedTools: If set, exclude tools in this list
|
|
471
|
+
*
|
|
472
|
+
* Caches the converted tool list by abilities array reference and config
|
|
473
|
+
* fingerprint. fetchAbilities() returns the same cached array while valid,
|
|
474
|
+
* so reference equality is a reliable cache key.
|
|
475
|
+
*
|
|
476
|
+
* @param config - Server configuration
|
|
477
|
+
* @param logger - Optional structured logger for filtering/verbosity messages
|
|
478
|
+
*/
|
|
479
|
+
export async function getTools(config, logger) {
|
|
480
|
+
const abilities = await fetchAbilities(config, false, logger);
|
|
481
|
+
const fingerprint = `${config.schemaVerbosity}|${config.allowedTools?.join(',') ?? ''}|${config.blockedTools?.join(',') ?? ''}`;
|
|
482
|
+
if (cachedTools &&
|
|
483
|
+
abilities === cachedToolsAbilitiesRef &&
|
|
484
|
+
fingerprint === cachedToolsFingerprint) {
|
|
485
|
+
return cachedTools;
|
|
486
|
+
}
|
|
487
|
+
let tools = abilities.map(ability => abilityToTool(ability, config.schemaVerbosity));
|
|
488
|
+
const originalCount = tools.length;
|
|
489
|
+
// Apply allowlist filter (whitelist)
|
|
490
|
+
if (config.allowedTools && config.allowedTools.length > 0) {
|
|
491
|
+
const allowedSet = new Set(config.allowedTools);
|
|
492
|
+
tools = tools.filter(tool => allowedSet.has(tool.name));
|
|
493
|
+
}
|
|
494
|
+
// Apply blocklist filter (blacklist)
|
|
495
|
+
if (config.blockedTools && config.blockedTools.length > 0) {
|
|
496
|
+
const blockedSet = new Set(config.blockedTools);
|
|
497
|
+
tools = tools.filter(tool => !blockedSet.has(tool.name));
|
|
498
|
+
}
|
|
499
|
+
// Log if tools were filtered
|
|
500
|
+
if (tools.length !== originalCount && logger) {
|
|
501
|
+
const allowedCount = config.allowedTools?.length ?? 'all';
|
|
502
|
+
const blockedCount = config.blockedTools?.length ?? 0;
|
|
503
|
+
logger.info('Tool filtering applied', {
|
|
504
|
+
originalCount,
|
|
505
|
+
filteredCount: tools.length,
|
|
506
|
+
allowedCount,
|
|
507
|
+
blockedCount,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// Log when non-default schema verbosity is active
|
|
511
|
+
if (config.schemaVerbosity !== 'standard' && logger) {
|
|
512
|
+
logger.info('Schema verbosity mode active', {
|
|
513
|
+
verbosity: config.schemaVerbosity,
|
|
514
|
+
note: 'Reduces token usage by ~30% with minimal descriptions',
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
cachedTools = tools;
|
|
518
|
+
cachedToolsAbilitiesRef = abilities;
|
|
519
|
+
cachedToolsFingerprint = fingerprint;
|
|
520
|
+
return tools;
|
|
521
|
+
}
|
|
522
|
+
// Re-export naming functions for backward compatibility
|
|
523
|
+
export { abilityNameToToolName, toolNameToAbilityName } from './naming.js';
|
|
524
|
+
/**
|
|
525
|
+
* Execute an MCP tool call by forwarding to the corresponding ability
|
|
526
|
+
*/
|
|
527
|
+
export async function executeTool(config, toolName, args, logger, options) {
|
|
528
|
+
const startTime = performance.now();
|
|
529
|
+
const hasArguments = Object.keys(args).length > 0;
|
|
530
|
+
const requestId = crypto.randomUUID();
|
|
531
|
+
// Create a child logger that includes requestId in every log entry
|
|
532
|
+
// for end-to-end tracing of this tool call through retry and API execution
|
|
533
|
+
const reqLogger = withRequestId(logger, requestId);
|
|
534
|
+
// SECURITY: Only log metadata (toolName, hasArguments boolean), never log actual
|
|
535
|
+
// argument values or response content as they may contain sensitive data.
|
|
536
|
+
reqLogger.debug('Tool execution started', { toolName, hasArguments });
|
|
537
|
+
let abilityName;
|
|
538
|
+
let annotations;
|
|
539
|
+
try {
|
|
540
|
+
// Check for cancellation before starting
|
|
541
|
+
if (options?.signal?.aborted) {
|
|
542
|
+
throw McpErrorFactory.cancelled();
|
|
543
|
+
}
|
|
544
|
+
// Validate input before forwarding to API
|
|
545
|
+
validateInput(args);
|
|
546
|
+
// Convert tool name to ability name
|
|
547
|
+
// Hardcoded 'mainwp' namespace - this server only supports MainWP abilities
|
|
548
|
+
abilityName = toolNameToAbilityName(toolName, 'mainwp');
|
|
549
|
+
// Fetch ability metadata to check if destructive
|
|
550
|
+
const ability = await getAbility(config, abilityName, reqLogger);
|
|
551
|
+
if (!ability) {
|
|
552
|
+
throw new Error(`Ability not found: ${abilityName}`);
|
|
553
|
+
}
|
|
554
|
+
// Check annotations for destructive classification
|
|
555
|
+
// Default-deny: treat missing annotations as destructive (fail-closed)
|
|
556
|
+
annotations = ability.meta?.annotations;
|
|
557
|
+
const isDestructive = annotations?.destructive ?? true;
|
|
558
|
+
let effectiveArgs = args;
|
|
559
|
+
// Always warn when annotations are missing — abilities without annotations
|
|
560
|
+
// are treated as destructive and require confirmation as a safety default
|
|
561
|
+
if (!annotations || typeof annotations.destructive !== 'boolean') {
|
|
562
|
+
reqLogger.warning('Ability missing destructive annotation, defaulting to destructive', {
|
|
563
|
+
toolName,
|
|
564
|
+
abilityName,
|
|
565
|
+
hasAnnotations: !!annotations,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
// Log all destructive operation attempts for audit purposes (regardless of safe mode)
|
|
569
|
+
if (isDestructive) {
|
|
570
|
+
reqLogger.info('Destructive operation invoked', {
|
|
571
|
+
toolName,
|
|
572
|
+
abilityName,
|
|
573
|
+
safeMode: config.safeMode,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
// Safe mode handling
|
|
577
|
+
if (config.safeMode) {
|
|
578
|
+
// Always strip confirm parameter in safe mode (defensive approach)
|
|
579
|
+
if ('confirm' in args) {
|
|
580
|
+
const { confirm, ...safeArgs } = args;
|
|
581
|
+
effectiveArgs = safeArgs;
|
|
582
|
+
reqLogger.info('Stripped confirm parameter in safe mode', {
|
|
583
|
+
toolName,
|
|
584
|
+
hadConfirm: confirm,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
// Block destructive operations with a clear user-visible message.
|
|
588
|
+
// Note: Safe-mode early-return responses are intentionally excluded from
|
|
589
|
+
// sessionDataBytes tracking. The session data limit is designed to prevent
|
|
590
|
+
// runaway API responses, not small fixed-size local error messages.
|
|
591
|
+
if (isDestructive) {
|
|
592
|
+
reqLogger.warning('Destructive operation blocked by safe mode', { toolName, abilityName });
|
|
593
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
594
|
+
return [
|
|
595
|
+
{
|
|
596
|
+
type: 'text',
|
|
597
|
+
text: formatJson(config, buildSafeModeBlockedResponse(ctx)),
|
|
598
|
+
},
|
|
599
|
+
];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Two-phase confirmation flow for destructive operations
|
|
603
|
+
// Only applies when requireUserConfirmation is enabled and tool is destructive
|
|
604
|
+
if (config.requireUserConfirmation && isDestructive) {
|
|
605
|
+
// Check if tool supports confirmation parameter
|
|
606
|
+
const schemaProps = ability.input_schema?.properties;
|
|
607
|
+
const hasConfirmParam = schemaProps !== null && typeof schemaProps === 'object' && 'confirm' in schemaProps;
|
|
608
|
+
// Validation: user_confirmed on tools without confirm parameter
|
|
609
|
+
if (!hasConfirmParam && args.user_confirmed === true) {
|
|
610
|
+
reqLogger.warning('Invalid parameter: user_confirmed on tool without confirm support', {
|
|
611
|
+
toolName,
|
|
612
|
+
abilityName,
|
|
613
|
+
});
|
|
614
|
+
// Note: This fixed-size local error message is intentionally excluded from
|
|
615
|
+
// sessionDataBytes tracking. The session data limit targets runaway API
|
|
616
|
+
// responses, not small validation errors.
|
|
617
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
618
|
+
const errorResponse = formatJson(config, buildInvalidParameterResponse(ctx));
|
|
619
|
+
return [{ type: 'text', text: errorResponse }];
|
|
620
|
+
}
|
|
621
|
+
if (hasConfirmParam) {
|
|
622
|
+
// Validation: Conflicting parameters (user_confirmed + dry_run)
|
|
623
|
+
if (args.user_confirmed === true && args.dry_run === true) {
|
|
624
|
+
reqLogger.warning('Conflicting parameters: user_confirmed and dry_run both set', {
|
|
625
|
+
toolName,
|
|
626
|
+
abilityName,
|
|
627
|
+
userConfirmed: args.user_confirmed,
|
|
628
|
+
dryRun: args.dry_run,
|
|
629
|
+
});
|
|
630
|
+
// Note: This fixed-size local error message is intentionally excluded from
|
|
631
|
+
// sessionDataBytes tracking. The session data limit targets runaway API
|
|
632
|
+
// responses, not small validation errors.
|
|
633
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
634
|
+
const errorResponse = formatJson(config, buildConflictingParametersResponse(ctx));
|
|
635
|
+
return [{ type: 'text', text: errorResponse }];
|
|
636
|
+
}
|
|
637
|
+
// Case 1: Explicit dry_run bypass - skip confirmation flow entirely
|
|
638
|
+
if (args.dry_run === true) {
|
|
639
|
+
reqLogger.debug('Explicit dry_run bypasses confirmation flow', { toolName });
|
|
640
|
+
// Proceed to normal execution with effectiveArgs unchanged
|
|
641
|
+
}
|
|
642
|
+
// Case 2: Preview request (confirm: true without user_confirmed)
|
|
643
|
+
else if (args.confirm === true && args.user_confirmed !== true) {
|
|
644
|
+
cleanupExpiredPreviews();
|
|
645
|
+
// Execute preview with dry_run: true
|
|
646
|
+
const previewArgs = { ...effectiveArgs, dry_run: true, confirm: undefined };
|
|
647
|
+
const previewResult = await executeAbility(config, abilityName, previewArgs, reqLogger);
|
|
648
|
+
// Store preview for later validation
|
|
649
|
+
const previewKey = getPreviewKey(toolName, args);
|
|
650
|
+
pendingPreviews.set(previewKey, Date.now());
|
|
651
|
+
// Clean up any existing token for this preview key before generating a new one
|
|
652
|
+
for (const [existingToken, existingKey] of tokenIndex.entries()) {
|
|
653
|
+
if (existingKey === previewKey) {
|
|
654
|
+
tokenIndex.delete(existingToken);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Generate confirmation token for secure token-based confirmation
|
|
659
|
+
const token = crypto.randomUUID();
|
|
660
|
+
tokenIndex.set(token, previewKey);
|
|
661
|
+
reqLogger.info('Preview generated for confirmation', { toolName });
|
|
662
|
+
// Return structured preview response
|
|
663
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
664
|
+
const confirmationResponse = buildConfirmationRequiredResponse(ctx, previewResult, token);
|
|
665
|
+
const previewResponse = formatJson(config, confirmationResponse);
|
|
666
|
+
// Track preview response size (contains API data that could be large)
|
|
667
|
+
const previewBytes = Buffer.byteLength(previewResponse, 'utf8');
|
|
668
|
+
if (sessionDataBytes + previewBytes > config.maxSessionData) {
|
|
669
|
+
reqLogger.error('Session data limit exceeded during preview', {
|
|
670
|
+
toolName,
|
|
671
|
+
previewBytes,
|
|
672
|
+
sessionDataBytes,
|
|
673
|
+
maxSessionData: config.maxSessionData,
|
|
674
|
+
wouldBe: sessionDataBytes + previewBytes,
|
|
675
|
+
});
|
|
676
|
+
throw McpErrorFactory.resourceExhausted(`Session data limit reached (${formatBytes(sessionDataBytes + previewBytes)} of ${formatBytes(config.maxSessionData)}). Start a new session to continue.`);
|
|
677
|
+
}
|
|
678
|
+
sessionDataBytes += previewBytes;
|
|
679
|
+
return [{ type: 'text', text: previewResponse }];
|
|
680
|
+
}
|
|
681
|
+
// Case 3: Confirmed execution (user_confirmed: true)
|
|
682
|
+
else if (args.user_confirmed === true) {
|
|
683
|
+
// Warning: Ambiguous parameters (confirm + user_confirmed both set)
|
|
684
|
+
// Per PRD line 319: allow execution but log for tracking
|
|
685
|
+
if (args.confirm === true) {
|
|
686
|
+
reqLogger.warning('Ambiguous parameters: both confirm and user_confirmed set, treating as confirmation', {
|
|
687
|
+
toolName,
|
|
688
|
+
abilityName,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
// Resolve preview key: prefer token-based lookup, fall back to key-based
|
|
692
|
+
let previewKey;
|
|
693
|
+
const confirmationToken = typeof args.confirmation_token === 'string' ? args.confirmation_token : undefined;
|
|
694
|
+
if (confirmationToken) {
|
|
695
|
+
const tokenPreviewKey = tokenIndex.get(confirmationToken);
|
|
696
|
+
if (!tokenPreviewKey) {
|
|
697
|
+
// Token is invalid or already consumed
|
|
698
|
+
reqLogger.warning('Confirmation failed - invalid confirmation token', { toolName });
|
|
699
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
700
|
+
return [
|
|
701
|
+
{ type: 'text', text: formatJson(config, buildPreviewRequiredResponse(ctx)) },
|
|
702
|
+
];
|
|
703
|
+
}
|
|
704
|
+
// Verify token belongs to this tool (prevent cross-tool reuse)
|
|
705
|
+
if (!tokenPreviewKey.startsWith(`${toolName}:`)) {
|
|
706
|
+
tokenIndex.delete(confirmationToken);
|
|
707
|
+
reqLogger.warning('Confirmation failed - token belongs to different tool', {
|
|
708
|
+
toolName,
|
|
709
|
+
});
|
|
710
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
711
|
+
return [
|
|
712
|
+
{ type: 'text', text: formatJson(config, buildPreviewRequiredResponse(ctx)) },
|
|
713
|
+
];
|
|
714
|
+
}
|
|
715
|
+
// Verify token matches current arguments (prevent arg-swap)
|
|
716
|
+
const currentPreviewKey = getPreviewKey(toolName, args);
|
|
717
|
+
if (currentPreviewKey !== tokenPreviewKey) {
|
|
718
|
+
tokenIndex.delete(confirmationToken);
|
|
719
|
+
reqLogger.warning('Confirmation failed - arguments do not match preview', {
|
|
720
|
+
toolName,
|
|
721
|
+
});
|
|
722
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
723
|
+
return [
|
|
724
|
+
{ type: 'text', text: formatJson(config, buildPreviewRequiredResponse(ctx)) },
|
|
725
|
+
];
|
|
726
|
+
}
|
|
727
|
+
previewKey = tokenPreviewKey;
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
previewKey = getPreviewKey(toolName, args);
|
|
731
|
+
}
|
|
732
|
+
// Note: We intentionally check preview expiry BEFORE running cleanup.
|
|
733
|
+
// This allows us to return the more helpful PREVIEW_EXPIRED error when
|
|
734
|
+
// a user's preview timed out, rather than the generic PREVIEW_REQUIRED.
|
|
735
|
+
// Memory management is handled by cleanup during preview generation.
|
|
736
|
+
const previewTimestamp = pendingPreviews.get(previewKey);
|
|
737
|
+
// Check if preview exists
|
|
738
|
+
if (previewTimestamp === undefined) {
|
|
739
|
+
reqLogger.warning('Confirmation failed - no preview found', { toolName });
|
|
740
|
+
// Note: This fixed-size local error message is intentionally excluded from
|
|
741
|
+
// sessionDataBytes tracking. The session data limit targets runaway API
|
|
742
|
+
// responses, not small validation errors.
|
|
743
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
744
|
+
const errorResponse = formatJson(config, buildPreviewRequiredResponse(ctx));
|
|
745
|
+
return [{ type: 'text', text: errorResponse }];
|
|
746
|
+
}
|
|
747
|
+
// Check if preview expired
|
|
748
|
+
if (Date.now() - previewTimestamp > PREVIEW_EXPIRY_MS) {
|
|
749
|
+
pendingPreviews.delete(previewKey);
|
|
750
|
+
if (confirmationToken)
|
|
751
|
+
tokenIndex.delete(confirmationToken);
|
|
752
|
+
reqLogger.warning('Confirmation failed - preview expired', { toolName });
|
|
753
|
+
// Note: This fixed-size local error message is intentionally excluded from
|
|
754
|
+
// sessionDataBytes tracking. The session data limit targets runaway API
|
|
755
|
+
// responses, not small validation errors.
|
|
756
|
+
const ctx = { tool: toolName, ability: abilityName };
|
|
757
|
+
const errorResponse = formatJson(config, buildPreviewExpiredResponse(ctx));
|
|
758
|
+
return [{ type: 'text', text: errorResponse }];
|
|
759
|
+
}
|
|
760
|
+
// Preview is valid - proceed with execution
|
|
761
|
+
pendingPreviews.delete(previewKey);
|
|
762
|
+
if (confirmationToken)
|
|
763
|
+
tokenIndex.delete(confirmationToken);
|
|
764
|
+
const previewAge = Date.now() - previewTimestamp;
|
|
765
|
+
reqLogger.info('User confirmation validated', { toolName, previewAge });
|
|
766
|
+
// Remove user_confirmed and confirmation_token flags, keep confirm: true for the actual execution
|
|
767
|
+
const { user_confirmed: _user_confirmed, confirmation_token: _confirmation_token, ...confirmedArgs } = effectiveArgs;
|
|
768
|
+
effectiveArgs = { ...confirmedArgs, confirm: true };
|
|
769
|
+
}
|
|
770
|
+
// Default case: no confirm or user_confirmed provided
|
|
771
|
+
else {
|
|
772
|
+
// This shouldn't happen for tools with confirm param if schema is properly enforced
|
|
773
|
+
// Log warning but proceed to maintain backward compatibility
|
|
774
|
+
reqLogger.warning('Destructive tool called without confirmation parameters', {
|
|
775
|
+
toolName,
|
|
776
|
+
abilityName,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const result = await executeAbility(config, abilityName, effectiveArgs, reqLogger);
|
|
782
|
+
// Check for cancellation after execution
|
|
783
|
+
if (options?.signal?.aborted) {
|
|
784
|
+
throw McpErrorFactory.cancelled();
|
|
785
|
+
}
|
|
786
|
+
// Format the result as JSON for the AI to parse
|
|
787
|
+
const formattedResult = formatJson(config, result);
|
|
788
|
+
// Track response size and enforce session data limit
|
|
789
|
+
const responseBytes = Buffer.byteLength(formattedResult, 'utf8');
|
|
790
|
+
if (sessionDataBytes + responseBytes > config.maxSessionData) {
|
|
791
|
+
reqLogger.error('Session data limit exceeded', {
|
|
792
|
+
toolName,
|
|
793
|
+
responseBytes,
|
|
794
|
+
sessionDataBytes,
|
|
795
|
+
maxSessionData: config.maxSessionData,
|
|
796
|
+
wouldBe: sessionDataBytes + responseBytes,
|
|
797
|
+
});
|
|
798
|
+
throw McpErrorFactory.resourceExhausted(`Session data limit reached (${formatBytes(sessionDataBytes + responseBytes)} of ${formatBytes(config.maxSessionData)}). Start a new session to continue.`);
|
|
799
|
+
}
|
|
800
|
+
sessionDataBytes += responseBytes;
|
|
801
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
802
|
+
reqLogger.info('Tool execution succeeded', {
|
|
803
|
+
toolName,
|
|
804
|
+
success: true,
|
|
805
|
+
durationMs,
|
|
806
|
+
responseBytes,
|
|
807
|
+
sessionDataBytes,
|
|
808
|
+
});
|
|
809
|
+
return [
|
|
810
|
+
{
|
|
811
|
+
type: 'text',
|
|
812
|
+
text: formattedResult,
|
|
813
|
+
},
|
|
814
|
+
];
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
// Idempotent no-op: tool already achieved the desired state (e.g. already_active)
|
|
818
|
+
if (annotations?.idempotent && isNoOpError(error)) {
|
|
819
|
+
const code = error.code;
|
|
820
|
+
const reason = NOOP_DESCRIPTIONS[code] ?? code;
|
|
821
|
+
const ctx = { tool: toolName, ability: abilityName ?? toolName };
|
|
822
|
+
const noChangeText = formatJson(config, buildNoChangeResponse(ctx, code, reason));
|
|
823
|
+
const responseBytes = Buffer.byteLength(noChangeText, 'utf8');
|
|
824
|
+
if (sessionDataBytes + responseBytes > config.maxSessionData) {
|
|
825
|
+
reqLogger.error('Session data limit exceeded during no-op response', {
|
|
826
|
+
toolName,
|
|
827
|
+
responseBytes,
|
|
828
|
+
sessionDataBytes,
|
|
829
|
+
maxSessionData: config.maxSessionData,
|
|
830
|
+
wouldBe: sessionDataBytes + responseBytes,
|
|
831
|
+
});
|
|
832
|
+
throw McpErrorFactory.resourceExhausted(`Session data limit reached (${formatBytes(sessionDataBytes + responseBytes)} of ${formatBytes(config.maxSessionData)}). Start a new session to continue.`);
|
|
833
|
+
}
|
|
834
|
+
sessionDataBytes += responseBytes;
|
|
835
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
836
|
+
reqLogger.info('Tool execution no-op (idempotent already-state)', {
|
|
837
|
+
toolName,
|
|
838
|
+
durationMs,
|
|
839
|
+
responseBytes,
|
|
840
|
+
sessionDataBytes,
|
|
841
|
+
});
|
|
842
|
+
return [{ type: 'text', text: noChangeText }];
|
|
843
|
+
}
|
|
844
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
845
|
+
const errorMessage = sanitizeError(error instanceof Error ? error.message : String(error));
|
|
846
|
+
reqLogger.error('Tool execution failed', {
|
|
847
|
+
toolName,
|
|
848
|
+
success: false,
|
|
849
|
+
durationMs,
|
|
850
|
+
error: errorMessage,
|
|
851
|
+
});
|
|
852
|
+
// Use standardized error format with code
|
|
853
|
+
return [
|
|
854
|
+
{
|
|
855
|
+
type: 'text',
|
|
856
|
+
text: formatErrorResponse(error, sanitizeError),
|
|
857
|
+
},
|
|
858
|
+
];
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
//# sourceMappingURL=tools.js.map
|