@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.
@@ -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
- type: 'object',
65
- description: 'replace-block / insert-child: { name, attributes?, innerHTML?, innerBlocks? }.',
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
 
@@ -192,7 +192,13 @@ export const WRITE_TOOLS = [
192
192
  {
193
193
  name: 'insert_blocks',
194
194
  description:
195
- 'Insert blocks at a top-level position. Anchoring options (use one): `after_ref`/`before_ref` (stable gk_ref — recommended), or `after_top_level`/`before_top_level` (top_level_counter). Omit anchors or set after_top_level:-1 to append; "start" prepends. Legacy-tier blocks rejected per the site policy. Blocks whose attribute schema is HTML-sourced (e.g. core/paragraph `content`, core/image `url`) must include `innerHTML` matching the attribute(s) — attribute-only inserts are rejected with `inner_html_required` to prevent the self-closing-block / "invalid content" failure mode. Response.inserted[] carries `ref`, `path`, and `top_level_counter` so you can chain edit_block_tree without re-reading.',
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. -1/omit = append, "start" = prepend.',
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
- removed: number;
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
- name: string;
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
- /** A block in structured form, suitable for create_post's `blocks` input. */
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
- | { post_id: number; success: true; seo: YoastSEOMeta }
861
- | { post_id: number; success: false; error: string }
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
+ }