@diviops/mcp-server 1.4.0 → 1.5.2

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/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
@@ -62,9 +62,14 @@ const DEFAULT_COMMANDS = [
62
62
  'rewrite flush',
63
63
  // Export (reads data, writes to file only)
64
64
  'export',
65
- // Info (read-only)
65
+ // Info (read-only + non-destructive writes)
66
66
  'cron event list',
67
67
  'plugin list',
68
+ // `plugin update` runs from authenticated sources (WP plugin repo or licensed
69
+ // update server) — same trust model as `core check-update`. Unlike `plugin
70
+ // activate` / `plugin deactivate` (extended tier), it does not enable
71
+ // previously-disabled code paths; it refreshes already-installed plugins.
72
+ 'plugin update',
68
73
  'theme list',
69
74
  'menu list',
70
75
  'site url',
@@ -333,19 +338,18 @@ export function createWpCli(config) {
333
338
  const runOptions = customWpCliCmd
334
339
  ? { ...execOptions, env, cwd: config.wpPath }
335
340
  : { ...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
341
  const runArgv = async (args) => {
346
342
  const check = isCommandAllowed(args);
347
343
  if (!check.allowed) {
348
- return { success: false, output: '', error: check.reason };
344
+ return {
345
+ success: false,
346
+ output: '',
347
+ error: check.reason,
348
+ stdout: '',
349
+ stderr: '',
350
+ exitCode: null,
351
+ failureKind: 'rejected',
352
+ };
349
353
  }
350
354
  // Second-pass FS validation for commands whose flags/args can read/write
351
355
  // arbitrary paths. Scoped to DEFAULT-tier FS commands only — EXTENDED
@@ -368,7 +372,15 @@ export function createWpCli(config) {
368
372
  isWrapper: !!customWpCliCmd,
369
373
  });
370
374
  if (!fsCheck.allowed) {
371
- return { success: false, output: '', error: fsCheck.reason };
375
+ return {
376
+ success: false,
377
+ output: '',
378
+ error: fsCheck.reason,
379
+ stdout: '',
380
+ stderr: '',
381
+ exitCode: null,
382
+ failureKind: 'rejected',
383
+ };
372
384
  }
373
385
  }
374
386
  const fullArgs = customWpCliCmd
@@ -376,22 +388,71 @@ export function createWpCli(config) {
376
388
  : [...args, `--path=${config.wpPath}`, '--no-color'];
377
389
  return new Promise((resolve) => {
378
390
  execFile(executable, fullArgs, runOptions, (error, stdout, stderr) => {
379
- // Filter PHP deprecation warnings from output
391
+ // Filter PHP deprecation warnings from the legacy concatenated
392
+ // `output` field; `stdout` / `stderr` siblings are kept raw so
393
+ // envelope adopters (meta_wp_cli passthrough, scf_* round-trip)
394
+ // see the unfiltered streams.
380
395
  const output = (stdout + '\n' + stderr)
381
396
  .split('\n')
382
397
  .filter((line) => !line.includes('Deprecated:') && !line.includes('PHP Deprecated'))
383
398
  .join('\n')
384
399
  .trim();
385
400
  if (error) {
386
- const detail = error.killed
387
- ? 'Command timed out'
388
- : error.signal
389
- ? `Killed by signal ${error.signal}`
390
- : `Exit code ${error.code ?? 'unknown'}`;
391
- resolve({ success: false, output, error: detail });
401
+ // execFile callback fired with an error. Three possible
402
+ // sub-cases; classifying them up-front so the handler can emit
403
+ // accurate hints per cause:
404
+ //
405
+ // - `error.killed === true` → timeout (Node sets
406
+ // this when the configured `timeout` expires).
407
+ // - `error.signal != null` → killed by signal.
408
+ // - typeof error.code === 'number' → child ran and exited
409
+ // with a numeric exit code (the normal "exited non-zero"
410
+ // case).
411
+ // - typeof error.code === 'string' or undefined → spawn-side
412
+ // failure. ENOENT (binary missing), EACCES (not executable),
413
+ // EPERM, etc. — execFile reports these via the same error
414
+ // callback but the child never started. Distinct from
415
+ // "killed" because there's no partial output to preserve
416
+ // and the fix path is environmental (PATH, install, perms),
417
+ // not "raise the timeout."
418
+ const codeRaw = error.code;
419
+ const isKilled = error.killed === true || error.signal != null;
420
+ const isExited = typeof codeRaw === 'number';
421
+ const exitCode = isExited ? codeRaw : null;
422
+ const failureKind = isKilled
423
+ ? 'killed'
424
+ : isExited
425
+ ? 'exited'
426
+ : 'spawn_failed';
427
+ const detail = isKilled
428
+ ? error.killed
429
+ ? 'Command timed out'
430
+ : `Killed by signal ${error.signal}`
431
+ : isExited
432
+ ? `Exit code ${codeRaw}`
433
+ : // spawn_failed — surface the system errno as the detail
434
+ // string ("Spawn failed: ENOENT") so the cause is visible
435
+ // without parsing structured fields. Falls back to the
436
+ // raw error.message when `code` is empty.
437
+ `Spawn failed: ${codeRaw ?? error.message ?? 'unknown'}`;
438
+ resolve({
439
+ success: false,
440
+ output,
441
+ error: detail,
442
+ stdout,
443
+ stderr,
444
+ exitCode,
445
+ failureKind,
446
+ });
392
447
  }
393
448
  else {
394
- resolve({ success: true, output });
449
+ resolve({
450
+ success: true,
451
+ output,
452
+ stdout,
453
+ stderr,
454
+ exitCode: 0,
455
+ });
395
456
  }
396
457
  });
397
458
  });
@@ -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 result = await this.request('/schema/settings');
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 ${result.builder?.version ?? 'unknown'}`,
297
+ message: `Connected to Divi ${response.data.builder?.version ?? 'unknown'}`,
153
298
  };
154
299
  }
155
300
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diviops/mcp-server",
3
- "version": "1.4.0",
3
+ "version": "1.5.2",
4
4
  "description": "MCP server exposing Divi 5 Visual Builder as tools for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",