@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
|
@@ -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
|