@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.
- package/README.md +34 -7
- package/dist/index.cjs +2556 -967
- package/package.json +12 -4
- package/src/__tests__/coerce.test.ts +57 -0
- package/src/__tests__/fixtures/error-envelopes.ts +19 -7
- package/src/__tests__/integration/dual-storage.test.ts +22 -26
- package/src/__tests__/integration/error-envelopes.test.ts +2 -2
- package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
- package/src/__tests__/integration/read-discovery.test.ts +313 -0
- package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
- package/src/__tests__/integration/write-surface.test.ts +603 -0
- package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
- package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
- package/src/__tests__/tools/posts/update_post.test.ts +14 -0
- package/src/__tests__/tools/read/get_block.test.ts +35 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
- package/src/__tests__/tools/read/pagination.test.ts +65 -0
- package/src/__tests__/tools/write/delete_block.test.ts +18 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
- package/src/__tests__/tools/write/update_block.test.ts +16 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
- package/src/__tests__/unit/instructions.test.ts +3 -3
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
- package/src/__tests__/unit/rest-url.test.ts +23 -0
- package/src/agent-guide.ts +85 -0
- package/src/client.ts +104 -40
- package/src/coerce.ts +41 -0
- package/src/config.ts +96 -0
- package/src/connect.ts +123 -38
- package/src/enrichers.ts +6 -2
- package/src/error-translator.ts +63 -11
- package/src/index.ts +49 -79
- package/src/instructions.ts +3 -3
- package/src/preferences.ts +56 -43
- package/src/rest-url.ts +18 -0
- package/src/tools/discovery.ts +10 -14
- package/src/tools/mutate.ts +21 -20
- package/src/tools/patterns.ts +3 -2
- package/src/tools/posts.ts +26 -26
- package/src/tools/read.ts +23 -6
- package/src/tools/write.ts +37 -36
- package/src/tools/yoast.ts +30 -31
- package/src/types.ts +45 -31
- 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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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:
|
|
546
|
+
data: BlockPatch
|
|
509
547
|
): Promise<BlockUpdateResponse> {
|
|
510
|
-
|
|
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:
|
|
575
|
+
data: BlockPatch
|
|
536
576
|
): Promise<BlockUpdateResponse> {
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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:
|
|
754
|
-
name: string;
|
|
755
|
-
attributes?: Record<string, unknown>;
|
|
756
|
-
innerHTML?: string;
|
|
757
|
-
}>
|
|
801
|
+
blocks: BlockInput[]
|
|
758
802
|
): Promise<BlockWriteResponse> {
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
967
|
+
const filename = args.filename ?? nodePath.basename(args.path);
|
|
913
968
|
const form = new FormData();
|
|
914
|
-
form.append('file',
|
|
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
|
|
922
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|