@gravitykit/block-mcp 2.0.0-beta → 2.1.0

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 (53) hide show
  1. package/README.md +34 -7
  2. package/dist/index.cjs +2556 -967
  3. package/package.json +12 -4
  4. package/src/__tests__/coerce.test.ts +57 -0
  5. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  6. package/src/__tests__/integration/dual-storage.test.ts +22 -26
  7. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  8. package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
  9. package/src/__tests__/integration/read-discovery.test.ts +313 -0
  10. package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
  11. package/src/__tests__/integration/write-surface.test.ts +603 -0
  12. package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
  13. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  14. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  15. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  16. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  17. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  18. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  19. package/src/__tests__/tools/read/pagination.test.ts +65 -0
  20. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  21. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  22. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  23. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  24. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  25. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  26. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  27. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  28. package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
  29. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  30. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
  31. package/src/__tests__/unit/instructions.test.ts +3 -3
  32. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  33. package/src/__tests__/unit/rest-url.test.ts +23 -0
  34. package/src/agent-guide.ts +85 -0
  35. package/src/client.ts +104 -40
  36. package/src/coerce.ts +41 -0
  37. package/src/config.ts +96 -0
  38. package/src/connect.ts +123 -38
  39. package/src/enrichers.ts +6 -2
  40. package/src/error-translator.ts +63 -11
  41. package/src/index.ts +49 -79
  42. package/src/instructions.ts +3 -3
  43. package/src/preferences.ts +56 -43
  44. package/src/rest-url.ts +18 -0
  45. package/src/tools/discovery.ts +10 -14
  46. package/src/tools/mutate.ts +21 -20
  47. package/src/tools/patterns.ts +3 -2
  48. package/src/tools/posts.ts +26 -26
  49. package/src/tools/read.ts +23 -6
  50. package/src/tools/write.ts +37 -36
  51. package/src/tools/yoast.ts +30 -31
  52. package/src/types.ts +45 -31
  53. package/src/validate-args.ts +109 -0
package/src/connect.ts CHANGED
@@ -27,7 +27,6 @@ import * as cp from 'node:child_process';
27
27
  export type ClientTarget =
28
28
  | 'claude-code'
29
29
  | 'cursor'
30
- | 'chatgpt-desktop'
31
30
  | 'claude-desktop'
32
31
  | 'print';
33
32
 
@@ -35,7 +34,6 @@ export type ClientTarget =
35
34
  const VALID_CLIENTS: ClientTarget[] = [
36
35
  'claude-code',
37
36
  'cursor',
38
- 'chatgpt-desktop',
39
37
  'claude-desktop',
40
38
  'print',
41
39
  ];
@@ -256,7 +254,7 @@ export function buildAuthorizeUrl(params: AuthorizeUrlParams): string {
256
254
  const { site, callback, state, client } = params;
257
255
  const base = `${site}/wp-admin/options-general.php`;
258
256
  const query = new URLSearchParams({
259
- page: 'gk-block-api-settings',
257
+ page: 'gk-block-mcp-settings',
260
258
  tab: 'connect',
261
259
  gk_authorize: '1',
262
260
  callback,
@@ -337,6 +335,50 @@ export function parseExchangeResponse(json: unknown): Credentials {
337
335
  /** Default timeout (ms) for the credential-exchange request. */
338
336
  export const EXCHANGE_FETCH_TIMEOUT_MS = 15_000;
339
337
 
338
+ /**
339
+ * TLS failure codes meaning the site's certificate chain is not trusted by
340
+ * Node's bundled CA store. Node ignores the operating system's trust store
341
+ * (macOS Keychain / Windows certificate store), so a local dev site whose CA
342
+ * the browser trusts — Laravel Herd/Valet, Local, OrbStack, mkcert — still
343
+ * fails here unless NODE_EXTRA_CA_CERTS points at that CA's .pem.
344
+ */
345
+ const TLS_TRUST_ERROR_CODES = new Set([
346
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
347
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
348
+ 'SELF_SIGNED_CERT_IN_CHAIN',
349
+ 'UNABLE_TO_GET_ISSUER_CERT',
350
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
351
+ 'CERT_UNTRUSTED',
352
+ ]);
353
+
354
+ /** Actionable guidance appended when the exchange fails on an untrusted certificate. */
355
+ const CA_TRUST_HINT =
356
+ "The site's TLS certificate is not trusted by Node.js, which uses its own CA bundle rather than " +
357
+ "the operating system's trust store. If this is a local development site, re-run with " +
358
+ 'NODE_EXTRA_CA_CERTS=<path to the root CA certificate (.pem) of the tool serving the site> — ' +
359
+ 'Laravel Herd/Valet, Local, OrbStack, and mkcert each keep one in their config directory. ' +
360
+ 'The variable is also copied into the generated MCP config so the server can reach the site too.';
361
+
362
+ /**
363
+ * Render a network-level fetch failure as an actionable error message.
364
+ *
365
+ * `fetch` reports every network failure as a bare "fetch failed" TypeError and
366
+ * hides the real reason (TLS, DNS, refused connection) in `cause`. Surface the
367
+ * cause's code so the failure is diagnosable, and append the CA-trust hint when
368
+ * the code means Node rejected the site's certificate.
369
+ */
370
+ function describeExchangeFetchError(url: string, err: unknown): string {
371
+ const error = err as Error & { cause?: { code?: unknown; message?: unknown } };
372
+ const causeCode = typeof error.cause?.code === 'string' ? error.cause.code : '';
373
+ const causeMessage = typeof error.cause?.message === 'string' ? error.cause.message : '';
374
+ const detail = causeCode || causeMessage;
375
+ const reason = detail ? `${error.message}: ${detail}` : error.message;
376
+
377
+ const isTrustFailure = TLS_TRUST_ERROR_CODES.has(causeCode);
378
+ const hint = isTrustFailure ? ` ${CA_TRUST_HINT}` : '';
379
+ return `Exchange failed: could not reach ${url} (${reason}).${hint}`;
380
+ }
381
+
340
382
  /**
341
383
  * Exchange a single-use code for the credential set.
342
384
  *
@@ -366,7 +408,6 @@ export async function exchangeCode(
366
408
  fetchFn: typeof fetch = fetch,
367
409
  timeoutMs: number = EXCHANGE_FETCH_TIMEOUT_MS
368
410
  ): Promise<Credentials> {
369
- const origin = new URL(site).origin;
370
411
  let url = `${site}/?rest_route=/gk-block-api/v1/connect/exchange`;
371
412
 
372
413
  const controller = new AbortController();
@@ -384,7 +425,7 @@ export async function exchangeCode(
384
425
  signal: controller.signal,
385
426
  });
386
427
  } catch (err) {
387
- throw new Error(`Exchange failed: could not reach ${url} (${(err as Error).message}).`);
428
+ throw new Error(describeExchangeFetchError(url, err));
388
429
  }
389
430
 
390
431
  // 2xx (or any non-redirect) → done.
@@ -392,15 +433,30 @@ export async function exchangeCode(
392
433
  break;
393
434
  }
394
435
 
395
- // Follow only same-origin redirects, re-POSTing the body.
436
+ // Follow same-origin redirects, re-POSTing the body. Also follow a
437
+ // same-host http->https upgrade: a site that forces HTTPS is a security
438
+ // upgrade to the same site, not an off-site leak, and refusing it bombed
439
+ // the connector for anyone who passed --site http://. Any other
440
+ // cross-origin redirect (different host, or an https->http downgrade) is
441
+ // still refused so the credential is never sent off the target site.
396
442
  const location = res.headers.get('location');
397
443
  if (!location || hops >= 3) {
398
444
  throw new Error(
399
445
  `Exchange failed: the site redirected the request (HTTP ${res.status}); ensure --site is the canonical site URL.`
400
446
  );
401
447
  }
448
+ const current = new URL(url);
402
449
  const next = new URL(location, url);
403
- if (next.origin !== origin) {
450
+ // Only the standard HTTPS port counts as a "force HTTPS" upgrade. A
451
+ // redirect to https on a non-standard port is not ordinary HSTS behavior,
452
+ // so the credential must not follow it even when the host matches.
453
+ const upgradesToStandardHttpsPort = next.port === '' || next.port === '443';
454
+ const sameHostHttpsUpgrade =
455
+ current.protocol === 'http:' &&
456
+ next.protocol === 'https:' &&
457
+ next.hostname === current.hostname &&
458
+ upgradesToStandardHttpsPort;
459
+ if (next.origin !== current.origin && !sameHostHttpsUpgrade) {
404
460
  throw new Error(
405
461
  `Exchange failed: the site redirected to a different origin (${next.origin}); refusing to send the credential off-site.`
406
462
  );
@@ -423,16 +479,31 @@ export async function exchangeCode(
423
479
 
424
480
  // ── MCP config builders ───────────────────────────────────────────────────────
425
481
 
426
- /** Build the mcpServers entry for @gravitykit/block-mcp. */
427
- export function buildMcpEntry(creds: Credentials): McpServerEntry {
482
+ /**
483
+ * Build the mcpServers entry for @gravitykit/block-mcp.
484
+ *
485
+ * When NODE_EXTRA_CA_CERTS is set (required to reach a local dev site whose
486
+ * certificate Node's bundled CA store rejects), it is copied into the entry's
487
+ * env: the MCP server talks to the same site over the same Node TLS stack, so
488
+ * it needs the same trust anchor the connector did.
489
+ */
490
+ export function buildMcpEntry(
491
+ creds: Credentials,
492
+ extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
493
+ ): McpServerEntry {
494
+ const env: Record<string, string> = {
495
+ WORDPRESS_URL: creds.site,
496
+ WORDPRESS_USER: creds.user,
497
+ WORDPRESS_APP_PASSWORD: creds.password,
498
+ };
499
+ const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
500
+ if (hasCaCerts) {
501
+ env.NODE_EXTRA_CA_CERTS = extraCaCerts;
502
+ }
428
503
  return {
429
504
  command: 'npx',
430
505
  args: ['-y', '@gravitykit/block-mcp'],
431
- env: {
432
- WORDPRESS_URL: creds.site,
433
- WORDPRESS_USER: creds.user,
434
- WORDPRESS_APP_PASSWORD: creds.password,
435
- },
506
+ env,
436
507
  };
437
508
  }
438
509
 
@@ -507,35 +578,57 @@ export function cursorConfigPath(): string {
507
578
  * That residual exposure is inherent to the `claude mcp add` interface; the
508
579
  * config it then writes is owned and protected by Claude Code.
509
580
  */
510
- export function claudeCodeAddArgs(creds: Credentials, name: string = 'block-mcp'): string[] {
511
- return [
512
- 'mcp',
513
- 'add',
514
- name,
515
- '--scope',
516
- 'user',
581
+ export function claudeCodeAddArgs(
582
+ creds: Credentials,
583
+ name: string = 'block-mcp',
584
+ extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
585
+ ): string[] {
586
+ const envArgs = [
517
587
  '--env',
518
588
  `WORDPRESS_URL=${creds.site}`,
519
589
  '--env',
520
590
  `WORDPRESS_USER=${creds.user}`,
521
591
  '--env',
522
592
  `WORDPRESS_APP_PASSWORD=${creds.password}`,
523
- '--',
524
- 'npx',
525
- '-y',
526
- '@gravitykit/block-mcp',
527
593
  ];
594
+ // Same propagation as buildMcpEntry: the server needs the CA bundle that the
595
+ // connector needed to reach the site.
596
+ const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
597
+ if (hasCaCerts) {
598
+ envArgs.push('--env', `NODE_EXTRA_CA_CERTS=${extraCaCerts}`);
599
+ }
600
+ return ['mcp', 'add', name, '--scope', 'user', ...envArgs, '--', 'npx', '-y', '@gravitykit/block-mcp'];
528
601
  }
529
602
 
530
603
  // ── Config file writers ───────────────────────────────────────────────────────
531
604
 
532
- /** Read a JSON file, return default if missing or unparseable. */
533
- function readJsonFile(filePath: string, defaultValue: McpConfig): McpConfig {
605
+ /**
606
+ * Read a JSON file, returning the default ONLY when the file does not exist.
607
+ *
608
+ * A missing config (ENOENT) is the normal first-run case, so fall back to the
609
+ * default. Any other failure — an unreadable file or, critically, malformed
610
+ * JSON — is rethrown so the caller surfaces it instead of silently treating a
611
+ * corrupt config as empty and overwriting it (which would clobber every other
612
+ * MCP server the user had configured).
613
+ */
614
+ export function readJsonFile(filePath: string, defaultValue: McpConfig): McpConfig {
615
+ let raw: string;
616
+ try {
617
+ raw = fs.readFileSync(filePath, 'utf8');
618
+ } catch (err) {
619
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
620
+ return defaultValue;
621
+ }
622
+ throw err;
623
+ }
624
+
534
625
  try {
535
- const raw = fs.readFileSync(filePath, 'utf8');
536
626
  return JSON.parse(raw) as McpConfig;
537
- } catch {
538
- return defaultValue;
627
+ } catch (err) {
628
+ throw new Error(
629
+ `Could not parse the existing MCP config at ${filePath}: ${(err as Error).message}. ` +
630
+ 'Fix or remove the file, then re-run connect — refusing to overwrite it.'
631
+ );
539
632
  }
540
633
  }
541
634
 
@@ -857,14 +950,6 @@ export async function runConnect(
857
950
  break;
858
951
  }
859
952
 
860
- case 'chatgpt-desktop': {
861
- // ChatGPT Desktop does not have a standardised config path yet.
862
- // Print the JSON block so the user can paste it (secret needed here).
863
- console.log(`\n✓ Authorized! Paste the following into ChatGPT Desktop's MCP config:\n`);
864
- printConfig(creds, true, args.name);
865
- break;
866
- }
867
-
868
953
  case 'print':
869
954
  default:
870
955
  printConfig(creds, args.reveal, args.name);
package/src/enrichers.ts CHANGED
@@ -180,7 +180,8 @@ export async function shikiHighlight(code: string, language: string, themeName?:
180
180
  .replace(/var\(--shiki-foreground\)/g, 'var(--shiki-color-text)')
181
181
  .replace(/var\(--shiki-background\)/g, 'var(--shiki-color-background)');
182
182
  if (themeName && themeName !== 'css-variables') {
183
- html = html.replace(/(<pre[^>]*class="shiki) css-variables(")/, `$1 ${themeName}$2`);
183
+ // Replacer function so a `$` in themeName is not read as a replacement token.
184
+ html = html.replace(/(<pre[^>]*class="shiki) css-variables(")/, (_m, p1, p2) => `${p1} ${themeName}${p2}`);
184
185
  }
185
186
  return html;
186
187
  }
@@ -305,9 +306,12 @@ registerBlockEnricher('kevinbatdorf/code-block-pro', async (block) => {
305
306
  // post renders a blank gap where the code should be.
306
307
  let updatedInnerHTML: string;
307
308
  if (incomingInnerHTML !== '') {
309
+ // Replacer function, not a string: codeHTML is arbitrary content and a
310
+ // `$`-sequence in it ($$, $&, $`) would otherwise be interpreted as a
311
+ // String.replace pattern and corrupt the rendered code.
308
312
  updatedInnerHTML = incomingInnerHTML.replace(
309
313
  /<pre class="shiki[\s\S]*?<\/pre>/,
310
- codeHTML,
314
+ () => codeHTML,
311
315
  );
312
316
  updatedInnerHTML = updatedInnerHTML.replace(
313
317
  /(<textarea[^>]*>)([\s\S]*?)(<\/textarea>)/,
@@ -18,14 +18,24 @@ interface ErrorContextHints {
18
18
  ref?: string;
19
19
  /** Block path (e.g. [0, 2, 1]) that was rejected, when applicable. */
20
20
  path?: number[];
21
- /** Block name involved in a legacy/avoid rejection. */
21
+ /** Block name involved in a legacy/avoid/dual-storage rejection. */
22
22
  block?: string;
23
- /** Replacement block name suggested by the preference engine. */
23
+ /** Suggested replacement block name suggested by the preference engine. */
24
24
  suggested_replacement?: string;
25
25
  /** Status from the data envelope, sometimes present even on success. */
26
26
  status?: number;
27
27
  /** Some endpoints return `block_name` instead of `block`. */
28
28
  block_name?: string;
29
+ /** flat_index that was rejected as out of range (invalid_index). */
30
+ flat_index?: number;
31
+ /** Revision ID the caller's If-Match expected (stale_revision). */
32
+ expected_revision?: number;
33
+ /** Revision ID currently stored (stale_revision). */
34
+ current_revision?: number;
35
+ /** Configured max nesting depth (block_depth_exceeded). */
36
+ max_depth?: number;
37
+ /** Actual nesting depth that exceeded max_depth (block_depth_exceeded). */
38
+ actual_depth?: number;
29
39
  /** Allow extra fields without typing every one. */
30
40
  [key: string]: unknown;
31
41
  }
@@ -49,6 +59,11 @@ function extractHints(data: unknown): ErrorContextHints {
49
59
  if (typeof d.block_name === 'string') hints.block_name = d.block_name;
50
60
  if (typeof d.suggested_replacement === 'string') hints.suggested_replacement = d.suggested_replacement;
51
61
  if (typeof d.status === 'number') hints.status = d.status;
62
+ if (typeof d.flat_index === 'number') hints.flat_index = d.flat_index;
63
+ if (typeof d.expected_revision === 'number') hints.expected_revision = d.expected_revision;
64
+ if (typeof d.current_revision === 'number') hints.current_revision = d.current_revision;
65
+ if (typeof d.max_depth === 'number') hints.max_depth = d.max_depth;
66
+ if (typeof d.actual_depth === 'number') hints.actual_depth = d.actual_depth;
52
67
 
53
68
  return hints;
54
69
  }
@@ -67,7 +82,7 @@ export function translateWpError(code: string | undefined, data: unknown): strin
67
82
  switch (code) {
68
83
  // ── Routing / auth ─────────────────────────────────────────────
69
84
  case 'rest_no_route':
70
- return 'REST route not found at this site. Confirm the gk-block-api plugin is active and the WORDPRESS_URL is correct.';
85
+ return 'REST route not found at this site. Confirm the Block MCP plugin is active and the WORDPRESS_URL is correct.';
71
86
 
72
87
  case 'rest_forbidden':
73
88
  case 'rest_cannot_edit':
@@ -79,8 +94,11 @@ export function translateWpError(code: string | undefined, data: unknown): strin
79
94
  return 'Authentication failed. Confirm WORDPRESS_USER and WORDPRESS_APP_PASSWORD are set to a valid Application Password (not a regular login password).';
80
95
 
81
96
  // ── Post lookup ────────────────────────────────────────────────
97
+ // `post_not_found` is the code the plugin actually emits;
98
+ // `rest_post_invalid_id` is WordPress core's equivalent, kept as an
99
+ // alias in case a core route ever surfaces it.
82
100
  case 'rest_post_invalid_id':
83
- case 'invalid_post_id': {
101
+ case 'post_not_found': {
84
102
  const target = hints.post_id !== undefined ? `Post ${hints.post_id}` : 'Post';
85
103
  return `${target} not found. List pages with \`list_posts\` to find the right ID.`;
86
104
  }
@@ -92,18 +110,23 @@ export function translateWpError(code: string | undefined, data: unknown): strin
92
110
  : 'Resource not found. It may have been deleted, or the ID is wrong.';
93
111
 
94
112
  // ── Block ref / path resolution ────────────────────────────────
95
- case 'gk_block_api_invalid_ref':
96
113
  case 'invalid_ref':
97
- return `Block ref \`${hints.ref ?? '?'}\` not found in post ${hints.post_id ?? '?'}. The post may have been edited since you last fetched it call \`get_page_blocks\` again to get the current refs.`;
114
+ return 'Ref must be a non-empty string. Use the `ref` value returned by `get_page_blocks`not a made-up ID.';
115
+
116
+ case 'ref_stale': {
117
+ const where = hints.post_id !== undefined ? ` in post ${hints.post_id}` : '';
118
+ return `Block ref \`${hints.ref ?? '?'}\`${where} no longer resolves to a block. It may have been deleted, or the ref is from an older snapshot — call \`get_page_blocks\` again to get current refs.`;
119
+ }
98
120
 
99
- case 'path_not_found':
100
121
  case 'invalid_path':
101
- return `Block path ${formatPath(hints.path)} doesn't address an existing block. Re-fetch the post with \`get_page_blocks\` to get current paths — paths shift when blocks are added or removed.`;
122
+ return `Block path ${formatPath(hints.path)} doesn't address an existing block (or isn't a valid array of non-negative integers). Re-fetch the post with \`get_page_blocks\` to get current paths — paths shift when blocks are added or removed.`;
102
123
 
103
- case 'path_out_of_bounds':
104
- return `Block path ${formatPath(hints.path)} is out of bounds. The post has fewer blocks than expected — re-fetch with \`get_page_blocks\` for current state.`;
124
+ case 'invalid_index': {
125
+ const idx = typeof hints.flat_index === 'number' ? ` ${hints.flat_index}` : '';
126
+ return `Block index${idx} out of range. Re-fetch the post with \`get_page_blocks\` to get current indices — they shift when blocks are added or removed.`;
127
+ }
105
128
 
106
- // ── Block tier / preference enforcement ────────────────────────
129
+ // ── Block tier / preference / storage enforcement ───────────────
107
130
  case 'legacy_block':
108
131
  return blockName
109
132
  ? `${blockName} is in a namespace this site has configured as legacy. Use ${hints.suggested_replacement ?? 'a core block instead'}.`
@@ -121,12 +144,38 @@ export function translateWpError(code: string | undefined, data: unknown): strin
121
144
  case 'static_markup_stale_risk':
122
145
  return 'Updating attributes on a static block without new innerHTML may leave its rendered markup stale. Pass `innerHTML` alongside `attributes`, or use a dynamic block.';
123
146
 
147
+ case 'dual_storage_requires_both':
148
+ return blockName
149
+ ? `${blockName} is dual-storage: \`attributes\` and \`innerHTML\` carry the same data and must be sent together — sending only one silently desyncs the other. Pass both fields in the same call.`
150
+ : 'This block is dual-storage: `attributes` and `innerHTML` carry the same data and must be sent together — sending only one silently desyncs the other. Pass both fields in the same call.';
151
+
152
+ case 'block_depth_exceeded': {
153
+ const depth = typeof hints.max_depth === 'number' && typeof hints.actual_depth === 'number'
154
+ ? ` (max ${hints.max_depth}, got ${hints.actual_depth})`
155
+ : '';
156
+ return `Block tree exceeds the maximum nesting depth${depth}. Flatten the structure — split deeply nested groups into separate top-level blocks.`;
157
+ }
158
+
159
+ // ── Concurrency / staleness ──────────────────────────────────────
160
+ case 'edit_conflict':
161
+ return 'The post content changed since it was read (a concurrent write raced this one). Re-fetch the page with `get_page_blocks` and retry your edit against the current content.';
162
+
163
+ case 'stale_revision': {
164
+ const rev = typeof hints.current_revision === 'number' ? ` (current revision: ${hints.current_revision})` : '';
165
+ return `The post has changed since you fetched it${rev}. Re-fetch with \`get_page_blocks\` and retry.`;
166
+ }
167
+
124
168
  // ── Rate limiting ──────────────────────────────────────────────
125
169
  case 'rate_limit_exceeded': {
126
170
  const where = hints.post_id !== undefined ? `on post ${hints.post_id} ` : '';
127
171
  return `Too many writes ${where}in the last minute. Wait ~60s before retrying, or batch your edits into a single \`edit_block_tree\` call.`;
128
172
  }
129
173
 
174
+ case 'rate_limit_locked': {
175
+ const where = hints.post_id !== undefined ? ` on post ${hints.post_id}` : '';
176
+ return `Another write${where} is in progress. Retry in a moment; the lock clears within a second. To avoid contention, batch edits into a single \`edit_block_tree\` call.`;
177
+ }
178
+
130
179
  // ── v1.2 post lifecycle ────────────────────────────────────────
131
180
  case 'mixed_trash_payload':
132
181
  return '`status: "trash"` cannot be combined with other fields. Trash the post in one call, then update other fields after.';
@@ -137,6 +186,9 @@ export function translateWpError(code: string | undefined, data: unknown): strin
137
186
  case 'invalid_status':
138
187
  return 'Post status not allowed. Valid values: draft, pending, publish, future, private. To trash, call update_post with status:"trash" (on its own, not combined with other fields).';
139
188
 
189
+ case 'trash_disabled':
190
+ return 'Moving posts to trash is turned off for this site. A site administrator can enable it under Block MCP → Settings, or use update_post with a different status.';
191
+
140
192
  // ── Media uploads ──────────────────────────────────────────────
141
193
  case 'invalid_url':
142
194
  return 'URL rejected by SSRF guard. Hostnames pointing at private/loopback/cloud-metadata IPs are blocked. Use a publicly reachable URL.';
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  GetPromptRequestSchema,
39
39
  } from '@modelcontextprotocol/sdk/types.js';
40
40
  import { WordPressBlockClient } from './client.js';
41
+ import { resolveWordPressConfig, buildNotConfiguredResult } from './config.js';
41
42
  import { getInstructions } from './instructions.js';
42
43
  import { DISCOVERY_TOOLS, handleDiscoveryTool } from './tools/discovery.js';
43
44
  import { READ_TOOLS, handleReadTool } from './tools/read.js';
@@ -48,6 +49,8 @@ import { POST_TOOLS, handlePostTool } from './tools/posts.js';
48
49
  import { TERM_TOOLS, handleTermTool } from './tools/terms.js';
49
50
  import { MEDIA_TOOLS, handleMediaTool } from './tools/media.js';
50
51
  import { YOAST_TOOLS, handleYoastTool } from './tools/yoast.js';
52
+ import { validateToolArgs } from './validate-args.js';
53
+ import { AGENT_GUIDE_CONTENT } from './agent-guide.js';
51
54
  import { runConnect } from './connect.js';
52
55
 
53
56
  // Environment variables are passed by the parent process (Claude Code, Hermes, etc.)
@@ -56,21 +59,8 @@ import { runConnect } from './connect.js';
56
59
  // ============================================
57
60
  // Initialize WordPress client
58
61
  // ============================================
59
-
60
- // Primary names (recommended): WORDPRESS_URL / WORDPRESS_USER / WORDPRESS_APP_PASSWORD.
61
- // Fall back to the legacy GK_-prefixed names so existing configs keep working;
62
- // emit a deprecation notice to stderr when they're used. The legacy names will
63
- // be removed in a future minor release.
64
- function readEnv(primary: string, legacy: string): string | undefined {
65
- const fromPrimary = process.env[primary];
66
- if (fromPrimary) return fromPrimary;
67
- const fromLegacy = process.env[legacy];
68
- if (fromLegacy) {
69
- console.error(`[block-mcp] DEPRECATED: ${legacy} is deprecated; rename to ${primary} in your MCP client config.`);
70
- return fromLegacy;
71
- }
72
- return undefined;
73
- }
62
+ // Config resolution (WORDPRESS_* with legacy GK_* fallback) lives in
63
+ // ./config.ts so it stays side-effect free and unit-testable.
74
64
 
75
65
  // ============================================
76
66
  // Aggregate all tool definitions
@@ -137,58 +127,8 @@ const AGENT_GUIDE_RESOURCE_URI = 'block-mcp://agent-guide';
137
127
  // integrations resolving the old URI don't 404.
138
128
  const LEGACY_PREFERENCES_RESOURCE_URI = 'block-mcp://block-preferences';
139
129
 
140
- const AGENT_GUIDE_CONTENT = `# Block MCP Agent Guide
141
-
142
- ## URL → post ID resolution
143
-
144
- NEVER run curl, wget, or any bash/shell command to hit wp-json or resolve a URL to a post ID.
145
- The MCP does this for you:
146
-
147
- - \`get_page_blocks\` accepts \`url\` as an alternative to \`post_id\`. Pass the full URL or path; the server resolves it via \`url_to_postid\`.
148
- - For explicit resolution (title, post_type, edit_url before editing), call \`resolve_url\`.
149
-
150
- If the user says "change X on https://example.com/some-page/", your first tool call should be \`get_page_blocks({ url: "...", search: "keyword" })\` or \`resolve_url({ url: "..." })\` — not a shell command.
151
-
152
- ## Moving / reordering blocks
153
-
154
- NEVER do a move as separate \`insert_blocks\` + \`delete_block\` calls — if the delete is skipped or fails, the page ends up with an orphaned clone of the original. The atomic primitive is the \`move\` op on \`edit_block_tree\`:
155
-
156
- - Target the source with \`ref\` (the \`gk_ref\` from \`get_page_blocks\`) or \`path\`. Prefer \`ref\` — it survives sibling shifts; paths go stale the moment any earlier block is inserted or removed.
157
- - Express the destination with \`destination_ref\` or \`destination\` (path). For path destinations, use **pre-move** indexing — write the path as if the source were still in place; the server adjusts indices after the removal.
158
- - Use \`count\` to move N consecutive siblings in a single op.
159
- - The server rejects moves into the source itself or any of its descendants.
160
- - The whole \`edit_block_tree\` call is one revision, reversible via \`revert_to_revision\`.
161
-
162
- If you must fall back to the flat-index tools, do \`insert_blocks\` + \`delete_block\` in the same turn and re-fetch \`get_page_blocks\` afterward to confirm exactly one copy remains.
163
-
164
- ## Verifying writes
165
-
166
- Every write echoes the canonical post-save snapshot. Use it. Do not fetch the public page to verify what saved.
167
-
168
- - \`update_block\` always returns \`saved.inner_html\` + \`saved.attributes\` — the exact content that just landed in post_content. The write call IS the verification round-trip.
169
- - \`update_blocks\` returns per-result \`saved\` only when called with \`verbose: true\` (default false to keep batch responses compact). Pass \`verbose: true\` if you need to confirm each item without a re-read.
170
- - For after-the-fact re-reads of a single known block, use \`get_block({ post_id, ref })\` — returns the same \`saved\` shape, lighter than \`get_page_blocks\`.
171
-
172
- For dynamic blocks (\`saved.is_dynamic: true\`, e.g. shortcodes, query loops, latest-posts), \`saved.inner_html\` is the stored template that runs at render time — not the rendered HTML the visitor sees. That's expected; the canonical state is the template.
173
-
174
- ## Block preferences (site-defined)
175
-
176
- Block preference policy is configured per-site in the WordPress admin (the
177
- gk-block-api Preferences option) and exposed dynamically. There is no
178
- client-side hardcoded list of "good" vs "bad" namespaces.
179
-
180
- How to discover the policy at runtime:
181
-
182
- 1. \`list_block_types\` returns blocks grouped by tier (PREFERRED / ACCEPTABLE / AVOID / LEGACY) for the current site. Use this when you need the full picture.
183
- 2. \`get_page_blocks\` annotates non-preferred blocks inline with \`preference.tier\` and (when configured) \`preference.suggested_replacement\`. Trust those fields — they reflect the live config.
184
- 3. \`insert_blocks\` rejects legacy-tier blocks with a \`legacy_block\` error that includes the rejected namespace, the suggested replacement, and a pointer back to this resource.
185
-
186
- How to behave:
187
-
188
- - Prefer the highest-tier blocks for new content. Defer to the server's classification rather than guessing from a namespace prefix.
189
- - Reuse existing patterns before building from scratch — call \`list_patterns\` first.
190
- - For patterns that need per-page customization, use \`synced: false\` to inline them.
191
- - When you encounter legacy blocks on a page during a read, note them but do not replace unless asked.`;
130
+ // AGENT_GUIDE_CONTENT lives in src/agent-guide.ts so the discoverability
131
+ // contract is testable (index.ts boots the server on import).
192
132
 
193
133
  // ============================================
194
134
  // Handler registration
@@ -200,7 +140,11 @@ How to behave:
200
140
  // `_instructions` field — see the construction note above).
201
141
  // ============================================
202
142
 
203
- function registerHandlers(server: McpServer, client: WordPressBlockClient): void {
143
+ function registerHandlers(
144
+ server: McpServer,
145
+ client: WordPressBlockClient | null,
146
+ notConfiguredMessage?: string
147
+ ): void {
204
148
 
205
149
  server.server.setRequestHandler(ListToolsRequestSchema, async () => {
206
150
  return { tools: ALL_TOOLS };
@@ -214,11 +158,28 @@ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
214
158
  const { name, arguments: args } = request.params;
215
159
  const toolArgs = (args ?? {}) as Record<string, unknown>;
216
160
 
161
+ // Degraded mode: the server started without a valid WordPress config, so it
162
+ // can list tools (the client connects and sees them) but cannot run any —
163
+ // return a clear reason instead of a crash the client shows as -32000.
164
+ if (!client) {
165
+ return buildNotConfiguredResult(notConfiguredMessage ?? 'Block MCP is not configured.');
166
+ }
167
+
217
168
  try {
218
169
  const handle = TOOL_DISPATCH.get(name);
219
170
  if (!handle) {
220
171
  throw new Error(`Unknown tool: ${name}`);
221
172
  }
173
+
174
+ // Reject unknown / misnamed arguments before the handler runs. The MCP SDK
175
+ // does not validate against inputSchema, so without this an unrecognized key
176
+ // (e.g. `after` instead of `after_top_level`) is silently dropped and the
177
+ // tool runs with defaults — a silent wrong-place write rather than an error.
178
+ const def = ALL_TOOLS.find((t) => t.name === name) as
179
+ | { inputSchema?: { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: unknown } }
180
+ | undefined;
181
+ validateToolArgs(name, def?.inputSchema, toolArgs);
182
+
222
183
  const result = await handle(name, toolArgs, client);
223
184
 
224
185
  // Emit `structuredContent` alongside the text fallback when the tool
@@ -396,22 +357,31 @@ async function main(): Promise<void> {
396
357
  }
397
358
 
398
359
  // ── env-var validation ──────────────────────────────────────────────────
399
- const WORDPRESS_URL = readEnv('WORDPRESS_URL', 'GK_SITE_URL');
400
- const WORDPRESS_USER = readEnv('WORDPRESS_USER', 'GK_BLOCK_API_USER');
401
- const WORDPRESS_APP_PASSWORD = readEnv('WORDPRESS_APP_PASSWORD', 'GK_BLOCK_API_APP_PASSWORD');
402
-
403
- if (!WORDPRESS_URL || !WORDPRESS_USER || !WORDPRESS_APP_PASSWORD) {
360
+ const cfg = resolveWordPressConfig(process.env);
361
+
362
+ // Start degraded rather than exiting on a missing/invalid config: exiting
363
+ // kills the stdio session before the handshake, so the MCP client only shows
364
+ // an opaque -32000. A running server lists its tools and returns cfg.message
365
+ // (which names the missing vars) on any tool call — a diagnosable error.
366
+ if (!cfg.ok) {
367
+ console.error(`Block MCP: ${cfg.message}`);
368
+ const degraded = new McpServer(
369
+ { name: 'block-mcp', version: pkg.version },
370
+ { capabilities: { tools: {}, resources: {}, prompts: {} } }
371
+ );
372
+ registerHandlers(degraded, null, cfg.message);
373
+ await degraded.connect(new StdioServerTransport());
404
374
  console.error(
405
- 'Missing required environment variables: WORDPRESS_URL, WORDPRESS_USER, WORDPRESS_APP_PASSWORD'
375
+ 'Block MCP Server running on stdio (unconfigured — tool calls return a configuration error)'
406
376
  );
407
- process.exit(1);
377
+ return;
408
378
  }
409
379
 
410
380
  const client = new WordPressBlockClient({
411
- wordpress_url: WORDPRESS_URL,
381
+ wordpress_url: cfg.config.url,
412
382
  auth: {
413
- username: WORDPRESS_USER,
414
- application_password: WORDPRESS_APP_PASSWORD,
383
+ username: cfg.config.user,
384
+ application_password: cfg.config.password,
415
385
  },
416
386
  });
417
387
 
@@ -420,7 +390,7 @@ async function main(): Promise<void> {
420
390
  // from the start — no post-construction mutation of SDK internals.
421
391
  // `getInstructions` never throws: on any failure it logs to stderr
422
392
  // and returns the baseline only.
423
- const instructions = await getInstructions(WORDPRESS_URL);
393
+ const instructions = await getInstructions(cfg.config.url);
424
394
 
425
395
  const server = new McpServer(
426
396
  {
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import axios, { AxiosError } from 'axios';
28
+ import { restRouteUrl } from './rest-url.js';
28
29
 
29
30
  /**
30
31
  * The baseline instructions string.
@@ -188,9 +189,8 @@ export async function fetchAddendum(wordpressUrl: string): Promise<string> {
188
189
  return '';
189
190
  }
190
191
 
191
- // Normalize trailing slash and resolve relative to wp-json.
192
- const base = wordpressUrl.replace(/\/+$/, '');
193
- const url = `${base}/wp-json/gk-block-api/v1/instructions`;
192
+ // Permalink-independent ?rest_route= form: /wp-json/ 404s on plain permalinks.
193
+ const url = restRouteUrl(wordpressUrl, '/instructions');
194
194
 
195
195
  try {
196
196
  const response = await axios.get<InstructionsResponse>(url, {