@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.
Files changed (49) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +1034 -0
  3. package/dist/abilities.d.ts +144 -0
  4. package/dist/abilities.d.ts.map +1 -0
  5. package/dist/abilities.js +529 -0
  6. package/dist/abilities.js.map +1 -0
  7. package/dist/config.d.ts +135 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +405 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/confirmation-responses.d.ts +44 -0
  12. package/dist/confirmation-responses.d.ts.map +1 -0
  13. package/dist/confirmation-responses.js +120 -0
  14. package/dist/confirmation-responses.js.map +1 -0
  15. package/dist/errors.d.ts +118 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +206 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/index.d.ts +17 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +506 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/logging.d.ts +34 -0
  24. package/dist/logging.d.ts.map +1 -0
  25. package/dist/logging.js +74 -0
  26. package/dist/logging.js.map +1 -0
  27. package/dist/naming.d.ts +23 -0
  28. package/dist/naming.d.ts.map +1 -0
  29. package/dist/naming.js +37 -0
  30. package/dist/naming.js.map +1 -0
  31. package/dist/prompts.d.ts +22 -0
  32. package/dist/prompts.d.ts.map +1 -0
  33. package/dist/prompts.js +414 -0
  34. package/dist/prompts.js.map +1 -0
  35. package/dist/retry.d.ts +77 -0
  36. package/dist/retry.d.ts.map +1 -0
  37. package/dist/retry.js +206 -0
  38. package/dist/retry.js.map +1 -0
  39. package/dist/security.d.ts +41 -0
  40. package/dist/security.d.ts.map +1 -0
  41. package/dist/security.js +154 -0
  42. package/dist/security.js.map +1 -0
  43. package/dist/tools.d.ts +82 -0
  44. package/dist/tools.d.ts.map +1 -0
  45. package/dist/tools.js +861 -0
  46. package/dist/tools.js.map +1 -0
  47. package/package.json +73 -0
  48. package/settings.example.json +30 -0
  49. 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