@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/client.ts CHANGED
@@ -8,6 +8,23 @@
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';
13
+
14
+ /** Best-effort MIME type from a filename extension, for multipart uploads. */
15
+ function mimeForFilename(filename: string): string {
16
+ const ext = filename.toLowerCase().split('.').pop() ?? '';
17
+ const map: Record<string, string> = {
18
+ png: 'image/png',
19
+ jpg: 'image/jpeg',
20
+ jpeg: 'image/jpeg',
21
+ gif: 'image/gif',
22
+ webp: 'image/webp',
23
+ svg: 'image/svg+xml',
24
+ pdf: 'application/pdf',
25
+ };
26
+ return map[ext] ?? 'application/octet-stream';
27
+ }
11
28
 
12
29
  /** Max retry attempts for transient server / network errors. */
13
30
  const MAX_RETRIES = 2;
@@ -91,6 +108,8 @@ import type {
91
108
  MutationRequest,
92
109
  MutationResponse,
93
110
  ResolveUrlResponse,
111
+ BlockInput,
112
+ BlockPatch,
94
113
  CreatePostRequest,
95
114
  UpdatePostRequest,
96
115
  PostMutationResponse,
@@ -116,7 +135,17 @@ interface PatternsResponse {
116
135
 
117
136
  /** Response wrapper for page blocks. */
118
137
  interface PageBlocksResponse {
138
+ post_id?: number;
139
+ summary?: Record<string, unknown>;
119
140
  blocks: Block[];
141
+ /** Present when `limit` pagination is in play. */
142
+ pagination?: {
143
+ limit?: number;
144
+ offset?: number;
145
+ total?: number;
146
+ /** Pass back as `cursor` to fetch the next page; null on the last page. */
147
+ next_cursor?: string | null;
148
+ };
120
149
  }
121
150
 
122
151
  /**
@@ -154,8 +183,9 @@ export class WordPressBlockClient {
154
183
  `${auth.username}:${auth.application_password}`
155
184
  ).toString('base64');
156
185
 
157
- const trimmed = wordpress_url.replace(/\/+$/, '');
158
- 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);
159
189
 
160
190
  this.client = axios.create({
161
191
  baseURL,
@@ -415,11 +445,17 @@ export class WordPressBlockClient {
415
445
  include_legacy_paths?: boolean;
416
446
  /** When true (default), missing gk_refs are assigned and persisted. Pass false to skip the silent write side effect. */
417
447
  persist_refs?: boolean;
448
+ /** Page size: top-level blocks per response. Pairs with `cursor`. */
449
+ limit?: number;
450
+ /** Opaque pagination cursor from a prior response's `next_cursor`. */
451
+ cursor?: string;
418
452
  }
419
453
  ): Promise<PageBlocksResponse> {
420
- if (postId === undefined || postId === null) {
454
+ const coercedPostId = coercePostId(postId, 'get_page_blocks');
455
+ if (coercedPostId === undefined) {
421
456
  throw new Error('Post ID is required');
422
457
  }
458
+ postId = coercedPostId;
423
459
 
424
460
  const queryParams: Record<string, string> = {};
425
461
  if (params?.fields) queryParams.fields = params.fields;
@@ -434,6 +470,8 @@ export class WordPressBlockClient {
434
470
  // when the param is omitted entirely.
435
471
  if (params?.persist_refs === false) queryParams.persist_refs = 'false';
436
472
  else if (params?.persist_refs === true) queryParams.persist_refs = 'true';
473
+ if (typeof params?.limit === 'number') queryParams.limit = String(params.limit);
474
+ if (params?.cursor) queryParams.cursor = params.cursor;
437
475
 
438
476
  const response = await this.client.get<PageBlocksResponse>(
439
477
  `/posts/${postId}/blocks`,
@@ -505,9 +543,11 @@ export class WordPressBlockClient {
505
543
  async updateBlock(
506
544
  postId: number,
507
545
  index: number,
508
- data: { attributes?: Record<string, unknown>; innerHTML?: string }
546
+ data: BlockPatch
509
547
  ): Promise<BlockUpdateResponse> {
510
- 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;
511
551
  if (index < 0) throw new Error('Block index must be non-negative');
512
552
  if (!data.attributes && !data.innerHTML) {
513
553
  throw new Error('At least one of attributes or innerHTML must be provided');
@@ -532,9 +572,11 @@ export class WordPressBlockClient {
532
572
  async updateBlockByRef(
533
573
  postId: number,
534
574
  ref: string,
535
- data: { attributes?: Record<string, unknown>; innerHTML?: string }
575
+ data: BlockPatch
536
576
  ): Promise<BlockUpdateResponse> {
537
- 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;
538
580
  if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
539
581
  if (!data.attributes && !data.innerHTML) {
540
582
  throw new Error('At least one of attributes or innerHTML must be provided');
@@ -568,7 +610,9 @@ export class WordPressBlockClient {
568
610
  updates: BlockBatchUpdateItem[],
569
611
  options: { verbose?: boolean } = {}
570
612
  ): Promise<BlockBatchUpdateResponse> {
571
- 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;
572
616
  if (!Array.isArray(updates) || updates.length === 0) {
573
617
  throw new Error('updates must be a non-empty array');
574
618
  }
@@ -600,7 +644,9 @@ export class WordPressBlockClient {
600
644
  postId: number,
601
645
  target: { ref?: string; flatIndex?: number }
602
646
  ): Promise<GetBlockResponse> {
603
- 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;
604
650
  const hasRef = typeof target.ref === 'string' && target.ref !== '';
605
651
  const hasIdx = typeof target.flatIndex === 'number';
606
652
  if (hasRef === hasIdx) {
@@ -629,14 +675,13 @@ export class WordPressBlockClient {
629
675
  before?: number;
630
676
  after_ref?: string;
631
677
  before_ref?: string;
632
- blocks: Array<{
633
- name: string;
634
- attributes?: Record<string, unknown>;
635
- innerHTML?: string;
636
- }>;
678
+ // Recursive: containers (groups/columns) nest children via innerBlocks.
679
+ blocks: BlockInput[];
637
680
  }
638
681
  ): Promise<BlockWriteResponse> {
639
- 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;
640
685
  if (!data.blocks || data.blocks.length === 0) {
641
686
  throw new Error('At least one block is required');
642
687
  }
@@ -661,7 +706,9 @@ export class WordPressBlockClient {
661
706
  index: number,
662
707
  count?: number
663
708
  ): Promise<BlockDeleteResponse> {
664
- 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;
665
712
  if (index < 0) throw new Error('Block index must be non-negative');
666
713
 
667
714
  const params: Record<string, string> = {};
@@ -686,7 +733,9 @@ export class WordPressBlockClient {
686
733
  ref: string,
687
734
  count?: number
688
735
  ): Promise<BlockDeleteResponse> {
689
- 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;
690
739
  if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
691
740
 
692
741
  const params: Record<string, string> = {};
@@ -713,14 +762,13 @@ export class WordPressBlockClient {
713
762
  data: {
714
763
  start: number;
715
764
  count: number;
716
- blocks: Array<{
717
- name: string;
718
- attributes?: Record<string, unknown>;
719
- innerHTML?: string;
720
- }>;
765
+ // Recursive: containers (groups/columns) nest children via innerBlocks.
766
+ blocks: BlockInput[];
721
767
  }
722
768
  ): Promise<BlockReplaceRangeResponse> {
723
- 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;
724
772
  if (typeof data.start !== 'number' || data.start < 0) {
725
773
  throw new Error('start must be a non-negative integer');
726
774
  }
@@ -750,13 +798,11 @@ export class WordPressBlockClient {
750
798
  */
751
799
  async replaceAllBlocks(
752
800
  postId: number,
753
- blocks: Array<{
754
- name: string;
755
- attributes?: Record<string, unknown>;
756
- innerHTML?: string;
757
- }>
801
+ blocks: BlockInput[]
758
802
  ): Promise<BlockWriteResponse> {
759
- 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;
760
806
  if (!blocks || blocks.length === 0) {
761
807
  throw new Error('At least one block is required for a full rewrite');
762
808
  }
@@ -780,9 +826,11 @@ export class WordPressBlockClient {
780
826
  * @returns Mutation result with revision IDs and optional warnings
781
827
  */
782
828
  async mutateBlockTree(postId: number, data: MutationRequest): Promise<MutationResponse> {
783
- if (postId === undefined || postId === null) {
829
+ const coercedPostId = coercePostId(postId, 'edit_block_tree');
830
+ if (coercedPostId === undefined) {
784
831
  throw new Error('Post ID is required');
785
832
  }
833
+ postId = coercedPostId;
786
834
 
787
835
  const response = await this.client.post<MutationResponse>(
788
836
  `/posts/${postId}/mutate`,
@@ -803,9 +851,11 @@ export class WordPressBlockClient {
803
851
  * @returns Revert result with revision IDs
804
852
  */
805
853
  async revertToRevision(postId: number, revisionId: number): Promise<unknown> {
806
- if (postId === undefined || postId === null) {
854
+ const coercedPostId = coercePostId(postId, 'revert_to_revision');
855
+ if (coercedPostId === undefined) {
807
856
  throw new Error('Post ID is required');
808
857
  }
858
+ postId = coercedPostId;
809
859
  const response = await this.client.post(`/posts/${postId}/revert`, { revision_id: revisionId });
810
860
  return response.data;
811
861
  }
@@ -830,7 +880,9 @@ export class WordPressBlockClient {
830
880
  synced?: boolean;
831
881
  }
832
882
  ): Promise<PatternInsertResponse> {
833
- 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;
834
886
  if (data.pattern_id === undefined || data.pattern_id === null) throw new Error('Pattern ID is required');
835
887
 
836
888
  const response = await this.client.post<PatternInsertResponse>(
@@ -869,9 +921,11 @@ export class WordPressBlockClient {
869
921
  * Use `status: trash` to trash; any non-trash status untrashes a trashed post.
870
922
  */
871
923
  async updatePost(postId: number, data: UpdatePostRequest): Promise<PostMutationResponse> {
872
- if (postId === undefined || postId === null) {
924
+ const coercedPostId = coercePostId(postId, 'update_post');
925
+ if (coercedPostId === undefined) {
873
926
  throw new Error('update_post: post_id is required');
874
927
  }
928
+ postId = coercedPostId;
875
929
  const response = await this.client.patch<PostMutationResponse>(`/posts/${postId}`, data);
876
930
  return response.data;
877
931
  }
@@ -907,19 +961,25 @@ export class WordPressBlockClient {
907
961
 
908
962
  if (args.path) {
909
963
  const fs = await import('node:fs/promises');
910
- const path = await import('node:path');
964
+ const nodePath = await import('node:path');
965
+ const { default: FormData } = await import('form-data');
911
966
  const data = await fs.readFile(args.path);
912
- const filename = args.filename ?? path.basename(args.path);
967
+ const filename = args.filename ?? nodePath.basename(args.path);
913
968
  const form = new FormData();
914
- form.append('file', new Blob([new Uint8Array(data)]), filename);
969
+ form.append('file', data, { filename, contentType: mimeForFilename(filename) });
915
970
  if (args.title) form.append('title', args.title);
916
971
  if (args.alt_text) form.append('alt_text', args.alt_text);
917
972
  if (args.caption) form.append('caption', args.caption);
918
973
  if (args.description) form.append('description', args.description);
919
974
  if (typeof args.post_id === 'number') form.append('post_id', String(args.post_id));
920
975
 
921
- // axios sets the multipart Content-Type and boundary automatically.
922
- const response = await this.client.post<UploadMediaResponse>('/media', form);
976
+ // The shared axios instance defaults Content-Type to application/json,
977
+ // which makes axios JSON-serialize the form and send NO file. The
978
+ // form-data package's getHeaders() supplies multipart/form-data WITH the
979
+ // boundary, overriding that default so this is a real multipart upload.
980
+ const response = await this.client.post<UploadMediaResponse>('/media', form, {
981
+ headers: form.getHeaders(),
982
+ });
923
983
  return response.data;
924
984
  }
925
985
 
@@ -937,18 +997,22 @@ export class WordPressBlockClient {
937
997
 
938
998
  /** Read all Yoast SEO metadata for a post. */
939
999
  async getYoastSEO(postId: number): Promise<YoastSEOMeta> {
940
- if (postId === undefined || postId === null) {
1000
+ const coercedPostId = coercePostId(postId, 'yoast_get_seo');
1001
+ if (coercedPostId === undefined) {
941
1002
  throw new Error('yoast_get_seo: post_id is required');
942
1003
  }
1004
+ postId = coercedPostId;
943
1005
  const response = await this.client.get<YoastSEOMeta>(`/yoast/${postId}`);
944
1006
  return response.data;
945
1007
  }
946
1008
 
947
1009
  /** Partial update of Yoast SEO fields on a single post. */
948
1010
  async updateYoastSEO(postId: number, fields: YoastUpdateRequest): Promise<YoastSEOMeta> {
949
- if (postId === undefined || postId === null) {
1011
+ const coercedPostId = coercePostId(postId, 'yoast_update_seo');
1012
+ if (coercedPostId === undefined) {
950
1013
  throw new Error('yoast_update_seo: post_id is required');
951
1014
  }
1015
+ postId = coercedPostId;
952
1016
  const response = await this.client.patch<YoastSEOMeta>(`/yoast/${postId}`, fields);
953
1017
  return response.data;
954
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
+ }