@gravitykit/block-mcp 2.0.0-beta → 2.0.1
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 +2247 -774
- package/package.json +6 -2
- package/src/__tests__/integration/dual-storage.test.ts +22 -26
- 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/read/pagination.test.ts +65 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +1 -1
- package/src/agent-guide.ts +85 -0
- package/src/client.ts +51 -22
- package/src/connect.ts +106 -35
- package/src/error-translator.ts +1 -1
- package/src/index.ts +14 -52
- package/src/tools/mutate.ts +3 -8
- package/src/tools/read.ts +16 -0
- package/src/tools/write.ts +11 -5
- package/src/types.ts +22 -18
- package/src/validate-args.ts +109 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool tests: get_page_blocks pagination (limit + cursor).
|
|
3
|
+
*
|
|
4
|
+
* The REST route and the client both support `limit`/`cursor` pagination,
|
|
5
|
+
* but the MCP tool never exposed them — agents had no way to page through a
|
|
6
|
+
* huge post. These pin the tool-layer contract:
|
|
7
|
+
* - limit/cursor are declared on the inputSchema (dispatch validation
|
|
8
|
+
* rejects undeclared keys, so omission = unusable);
|
|
9
|
+
* - both forward to client.getPageBlocks;
|
|
10
|
+
* - the response's `pagination` (incl. next_cursor) passes through so the
|
|
11
|
+
* agent can fetch the next page.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
15
|
+
import { handleReadTool, READ_TOOLS } from '../../../tools/read.js';
|
|
16
|
+
import { makeMockClient } from '../../helpers/mock-client.js';
|
|
17
|
+
import { pageBlocksResponse } from '../../fixtures/rest-responses.js';
|
|
18
|
+
|
|
19
|
+
describe('get_page_blocks — pagination', () => {
|
|
20
|
+
let client: ReturnType<typeof makeMockClient>;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
client = makeMockClient();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('declares limit and cursor on the inputSchema', () => {
|
|
26
|
+
const tool = READ_TOOLS.find((t) => t.name === 'get_page_blocks');
|
|
27
|
+
const props = (tool?.inputSchema as { properties?: Record<string, unknown> })?.properties ?? {};
|
|
28
|
+
expect(props.limit).toBeDefined();
|
|
29
|
+
expect(props.cursor).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('forwards limit and cursor to the client', async () => {
|
|
33
|
+
client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
|
|
34
|
+
await handleReadTool(
|
|
35
|
+
'get_page_blocks',
|
|
36
|
+
{ post_id: 42, limit: 2, cursor: 'idx_2' },
|
|
37
|
+
client as never,
|
|
38
|
+
);
|
|
39
|
+
expect(client.getPageBlocks).toHaveBeenCalledWith(
|
|
40
|
+
42,
|
|
41
|
+
expect.objectContaining({ limit: 2, cursor: 'idx_2' }),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('passes the pagination meta (next_cursor) through to the agent', async () => {
|
|
46
|
+
client.getPageBlocks.mockResolvedValue({
|
|
47
|
+
...pageBlocksResponse,
|
|
48
|
+
pagination: { limit: 2, offset: 0, total: 4, next_cursor: 'idx_2' },
|
|
49
|
+
});
|
|
50
|
+
const result = (await handleReadTool(
|
|
51
|
+
'get_page_blocks',
|
|
52
|
+
{ post_id: 42, limit: 2 },
|
|
53
|
+
client as never,
|
|
54
|
+
)) as { pagination?: { next_cursor?: string | null } };
|
|
55
|
+
expect(result.pagination?.next_cursor).toBe('idx_2');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('omits pagination from the response when not paginating', async () => {
|
|
59
|
+
client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
|
|
60
|
+
const result = (await handleReadTool('get_page_blocks', { post_id: 42 }, client as never)) as {
|
|
61
|
+
pagination?: unknown;
|
|
62
|
+
};
|
|
63
|
+
expect(result.pagination).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -191,12 +191,9 @@ describe('Client — mutateBlockTree ref/path body', () => {
|
|
|
191
191
|
expect(body).not.toHaveProperty('ref');
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/mutate');
|
|
198
|
-
expect((req!.data as Record<string, unknown>).before_ref).toBe('blk_anchor');
|
|
199
|
-
});
|
|
194
|
+
// NOTE: `before_ref`/`before` were removed from MutationRequest — the REST
|
|
195
|
+
// route only accepts `destination`/`destination_ref`, so forwarding the
|
|
196
|
+
// aliases pinned a param the server silently dropped.
|
|
200
197
|
|
|
201
198
|
it('forwards destination_ref in move body', async () => {
|
|
202
199
|
const client = makeClient();
|
|
@@ -44,7 +44,7 @@ describe('translateWpError — null contract', () => {
|
|
|
44
44
|
describe('translateWpError — routing & auth', () => {
|
|
45
45
|
it('rest_no_route mentions the plugin name', () => {
|
|
46
46
|
const msg = translateWpError('rest_no_route', null)!;
|
|
47
|
-
expect(msg).toMatch(/
|
|
47
|
+
expect(msg).toMatch(/Block MCP/);
|
|
48
48
|
expect(msg).toMatch(/WORDPRESS_URL/);
|
|
49
49
|
});
|
|
50
50
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-guide resource content (block-mcp://agent-guide).
|
|
3
|
+
*
|
|
4
|
+
* Workflow guidance served to MCP clients as a resource and prompt seed.
|
|
5
|
+
* Lives in its own module (not index.ts, which boots the server on import)
|
|
6
|
+
* so the discoverability contract is testable: everything an agent needs to
|
|
7
|
+
* author content — including container nesting — must be learnable from the
|
|
8
|
+
* tool surface + this guide, never from reading this repo's source.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const AGENT_GUIDE_CONTENT = `# Block MCP — Agent Guide
|
|
12
|
+
|
|
13
|
+
## URL → post ID resolution
|
|
14
|
+
|
|
15
|
+
NEVER run curl, wget, or any bash/shell command to hit wp-json or resolve a URL to a post ID.
|
|
16
|
+
The MCP does this for you:
|
|
17
|
+
|
|
18
|
+
- \`get_page_blocks\` accepts \`url\` as an alternative to \`post_id\`. Pass the full URL or path; the server resolves it via \`url_to_postid\`.
|
|
19
|
+
- For explicit resolution (title, post_type, edit_url before editing), call \`resolve_url\`.
|
|
20
|
+
|
|
21
|
+
If the user says "change X on https://example.com/some-page/", your first tool call should be \`get_page_blocks({ url: "...", search: "keyword" })\` or \`resolve_url({ url: "..." })\` — not a shell command.
|
|
22
|
+
|
|
23
|
+
## Moving / reordering blocks
|
|
24
|
+
|
|
25
|
+
NEVER do a move as separate \`insert_blocks\` + \`delete_block\` calls — if the delete is skipped or fails, the page ends up with an orphaned clone of the original. The atomic primitive is the \`move\` op on \`edit_block_tree\`:
|
|
26
|
+
|
|
27
|
+
- Target the source with \`ref\` (the \`gk_ref\` from \`get_page_blocks\`) or \`path\`. Prefer \`ref\` — it survives sibling shifts; paths go stale the moment any earlier block is inserted or removed.
|
|
28
|
+
- Express the destination with \`destination_ref\` or \`destination\` (path). For path destinations, use **pre-move** indexing — write the path as if the source were still in place; the server adjusts indices after the removal.
|
|
29
|
+
- Use \`count\` to move N consecutive siblings in a single op.
|
|
30
|
+
- The server rejects moves into the source itself or any of its descendants.
|
|
31
|
+
- The whole \`edit_block_tree\` call is one revision, reversible via \`revert_to_revision\`.
|
|
32
|
+
|
|
33
|
+
If you must fall back to the flat-index tools, do \`insert_blocks\` + \`delete_block\` in the same turn and re-fetch \`get_page_blocks\` afterward to confirm exactly one copy remains.
|
|
34
|
+
|
|
35
|
+
## Building container blocks (groups, columns, callouts)
|
|
36
|
+
|
|
37
|
+
Every block def accepted by \`insert_blocks\`, \`replace_block_range\`, and \`rewrite_post_blocks\` nests recursively via \`innerBlocks\` — build a whole container (and its children) in ONE call instead of inserting pieces and moving them around.
|
|
38
|
+
|
|
39
|
+
- The container's \`innerHTML\` is its wrapper element only — an empty wrapper; children render inside it. E.g. \`core/group\` → \`<div class="wp-block-group"></div>\`, \`core/list\` → \`<ul class="wp-block-list"></ul>\`.
|
|
40
|
+
- Each entry in \`innerBlocks\` is a full block def and may nest further (columns → column → content).
|
|
41
|
+
- Include any style class in BOTH the \`className\` attribute and the wrapper \`innerHTML\`.
|
|
42
|
+
|
|
43
|
+
Example — a styled callout (a \`core/group\` wrapping a paragraph):
|
|
44
|
+
|
|
45
|
+
\`\`\`json
|
|
46
|
+
{
|
|
47
|
+
"name": "core/group",
|
|
48
|
+
"attributes": { "className": "is-style-callout-info", "layout": { "type": "constrained" } },
|
|
49
|
+
"innerHTML": "<div class=\\"wp-block-group is-style-callout-info\\"></div>",
|
|
50
|
+
"innerBlocks": [
|
|
51
|
+
{ "name": "core/paragraph", "innerHTML": "<p>Tip text here.</p>" }
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
Site-specific style conventions (e.g. callout class names) come from this site's instructions addendum — prefer those over inventing classes.
|
|
57
|
+
|
|
58
|
+
## Verifying writes
|
|
59
|
+
|
|
60
|
+
Every write echoes the canonical post-save snapshot. Use it. Do not fetch the public page to verify what saved.
|
|
61
|
+
|
|
62
|
+
- \`update_block\` always returns \`saved.inner_html\` + \`saved.attributes\` — the exact content that just landed in post_content. The write call IS the verification round-trip.
|
|
63
|
+
- \`update_blocks\` returns per-result \`saved\` only when called with \`verbose: true\` (default false to keep batch responses compact). Pass \`verbose: true\` if you need to confirm each item without a re-read.
|
|
64
|
+
- For after-the-fact re-reads of a single known block, use \`get_block({ post_id, ref })\` — returns the same \`saved\` shape, lighter than \`get_page_blocks\`.
|
|
65
|
+
|
|
66
|
+
For dynamic blocks (\`saved.is_dynamic: true\`, e.g. shortcodes, query loops, latest-posts), \`saved.inner_html\` is the stored template that runs at render time — not the rendered HTML the visitor sees. That's expected; the canonical state is the template.
|
|
67
|
+
|
|
68
|
+
## Block preferences (site-defined)
|
|
69
|
+
|
|
70
|
+
Block preference policy is configured per-site in the WordPress admin (the
|
|
71
|
+
gk-block-api Preferences option) and exposed dynamically. There is no
|
|
72
|
+
client-side hardcoded list of "good" vs "bad" namespaces.
|
|
73
|
+
|
|
74
|
+
How to discover the policy at runtime:
|
|
75
|
+
|
|
76
|
+
1. \`list_block_types\` returns blocks grouped by tier (PREFERRED / ACCEPTABLE / AVOID / LEGACY) for the current site. Use this when you need the full picture.
|
|
77
|
+
2. \`get_page_blocks\` annotates non-preferred blocks inline with \`preference.tier\` and (when configured) \`preference.suggested_replacement\`. Trust those fields — they reflect the live config.
|
|
78
|
+
3. \`insert_blocks\` rejects legacy-tier blocks with a \`legacy_block\` error that includes the rejected namespace, the suggested replacement, and a pointer back to this resource.
|
|
79
|
+
|
|
80
|
+
How to behave:
|
|
81
|
+
|
|
82
|
+
- Prefer the highest-tier blocks for new content. Defer to the server's classification rather than guessing from a namespace prefix.
|
|
83
|
+
- Reuse existing patterns before building from scratch — call \`list_patterns\` first.
|
|
84
|
+
- For patterns that need per-page customization, use \`synced: false\` to inline them.
|
|
85
|
+
- When you encounter legacy blocks on a page during a read, note them but do not replace unless asked.`;
|
package/src/client.ts
CHANGED
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios';
|
|
10
10
|
import { translateWpError } from './error-translator.js';
|
|
11
11
|
|
|
12
|
+
/** Best-effort MIME type from a filename extension, for multipart uploads. */
|
|
13
|
+
function mimeForFilename(filename: string): string {
|
|
14
|
+
const ext = filename.toLowerCase().split('.').pop() ?? '';
|
|
15
|
+
const map: Record<string, string> = {
|
|
16
|
+
png: 'image/png',
|
|
17
|
+
jpg: 'image/jpeg',
|
|
18
|
+
jpeg: 'image/jpeg',
|
|
19
|
+
gif: 'image/gif',
|
|
20
|
+
webp: 'image/webp',
|
|
21
|
+
svg: 'image/svg+xml',
|
|
22
|
+
pdf: 'application/pdf',
|
|
23
|
+
};
|
|
24
|
+
return map[ext] ?? 'application/octet-stream';
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
/** Max retry attempts for transient server / network errors. */
|
|
13
28
|
const MAX_RETRIES = 2;
|
|
14
29
|
|
|
@@ -91,6 +106,8 @@ import type {
|
|
|
91
106
|
MutationRequest,
|
|
92
107
|
MutationResponse,
|
|
93
108
|
ResolveUrlResponse,
|
|
109
|
+
BlockInput,
|
|
110
|
+
BlockPatch,
|
|
94
111
|
CreatePostRequest,
|
|
95
112
|
UpdatePostRequest,
|
|
96
113
|
PostMutationResponse,
|
|
@@ -116,7 +133,17 @@ interface PatternsResponse {
|
|
|
116
133
|
|
|
117
134
|
/** Response wrapper for page blocks. */
|
|
118
135
|
interface PageBlocksResponse {
|
|
136
|
+
post_id?: number;
|
|
137
|
+
summary?: Record<string, unknown>;
|
|
119
138
|
blocks: Block[];
|
|
139
|
+
/** Present when `limit` pagination is in play. */
|
|
140
|
+
pagination?: {
|
|
141
|
+
limit?: number;
|
|
142
|
+
offset?: number;
|
|
143
|
+
total?: number;
|
|
144
|
+
/** Pass back as `cursor` to fetch the next page; null on the last page. */
|
|
145
|
+
next_cursor?: string | null;
|
|
146
|
+
};
|
|
120
147
|
}
|
|
121
148
|
|
|
122
149
|
/**
|
|
@@ -415,6 +442,10 @@ export class WordPressBlockClient {
|
|
|
415
442
|
include_legacy_paths?: boolean;
|
|
416
443
|
/** When true (default), missing gk_refs are assigned and persisted. Pass false to skip the silent write side effect. */
|
|
417
444
|
persist_refs?: boolean;
|
|
445
|
+
/** Page size: top-level blocks per response. Pairs with `cursor`. */
|
|
446
|
+
limit?: number;
|
|
447
|
+
/** Opaque pagination cursor from a prior response's `next_cursor`. */
|
|
448
|
+
cursor?: string;
|
|
418
449
|
}
|
|
419
450
|
): Promise<PageBlocksResponse> {
|
|
420
451
|
if (postId === undefined || postId === null) {
|
|
@@ -434,6 +465,8 @@ export class WordPressBlockClient {
|
|
|
434
465
|
// when the param is omitted entirely.
|
|
435
466
|
if (params?.persist_refs === false) queryParams.persist_refs = 'false';
|
|
436
467
|
else if (params?.persist_refs === true) queryParams.persist_refs = 'true';
|
|
468
|
+
if (typeof params?.limit === 'number') queryParams.limit = String(params.limit);
|
|
469
|
+
if (params?.cursor) queryParams.cursor = params.cursor;
|
|
437
470
|
|
|
438
471
|
const response = await this.client.get<PageBlocksResponse>(
|
|
439
472
|
`/posts/${postId}/blocks`,
|
|
@@ -505,7 +538,7 @@ export class WordPressBlockClient {
|
|
|
505
538
|
async updateBlock(
|
|
506
539
|
postId: number,
|
|
507
540
|
index: number,
|
|
508
|
-
data:
|
|
541
|
+
data: BlockPatch
|
|
509
542
|
): Promise<BlockUpdateResponse> {
|
|
510
543
|
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
511
544
|
if (index < 0) throw new Error('Block index must be non-negative');
|
|
@@ -532,7 +565,7 @@ export class WordPressBlockClient {
|
|
|
532
565
|
async updateBlockByRef(
|
|
533
566
|
postId: number,
|
|
534
567
|
ref: string,
|
|
535
|
-
data:
|
|
568
|
+
data: BlockPatch
|
|
536
569
|
): Promise<BlockUpdateResponse> {
|
|
537
570
|
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
538
571
|
if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
|
|
@@ -629,11 +662,8 @@ export class WordPressBlockClient {
|
|
|
629
662
|
before?: number;
|
|
630
663
|
after_ref?: string;
|
|
631
664
|
before_ref?: string;
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
attributes?: Record<string, unknown>;
|
|
635
|
-
innerHTML?: string;
|
|
636
|
-
}>;
|
|
665
|
+
// Recursive: containers (groups/columns) nest children via innerBlocks.
|
|
666
|
+
blocks: BlockInput[];
|
|
637
667
|
}
|
|
638
668
|
): Promise<BlockWriteResponse> {
|
|
639
669
|
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
@@ -713,11 +743,8 @@ export class WordPressBlockClient {
|
|
|
713
743
|
data: {
|
|
714
744
|
start: number;
|
|
715
745
|
count: number;
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
attributes?: Record<string, unknown>;
|
|
719
|
-
innerHTML?: string;
|
|
720
|
-
}>;
|
|
746
|
+
// Recursive: containers (groups/columns) nest children via innerBlocks.
|
|
747
|
+
blocks: BlockInput[];
|
|
721
748
|
}
|
|
722
749
|
): Promise<BlockReplaceRangeResponse> {
|
|
723
750
|
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
@@ -750,11 +777,7 @@ export class WordPressBlockClient {
|
|
|
750
777
|
*/
|
|
751
778
|
async replaceAllBlocks(
|
|
752
779
|
postId: number,
|
|
753
|
-
blocks:
|
|
754
|
-
name: string;
|
|
755
|
-
attributes?: Record<string, unknown>;
|
|
756
|
-
innerHTML?: string;
|
|
757
|
-
}>
|
|
780
|
+
blocks: BlockInput[]
|
|
758
781
|
): Promise<BlockWriteResponse> {
|
|
759
782
|
if (postId === undefined || postId === null) throw new Error('Post ID is required');
|
|
760
783
|
if (!blocks || blocks.length === 0) {
|
|
@@ -907,19 +930,25 @@ export class WordPressBlockClient {
|
|
|
907
930
|
|
|
908
931
|
if (args.path) {
|
|
909
932
|
const fs = await import('node:fs/promises');
|
|
910
|
-
const
|
|
933
|
+
const nodePath = await import('node:path');
|
|
934
|
+
const { default: FormData } = await import('form-data');
|
|
911
935
|
const data = await fs.readFile(args.path);
|
|
912
|
-
const filename = args.filename ??
|
|
936
|
+
const filename = args.filename ?? nodePath.basename(args.path);
|
|
913
937
|
const form = new FormData();
|
|
914
|
-
form.append('file',
|
|
938
|
+
form.append('file', data, { filename, contentType: mimeForFilename(filename) });
|
|
915
939
|
if (args.title) form.append('title', args.title);
|
|
916
940
|
if (args.alt_text) form.append('alt_text', args.alt_text);
|
|
917
941
|
if (args.caption) form.append('caption', args.caption);
|
|
918
942
|
if (args.description) form.append('description', args.description);
|
|
919
943
|
if (typeof args.post_id === 'number') form.append('post_id', String(args.post_id));
|
|
920
944
|
|
|
921
|
-
// axios
|
|
922
|
-
|
|
945
|
+
// The shared axios instance defaults Content-Type to application/json,
|
|
946
|
+
// which makes axios JSON-serialize the form and send NO file. The
|
|
947
|
+
// form-data package's getHeaders() supplies multipart/form-data WITH the
|
|
948
|
+
// boundary, overriding that default so this is a real multipart upload.
|
|
949
|
+
const response = await this.client.post<UploadMediaResponse>('/media', form, {
|
|
950
|
+
headers: form.getHeaders(),
|
|
951
|
+
});
|
|
923
952
|
return response.data;
|
|
924
953
|
}
|
|
925
954
|
|
package/src/connect.ts
CHANGED
|
@@ -27,7 +27,6 @@ import * as cp from 'node:child_process';
|
|
|
27
27
|
export type ClientTarget =
|
|
28
28
|
| 'claude-code'
|
|
29
29
|
| 'cursor'
|
|
30
|
-
| 'chatgpt-desktop'
|
|
31
30
|
| 'claude-desktop'
|
|
32
31
|
| 'print';
|
|
33
32
|
|
|
@@ -35,7 +34,6 @@ export type ClientTarget =
|
|
|
35
34
|
const VALID_CLIENTS: ClientTarget[] = [
|
|
36
35
|
'claude-code',
|
|
37
36
|
'cursor',
|
|
38
|
-
'chatgpt-desktop',
|
|
39
37
|
'claude-desktop',
|
|
40
38
|
'print',
|
|
41
39
|
];
|
|
@@ -256,7 +254,7 @@ export function buildAuthorizeUrl(params: AuthorizeUrlParams): string {
|
|
|
256
254
|
const { site, callback, state, client } = params;
|
|
257
255
|
const base = `${site}/wp-admin/options-general.php`;
|
|
258
256
|
const query = new URLSearchParams({
|
|
259
|
-
page: 'gk-block-
|
|
257
|
+
page: 'gk-block-mcp-settings',
|
|
260
258
|
tab: 'connect',
|
|
261
259
|
gk_authorize: '1',
|
|
262
260
|
callback,
|
|
@@ -337,6 +335,50 @@ export function parseExchangeResponse(json: unknown): Credentials {
|
|
|
337
335
|
/** Default timeout (ms) for the credential-exchange request. */
|
|
338
336
|
export const EXCHANGE_FETCH_TIMEOUT_MS = 15_000;
|
|
339
337
|
|
|
338
|
+
/**
|
|
339
|
+
* TLS failure codes meaning the site's certificate chain is not trusted by
|
|
340
|
+
* Node's bundled CA store. Node ignores the operating system's trust store
|
|
341
|
+
* (macOS Keychain / Windows certificate store), so a local dev site whose CA
|
|
342
|
+
* the browser trusts — Laravel Herd/Valet, Local, OrbStack, mkcert — still
|
|
343
|
+
* fails here unless NODE_EXTRA_CA_CERTS points at that CA's .pem.
|
|
344
|
+
*/
|
|
345
|
+
const TLS_TRUST_ERROR_CODES = new Set([
|
|
346
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
347
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
348
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
349
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
350
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
351
|
+
'CERT_UNTRUSTED',
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
/** Actionable guidance appended when the exchange fails on an untrusted certificate. */
|
|
355
|
+
const CA_TRUST_HINT =
|
|
356
|
+
"The site's TLS certificate is not trusted by Node.js, which uses its own CA bundle rather than " +
|
|
357
|
+
"the operating system's trust store. If this is a local development site, re-run with " +
|
|
358
|
+
'NODE_EXTRA_CA_CERTS=<path to the root CA certificate (.pem) of the tool serving the site> — ' +
|
|
359
|
+
'Laravel Herd/Valet, Local, OrbStack, and mkcert each keep one in their config directory. ' +
|
|
360
|
+
'The variable is also copied into the generated MCP config so the server can reach the site too.';
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Render a network-level fetch failure as an actionable error message.
|
|
364
|
+
*
|
|
365
|
+
* `fetch` reports every network failure as a bare "fetch failed" TypeError and
|
|
366
|
+
* hides the real reason (TLS, DNS, refused connection) in `cause`. Surface the
|
|
367
|
+
* cause's code so the failure is diagnosable, and append the CA-trust hint when
|
|
368
|
+
* the code means Node rejected the site's certificate.
|
|
369
|
+
*/
|
|
370
|
+
function describeExchangeFetchError(url: string, err: unknown): string {
|
|
371
|
+
const error = err as Error & { cause?: { code?: unknown; message?: unknown } };
|
|
372
|
+
const causeCode = typeof error.cause?.code === 'string' ? error.cause.code : '';
|
|
373
|
+
const causeMessage = typeof error.cause?.message === 'string' ? error.cause.message : '';
|
|
374
|
+
const detail = causeCode || causeMessage;
|
|
375
|
+
const reason = detail ? `${error.message}: ${detail}` : error.message;
|
|
376
|
+
|
|
377
|
+
const isTrustFailure = TLS_TRUST_ERROR_CODES.has(causeCode);
|
|
378
|
+
const hint = isTrustFailure ? ` ${CA_TRUST_HINT}` : '';
|
|
379
|
+
return `Exchange failed: could not reach ${url} (${reason}).${hint}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
340
382
|
/**
|
|
341
383
|
* Exchange a single-use code for the credential set.
|
|
342
384
|
*
|
|
@@ -384,7 +426,7 @@ export async function exchangeCode(
|
|
|
384
426
|
signal: controller.signal,
|
|
385
427
|
});
|
|
386
428
|
} catch (err) {
|
|
387
|
-
throw new Error(
|
|
429
|
+
throw new Error(describeExchangeFetchError(url, err));
|
|
388
430
|
}
|
|
389
431
|
|
|
390
432
|
// 2xx (or any non-redirect) → done.
|
|
@@ -423,16 +465,31 @@ export async function exchangeCode(
|
|
|
423
465
|
|
|
424
466
|
// ── MCP config builders ───────────────────────────────────────────────────────
|
|
425
467
|
|
|
426
|
-
/**
|
|
427
|
-
|
|
468
|
+
/**
|
|
469
|
+
* Build the mcpServers entry for @gravitykit/block-mcp.
|
|
470
|
+
*
|
|
471
|
+
* When NODE_EXTRA_CA_CERTS is set (required to reach a local dev site whose
|
|
472
|
+
* certificate Node's bundled CA store rejects), it is copied into the entry's
|
|
473
|
+
* env: the MCP server talks to the same site over the same Node TLS stack, so
|
|
474
|
+
* it needs the same trust anchor the connector did.
|
|
475
|
+
*/
|
|
476
|
+
export function buildMcpEntry(
|
|
477
|
+
creds: Credentials,
|
|
478
|
+
extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
|
|
479
|
+
): McpServerEntry {
|
|
480
|
+
const env: Record<string, string> = {
|
|
481
|
+
WORDPRESS_URL: creds.site,
|
|
482
|
+
WORDPRESS_USER: creds.user,
|
|
483
|
+
WORDPRESS_APP_PASSWORD: creds.password,
|
|
484
|
+
};
|
|
485
|
+
const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
|
|
486
|
+
if (hasCaCerts) {
|
|
487
|
+
env.NODE_EXTRA_CA_CERTS = extraCaCerts;
|
|
488
|
+
}
|
|
428
489
|
return {
|
|
429
490
|
command: 'npx',
|
|
430
491
|
args: ['-y', '@gravitykit/block-mcp'],
|
|
431
|
-
env
|
|
432
|
-
WORDPRESS_URL: creds.site,
|
|
433
|
-
WORDPRESS_USER: creds.user,
|
|
434
|
-
WORDPRESS_APP_PASSWORD: creds.password,
|
|
435
|
-
},
|
|
492
|
+
env,
|
|
436
493
|
};
|
|
437
494
|
}
|
|
438
495
|
|
|
@@ -507,35 +564,57 @@ export function cursorConfigPath(): string {
|
|
|
507
564
|
* That residual exposure is inherent to the `claude mcp add` interface; the
|
|
508
565
|
* config it then writes is owned and protected by Claude Code.
|
|
509
566
|
*/
|
|
510
|
-
export function claudeCodeAddArgs(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
'user',
|
|
567
|
+
export function claudeCodeAddArgs(
|
|
568
|
+
creds: Credentials,
|
|
569
|
+
name: string = 'block-mcp',
|
|
570
|
+
extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
|
|
571
|
+
): string[] {
|
|
572
|
+
const envArgs = [
|
|
517
573
|
'--env',
|
|
518
574
|
`WORDPRESS_URL=${creds.site}`,
|
|
519
575
|
'--env',
|
|
520
576
|
`WORDPRESS_USER=${creds.user}`,
|
|
521
577
|
'--env',
|
|
522
578
|
`WORDPRESS_APP_PASSWORD=${creds.password}`,
|
|
523
|
-
'--',
|
|
524
|
-
'npx',
|
|
525
|
-
'-y',
|
|
526
|
-
'@gravitykit/block-mcp',
|
|
527
579
|
];
|
|
580
|
+
// Same propagation as buildMcpEntry: the server needs the CA bundle that the
|
|
581
|
+
// connector needed to reach the site.
|
|
582
|
+
const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
|
|
583
|
+
if (hasCaCerts) {
|
|
584
|
+
envArgs.push('--env', `NODE_EXTRA_CA_CERTS=${extraCaCerts}`);
|
|
585
|
+
}
|
|
586
|
+
return ['mcp', 'add', name, '--scope', 'user', ...envArgs, '--', 'npx', '-y', '@gravitykit/block-mcp'];
|
|
528
587
|
}
|
|
529
588
|
|
|
530
589
|
// ── Config file writers ───────────────────────────────────────────────────────
|
|
531
590
|
|
|
532
|
-
/**
|
|
533
|
-
|
|
591
|
+
/**
|
|
592
|
+
* Read a JSON file, returning the default ONLY when the file does not exist.
|
|
593
|
+
*
|
|
594
|
+
* A missing config (ENOENT) is the normal first-run case, so fall back to the
|
|
595
|
+
* default. Any other failure — an unreadable file or, critically, malformed
|
|
596
|
+
* JSON — is rethrown so the caller surfaces it instead of silently treating a
|
|
597
|
+
* corrupt config as empty and overwriting it (which would clobber every other
|
|
598
|
+
* MCP server the user had configured).
|
|
599
|
+
*/
|
|
600
|
+
export function readJsonFile(filePath: string, defaultValue: McpConfig): McpConfig {
|
|
601
|
+
let raw: string;
|
|
602
|
+
try {
|
|
603
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
604
|
+
} catch (err) {
|
|
605
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
606
|
+
return defaultValue;
|
|
607
|
+
}
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
|
|
534
611
|
try {
|
|
535
|
-
const raw = fs.readFileSync(filePath, 'utf8');
|
|
536
612
|
return JSON.parse(raw) as McpConfig;
|
|
537
|
-
} catch {
|
|
538
|
-
|
|
613
|
+
} catch (err) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`Could not parse the existing MCP config at ${filePath}: ${(err as Error).message}. ` +
|
|
616
|
+
'Fix or remove the file, then re-run connect — refusing to overwrite it.'
|
|
617
|
+
);
|
|
539
618
|
}
|
|
540
619
|
}
|
|
541
620
|
|
|
@@ -857,14 +936,6 @@ export async function runConnect(
|
|
|
857
936
|
break;
|
|
858
937
|
}
|
|
859
938
|
|
|
860
|
-
case 'chatgpt-desktop': {
|
|
861
|
-
// ChatGPT Desktop does not have a standardised config path yet.
|
|
862
|
-
// Print the JSON block so the user can paste it (secret needed here).
|
|
863
|
-
console.log(`\n✓ Authorized! Paste the following into ChatGPT Desktop's MCP config:\n`);
|
|
864
|
-
printConfig(creds, true, args.name);
|
|
865
|
-
break;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
939
|
case 'print':
|
|
869
940
|
default:
|
|
870
941
|
printConfig(creds, args.reveal, args.name);
|
package/src/error-translator.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function translateWpError(code: string | undefined, data: unknown): strin
|
|
|
67
67
|
switch (code) {
|
|
68
68
|
// ── Routing / auth ─────────────────────────────────────────────
|
|
69
69
|
case 'rest_no_route':
|
|
70
|
-
return 'REST route not found at this site. Confirm the
|
|
70
|
+
return 'REST route not found at this site. Confirm the Block MCP plugin is active and the WORDPRESS_URL is correct.';
|
|
71
71
|
|
|
72
72
|
case 'rest_forbidden':
|
|
73
73
|
case 'rest_cannot_edit':
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,8 @@ import { POST_TOOLS, handlePostTool } from './tools/posts.js';
|
|
|
48
48
|
import { TERM_TOOLS, handleTermTool } from './tools/terms.js';
|
|
49
49
|
import { MEDIA_TOOLS, handleMediaTool } from './tools/media.js';
|
|
50
50
|
import { YOAST_TOOLS, handleYoastTool } from './tools/yoast.js';
|
|
51
|
+
import { validateToolArgs } from './validate-args.js';
|
|
52
|
+
import { AGENT_GUIDE_CONTENT } from './agent-guide.js';
|
|
51
53
|
import { runConnect } from './connect.js';
|
|
52
54
|
|
|
53
55
|
// Environment variables are passed by the parent process (Claude Code, Hermes, etc.)
|
|
@@ -137,58 +139,8 @@ const AGENT_GUIDE_RESOURCE_URI = 'block-mcp://agent-guide';
|
|
|
137
139
|
// integrations resolving the old URI don't 404.
|
|
138
140
|
const LEGACY_PREFERENCES_RESOURCE_URI = 'block-mcp://block-preferences';
|
|
139
141
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
## URL → post ID resolution
|
|
143
|
-
|
|
144
|
-
NEVER run curl, wget, or any bash/shell command to hit wp-json or resolve a URL to a post ID.
|
|
145
|
-
The MCP does this for you:
|
|
146
|
-
|
|
147
|
-
- \`get_page_blocks\` accepts \`url\` as an alternative to \`post_id\`. Pass the full URL or path; the server resolves it via \`url_to_postid\`.
|
|
148
|
-
- For explicit resolution (title, post_type, edit_url before editing), call \`resolve_url\`.
|
|
149
|
-
|
|
150
|
-
If the user says "change X on https://example.com/some-page/", your first tool call should be \`get_page_blocks({ url: "...", search: "keyword" })\` or \`resolve_url({ url: "..." })\` — not a shell command.
|
|
151
|
-
|
|
152
|
-
## Moving / reordering blocks
|
|
153
|
-
|
|
154
|
-
NEVER do a move as separate \`insert_blocks\` + \`delete_block\` calls — if the delete is skipped or fails, the page ends up with an orphaned clone of the original. The atomic primitive is the \`move\` op on \`edit_block_tree\`:
|
|
155
|
-
|
|
156
|
-
- Target the source with \`ref\` (the \`gk_ref\` from \`get_page_blocks\`) or \`path\`. Prefer \`ref\` — it survives sibling shifts; paths go stale the moment any earlier block is inserted or removed.
|
|
157
|
-
- Express the destination with \`destination_ref\` or \`destination\` (path). For path destinations, use **pre-move** indexing — write the path as if the source were still in place; the server adjusts indices after the removal.
|
|
158
|
-
- Use \`count\` to move N consecutive siblings in a single op.
|
|
159
|
-
- The server rejects moves into the source itself or any of its descendants.
|
|
160
|
-
- The whole \`edit_block_tree\` call is one revision, reversible via \`revert_to_revision\`.
|
|
161
|
-
|
|
162
|
-
If you must fall back to the flat-index tools, do \`insert_blocks\` + \`delete_block\` in the same turn and re-fetch \`get_page_blocks\` afterward to confirm exactly one copy remains.
|
|
163
|
-
|
|
164
|
-
## Verifying writes
|
|
165
|
-
|
|
166
|
-
Every write echoes the canonical post-save snapshot. Use it. Do not fetch the public page to verify what saved.
|
|
167
|
-
|
|
168
|
-
- \`update_block\` always returns \`saved.inner_html\` + \`saved.attributes\` — the exact content that just landed in post_content. The write call IS the verification round-trip.
|
|
169
|
-
- \`update_blocks\` returns per-result \`saved\` only when called with \`verbose: true\` (default false to keep batch responses compact). Pass \`verbose: true\` if you need to confirm each item without a re-read.
|
|
170
|
-
- For after-the-fact re-reads of a single known block, use \`get_block({ post_id, ref })\` — returns the same \`saved\` shape, lighter than \`get_page_blocks\`.
|
|
171
|
-
|
|
172
|
-
For dynamic blocks (\`saved.is_dynamic: true\`, e.g. shortcodes, query loops, latest-posts), \`saved.inner_html\` is the stored template that runs at render time — not the rendered HTML the visitor sees. That's expected; the canonical state is the template.
|
|
173
|
-
|
|
174
|
-
## Block preferences (site-defined)
|
|
175
|
-
|
|
176
|
-
Block preference policy is configured per-site in the WordPress admin (the
|
|
177
|
-
gk-block-api Preferences option) and exposed dynamically. There is no
|
|
178
|
-
client-side hardcoded list of "good" vs "bad" namespaces.
|
|
179
|
-
|
|
180
|
-
How to discover the policy at runtime:
|
|
181
|
-
|
|
182
|
-
1. \`list_block_types\` returns blocks grouped by tier (PREFERRED / ACCEPTABLE / AVOID / LEGACY) for the current site. Use this when you need the full picture.
|
|
183
|
-
2. \`get_page_blocks\` annotates non-preferred blocks inline with \`preference.tier\` and (when configured) \`preference.suggested_replacement\`. Trust those fields — they reflect the live config.
|
|
184
|
-
3. \`insert_blocks\` rejects legacy-tier blocks with a \`legacy_block\` error that includes the rejected namespace, the suggested replacement, and a pointer back to this resource.
|
|
185
|
-
|
|
186
|
-
How to behave:
|
|
187
|
-
|
|
188
|
-
- Prefer the highest-tier blocks for new content. Defer to the server's classification rather than guessing from a namespace prefix.
|
|
189
|
-
- Reuse existing patterns before building from scratch — call \`list_patterns\` first.
|
|
190
|
-
- For patterns that need per-page customization, use \`synced: false\` to inline them.
|
|
191
|
-
- When you encounter legacy blocks on a page during a read, note them but do not replace unless asked.`;
|
|
142
|
+
// AGENT_GUIDE_CONTENT lives in src/agent-guide.ts so the discoverability
|
|
143
|
+
// contract is testable (index.ts boots the server on import).
|
|
192
144
|
|
|
193
145
|
// ============================================
|
|
194
146
|
// Handler registration
|
|
@@ -219,6 +171,16 @@ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
219
171
|
if (!handle) {
|
|
220
172
|
throw new Error(`Unknown tool: ${name}`);
|
|
221
173
|
}
|
|
174
|
+
|
|
175
|
+
// Reject unknown / misnamed arguments before the handler runs. The MCP SDK
|
|
176
|
+
// does not validate against inputSchema, so without this an unrecognized key
|
|
177
|
+
// (e.g. `after` instead of `after_top_level`) is silently dropped and the
|
|
178
|
+
// tool runs with defaults — a silent wrong-place write rather than an error.
|
|
179
|
+
const def = ALL_TOOLS.find((t) => t.name === name) as
|
|
180
|
+
| { inputSchema?: { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: unknown } }
|
|
181
|
+
| undefined;
|
|
182
|
+
validateToolArgs(name, def?.inputSchema, toolArgs);
|
|
183
|
+
|
|
222
184
|
const result = await handle(name, toolArgs, client);
|
|
223
185
|
|
|
224
186
|
// Emit `structuredContent` alongside the text fallback when the tool
|