@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
@@ -0,0 +1,144 @@
1
+ /**
2
+ * MainWP Abilities Discovery and Management
3
+ *
4
+ * Fetches and caches ability definitions from the MainWP Dashboard's
5
+ * Abilities API REST endpoints.
6
+ */
7
+ import { Config } from './config.js';
8
+ import type { Logger } from './logging.js';
9
+ /**
10
+ * Initialize the rate limiter with the configured requests per minute.
11
+ * Called once at startup from index.ts.
12
+ */
13
+ export declare function initRateLimiter(requestsPerMinute: number): void;
14
+ /**
15
+ * Ability annotation metadata
16
+ */
17
+ export interface AbilityAnnotations {
18
+ instructions?: string;
19
+ readonly: boolean;
20
+ destructive: boolean;
21
+ idempotent: boolean;
22
+ }
23
+ /**
24
+ * Ability definition from the REST API
25
+ */
26
+ export interface Ability {
27
+ name: string;
28
+ label: string;
29
+ description: string;
30
+ category: string;
31
+ input_schema?: Record<string, unknown>;
32
+ output_schema?: Record<string, unknown>;
33
+ meta?: {
34
+ show_in_rest?: boolean;
35
+ annotations?: AbilityAnnotations;
36
+ };
37
+ }
38
+ /**
39
+ * Category definition from the REST API
40
+ */
41
+ export interface Category {
42
+ slug: string;
43
+ label: string;
44
+ description: string;
45
+ }
46
+ /**
47
+ * Cache refresh callbacks
48
+ */
49
+ type CacheRefreshCallback = () => void;
50
+ /**
51
+ * Register a callback to be called when the abilities cache is refreshed
52
+ */
53
+ export declare function onCacheRefresh(callback: CacheRefreshCallback): void;
54
+ /**
55
+ * Read a response body with streaming size enforcement.
56
+ * Prevents unbounded memory consumption from chunked responses without Content-Length.
57
+ *
58
+ * The fallback path (no ReadableStream) buffers the full body before checking size.
59
+ * In production Node.js 18+, fetch responses always have a ReadableStream body,
60
+ * so the fallback only executes in test mocks with minimal Response objects.
61
+ *
62
+ * @internal Exported for testing
63
+ * @param response - The HTTP response to read
64
+ * @param maxBytes - Maximum allowed body size in bytes
65
+ * @returns The response body as a string
66
+ */
67
+ export declare function readLimitedBody(response: Response, maxBytes: number): Promise<string>;
68
+ /**
69
+ * Fetch all abilities from the MainWP Dashboard
70
+ */
71
+ export declare function fetchAbilities(config: Config, forceRefresh?: boolean, logger?: Logger): Promise<Ability[]>;
72
+ /**
73
+ * Fetch all categories from the MainWP Dashboard
74
+ */
75
+ export declare function fetchCategories(config: Config, forceRefresh?: boolean, logger?: Logger): Promise<Category[]>;
76
+ /**
77
+ * Get a specific ability by name
78
+ */
79
+ export declare function getAbility(config: Config, name: string, logger?: Logger): Promise<Ability | undefined>;
80
+ /**
81
+ * Execute an ability via the REST API
82
+ *
83
+ * @param config - Server configuration
84
+ * @param abilityName - Name of the ability to execute
85
+ * @param input - Optional input parameters
86
+ * @param logger - Optional logger for retry logging
87
+ */
88
+ export declare function executeAbility(config: Config, abilityName: string, input?: Record<string, unknown>, logger?: Logger): Promise<unknown>;
89
+ /**
90
+ * Clear the abilities cache
91
+ */
92
+ export declare function clearCache(): void;
93
+ /**
94
+ * Help documentation for a single tool
95
+ */
96
+ export interface ToolHelp {
97
+ toolName: string;
98
+ abilityName: string;
99
+ label: string;
100
+ description: string;
101
+ category: string;
102
+ annotations: {
103
+ readonly: boolean;
104
+ destructive: boolean;
105
+ idempotent: boolean;
106
+ instructions?: string;
107
+ };
108
+ safetyFeatures: {
109
+ supportsDryRun: boolean;
110
+ requiresConfirm: boolean;
111
+ };
112
+ parameters: Array<{
113
+ name: string;
114
+ type: string;
115
+ required: boolean;
116
+ description?: string;
117
+ }>;
118
+ }
119
+ /**
120
+ * Complete help document structure
121
+ */
122
+ export interface HelpDocument {
123
+ version: string;
124
+ generated: string;
125
+ overview: {
126
+ totalTools: number;
127
+ categories: string[];
128
+ safetyConventions: Record<string, string>;
129
+ };
130
+ destructiveTools: string[];
131
+ toolsWithDryRun: string[];
132
+ toolsRequiringConfirm: string[];
133
+ toolsByCategory: Record<string, ToolHelp[]>;
134
+ }
135
+ /**
136
+ * Generate help documentation for a single ability
137
+ */
138
+ export declare function generateToolHelp(ability: Ability): ToolHelp;
139
+ /**
140
+ * Generate complete help document from all abilities
141
+ */
142
+ export declare function generateHelpDocument(abilities: Ability[]): HelpDocument;
143
+ export {};
144
+ //# sourceMappingURL=abilities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abilities.d.ts","sourceRoot":"","sources":["../src/abilities.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,529 @@
1
+ /**
2
+ * MainWP Abilities Discovery and Management
3
+ *
4
+ * Fetches and caches ability definitions from the MainWP Dashboard's
5
+ * Abilities API REST endpoints.
6
+ */
7
+ import { getAbilitiesApiUrl, getAuthHeaders } from './config.js';
8
+ import { McpErrorFactory } from './errors.js';
9
+ import { RateLimiter, sanitizeError } from './security.js';
10
+ import { abilityNameToToolName } from './naming.js';
11
+ import { withRetry } from './retry.js';
12
+ /** Maximum size for error response bodies (64KB) — prevents transient memory spikes */
13
+ const MAX_ERROR_BODY_BYTES = 65536;
14
+ /** Maximum URL length for GET/DELETE requests — most HTTP servers reject URLs > 8KB */
15
+ const MAX_URL_LENGTH = 8000;
16
+ /** Maximum number of pages to fetch during pagination — prevents unbounded requests */
17
+ const MAX_PAGES = 50;
18
+ /**
19
+ * Rate limiter instance (initialized via initRateLimiter)
20
+ */
21
+ let rateLimiter = null;
22
+ /**
23
+ * Initialize the rate limiter with the configured requests per minute.
24
+ * Called once at startup from index.ts.
25
+ */
26
+ export function initRateLimiter(requestsPerMinute) {
27
+ rateLimiter = new RateLimiter(requestsPerMinute);
28
+ }
29
+ /**
30
+ * Cached abilities data
31
+ */
32
+ let cachedAbilities = null;
33
+ let abilitiesIndex = null;
34
+ let cachedCategories = null;
35
+ let abilitiesCacheTimestamp = 0;
36
+ let categoriesCacheTimestamp = 0;
37
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
38
+ let sslVerifyDisabled = false;
39
+ /**
40
+ * Hardcoded namespace filter for MainWP abilities.
41
+ * This server only supports MainWP abilities (mainwp/* namespace).
42
+ */
43
+ const NAMESPACE_FILTER = 'mainwp/';
44
+ const CATEGORY_FILTER = 'mainwp-';
45
+ const cacheRefreshCallbacks = [];
46
+ /**
47
+ * Register a callback to be called when the abilities cache is refreshed
48
+ */
49
+ export function onCacheRefresh(callback) {
50
+ cacheRefreshCallbacks.push(callback);
51
+ }
52
+ /**
53
+ * Notify all registered callbacks that the cache was refreshed
54
+ */
55
+ function notifyCacheRefresh() {
56
+ for (const callback of cacheRefreshCallbacks) {
57
+ try {
58
+ callback();
59
+ }
60
+ catch {
61
+ // Ignore callback errors
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * Create a fetch function that handles SSL verification, request timeout, and response size limits.
67
+ * Wraps any caller-provided AbortSignal to enforce timeout while preserving external cancellation.
68
+ *
69
+ * @param config - Server configuration
70
+ * @param perCallTimeout - Optional per-call timeout override (for retry budget enforcement)
71
+ */
72
+ function createFetch(config, perCallTimeout) {
73
+ if (config.skipSslVerify && !sslVerifyDisabled) {
74
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
75
+ sslVerifyDisabled = true;
76
+ }
77
+ const effectiveTimeout = perCallTimeout ?? config.requestTimeout;
78
+ return async (url, options = {}) => {
79
+ const controller = new AbortController();
80
+ const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
81
+ // Forward external signal abort to our controller (preserves caller cancellation)
82
+ const externalSignal = options.signal;
83
+ if (externalSignal) {
84
+ if (externalSignal.aborted) {
85
+ controller.abort();
86
+ }
87
+ else {
88
+ externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
89
+ }
90
+ }
91
+ try {
92
+ const fetchOptions = {
93
+ ...options,
94
+ signal: controller.signal,
95
+ headers: {
96
+ ...getAuthHeaders(config),
97
+ ...options.headers,
98
+ },
99
+ };
100
+ const response = await fetch(url, fetchOptions);
101
+ clearTimeout(timeoutId);
102
+ // Check response size before parsing (if content-length is provided)
103
+ const contentLength = response.headers.get('content-length');
104
+ if (contentLength) {
105
+ const size = parseInt(contentLength, 10);
106
+ if (size > config.maxResponseSize) {
107
+ throw new Error(`Response size ${size} bytes exceeds maximum allowed ${config.maxResponseSize} bytes`);
108
+ }
109
+ }
110
+ return response;
111
+ }
112
+ catch (error) {
113
+ clearTimeout(timeoutId);
114
+ if (error.name === 'AbortError') {
115
+ // Create timeout error with ETIMEDOUT code for retry detection
116
+ const timeoutError = new Error(`Request timeout after ${effectiveTimeout}ms: ${url}`);
117
+ timeoutError.code = 'ETIMEDOUT';
118
+ throw timeoutError;
119
+ }
120
+ throw error;
121
+ }
122
+ };
123
+ }
124
+ /**
125
+ * Read a response body with streaming size enforcement.
126
+ * Prevents unbounded memory consumption from chunked responses without Content-Length.
127
+ *
128
+ * The fallback path (no ReadableStream) buffers the full body before checking size.
129
+ * In production Node.js 18+, fetch responses always have a ReadableStream body,
130
+ * so the fallback only executes in test mocks with minimal Response objects.
131
+ *
132
+ * @internal Exported for testing
133
+ * @param response - The HTTP response to read
134
+ * @param maxBytes - Maximum allowed body size in bytes
135
+ * @returns The response body as a string
136
+ */
137
+ export async function readLimitedBody(response, maxBytes) {
138
+ const reader = response.body?.getReader();
139
+ if (!reader) {
140
+ // Fallback for test mocks without ReadableStream body
141
+ let text;
142
+ if (typeof response.text === 'function') {
143
+ text = await response.text();
144
+ }
145
+ else {
146
+ // Last resort: json() + stringify (handles minimal mock objects)
147
+ text = JSON.stringify(await response.json());
148
+ }
149
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) {
150
+ throw new Error(`Response body exceeds ${maxBytes} bytes limit`);
151
+ }
152
+ return text;
153
+ }
154
+ const chunks = [];
155
+ let totalBytes = 0;
156
+ while (true) {
157
+ const { done, value } = await reader.read();
158
+ if (done)
159
+ break;
160
+ totalBytes += value.byteLength;
161
+ if (totalBytes > maxBytes) {
162
+ reader.cancel();
163
+ throw new Error(`Response body exceeds ${maxBytes} bytes limit`);
164
+ }
165
+ chunks.push(value);
166
+ }
167
+ return new TextDecoder().decode(Buffer.concat(chunks));
168
+ }
169
+ /**
170
+ * Fetch all abilities from the MainWP Dashboard
171
+ */
172
+ export async function fetchAbilities(config, forceRefresh = false, logger) {
173
+ // Return cached data if still valid
174
+ if (!forceRefresh && cachedAbilities && Date.now() - abilitiesCacheTimestamp < CACHE_TTL_MS) {
175
+ return cachedAbilities;
176
+ }
177
+ const baseUrl = getAbilitiesApiUrl(config);
178
+ const customFetch = createFetch(config);
179
+ try {
180
+ // Paginate through all abilities
181
+ let page = 1;
182
+ const allAbilities = [];
183
+ while (true) {
184
+ const response = await customFetch(`${baseUrl}/abilities?per_page=100&page=${page}`);
185
+ if (!response.ok) {
186
+ const errorText = await readLimitedBody(response, MAX_ERROR_BODY_BYTES);
187
+ throw new Error(`Failed to fetch abilities: ${response.status} ${response.statusText} - ${sanitizeError(errorText)}`);
188
+ }
189
+ const body = await readLimitedBody(response, config.maxResponseSize);
190
+ const batch = JSON.parse(body);
191
+ allAbilities.push(...batch);
192
+ const totalPages = parseInt(response.headers.get('X-WP-TotalPages') || '1', 10);
193
+ if (page >= totalPages || page >= MAX_PAGES)
194
+ break;
195
+ page++;
196
+ }
197
+ if (page >= MAX_PAGES) {
198
+ logger?.warning(`Pagination capped at ${MAX_PAGES} pages (fetched ${allAbilities.length} abilities) — some may be missing`);
199
+ }
200
+ else if (page > 1) {
201
+ logger?.info(`Fetched ${allAbilities.length} abilities across ${page} pages`);
202
+ }
203
+ // Filter abilities to only MainWP namespace
204
+ const newAbilities = allAbilities.filter(a => a.name.startsWith(NAMESPACE_FILTER));
205
+ // Check if abilities have changed (compare names)
206
+ const oldNames = cachedAbilities
207
+ ?.map(a => a.name)
208
+ .sort()
209
+ .join(',') ?? '';
210
+ const newNames = newAbilities
211
+ .map(a => a.name)
212
+ .sort()
213
+ .join(',');
214
+ const hasChanged = oldNames !== newNames;
215
+ cachedAbilities = newAbilities;
216
+ abilitiesIndex = new Map();
217
+ for (const ability of newAbilities) {
218
+ abilitiesIndex.set(ability.name, ability);
219
+ }
220
+ abilitiesCacheTimestamp = Date.now();
221
+ // Notify callbacks if abilities changed
222
+ if (hasChanged && oldNames !== '') {
223
+ notifyCacheRefresh();
224
+ }
225
+ return cachedAbilities;
226
+ }
227
+ catch (error) {
228
+ // If we have cached data, return it even if expired
229
+ if (cachedAbilities) {
230
+ const cacheAgeMinutes = Math.round((Date.now() - abilitiesCacheTimestamp) / 60000);
231
+ logger?.warning('Failed to refresh abilities, using cached data', {
232
+ error: sanitizeError(String(error)),
233
+ cacheAgeMinutes,
234
+ });
235
+ return cachedAbilities;
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+ /**
241
+ * Fetch all categories from the MainWP Dashboard
242
+ */
243
+ export async function fetchCategories(config, forceRefresh = false, logger) {
244
+ // Return cached data if still valid
245
+ if (!forceRefresh && cachedCategories && Date.now() - categoriesCacheTimestamp < CACHE_TTL_MS) {
246
+ return cachedCategories;
247
+ }
248
+ const baseUrl = getAbilitiesApiUrl(config);
249
+ const customFetch = createFetch(config);
250
+ try {
251
+ // Paginate through all categories
252
+ let page = 1;
253
+ const allCategories = [];
254
+ while (true) {
255
+ const response = await customFetch(`${baseUrl}/categories?per_page=100&page=${page}`);
256
+ if (!response.ok) {
257
+ const errorText = await readLimitedBody(response, MAX_ERROR_BODY_BYTES);
258
+ throw new Error(`Failed to fetch categories: ${response.status} ${response.statusText} - ${sanitizeError(errorText)}`);
259
+ }
260
+ const body = await readLimitedBody(response, config.maxResponseSize);
261
+ const batch = JSON.parse(body);
262
+ allCategories.push(...batch);
263
+ const totalPages = parseInt(response.headers.get('X-WP-TotalPages') || '1', 10);
264
+ if (page >= totalPages || page >= MAX_PAGES)
265
+ break;
266
+ page++;
267
+ }
268
+ if (page >= MAX_PAGES) {
269
+ logger?.warning(`Pagination capped at ${MAX_PAGES} pages (fetched ${allCategories.length} categories) — some may be missing`);
270
+ }
271
+ // Filter categories to only MainWP namespace
272
+ cachedCategories = allCategories.filter(c => c.slug.startsWith(CATEGORY_FILTER));
273
+ categoriesCacheTimestamp = Date.now();
274
+ return cachedCategories;
275
+ }
276
+ catch (error) {
277
+ if (cachedCategories) {
278
+ const cacheAgeMinutes = Math.round((Date.now() - categoriesCacheTimestamp) / 60000);
279
+ logger?.warning('Failed to refresh categories, using cached data', {
280
+ error: sanitizeError(String(error)),
281
+ cacheAgeMinutes,
282
+ });
283
+ return cachedCategories;
284
+ }
285
+ throw error;
286
+ }
287
+ }
288
+ /**
289
+ * Get a specific ability by name
290
+ */
291
+ export async function getAbility(config, name, logger) {
292
+ await fetchAbilities(config, false, logger);
293
+ return abilitiesIndex?.get(name);
294
+ }
295
+ /**
296
+ * Serialize input to PHP-style query string for GET requests.
297
+ * WordPress REST API parses PHP array notation: input[key][]=value
298
+ */
299
+ function serializeToPhpQueryString(input) {
300
+ const params = [];
301
+ for (const [key, value] of Object.entries(input)) {
302
+ if (Array.isArray(value)) {
303
+ // Arrays: input[key][]=val1&input[key][]=val2
304
+ for (const item of value) {
305
+ params.push(`input[${encodeURIComponent(key)}][]=${encodeURIComponent(String(item))}`);
306
+ }
307
+ }
308
+ else if (typeof value === 'object' && value !== null) {
309
+ // Nested objects: input[key][subkey]=val
310
+ for (const [subKey, subVal] of Object.entries(value)) {
311
+ params.push(`input[${encodeURIComponent(key)}][${encodeURIComponent(subKey)}]=${encodeURIComponent(String(subVal))}`);
312
+ }
313
+ }
314
+ else if (value !== undefined && value !== null) {
315
+ // Scalars: input[key]=val
316
+ params.push(`input[${encodeURIComponent(key)}]=${encodeURIComponent(String(value))}`);
317
+ }
318
+ }
319
+ return params.length > 0 ? '?' + params.join('&') : '';
320
+ }
321
+ /**
322
+ * Create an HTTP error with status code for retry detection.
323
+ * The status is embedded in the error object and message for isRetryableError() to detect.
324
+ *
325
+ * @param status - HTTP status code
326
+ * @param errorCode - Error code (from JSON response or status string)
327
+ * @param message - Error message
328
+ */
329
+ function createHttpError(status, errorCode, message) {
330
+ const error = new Error(`Ability execution failed: ${errorCode} - ${message}`);
331
+ const httpError = error;
332
+ httpError.status = status;
333
+ httpError.code = errorCode;
334
+ return error;
335
+ }
336
+ /**
337
+ * Execute an ability via the REST API
338
+ *
339
+ * @param config - Server configuration
340
+ * @param abilityName - Name of the ability to execute
341
+ * @param input - Optional input parameters
342
+ * @param logger - Optional logger for retry logging
343
+ */
344
+ export async function executeAbility(config, abilityName, input, logger) {
345
+ // Apply rate limiting BEFORE retry logic
346
+ // This ensures retries bypass the rate limiter to avoid deadlocks
347
+ if (rateLimiter) {
348
+ await rateLimiter.acquire();
349
+ }
350
+ const baseUrl = getAbilitiesApiUrl(config);
351
+ // Get ability to check if it's readonly
352
+ const ability = await getAbility(config, abilityName, logger);
353
+ if (!ability) {
354
+ throw McpErrorFactory.abilityNotFound(abilityName);
355
+ }
356
+ const isReadonly = ability.meta?.annotations?.readonly ?? false;
357
+ const isDestructive = ability.meta?.annotations?.destructive ?? true;
358
+ const isIdempotent = ability.meta?.annotations?.idempotent ?? false;
359
+ const url = `${baseUrl}/abilities/${abilityName}/run`;
360
+ const hasInput = input && Object.keys(input).length > 0;
361
+ // Audit log for destructive operations - logs operation name only, no sensitive parameters
362
+ if (isDestructive) {
363
+ logger?.info('AUDIT: Destructive operation requested', { abilityName });
364
+ }
365
+ /**
366
+ * Fetch and validate response in a single operation.
367
+ * This ensures HTTP errors (5xx, 429) are thrown and can be retried.
368
+ *
369
+ * @param context - Retry context with remaining timeout budget
370
+ */
371
+ const fetchAndValidate = async (context) => {
372
+ // Use remaining budget as timeout
373
+ const timeout = Math.max(1, context.remainingBudget);
374
+ const customFetch = createFetch(config, timeout);
375
+ let response;
376
+ if (isReadonly) {
377
+ // GET request for read-only abilities, with optional params as query string
378
+ const queryString = hasInput ? serializeToPhpQueryString(input) : '';
379
+ const fullUrl = url + queryString;
380
+ if (fullUrl.length > MAX_URL_LENGTH) {
381
+ throw new Error(`Request URL exceeds ${MAX_URL_LENGTH} characters (${fullUrl.length}); reduce input parameters`);
382
+ }
383
+ response = await customFetch(fullUrl, { method: 'GET' });
384
+ }
385
+ else if (isDestructive && isIdempotent) {
386
+ // DELETE request for destructive + idempotent abilities
387
+ // Uses query string parameters like GET - WP Abilities API doesn't parse DELETE bodies
388
+ const queryString = hasInput ? serializeToPhpQueryString(input) : '';
389
+ const fullUrl = url + queryString;
390
+ if (fullUrl.length > MAX_URL_LENGTH) {
391
+ throw new Error(`Request URL exceeds ${MAX_URL_LENGTH} characters (${fullUrl.length}); reduce input parameters`);
392
+ }
393
+ response = await customFetch(fullUrl, { method: 'DELETE' });
394
+ }
395
+ else {
396
+ // POST request for non-destructive write operations
397
+ response = await customFetch(url, {
398
+ method: 'POST',
399
+ body: JSON.stringify({ input: input ?? {} }),
400
+ });
401
+ }
402
+ // Validate response - throw HTTP error for non-ok status
403
+ // This allows isRetryableError() to detect 5xx/429 and trigger retries
404
+ if (!response.ok) {
405
+ // Read body with size limit to prevent transient memory spikes from large error responses
406
+ const bodyText = await readLimitedBody(response, MAX_ERROR_BODY_BYTES);
407
+ let errorCode = String(response.status);
408
+ let errorMsg = response.statusText;
409
+ // Only try to parse as JSON if the body looks like JSON
410
+ if (bodyText.trim().startsWith('{') || bodyText.trim().startsWith('[')) {
411
+ try {
412
+ const errorData = JSON.parse(bodyText);
413
+ errorCode = errorData.code || errorCode;
414
+ errorMsg = errorData.message || errorMsg;
415
+ }
416
+ catch {
417
+ // JSON parse failed - use raw text as message
418
+ errorMsg = bodyText || response.statusText;
419
+ }
420
+ }
421
+ else if (bodyText) {
422
+ // Non-JSON response body - use as error message
423
+ errorMsg = bodyText;
424
+ }
425
+ throw createHttpError(response.status, errorCode, sanitizeError(errorMsg));
426
+ }
427
+ // Read response body with streaming size enforcement
428
+ const responseBody = await readLimitedBody(response, config.maxResponseSize);
429
+ return JSON.parse(responseBody);
430
+ };
431
+ // Apply retry logic only for read-only operations when enabled
432
+ if (config.retryEnabled && isReadonly) {
433
+ return await withRetry(fetchAndValidate, {
434
+ maxRetries: config.maxRetries,
435
+ baseDelay: config.retryBaseDelay,
436
+ maxDelay: config.retryMaxDelay,
437
+ timeoutBudget: config.requestTimeout,
438
+ logger,
439
+ });
440
+ }
441
+ else {
442
+ // No retry: execute directly with synthetic context
443
+ return await fetchAndValidate({
444
+ remainingBudget: config.requestTimeout,
445
+ attempt: 0,
446
+ });
447
+ }
448
+ }
449
+ /**
450
+ * Clear the abilities cache
451
+ */
452
+ export function clearCache() {
453
+ cachedAbilities = null;
454
+ abilitiesIndex = null;
455
+ cachedCategories = null;
456
+ abilitiesCacheTimestamp = 0;
457
+ categoriesCacheTimestamp = 0;
458
+ }
459
+ /**
460
+ * Generate help documentation for a single ability
461
+ */
462
+ export function generateToolHelp(ability) {
463
+ const toolName = abilityNameToToolName(ability.name);
464
+ const props = (ability.input_schema?.properties || {});
465
+ const required = ability.input_schema?.required || [];
466
+ const parameters = Object.entries(props).map(([name, prop]) => ({
467
+ name,
468
+ type: String(prop.type || 'unknown'),
469
+ required: required.includes(name),
470
+ description: prop.description,
471
+ }));
472
+ return {
473
+ toolName,
474
+ abilityName: ability.name,
475
+ label: ability.label,
476
+ description: ability.description,
477
+ category: ability.category,
478
+ annotations: {
479
+ readonly: ability.meta?.annotations?.readonly ?? false,
480
+ destructive: ability.meta?.annotations?.destructive ?? true,
481
+ idempotent: ability.meta?.annotations?.idempotent ?? false,
482
+ instructions: ability.meta?.annotations?.instructions,
483
+ },
484
+ safetyFeatures: {
485
+ supportsDryRun: 'dry_run' in props,
486
+ requiresConfirm: 'confirm' in props,
487
+ },
488
+ parameters,
489
+ };
490
+ }
491
+ /**
492
+ * Generate complete help document from all abilities
493
+ */
494
+ export function generateHelpDocument(abilities) {
495
+ const toolHelps = abilities.map(generateToolHelp);
496
+ // Use normalized categories matching toolsByCategory grouping logic
497
+ const categories = [
498
+ ...new Set(toolHelps.map(h => (h.category && h.category.trim()) || 'uncategorized')),
499
+ ].sort();
500
+ const toolsByCategory = {};
501
+ for (const help of toolHelps) {
502
+ // Handle empty string, null, undefined as 'uncategorized'
503
+ const cat = (help.category && help.category.trim()) || 'uncategorized';
504
+ if (!toolsByCategory[cat])
505
+ toolsByCategory[cat] = [];
506
+ toolsByCategory[cat].push(help);
507
+ }
508
+ return {
509
+ version: '1.0',
510
+ generated: new Date().toISOString(),
511
+ overview: {
512
+ totalTools: abilities.length,
513
+ categories,
514
+ safetyConventions: {
515
+ dryRun: 'Pass dry_run: true to preview the operation without making changes',
516
+ confirm: 'Pass confirm: true to execute destructive operations',
517
+ destructive: 'These tools can permanently delete or modify data',
518
+ readonly: 'These tools only read data and never modify anything',
519
+ },
520
+ },
521
+ destructiveTools: toolHelps.filter(h => h.annotations.destructive).map(h => h.toolName),
522
+ toolsWithDryRun: toolHelps.filter(h => h.safetyFeatures.supportsDryRun).map(h => h.toolName),
523
+ toolsRequiringConfirm: toolHelps
524
+ .filter(h => h.safetyFeatures.requiresConfirm)
525
+ .map(h => h.toolName),
526
+ toolsByCategory,
527
+ };
528
+ }
529
+ //# sourceMappingURL=abilities.js.map