@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.
- package/dist/index.cjs +311 -195
- package/package.json +8 -4
- package/src/__tests__/coerce.test.ts +57 -0
- package/src/__tests__/fixtures/error-envelopes.ts +19 -7
- package/src/__tests__/integration/error-envelopes.test.ts +2 -2
- 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/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/enrichers/cbp-enricher.test.ts +21 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +100 -29
- 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/client.ts +53 -18
- package/src/coerce.ts +41 -0
- package/src/config.ts +96 -0
- package/src/connect.ts +17 -3
- package/src/enrichers.ts +6 -2
- package/src/error-translator.ts +62 -10
- package/src/index.ts +35 -27
- 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 +18 -12
- package/src/tools/patterns.ts +3 -2
- package/src/tools/posts.ts +26 -26
- package/src/tools/read.ts +7 -6
- package/src/tools/write.ts +26 -31
- package/src/tools/yoast.ts +30 -31
- 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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>)/,
|
package/src/error-translator.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 '
|
|
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
|
|
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 '
|
|
104
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
'
|
|
375
|
+
'Block MCP Server running on stdio (unconfigured — tool calls return a configuration error)'
|
|
368
376
|
);
|
|
369
|
-
|
|
377
|
+
return;
|
|
370
378
|
}
|
|
371
379
|
|
|
372
380
|
const client = new WordPressBlockClient({
|
|
373
|
-
wordpress_url:
|
|
381
|
+
wordpress_url: cfg.config.url,
|
|
374
382
|
auth: {
|
|
375
|
-
username:
|
|
376
|
-
application_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(
|
|
393
|
+
const instructions = await getInstructions(cfg.config.url);
|
|
386
394
|
|
|
387
395
|
const server = new McpServer(
|
|
388
396
|
{
|