@diviops/mcp-server 1.3.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/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 { success: false, output: '', error: check.reason };
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 { success: false, output: '', error: fsCheck.reason };
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 output
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
- 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 });
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({ success: true, output });
444
+ resolve({
445
+ success: true,
446
+ output,
447
+ stdout,
448
+ stderr,
449
+ exitCode: 0,
450
+ });
395
451
  }
396
452
  });
397
453
  });
@@ -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.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "MCP server exposing Divi 5 Visual Builder as tools for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,8 @@
16
16
  "build": "tsc",
17
17
  "start": "node dist/index.js",
18
18
  "dev": "tsc --watch",
19
- "prepublishOnly": "npm run build"
19
+ "prepublishOnly": "npm run build",
20
+ "regen:skill": "node scripts/regen-module-formats.mjs"
20
21
  },
21
22
  "keywords": [
22
23
  "mcp",