@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
package/src/tools/mutate.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { WordPressBlockClient } from '../client.js';
|
|
13
13
|
import type { MutationOp, MutationRequest, MutationResponse, StaticBlockWarning } from '../types.js';
|
|
14
14
|
import { formatPreferenceWarning } from '../preferences.js';
|
|
15
|
+
import { BLOCK_INPUT_SCHEMA } from './write.js';
|
|
15
16
|
|
|
16
17
|
const OPS: readonly MutationOp[] = [
|
|
17
18
|
'update-attrs',
|
|
@@ -61,14 +62,8 @@ export const MUTATE_TOOLS = [{
|
|
|
61
62
|
attributes: { type: 'object', description: 'update-attrs: attributes to merge.' },
|
|
62
63
|
innerHTML: { type: 'string', description: 'update-html: replacement innerHTML.' },
|
|
63
64
|
block: {
|
|
64
|
-
|
|
65
|
-
description: 'replace-block / insert-child:
|
|
66
|
-
properties: {
|
|
67
|
-
name: { type: 'string', description: 'Fully-qualified block name.' },
|
|
68
|
-
attributes: { type: 'object' },
|
|
69
|
-
innerHTML: { type: 'string' },
|
|
70
|
-
innerBlocks: { type: 'array' },
|
|
71
|
-
},
|
|
65
|
+
...BLOCK_INPUT_SCHEMA,
|
|
66
|
+
description: 'replace-block / insert-child: a block def. Nests recursively via `innerBlocks`; containers need their empty wrapper `innerHTML`.',
|
|
72
67
|
},
|
|
73
68
|
wrapper: {
|
|
74
69
|
type: 'object',
|
package/src/tools/read.ts
CHANGED
|
@@ -71,6 +71,14 @@ export const READ_TOOLS = [
|
|
|
71
71
|
type: 'boolean',
|
|
72
72
|
description: 'Default true. When true, missing block refs (attrs.metadata.gk_ref) are assigned and persisted silently (no revision created). Set false for read-only callers that don\'t want write side effects — refs in the response will not resolve in subsequent mutation calls.',
|
|
73
73
|
},
|
|
74
|
+
limit: {
|
|
75
|
+
type: 'number',
|
|
76
|
+
description: 'Page size: top-level blocks per response. When more remain, the response carries `pagination.next_cursor` — pass it back via `cursor` to fetch the next page.',
|
|
77
|
+
},
|
|
78
|
+
cursor: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: "Opaque cursor from a previous response's `pagination.next_cursor`. Omit on the first request.",
|
|
81
|
+
},
|
|
74
82
|
},
|
|
75
83
|
},
|
|
76
84
|
},
|
|
@@ -139,6 +147,8 @@ export async function handleReadTool(
|
|
|
139
147
|
const summaryOnly = args.summary_only as boolean | undefined;
|
|
140
148
|
const includeLegacyPaths = args.include_legacy_paths as boolean | undefined;
|
|
141
149
|
const persistRefs = args.persist_refs as boolean | undefined;
|
|
150
|
+
const limit = args.limit as number | undefined;
|
|
151
|
+
const cursor = args.cursor as string | undefined;
|
|
142
152
|
|
|
143
153
|
if ((postId === undefined || postId === null) && !url) {
|
|
144
154
|
throw new Error('Either post_id or url is required');
|
|
@@ -154,6 +164,8 @@ export async function handleReadTool(
|
|
|
154
164
|
summary_only: summaryOnly,
|
|
155
165
|
include_legacy_paths: includeLegacyPaths,
|
|
156
166
|
...(persistRefs !== undefined ? { persist_refs: persistRefs } : {}),
|
|
167
|
+
...(limit !== undefined ? { limit } : {}),
|
|
168
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
157
169
|
});
|
|
158
170
|
|
|
159
171
|
// summary_only mode: return server summary as-is.
|
|
@@ -166,12 +178,16 @@ export async function handleReadTool(
|
|
|
166
178
|
|
|
167
179
|
const enriched = enrichBlockList(response.blocks || []);
|
|
168
180
|
|
|
181
|
+
// Surface pagination meta (incl. next_cursor) so the agent can page.
|
|
182
|
+
const pagination = (response as { pagination?: unknown }).pagination;
|
|
183
|
+
|
|
169
184
|
return {
|
|
170
185
|
post_id: postId,
|
|
171
186
|
summary: (response as { summary?: unknown }).summary,
|
|
172
187
|
blocks: enriched.blocks,
|
|
173
188
|
block_count: enriched.blocks.length,
|
|
174
189
|
warnings: enriched.warnings,
|
|
190
|
+
...(pagination !== undefined ? { pagination } : {}),
|
|
175
191
|
};
|
|
176
192
|
}
|
|
177
193
|
|
package/src/tools/write.ts
CHANGED
|
@@ -192,7 +192,13 @@ export const WRITE_TOOLS = [
|
|
|
192
192
|
{
|
|
193
193
|
name: 'insert_blocks',
|
|
194
194
|
description:
|
|
195
|
-
'Insert
|
|
195
|
+
'Insert top-level blocks into a post.\n\n' +
|
|
196
|
+
'POSITIONING — use exactly ONE anchor (any other key, e.g. `after`, `before`, `position`, is rejected):\n' +
|
|
197
|
+
'- `before_ref` / `after_ref` — gk_ref from get_page_blocks. Recommended: refs survive sibling shifts.\n' +
|
|
198
|
+
'- `before_top_level` / `after_top_level` — top_level_counter position.\n' +
|
|
199
|
+
'- Prepend at the very top: `before_top_level: 0`. Append at the end: omit all anchors.\n\n' +
|
|
200
|
+
'CONTENT — legacy-tier blocks are rejected per the site policy. Blocks with HTML-sourced attributes (e.g. core/paragraph `content`, core/image `url`) must include matching `innerHTML`; attribute-only inserts fail with `inner_html_required`.\n\n' +
|
|
201
|
+
'VERIFY — the response\'s inserted[] returns `ref`, `path`, and `top_level_counter`: confirm the block landed where you intended before moving on. The refs let you chain edit_block_tree without re-reading.',
|
|
196
202
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Insert blocks' },
|
|
197
203
|
outputSchema: INSERTED_REFS_SCHEMA,
|
|
198
204
|
inputSchema: {
|
|
@@ -201,7 +207,7 @@ export const WRITE_TOOLS = [
|
|
|
201
207
|
post_id: { type: 'number', description: 'Post ID.' },
|
|
202
208
|
after_top_level: {
|
|
203
209
|
type: ['number', 'string'],
|
|
204
|
-
description: 'top_level_counter to insert AFTER
|
|
210
|
+
description: 'top_level_counter to insert AFTER (omit or -1 = append). To prepend at the very top, prefer `before_top_level: 0`.',
|
|
205
211
|
},
|
|
206
212
|
before_top_level: {
|
|
207
213
|
type: 'number',
|
|
@@ -217,7 +223,7 @@ export const WRITE_TOOLS = [
|
|
|
217
223
|
},
|
|
218
224
|
blocks: {
|
|
219
225
|
type: 'array',
|
|
220
|
-
description: 'Blocks to insert.',
|
|
226
|
+
description: 'Blocks to insert. Each def nests recursively via `innerBlocks` — build a whole container (group/columns/callout) with its children in this one call; give the container its empty wrapper `innerHTML` (e.g. \'<div class="wp-block-group"></div>\').',
|
|
221
227
|
items: BLOCK_INPUT_SCHEMA,
|
|
222
228
|
},
|
|
223
229
|
},
|
|
@@ -260,7 +266,7 @@ export const WRITE_TOOLS = [
|
|
|
260
266
|
post_id: { type: 'number', description: 'Post ID.' },
|
|
261
267
|
start: { type: 'number', description: 'top_level_counter of first block to replace.' },
|
|
262
268
|
count: { type: 'number', description: 'How many top-level blocks to remove. Pass 0 to insert without removing.' },
|
|
263
|
-
blocks: { type: 'array', description: 'Replacement blocks. May be empty (pure delete) or any length.', items: BLOCK_INPUT_SCHEMA },
|
|
269
|
+
blocks: { type: 'array', description: 'Replacement blocks. May be empty (pure delete) or any length. Each def nests recursively via `innerBlocks` for containers (group/columns/callout) — give the container its empty wrapper `innerHTML`.', items: BLOCK_INPUT_SCHEMA },
|
|
264
270
|
},
|
|
265
271
|
required: ['post_id', 'start', 'count', 'blocks'],
|
|
266
272
|
},
|
|
@@ -297,7 +303,7 @@ export const WRITE_TOOLS = [
|
|
|
297
303
|
type: 'object' as const,
|
|
298
304
|
properties: {
|
|
299
305
|
post_id: { type: 'number', description: 'Post ID.' },
|
|
300
|
-
blocks: { type: 'array', description: 'Complete blocks array (replaces all).', items: BLOCK_INPUT_SCHEMA },
|
|
306
|
+
blocks: { type: 'array', description: 'Complete blocks array (replaces all). Each def nests recursively via `innerBlocks` for containers (group/columns/callout) — give the container its empty wrapper `innerHTML`.', items: BLOCK_INPUT_SCHEMA },
|
|
301
307
|
},
|
|
302
308
|
required: ['post_id', 'blocks'],
|
|
303
309
|
},
|
package/src/types.ts
CHANGED
|
@@ -275,8 +275,12 @@ export interface BlockWriteResponse {
|
|
|
275
275
|
/** Response from block delete (DELETE) operations. */
|
|
276
276
|
export interface BlockDeleteResponse {
|
|
277
277
|
success: boolean;
|
|
278
|
+
/** Flat index of the first removed block */
|
|
279
|
+
deleted_index: number;
|
|
278
280
|
/** Number of blocks removed */
|
|
279
|
-
|
|
281
|
+
deleted_count: number;
|
|
282
|
+
/** Non-fatal warnings emitted by the delete */
|
|
283
|
+
warnings: unknown[];
|
|
280
284
|
/** WordPress revision ID of the pre-edit snapshot */
|
|
281
285
|
before_revision_id: number;
|
|
282
286
|
/** WordPress revision ID of the post-edit state */
|
|
@@ -566,16 +570,8 @@ export interface MutationRequest {
|
|
|
566
570
|
ref?: string;
|
|
567
571
|
attributes?: Record<string, unknown>;
|
|
568
572
|
innerHTML?: string;
|
|
569
|
-
block
|
|
570
|
-
|
|
571
|
-
attributes?: Record<string, unknown>;
|
|
572
|
-
innerHTML?: string;
|
|
573
|
-
innerBlocks?: Array<{
|
|
574
|
-
name: string;
|
|
575
|
-
attributes?: Record<string, unknown>;
|
|
576
|
-
innerHTML?: string;
|
|
577
|
-
}>;
|
|
578
|
-
};
|
|
573
|
+
/** replace-block / insert-child payload. Nests recursively via innerBlocks. */
|
|
574
|
+
block?: BlockInput;
|
|
579
575
|
wrapper?: {
|
|
580
576
|
name?: string;
|
|
581
577
|
attributes?: Record<string, unknown>;
|
|
@@ -584,12 +580,10 @@ export interface MutationRequest {
|
|
|
584
580
|
destination?: number[];
|
|
585
581
|
/** Alternative to `destination` — resolve from a ref instead of a path. */
|
|
586
582
|
destination_ref?: string;
|
|
587
|
-
/** Alias for destination — path of block to insert BEFORE (pre-move indexing). */
|
|
588
|
-
before?: number[];
|
|
589
|
-
/** Alternative to `before` — resolve from a ref instead of a path. */
|
|
590
|
-
before_ref?: string;
|
|
591
583
|
/** Number of consecutive blocks to move/operate on. Default: 1. */
|
|
592
584
|
count?: number;
|
|
585
|
+
/** Preview the mutation without writing. */
|
|
586
|
+
dry_run?: boolean;
|
|
593
587
|
}
|
|
594
588
|
|
|
595
589
|
/** Warning about static block markup staleness. */
|
|
@@ -622,7 +616,11 @@ export interface MutationResponse {
|
|
|
622
616
|
// v1.2 — Docs Lifecycle: Posts
|
|
623
617
|
// ============================================
|
|
624
618
|
|
|
625
|
-
/**
|
|
619
|
+
/**
|
|
620
|
+
* A block in structured form — the def shape accepted by every write that
|
|
621
|
+
* creates blocks (create_post `blocks`, insert_blocks, replace ranges/all,
|
|
622
|
+
* mutate replace-block / insert-child). Nests recursively via `innerBlocks`.
|
|
623
|
+
*/
|
|
626
624
|
export interface BlockInput {
|
|
627
625
|
name: string;
|
|
628
626
|
attributes?: Record<string, unknown>;
|
|
@@ -631,6 +629,12 @@ export interface BlockInput {
|
|
|
631
629
|
innerContent?: unknown[];
|
|
632
630
|
}
|
|
633
631
|
|
|
632
|
+
/** Partial-update payload for a single existing block (update_block / by-ref / batch items). */
|
|
633
|
+
export interface BlockPatch {
|
|
634
|
+
attributes?: Record<string, unknown>;
|
|
635
|
+
innerHTML?: string;
|
|
636
|
+
}
|
|
637
|
+
|
|
634
638
|
export type CreatePostStatus = 'draft' | 'pending' | 'private' | 'publish' | 'future';
|
|
635
639
|
export type UpdatePostStatus = CreatePostStatus | 'trash';
|
|
636
640
|
export type CommentPingStatus = 'open' | 'closed';
|
|
@@ -857,6 +861,6 @@ export interface YoastBulkUpdateItem extends YoastSEOFields {
|
|
|
857
861
|
}
|
|
858
862
|
|
|
859
863
|
export type YoastBulkUpdateResponse = Array<
|
|
860
|
-
|
|
|
861
|
-
| { post_id
|
|
864
|
+
| YoastSEOMeta
|
|
865
|
+
| { post_id?: number; error: string }
|
|
862
866
|
>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-argument validation for the MCP dispatch.
|
|
3
|
+
*
|
|
4
|
+
* The MCP SDK does not validate CallTool arguments against a tool's
|
|
5
|
+
* `inputSchema`, so a misnamed parameter (e.g. `after` instead of
|
|
6
|
+
* `after_top_level`) was silently dropped and the handler ran with its
|
|
7
|
+
* defaults — turning a positioning mistake into a wrong-but-"successful" write.
|
|
8
|
+
*
|
|
9
|
+
* This rejects unknown top-level keys loudly — naming the valid ones with a
|
|
10
|
+
* "did you mean" suggestion — and flags missing required keys, before the
|
|
11
|
+
* handler runs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface InputSchema {
|
|
15
|
+
properties?: Record<string, unknown>;
|
|
16
|
+
required?: string[];
|
|
17
|
+
additionalProperties?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Throw a descriptive Error if `args` carries keys the tool doesn't declare, or
|
|
22
|
+
* omits a required key. No-op when the tool has no schema, or when the schema
|
|
23
|
+
* explicitly opts into additional properties.
|
|
24
|
+
*/
|
|
25
|
+
export function validateToolArgs(
|
|
26
|
+
toolName: string,
|
|
27
|
+
inputSchema: InputSchema | undefined,
|
|
28
|
+
args: Record<string, unknown>,
|
|
29
|
+
): void {
|
|
30
|
+
const properties = inputSchema?.properties;
|
|
31
|
+
if (!properties) {
|
|
32
|
+
return; // No declared shape — nothing to validate against.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const known = Object.keys(properties);
|
|
36
|
+
const provided = Object.keys(args ?? {});
|
|
37
|
+
|
|
38
|
+
// Respect an explicit opt-in to extra props (a map-shaped schema sets
|
|
39
|
+
// `additionalProperties` to a schema object rather than leaving it absent).
|
|
40
|
+
const allowsExtra =
|
|
41
|
+
inputSchema?.additionalProperties !== undefined && inputSchema.additionalProperties !== false;
|
|
42
|
+
|
|
43
|
+
if (!allowsExtra) {
|
|
44
|
+
const unknown = provided.filter((k) => !known.includes(k));
|
|
45
|
+
if (unknown.length > 0) {
|
|
46
|
+
const parts = unknown.map((k) => {
|
|
47
|
+
const near = closestKey(k, known);
|
|
48
|
+
return near ? `'${k}' (did you mean '${near}'?)` : `'${k}'`;
|
|
49
|
+
});
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Unknown parameter(s) for ${toolName}: ${parts.join(', ')}. ` +
|
|
52
|
+
`Valid parameters: ${known.join(', ')}.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const required = inputSchema?.required ?? [];
|
|
58
|
+
const missing = required.filter((r) => !(r in (args ?? {})));
|
|
59
|
+
if (missing.length > 0) {
|
|
60
|
+
throw new Error(`Missing required parameter(s) for ${toolName}: ${missing.join(', ')}.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Best-effort suggestion for a mistyped key. Prefers a known key that shares a
|
|
66
|
+
* prefix/substring with the input — the common agent mistake is a shortened
|
|
67
|
+
* name like `after` for `after_top_level` — then falls back to edit distance.
|
|
68
|
+
*/
|
|
69
|
+
function closestKey(input: string, candidates: string[]): string | null {
|
|
70
|
+
const lc = input.toLowerCase();
|
|
71
|
+
const affixed = candidates
|
|
72
|
+
.filter((c) => {
|
|
73
|
+
const cl = c.toLowerCase();
|
|
74
|
+
return cl.startsWith(lc) || lc.startsWith(cl) || cl.includes(lc) || lc.includes(cl);
|
|
75
|
+
})
|
|
76
|
+
.sort((a, b) => a.length - b.length);
|
|
77
|
+
if (affixed.length > 0) {
|
|
78
|
+
return affixed[0];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let best: string | null = null;
|
|
82
|
+
let bestDist = Infinity;
|
|
83
|
+
for (const c of candidates) {
|
|
84
|
+
const d = levenshtein(lc, c.toLowerCase());
|
|
85
|
+
if (d < bestDist) {
|
|
86
|
+
bestDist = d;
|
|
87
|
+
best = c;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return best !== null && bestDist <= Math.max(2, Math.ceil(input.length / 2)) ? best : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function levenshtein(a: string, b: string): number {
|
|
94
|
+
const m = a.length;
|
|
95
|
+
const n = b.length;
|
|
96
|
+
if (m === 0) return n;
|
|
97
|
+
if (n === 0) return m;
|
|
98
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
99
|
+
let curr = new Array<number>(n + 1);
|
|
100
|
+
for (let i = 1; i <= m; i++) {
|
|
101
|
+
curr[0] = i;
|
|
102
|
+
for (let j = 1; j <= n; j++) {
|
|
103
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
104
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
105
|
+
}
|
|
106
|
+
[prev, curr] = [curr, prev];
|
|
107
|
+
}
|
|
108
|
+
return prev[n];
|
|
109
|
+
}
|