@diviops/mcp-server 1.4.0 → 1.4.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/README.md +13 -1
- package/dist/envelope.d.ts +117 -0
- package/dist/envelope.js +171 -0
- package/dist/index.js +846 -428
- package/dist/wp-cli.d.ts +8 -0
- package/dist/wp-cli.js +75 -19
- package/dist/wp-client.d.ts +38 -0
- package/dist/wp-client.js +147 -2
- package/package.json +1 -1
package/dist/wp-cli.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export declare function createWpCli(config: WpCliConfig): {
|
|
|
27
27
|
success: boolean;
|
|
28
28
|
output: string;
|
|
29
29
|
error?: string;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
exitCode: number | null;
|
|
33
|
+
failureKind?: "rejected" | "spawn_failed" | "killed" | "exited";
|
|
30
34
|
}>;
|
|
31
35
|
/**
|
|
32
36
|
* Execute a WP-CLI command from a pre-built argv array. Skips
|
|
@@ -39,6 +43,10 @@ export declare function createWpCli(config: WpCliConfig): {
|
|
|
39
43
|
success: boolean;
|
|
40
44
|
output: string;
|
|
41
45
|
error?: string;
|
|
46
|
+
stdout: string;
|
|
47
|
+
stderr: string;
|
|
48
|
+
exitCode: number | null;
|
|
49
|
+
failureKind?: "rejected" | "spawn_failed" | "killed" | "exited";
|
|
42
50
|
}>;
|
|
43
51
|
/** Return the list of allowed commands and available extensions. */
|
|
44
52
|
getAllowedCommands(): {
|
package/dist/wp-cli.js
CHANGED
|
@@ -333,19 +333,18 @@ export function createWpCli(config) {
|
|
|
333
333
|
const runOptions = customWpCliCmd
|
|
334
334
|
? { ...execOptions, env, cwd: config.wpPath }
|
|
335
335
|
: { ...execOptions, env };
|
|
336
|
-
// Internal argv-based runner — used by both `run(string)` (after
|
|
337
|
-
// parseCommand) and `runArgs(string[])` (skip parseCommand). Typed
|
|
338
|
-
// wrappers should call runArgs to bypass parseCommand's quote-toggling
|
|
339
|
-
// weakness: when a value contains an apostrophe (e.g. label "Bob's
|
|
340
|
-
// Group", file path /tmp/it's-fine.json), parseCommand mis-splits the
|
|
341
|
-
// argv because it treats the embedded `'` as a quote toggle. Passing
|
|
342
|
-
// pre-built argv eliminates the parsing step entirely so user-provided
|
|
343
|
-
// strings flow through verbatim — execFile (no shell) handles them
|
|
344
|
-
// correctly. Raised in PR #473 review (Copilot/Gemini both flagged).
|
|
345
336
|
const runArgv = async (args) => {
|
|
346
337
|
const check = isCommandAllowed(args);
|
|
347
338
|
if (!check.allowed) {
|
|
348
|
-
return {
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
output: '',
|
|
342
|
+
error: check.reason,
|
|
343
|
+
stdout: '',
|
|
344
|
+
stderr: '',
|
|
345
|
+
exitCode: null,
|
|
346
|
+
failureKind: 'rejected',
|
|
347
|
+
};
|
|
349
348
|
}
|
|
350
349
|
// Second-pass FS validation for commands whose flags/args can read/write
|
|
351
350
|
// arbitrary paths. Scoped to DEFAULT-tier FS commands only — EXTENDED
|
|
@@ -368,7 +367,15 @@ export function createWpCli(config) {
|
|
|
368
367
|
isWrapper: !!customWpCliCmd,
|
|
369
368
|
});
|
|
370
369
|
if (!fsCheck.allowed) {
|
|
371
|
-
return {
|
|
370
|
+
return {
|
|
371
|
+
success: false,
|
|
372
|
+
output: '',
|
|
373
|
+
error: fsCheck.reason,
|
|
374
|
+
stdout: '',
|
|
375
|
+
stderr: '',
|
|
376
|
+
exitCode: null,
|
|
377
|
+
failureKind: 'rejected',
|
|
378
|
+
};
|
|
372
379
|
}
|
|
373
380
|
}
|
|
374
381
|
const fullArgs = customWpCliCmd
|
|
@@ -376,22 +383,71 @@ export function createWpCli(config) {
|
|
|
376
383
|
: [...args, `--path=${config.wpPath}`, '--no-color'];
|
|
377
384
|
return new Promise((resolve) => {
|
|
378
385
|
execFile(executable, fullArgs, runOptions, (error, stdout, stderr) => {
|
|
379
|
-
// Filter PHP deprecation warnings from
|
|
386
|
+
// Filter PHP deprecation warnings from the legacy concatenated
|
|
387
|
+
// `output` field; `stdout` / `stderr` siblings are kept raw so
|
|
388
|
+
// envelope adopters (meta_wp_cli passthrough, scf_* round-trip)
|
|
389
|
+
// see the unfiltered streams.
|
|
380
390
|
const output = (stdout + '\n' + stderr)
|
|
381
391
|
.split('\n')
|
|
382
392
|
.filter((line) => !line.includes('Deprecated:') && !line.includes('PHP Deprecated'))
|
|
383
393
|
.join('\n')
|
|
384
394
|
.trim();
|
|
385
395
|
if (error) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
396
|
+
// execFile callback fired with an error. Three possible
|
|
397
|
+
// sub-cases; classifying them up-front so the handler can emit
|
|
398
|
+
// accurate hints per cause:
|
|
399
|
+
//
|
|
400
|
+
// - `error.killed === true` → timeout (Node sets
|
|
401
|
+
// this when the configured `timeout` expires).
|
|
402
|
+
// - `error.signal != null` → killed by signal.
|
|
403
|
+
// - typeof error.code === 'number' → child ran and exited
|
|
404
|
+
// with a numeric exit code (the normal "exited non-zero"
|
|
405
|
+
// case).
|
|
406
|
+
// - typeof error.code === 'string' or undefined → spawn-side
|
|
407
|
+
// failure. ENOENT (binary missing), EACCES (not executable),
|
|
408
|
+
// EPERM, etc. — execFile reports these via the same error
|
|
409
|
+
// callback but the child never started. Distinct from
|
|
410
|
+
// "killed" because there's no partial output to preserve
|
|
411
|
+
// and the fix path is environmental (PATH, install, perms),
|
|
412
|
+
// not "raise the timeout."
|
|
413
|
+
const codeRaw = error.code;
|
|
414
|
+
const isKilled = error.killed === true || error.signal != null;
|
|
415
|
+
const isExited = typeof codeRaw === 'number';
|
|
416
|
+
const exitCode = isExited ? codeRaw : null;
|
|
417
|
+
const failureKind = isKilled
|
|
418
|
+
? 'killed'
|
|
419
|
+
: isExited
|
|
420
|
+
? 'exited'
|
|
421
|
+
: 'spawn_failed';
|
|
422
|
+
const detail = isKilled
|
|
423
|
+
? error.killed
|
|
424
|
+
? 'Command timed out'
|
|
425
|
+
: `Killed by signal ${error.signal}`
|
|
426
|
+
: isExited
|
|
427
|
+
? `Exit code ${codeRaw}`
|
|
428
|
+
: // spawn_failed — surface the system errno as the detail
|
|
429
|
+
// string ("Spawn failed: ENOENT") so the cause is visible
|
|
430
|
+
// without parsing structured fields. Falls back to the
|
|
431
|
+
// raw error.message when `code` is empty.
|
|
432
|
+
`Spawn failed: ${codeRaw ?? error.message ?? 'unknown'}`;
|
|
433
|
+
resolve({
|
|
434
|
+
success: false,
|
|
435
|
+
output,
|
|
436
|
+
error: detail,
|
|
437
|
+
stdout,
|
|
438
|
+
stderr,
|
|
439
|
+
exitCode,
|
|
440
|
+
failureKind,
|
|
441
|
+
});
|
|
392
442
|
}
|
|
393
443
|
else {
|
|
394
|
-
resolve({
|
|
444
|
+
resolve({
|
|
445
|
+
success: true,
|
|
446
|
+
output,
|
|
447
|
+
stdout,
|
|
448
|
+
stderr,
|
|
449
|
+
exitCode: 0,
|
|
450
|
+
});
|
|
395
451
|
}
|
|
396
452
|
});
|
|
397
453
|
});
|
package/dist/wp-client.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Generate one at: WP Admin → Users → Your Profile → Application Passwords.
|
|
6
6
|
*/
|
|
7
7
|
import { type HandshakeResult } from './compatibility.js';
|
|
8
|
+
import { type DiviopsResponse } from './envelope.js';
|
|
8
9
|
export interface WPClientConfig {
|
|
9
10
|
siteUrl: string;
|
|
10
11
|
username: string;
|
|
@@ -22,8 +23,45 @@ export declare class WPClient {
|
|
|
22
23
|
body?: Record<string, unknown>;
|
|
23
24
|
params?: Record<string, string>;
|
|
24
25
|
}): Promise<T>;
|
|
26
|
+
/**
|
|
27
|
+
* Envelope-aware sibling of `request()`.
|
|
28
|
+
*
|
|
29
|
+
* Issue the same HTTP call as `request()`, but return the parsed body
|
|
30
|
+
* directly as a `DiviopsResponse<T>` without throwing on envelope errors:
|
|
31
|
+
*
|
|
32
|
+
* - Body is an envelope (`{ok: true, data}` or `{ok: false, error}`)
|
|
33
|
+
* → return it verbatim. Plugin-emitted error envelopes (typically
|
|
34
|
+
* 4xx with `{ok: false, error: {code, message, hint?}}`) flow back
|
|
35
|
+
* to the caller as a typed result, not a throw.
|
|
36
|
+
* - Response is non-2xx and body is NOT an envelope (e.g. a WP REST
|
|
37
|
+
* framework error before the route runs, an unexpected 5xx)
|
|
38
|
+
* → synthesize `{ok: false, error: {code: 'wp_error', message: ...}}`
|
|
39
|
+
* so callers see a uniform shape regardless of upstream.
|
|
40
|
+
* - Response is 2xx but body is not enveloped (legacy routes that
|
|
41
|
+
* have not adopted yet) → wrap as `{ok: true, data: <body>}` to
|
|
42
|
+
* preserve a single contract for adopting tools to consume.
|
|
43
|
+
*
|
|
44
|
+
* Transport errors (network, JSON parse failure on a 2xx body) still
|
|
45
|
+
* throw — those are not domain-level outcomes the envelope models.
|
|
46
|
+
*
|
|
47
|
+
* Migration: pilot's three `schema_*` tools and every subsequent
|
|
48
|
+
* namespace adoption use this method. Once the rollout completes,
|
|
49
|
+
* `request()` becomes orphan and is removed.
|
|
50
|
+
*/
|
|
51
|
+
requestEnveloped<T = unknown>(endpoint: string, options?: {
|
|
52
|
+
method?: string;
|
|
53
|
+
body?: Record<string, unknown>;
|
|
54
|
+
params?: Record<string, string>;
|
|
55
|
+
}): Promise<DiviopsResponse<T>>;
|
|
25
56
|
/**
|
|
26
57
|
* Test the connection to WordPress.
|
|
58
|
+
*
|
|
59
|
+
* Routes through `requestEnveloped` because `/schema/settings` was
|
|
60
|
+
* envelope-adopted in the schema_* pilot — its body is now
|
|
61
|
+
* `{ ok: true, data: { builder, ... } }`. Reading
|
|
62
|
+
* `result.builder.version` against that shape (the pre-pilot pattern)
|
|
63
|
+
* silently regresses meta_ping to "Connected to Divi unknown" on
|
|
64
|
+
* healthy sites.
|
|
27
65
|
*/
|
|
28
66
|
testConnection(): Promise<{
|
|
29
67
|
ok: boolean;
|
package/dist/wp-client.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Uses WP Application Passwords (built into WP 5.6+) for auth.
|
|
5
5
|
* Generate one at: WP Admin → Users → Your Profile → Application Passwords.
|
|
6
6
|
*/
|
|
7
|
+
import { ErrorCodes, isEnveloped, } from './envelope.js';
|
|
7
8
|
/**
|
|
8
9
|
* Normalize quote-escape pathologies inside `$variable(...)$` token regions only.
|
|
9
10
|
*
|
|
@@ -141,15 +142,159 @@ export class WPClient {
|
|
|
141
142
|
}
|
|
142
143
|
return response.json();
|
|
143
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Envelope-aware sibling of `request()`.
|
|
147
|
+
*
|
|
148
|
+
* Issue the same HTTP call as `request()`, but return the parsed body
|
|
149
|
+
* directly as a `DiviopsResponse<T>` without throwing on envelope errors:
|
|
150
|
+
*
|
|
151
|
+
* - Body is an envelope (`{ok: true, data}` or `{ok: false, error}`)
|
|
152
|
+
* → return it verbatim. Plugin-emitted error envelopes (typically
|
|
153
|
+
* 4xx with `{ok: false, error: {code, message, hint?}}`) flow back
|
|
154
|
+
* to the caller as a typed result, not a throw.
|
|
155
|
+
* - Response is non-2xx and body is NOT an envelope (e.g. a WP REST
|
|
156
|
+
* framework error before the route runs, an unexpected 5xx)
|
|
157
|
+
* → synthesize `{ok: false, error: {code: 'wp_error', message: ...}}`
|
|
158
|
+
* so callers see a uniform shape regardless of upstream.
|
|
159
|
+
* - Response is 2xx but body is not enveloped (legacy routes that
|
|
160
|
+
* have not adopted yet) → wrap as `{ok: true, data: <body>}` to
|
|
161
|
+
* preserve a single contract for adopting tools to consume.
|
|
162
|
+
*
|
|
163
|
+
* Transport errors (network, JSON parse failure on a 2xx body) still
|
|
164
|
+
* throw — those are not domain-level outcomes the envelope models.
|
|
165
|
+
*
|
|
166
|
+
* Migration: pilot's three `schema_*` tools and every subsequent
|
|
167
|
+
* namespace adoption use this method. Once the rollout completes,
|
|
168
|
+
* `request()` becomes orphan and is removed.
|
|
169
|
+
*/
|
|
170
|
+
async requestEnveloped(endpoint, options = {}) {
|
|
171
|
+
const { method = 'GET', body, params } = options;
|
|
172
|
+
let url = `${this.baseUrl}/wp-json/diviops/v1${endpoint}`;
|
|
173
|
+
if (params) {
|
|
174
|
+
const searchParams = new URLSearchParams(params);
|
|
175
|
+
url += `?${searchParams.toString()}`;
|
|
176
|
+
}
|
|
177
|
+
const fetchOptions = {
|
|
178
|
+
method,
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: this.authHeader,
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
Accept: 'application/json',
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
if (body && method !== 'GET') {
|
|
186
|
+
fetchOptions.body = JSON.stringify(normalizeBody(body));
|
|
187
|
+
}
|
|
188
|
+
const response = await fetch(url, fetchOptions);
|
|
189
|
+
const rawBody = await response.text();
|
|
190
|
+
// Try to parse the body as JSON. Failure is recoverable for non-2xx
|
|
191
|
+
// responses (HTML/plain-text error pages from a misconfigured host or
|
|
192
|
+
// upstream proxy synthesize a `wp_error` envelope rather than throwing)
|
|
193
|
+
// and is fatal only on a 2xx body that promised JSON but didn't deliver.
|
|
194
|
+
let parsed;
|
|
195
|
+
let parseError = null;
|
|
196
|
+
try {
|
|
197
|
+
parsed = rawBody === '' ? null : JSON.parse(rawBody);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
parseError = e;
|
|
201
|
+
}
|
|
202
|
+
if (parseError === null && isEnveloped(parsed)) {
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
// Non-2xx + body either non-JSON or non-enveloped JSON. Two sub-cases:
|
|
207
|
+
//
|
|
208
|
+
// (1) Body is a parsed `WP_Error`-shaped JSON object — `{code, message,
|
|
209
|
+
// data?: {status?, hint?}}`. This is what older diviops-agent
|
|
210
|
+
// versions (pre-envelope-adoption) emit alongside `WP_Error`-based
|
|
211
|
+
// handlers, and what the WP REST framework itself emits for
|
|
212
|
+
// framework-level errors (`rest_forbidden`, `rest_no_route`,
|
|
213
|
+
// `rest_invalid_param`, etc.). Promote `code` to the envelope's
|
|
214
|
+
// `error.code` so callers running against a mixed-version
|
|
215
|
+
// deployment (new server + older plugin emitting non-envelope
|
|
216
|
+
// error bodies) still receive granular codes like `invalid_type`,
|
|
217
|
+
// `not_found`, `rest_forbidden` — instead of having every legacy
|
|
218
|
+
// 4xx collapse to a generic `wp_error` and lose the upstream
|
|
219
|
+
// slug. Hint is forwarded when present in `data.hint` (matches
|
|
220
|
+
// the convention `envelope_from_wp_error` writes plugin-side).
|
|
221
|
+
//
|
|
222
|
+
// (2) Body is non-JSON, or JSON without `code`/`message` (HTML/plain
|
|
223
|
+
// error pages from a misconfigured host, host firewall pages,
|
|
224
|
+
// 502/504 from a reverse proxy, etc.) — fall back to a synthesized
|
|
225
|
+
// `wp_error` so adopted tools still see a uniform envelope.
|
|
226
|
+
const isLegacyWpErrorBody = parseError === null &&
|
|
227
|
+
parsed !== null &&
|
|
228
|
+
typeof parsed === 'object' &&
|
|
229
|
+
typeof parsed.code === 'string' &&
|
|
230
|
+
typeof parsed.message === 'string';
|
|
231
|
+
if (isLegacyWpErrorBody) {
|
|
232
|
+
const obj = parsed;
|
|
233
|
+
const out = {
|
|
234
|
+
code: obj.code,
|
|
235
|
+
message: obj.message,
|
|
236
|
+
};
|
|
237
|
+
const data = obj.data;
|
|
238
|
+
if (data !== null &&
|
|
239
|
+
typeof data === 'object' &&
|
|
240
|
+
'hint' in data &&
|
|
241
|
+
typeof data.hint === 'string') {
|
|
242
|
+
out.hint = data.hint;
|
|
243
|
+
}
|
|
244
|
+
if (response.status === 429) {
|
|
245
|
+
const retryAfter = response.headers.get('Retry-After') || '60';
|
|
246
|
+
out.message = `${out.message} (retry after ${retryAfter}s)`;
|
|
247
|
+
}
|
|
248
|
+
return { ok: false, error: out };
|
|
249
|
+
}
|
|
250
|
+
const messageFromBody = parseError
|
|
251
|
+
? rawBody.slice(0, 200)
|
|
252
|
+
: parsed && typeof parsed === 'object' && parsed !== null && 'message' in parsed
|
|
253
|
+
? String(parsed.message)
|
|
254
|
+
: rawBody;
|
|
255
|
+
let message = `WordPress API error (${response.status}): ${messageFromBody}`;
|
|
256
|
+
if (response.status === 429) {
|
|
257
|
+
const retryAfter = response.headers.get('Retry-After') || '60';
|
|
258
|
+
message = `Rate limited (${response.status}): ${messageFromBody} (retry after ${retryAfter}s)`;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
error: { code: ErrorCodes.WP_ERROR, message },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// 2xx with non-JSON body — only legitimate failure mode that warrants a
|
|
266
|
+
// throw. A successful HTTP status with garbage in the body is a server-
|
|
267
|
+
// side contract violation, not a domain-level outcome the envelope can
|
|
268
|
+
// represent.
|
|
269
|
+
if (parseError) {
|
|
270
|
+
throw new Error(`WordPress API non-JSON body (${response.status}): ${rawBody.slice(0, 200)}`);
|
|
271
|
+
}
|
|
272
|
+
// 2xx body that is not yet shaped as an envelope — legacy route. Wrap
|
|
273
|
+
// it so adopting tools always see a uniform success shape.
|
|
274
|
+
return { ok: true, data: parsed };
|
|
275
|
+
}
|
|
144
276
|
/**
|
|
145
277
|
* Test the connection to WordPress.
|
|
278
|
+
*
|
|
279
|
+
* Routes through `requestEnveloped` because `/schema/settings` was
|
|
280
|
+
* envelope-adopted in the schema_* pilot — its body is now
|
|
281
|
+
* `{ ok: true, data: { builder, ... } }`. Reading
|
|
282
|
+
* `result.builder.version` against that shape (the pre-pilot pattern)
|
|
283
|
+
* silently regresses meta_ping to "Connected to Divi unknown" on
|
|
284
|
+
* healthy sites.
|
|
146
285
|
*/
|
|
147
286
|
async testConnection() {
|
|
148
287
|
try {
|
|
149
|
-
const
|
|
288
|
+
const response = await this.requestEnveloped('/schema/settings');
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
message: `Connection failed: [${response.error.code}] ${response.error.message}`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
150
295
|
return {
|
|
151
296
|
ok: true,
|
|
152
|
-
message: `Connected to Divi ${
|
|
297
|
+
message: `Connected to Divi ${response.data.builder?.version ?? 'unknown'}`,
|
|
153
298
|
};
|
|
154
299
|
}
|
|
155
300
|
catch (error) {
|