@gravitykit/block-mcp 2.0.1 → 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 (42) hide show
  1. package/dist/index.cjs +311 -195
  2. package/package.json +8 -4
  3. package/src/__tests__/coerce.test.ts +57 -0
  4. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  5. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  6. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  7. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  8. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  9. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  10. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  11. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  12. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  13. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  14. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  15. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  16. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  17. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  18. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  19. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  20. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  21. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +100 -29
  22. package/src/__tests__/unit/instructions.test.ts +3 -3
  23. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  24. package/src/__tests__/unit/rest-url.test.ts +23 -0
  25. package/src/client.ts +53 -18
  26. package/src/coerce.ts +41 -0
  27. package/src/config.ts +96 -0
  28. package/src/connect.ts +17 -3
  29. package/src/enrichers.ts +6 -2
  30. package/src/error-translator.ts +62 -10
  31. package/src/index.ts +35 -27
  32. package/src/instructions.ts +3 -3
  33. package/src/preferences.ts +56 -43
  34. package/src/rest-url.ts +18 -0
  35. package/src/tools/discovery.ts +10 -14
  36. package/src/tools/mutate.ts +18 -12
  37. package/src/tools/patterns.ts +3 -2
  38. package/src/tools/posts.ts +26 -26
  39. package/src/tools/read.ts +7 -6
  40. package/src/tools/write.ts +26 -31
  41. package/src/tools/yoast.ts +30 -31
  42. package/src/types.ts +23 -13
package/src/client.ts CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios';
10
10
  import { translateWpError } from './error-translator.js';
11
+ import { restRouteUrl } from './rest-url.js';
12
+ import { coercePostId } from './coerce.js';
11
13
 
12
14
  /** Best-effort MIME type from a filename extension, for multipart uploads. */
13
15
  function mimeForFilename(filename: string): string {
@@ -181,8 +183,9 @@ export class WordPressBlockClient {
181
183
  `${auth.username}:${auth.application_password}`
182
184
  ).toString('base64');
183
185
 
184
- const trimmed = wordpress_url.replace(/\/+$/, '');
185
- const baseURL = `${trimmed}/wp-json/gk-block-api/v1`;
186
+ // Permalink-independent ?rest_route= form so tool calls don't 404 on a
187
+ // plain-permalink site after the connector completes.
188
+ const baseURL = restRouteUrl(wordpress_url);
186
189
 
187
190
  this.client = axios.create({
188
191
  baseURL,
@@ -448,9 +451,11 @@ export class WordPressBlockClient {
448
451
  cursor?: string;
449
452
  }
450
453
  ): Promise<PageBlocksResponse> {
451
- if (postId === undefined || postId === null) {
454
+ const coercedPostId = coercePostId(postId, 'get_page_blocks');
455
+ if (coercedPostId === undefined) {
452
456
  throw new Error('Post ID is required');
453
457
  }
458
+ postId = coercedPostId;
454
459
 
455
460
  const queryParams: Record<string, string> = {};
456
461
  if (params?.fields) queryParams.fields = params.fields;
@@ -540,7 +545,9 @@ export class WordPressBlockClient {
540
545
  index: number,
541
546
  data: BlockPatch
542
547
  ): Promise<BlockUpdateResponse> {
543
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
548
+ const coercedPostId = coercePostId(postId, 'update_block');
549
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
550
+ postId = coercedPostId;
544
551
  if (index < 0) throw new Error('Block index must be non-negative');
545
552
  if (!data.attributes && !data.innerHTML) {
546
553
  throw new Error('At least one of attributes or innerHTML must be provided');
@@ -567,7 +574,9 @@ export class WordPressBlockClient {
567
574
  ref: string,
568
575
  data: BlockPatch
569
576
  ): Promise<BlockUpdateResponse> {
570
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
577
+ const coercedPostId = coercePostId(postId, 'update_block');
578
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
579
+ postId = coercedPostId;
571
580
  if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
572
581
  if (!data.attributes && !data.innerHTML) {
573
582
  throw new Error('At least one of attributes or innerHTML must be provided');
@@ -601,7 +610,9 @@ export class WordPressBlockClient {
601
610
  updates: BlockBatchUpdateItem[],
602
611
  options: { verbose?: boolean } = {}
603
612
  ): Promise<BlockBatchUpdateResponse> {
604
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
613
+ const coercedPostId = coercePostId(postId, 'update_blocks');
614
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
615
+ postId = coercedPostId;
605
616
  if (!Array.isArray(updates) || updates.length === 0) {
606
617
  throw new Error('updates must be a non-empty array');
607
618
  }
@@ -633,7 +644,9 @@ export class WordPressBlockClient {
633
644
  postId: number,
634
645
  target: { ref?: string; flatIndex?: number }
635
646
  ): Promise<GetBlockResponse> {
636
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
647
+ const coercedPostId = coercePostId(postId, 'get_block');
648
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
649
+ postId = coercedPostId;
637
650
  const hasRef = typeof target.ref === 'string' && target.ref !== '';
638
651
  const hasIdx = typeof target.flatIndex === 'number';
639
652
  if (hasRef === hasIdx) {
@@ -666,7 +679,9 @@ export class WordPressBlockClient {
666
679
  blocks: BlockInput[];
667
680
  }
668
681
  ): Promise<BlockWriteResponse> {
669
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
682
+ const coercedPostId = coercePostId(postId, 'insert_blocks');
683
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
684
+ postId = coercedPostId;
670
685
  if (!data.blocks || data.blocks.length === 0) {
671
686
  throw new Error('At least one block is required');
672
687
  }
@@ -691,7 +706,9 @@ export class WordPressBlockClient {
691
706
  index: number,
692
707
  count?: number
693
708
  ): Promise<BlockDeleteResponse> {
694
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
709
+ const coercedPostId = coercePostId(postId, 'delete_block');
710
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
711
+ postId = coercedPostId;
695
712
  if (index < 0) throw new Error('Block index must be non-negative');
696
713
 
697
714
  const params: Record<string, string> = {};
@@ -716,7 +733,9 @@ export class WordPressBlockClient {
716
733
  ref: string,
717
734
  count?: number
718
735
  ): Promise<BlockDeleteResponse> {
719
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
736
+ const coercedPostId = coercePostId(postId, 'delete_block');
737
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
738
+ postId = coercedPostId;
720
739
  if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
721
740
 
722
741
  const params: Record<string, string> = {};
@@ -747,7 +766,9 @@ export class WordPressBlockClient {
747
766
  blocks: BlockInput[];
748
767
  }
749
768
  ): Promise<BlockReplaceRangeResponse> {
750
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
769
+ const coercedPostId = coercePostId(postId, 'replace_block_range');
770
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
771
+ postId = coercedPostId;
751
772
  if (typeof data.start !== 'number' || data.start < 0) {
752
773
  throw new Error('start must be a non-negative integer');
753
774
  }
@@ -779,7 +800,9 @@ export class WordPressBlockClient {
779
800
  postId: number,
780
801
  blocks: BlockInput[]
781
802
  ): Promise<BlockWriteResponse> {
782
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
803
+ const coercedPostId = coercePostId(postId, 'rewrite_post_blocks');
804
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
805
+ postId = coercedPostId;
783
806
  if (!blocks || blocks.length === 0) {
784
807
  throw new Error('At least one block is required for a full rewrite');
785
808
  }
@@ -803,9 +826,11 @@ export class WordPressBlockClient {
803
826
  * @returns Mutation result with revision IDs and optional warnings
804
827
  */
805
828
  async mutateBlockTree(postId: number, data: MutationRequest): Promise<MutationResponse> {
806
- if (postId === undefined || postId === null) {
829
+ const coercedPostId = coercePostId(postId, 'edit_block_tree');
830
+ if (coercedPostId === undefined) {
807
831
  throw new Error('Post ID is required');
808
832
  }
833
+ postId = coercedPostId;
809
834
 
810
835
  const response = await this.client.post<MutationResponse>(
811
836
  `/posts/${postId}/mutate`,
@@ -826,9 +851,11 @@ export class WordPressBlockClient {
826
851
  * @returns Revert result with revision IDs
827
852
  */
828
853
  async revertToRevision(postId: number, revisionId: number): Promise<unknown> {
829
- if (postId === undefined || postId === null) {
854
+ const coercedPostId = coercePostId(postId, 'revert_to_revision');
855
+ if (coercedPostId === undefined) {
830
856
  throw new Error('Post ID is required');
831
857
  }
858
+ postId = coercedPostId;
832
859
  const response = await this.client.post(`/posts/${postId}/revert`, { revision_id: revisionId });
833
860
  return response.data;
834
861
  }
@@ -853,7 +880,9 @@ export class WordPressBlockClient {
853
880
  synced?: boolean;
854
881
  }
855
882
  ): Promise<PatternInsertResponse> {
856
- if (postId === undefined || postId === null) throw new Error('Post ID is required');
883
+ const coercedPostId = coercePostId(postId, 'insert_pattern');
884
+ if (coercedPostId === undefined) throw new Error('Post ID is required');
885
+ postId = coercedPostId;
857
886
  if (data.pattern_id === undefined || data.pattern_id === null) throw new Error('Pattern ID is required');
858
887
 
859
888
  const response = await this.client.post<PatternInsertResponse>(
@@ -892,9 +921,11 @@ export class WordPressBlockClient {
892
921
  * Use `status: trash` to trash; any non-trash status untrashes a trashed post.
893
922
  */
894
923
  async updatePost(postId: number, data: UpdatePostRequest): Promise<PostMutationResponse> {
895
- if (postId === undefined || postId === null) {
924
+ const coercedPostId = coercePostId(postId, 'update_post');
925
+ if (coercedPostId === undefined) {
896
926
  throw new Error('update_post: post_id is required');
897
927
  }
928
+ postId = coercedPostId;
898
929
  const response = await this.client.patch<PostMutationResponse>(`/posts/${postId}`, data);
899
930
  return response.data;
900
931
  }
@@ -966,18 +997,22 @@ export class WordPressBlockClient {
966
997
 
967
998
  /** Read all Yoast SEO metadata for a post. */
968
999
  async getYoastSEO(postId: number): Promise<YoastSEOMeta> {
969
- if (postId === undefined || postId === null) {
1000
+ const coercedPostId = coercePostId(postId, 'yoast_get_seo');
1001
+ if (coercedPostId === undefined) {
970
1002
  throw new Error('yoast_get_seo: post_id is required');
971
1003
  }
1004
+ postId = coercedPostId;
972
1005
  const response = await this.client.get<YoastSEOMeta>(`/yoast/${postId}`);
973
1006
  return response.data;
974
1007
  }
975
1008
 
976
1009
  /** Partial update of Yoast SEO fields on a single post. */
977
1010
  async updateYoastSEO(postId: number, fields: YoastUpdateRequest): Promise<YoastSEOMeta> {
978
- if (postId === undefined || postId === null) {
1011
+ const coercedPostId = coercePostId(postId, 'yoast_update_seo');
1012
+ if (coercedPostId === undefined) {
979
1013
  throw new Error('yoast_update_seo: post_id is required');
980
1014
  }
1015
+ postId = coercedPostId;
981
1016
  const response = await this.client.patch<YoastSEOMeta>(`/yoast/${postId}`, fields);
982
1017
  return response.data;
983
1018
  }
package/src/coerce.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Input coercion helpers shared across the MCP tool handlers.
3
+ *
4
+ * MCP clients and untyped JSON transports vary in how they encode a post ID:
5
+ * some send the JSON number `123`, others the string `"123"`. The tool surface
6
+ * must behave identically for both, or the same post is editable through one
7
+ * tool and rejected by another in the same session.
8
+ */
9
+
10
+ /**
11
+ * Coerce a caller-supplied post ID to a positive integer.
12
+ *
13
+ * Accepts a JSON number that is a positive integer, or a string of digits
14
+ * (`"123"`). Returns `undefined` when the value is absent (`undefined`/`null`),
15
+ * so callers with an alternate selector (url/slug) can allow that. Throws for a
16
+ * present-but-invalid value (float, negative, non-numeric string), with the
17
+ * `label:` prefix the calling tool uses in its other errors.
18
+ *
19
+ * @param value - Raw post_id from the tool arguments.
20
+ * @param label - Tool name used to prefix the thrown error message.
21
+ * @returns The positive integer, or undefined when the value is absent.
22
+ */
23
+ export function coercePostId(value: unknown, label: string): number | undefined {
24
+ if (value === undefined || value === null) {
25
+ return undefined;
26
+ }
27
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
28
+ return value;
29
+ }
30
+ if (typeof value === 'string' && /^[0-9]+$/.test(value)) {
31
+ const parsed = parseInt(value, 10);
32
+ // Re-apply the positive check so a zero-valued string ("0", "00") is
33
+ // rejected exactly like the number 0, not silently accepted as 0.
34
+ // Number.isSafeInteger rejects values above 2^53-1, which parseInt would
35
+ // otherwise round to a different id — targeting the wrong post.
36
+ if (parsed > 0 && Number.isSafeInteger(parsed)) {
37
+ return parsed;
38
+ }
39
+ }
40
+ throw new Error(`${label}: post_id must be a positive integer`);
41
+ }
package/src/config.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * WordPress connection config resolution for the MCP server.
3
+ *
4
+ * Kept side-effect free (no server construction, no process.exit) so the
5
+ * startup decision is unit-testable and so a missing/invalid config degrades
6
+ * the server gracefully instead of crashing it — a crash reaches the MCP client
7
+ * only as an opaque -32000 with the real reason stranded on stderr.
8
+ */
9
+
10
+ export interface WordPressConfig {
11
+ url: string;
12
+ user: string;
13
+ password: string;
14
+ }
15
+
16
+ export type ConfigResult =
17
+ | { ok: true; config: WordPressConfig }
18
+ | { ok: false; missing: string[]; message: string };
19
+
20
+ /**
21
+ * Read an env var by its primary name, falling back to a legacy alias. An
22
+ * empty/whitespace-only value counts as absent, not as a valid empty config.
23
+ */
24
+ function readEnv(env: NodeJS.ProcessEnv, primary: string, legacy: string): string | undefined {
25
+ const fromPrimary = env[primary];
26
+ if (fromPrimary && fromPrimary.trim() !== '') {
27
+ return fromPrimary;
28
+ }
29
+ const fromLegacy = env[legacy];
30
+ if (fromLegacy && fromLegacy.trim() !== '') {
31
+ console.error(`[block-mcp] DEPRECATED: ${legacy} is deprecated; rename to ${primary} in your MCP client config.`);
32
+ return fromLegacy;
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Resolve the WordPress connection config from the process environment.
39
+ *
40
+ * Returns `{ ok: false, missing, message }` when any of the three required
41
+ * values is absent — the caller starts the server in a degraded mode and
42
+ * surfaces `message` on tool calls, rather than exiting.
43
+ */
44
+ export function resolveWordPressConfig(env: NodeJS.ProcessEnv): ConfigResult {
45
+ const url = readEnv(env, 'WORDPRESS_URL', 'GK_SITE_URL');
46
+ const user = readEnv(env, 'WORDPRESS_USER', 'GK_BLOCK_API_USER');
47
+ const password = readEnv(env, 'WORDPRESS_APP_PASSWORD', 'GK_BLOCK_API_APP_PASSWORD');
48
+
49
+ const missing: string[] = [];
50
+ if (!url) {
51
+ missing.push('WORDPRESS_URL');
52
+ }
53
+ if (!user) {
54
+ missing.push('WORDPRESS_USER');
55
+ }
56
+ if (!password) {
57
+ missing.push('WORDPRESS_APP_PASSWORD');
58
+ }
59
+
60
+ if (missing.length > 0) {
61
+ const message =
62
+ `Block MCP is not configured: ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} missing. ` +
63
+ 'Set them in the "env" block of the block-mcp server entry in your MCP client config. ' +
64
+ 'Your site\'s Settings → Block MCP → Connect generates a ready-made config for you.';
65
+ return { ok: false, missing, message };
66
+ }
67
+
68
+ return { ok: true, config: { url: url as string, user: user as string, password: password as string } };
69
+ }
70
+
71
+ /**
72
+ * MCP tool-call result returned for every tool while the server is unconfigured.
73
+ * The index signature keeps it assignable to the SDK's ServerResult union.
74
+ */
75
+ export interface NotConfiguredResult {
76
+ [key: string]: unknown;
77
+ content: Array<{ type: string; text: string }>;
78
+ isError: true;
79
+ }
80
+
81
+ /**
82
+ * Build the structured tool-call error returned when the server is running but
83
+ * has no valid WordPress config, so the client shows an actionable reason
84
+ * instead of an opaque connection failure.
85
+ */
86
+ export function buildNotConfiguredResult(message: string): NotConfiguredResult {
87
+ return {
88
+ content: [
89
+ {
90
+ type: 'text',
91
+ text: JSON.stringify({ error: true, code: 'not_configured', message }, null, 2),
92
+ },
93
+ ],
94
+ isError: true,
95
+ };
96
+ }
package/src/connect.ts CHANGED
@@ -408,7 +408,6 @@ export async function exchangeCode(
408
408
  fetchFn: typeof fetch = fetch,
409
409
  timeoutMs: number = EXCHANGE_FETCH_TIMEOUT_MS
410
410
  ): Promise<Credentials> {
411
- const origin = new URL(site).origin;
412
411
  let url = `${site}/?rest_route=/gk-block-api/v1/connect/exchange`;
413
412
 
414
413
  const controller = new AbortController();
@@ -434,15 +433,30 @@ export async function exchangeCode(
434
433
  break;
435
434
  }
436
435
 
437
- // 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.
438
442
  const location = res.headers.get('location');
439
443
  if (!location || hops >= 3) {
440
444
  throw new Error(
441
445
  `Exchange failed: the site redirected the request (HTTP ${res.status}); ensure --site is the canonical site URL.`
442
446
  );
443
447
  }
448
+ const current = new URL(url);
444
449
  const next = new URL(location, url);
445
- 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) {
446
460
  throw new Error(
447
461
  `Exchange failed: the site redirected to a different origin (${next.origin}); refusing to send the credential off-site.`
448
462
  );
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
  }
@@ -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';
@@ -58,21 +59,8 @@ import { runConnect } from './connect.js';
58
59
  // ============================================
59
60
  // Initialize WordPress client
60
61
  // ============================================
61
-
62
- // Primary names (recommended): WORDPRESS_URL / WORDPRESS_USER / WORDPRESS_APP_PASSWORD.
63
- // Fall back to the legacy GK_-prefixed names so existing configs keep working;
64
- // emit a deprecation notice to stderr when they're used. The legacy names will
65
- // be removed in a future minor release.
66
- function readEnv(primary: string, legacy: string): string | undefined {
67
- const fromPrimary = process.env[primary];
68
- if (fromPrimary) return fromPrimary;
69
- const fromLegacy = process.env[legacy];
70
- if (fromLegacy) {
71
- console.error(`[block-mcp] DEPRECATED: ${legacy} is deprecated; rename to ${primary} in your MCP client config.`);
72
- return fromLegacy;
73
- }
74
- return undefined;
75
- }
62
+ // Config resolution (WORDPRESS_* with legacy GK_* fallback) lives in
63
+ // ./config.ts so it stays side-effect free and unit-testable.
76
64
 
77
65
  // ============================================
78
66
  // Aggregate all tool definitions
@@ -152,7 +140,11 @@ const LEGACY_PREFERENCES_RESOURCE_URI = 'block-mcp://block-preferences';
152
140
  // `_instructions` field — see the construction note above).
153
141
  // ============================================
154
142
 
155
- function registerHandlers(server: McpServer, client: WordPressBlockClient): void {
143
+ function registerHandlers(
144
+ server: McpServer,
145
+ client: WordPressBlockClient | null,
146
+ notConfiguredMessage?: string
147
+ ): void {
156
148
 
157
149
  server.server.setRequestHandler(ListToolsRequestSchema, async () => {
158
150
  return { tools: ALL_TOOLS };
@@ -166,6 +158,13 @@ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
166
158
  const { name, arguments: args } = request.params;
167
159
  const toolArgs = (args ?? {}) as Record<string, unknown>;
168
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
+
169
168
  try {
170
169
  const handle = TOOL_DISPATCH.get(name);
171
170
  if (!handle) {
@@ -358,22 +357,31 @@ async function main(): Promise<void> {
358
357
  }
359
358
 
360
359
  // ── env-var validation ──────────────────────────────────────────────────
361
- const WORDPRESS_URL = readEnv('WORDPRESS_URL', 'GK_SITE_URL');
362
- const WORDPRESS_USER = readEnv('WORDPRESS_USER', 'GK_BLOCK_API_USER');
363
- const WORDPRESS_APP_PASSWORD = readEnv('WORDPRESS_APP_PASSWORD', 'GK_BLOCK_API_APP_PASSWORD');
364
-
365
- 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());
366
374
  console.error(
367
- '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)'
368
376
  );
369
- process.exit(1);
377
+ return;
370
378
  }
371
379
 
372
380
  const client = new WordPressBlockClient({
373
- wordpress_url: WORDPRESS_URL,
381
+ wordpress_url: cfg.config.url,
374
382
  auth: {
375
- username: WORDPRESS_USER,
376
- application_password: WORDPRESS_APP_PASSWORD,
383
+ username: cfg.config.user,
384
+ application_password: cfg.config.password,
377
385
  },
378
386
  });
379
387
 
@@ -382,7 +390,7 @@ async function main(): Promise<void> {
382
390
  // from the start — no post-construction mutation of SDK internals.
383
391
  // `getInstructions` never throws: on any failure it logs to stderr
384
392
  // and returns the baseline only.
385
- const instructions = await getInstructions(WORDPRESS_URL);
393
+ const instructions = await getInstructions(cfg.config.url);
386
394
 
387
395
  const server = new McpServer(
388
396
  {