@gravitykit/block-mcp 2.0.0-beta

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.
Files changed (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Write Tools — single-block updates, insertion, deletion, range/full
3
+ * replacement, revert. All ops create a WordPress revision.
4
+ *
5
+ * Naming conventions (1.4.0+):
6
+ * - `flat_index` = sequential position across ALL blocks (incl. nested).
7
+ * - `top_level_counter` = sequential position among top-level blocks only.
8
+ * - tool names use verb_noun; range tools spell out the scope.
9
+ */
10
+
11
+ import type { WordPressBlockClient } from '../client.js';
12
+ import { formatPreferenceWarning } from '../preferences.js';
13
+ import { enrichBlock, enrichBlocks, type BlockDef } from '../enrichers.js';
14
+
15
+ /** Shape shared by every block-input arg in this module. */
16
+ export const BLOCK_INPUT_SCHEMA = {
17
+ type: 'object',
18
+ properties: {
19
+ name: { type: 'string', description: 'Fully-qualified block name (e.g. "core/heading").' },
20
+ attributes: { type: 'object', description: 'Block attributes.' },
21
+ innerHTML: { type: 'string', description: 'Wrapper HTML for container blocks (e.g. "<ul class=\"wp-block-list\"></ul>"); leaf-block HTML otherwise. REQUIRED when `attributes` includes any source-bound field (rich-text / html / text / raw / attribute / query — e.g. `content` on core/paragraph, `url` on core/image). The server returns `inner_html_required` (HTTP 400) otherwise: an attribute-only insert saves a self-closing block that Gutenberg flags as "Block contains unexpected or invalid content" on next edit.' },
22
+ innerBlocks: { type: 'array', description: 'Child blocks. Nest recursively to build lists, columns, groups, etc.', items: { type: 'object' } },
23
+ },
24
+ required: ['name'],
25
+ } as const;
26
+
27
+ /**
28
+ * Output schema for write ops that return inserted-block refs (insert + replace).
29
+ *
30
+ * Per-ref shape mirrors the disambiguated 1.4.0 surface: `top_level_counter`
31
+ * for ordinal addressing, `path` for `edit_block_tree` chaining. The legacy
32
+ * `index` field was dropped from the schema in 1.4.0 so typed clients see
33
+ * exactly the two canonical addressing modes.
34
+ */
35
+ const INSERTED_REFS_SCHEMA = {
36
+ type: 'object',
37
+ properties: {
38
+ success: { type: 'boolean' },
39
+ inserted: {
40
+ type: 'array',
41
+ items: {
42
+ type: 'object',
43
+ properties: {
44
+ top_level_counter: { type: 'number' },
45
+ path: { type: 'array', items: { type: 'integer' } },
46
+ // ref is present on every insert path the PHP plugin returns —
47
+ // bake it into the shared schema instead of overriding the parent
48
+ // shape per-tool.
49
+ ref: { type: 'string' },
50
+ name: { type: 'string' },
51
+ },
52
+ },
53
+ },
54
+ warnings: { type: 'array' },
55
+ before_revision_id: { type: 'number' },
56
+ revision_id: { type: 'number' },
57
+ },
58
+ } as const;
59
+
60
+ /** Output schema for write ops that report a single revision result. */
61
+ const REVISION_ONLY_SCHEMA = {
62
+ type: 'object',
63
+ properties: {
64
+ success: { type: 'boolean' },
65
+ before_revision_id: { type: 'number' },
66
+ revision_id: { type: 'number' },
67
+ },
68
+ } as const;
69
+
70
+ export const WRITE_TOOLS = [
71
+ {
72
+ name: 'update_block',
73
+ description:
74
+ 'Update one block by flat_index OR by ref (stable gk_ref from get_page_blocks). Provide exactly one targeting field. Refs are recommended for chained mutations because they survive sibling shifts. attributes are SHALLOW-merged at top level — pass full arrays, not deltas. innerHTML replaces atomically. For dual-storage blocks (e.g. yoast/faq-block) you MUST send both fields together; innerHTML-only is rejected. Response includes `saved.inner_html` and `saved.attributes` — the canonical post-save snapshot from the database. Do not fetch the public page to verify edits.',
75
+ // idempotentHint is false: every call creates a new revision, and
76
+ // revision history is observable to other readers. Same-input/same-state
77
+ // is true at the block level but not at the post level.
78
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Update one block' },
79
+ outputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ success: { type: 'boolean' },
83
+ block: { type: 'object', properties: { index: { type: 'number' }, name: { type: 'string' }, attributes: { type: 'object' }, ref: { type: 'string' } } },
84
+ saved: {
85
+ type: 'object',
86
+ description: 'Canonical post-save snapshot of the updated block — exactly what is now in post_content. For dynamic blocks, inner_html is the stored template, not the rendered output.',
87
+ properties: {
88
+ flat_index: { type: 'number' },
89
+ block_name: { type: 'string' },
90
+ attributes: { type: 'object' },
91
+ inner_html: { type: 'string' },
92
+ is_dynamic: { type: 'boolean' },
93
+ ref: { type: 'string' },
94
+ },
95
+ },
96
+ before_revision_id: { type: 'number' },
97
+ revision_id: { type: 'number' },
98
+ },
99
+ },
100
+ inputSchema: {
101
+ type: 'object' as const,
102
+ properties: {
103
+ post_id: { type: 'number', description: 'Post ID.' },
104
+ flat_index: {
105
+ type: 'number',
106
+ description: 'Zero-based flat `index` from get_page_blocks (counts every block, including innerBlocks). Provide this OR `ref`.',
107
+ },
108
+ ref: {
109
+ type: 'string',
110
+ description: 'Stable gk_ref (e.g. "blk_a3f2c1q9") from get_page_blocks. Survives sibling shifts so chained mutations don\'t go stale. Provide this OR `flat_index`.',
111
+ },
112
+ block_name: {
113
+ type: 'string',
114
+ description: 'Block type being updated (e.g. "kevinbatdorf/code-block-pro"). Required to activate enrichers — e.g. auto-generating codeHTML from code + language.',
115
+ },
116
+ attributes: { type: 'object', description: 'Partial attrs (top-level shallow merge). Enrichers derive computed fields (e.g. codeHTML) automatically when block_name is provided.' },
117
+ innerHTML: { type: 'string', description: 'Replacement innerHTML.' },
118
+ },
119
+ required: ['post_id'],
120
+ },
121
+ },
122
+ {
123
+ name: 'update_blocks',
124
+ description:
125
+ 'Update N independent blocks atomically in ONE revision. Each item targets one block by `ref` (recommended) or `flat_index`, with `attributes` and/or `innerHTML`. Validation is all-or-nothing: any stale ref / out-of-range index / dual-storage rejection / duplicate target aborts the batch with itemized errors — no partial writes hit disk. Max 50 items per call. Counts as ONE write against the per-post rate limit. Use this instead of looping update_block when fixing multiple blocks on the same post — keeps revision history clean. Pass `verbose: true` to include `saved.inner_html` + `saved.attributes` per result for per-item verification without a re-read.',
126
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Batch-update blocks' },
127
+ outputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ success: { type: 'boolean' },
131
+ count: { type: 'number' },
132
+ results: {
133
+ type: 'array',
134
+ items: {
135
+ type: 'object',
136
+ properties: {
137
+ batch_index: { type: 'number' },
138
+ block: {
139
+ type: 'object',
140
+ properties: {
141
+ index: { type: 'number' },
142
+ name: { type: 'string' },
143
+ attributes: { type: 'object' },
144
+ ref: { type: 'string' },
145
+ },
146
+ },
147
+ saved: {
148
+ type: 'object',
149
+ description: 'Canonical post-save snapshot. Present only when called with `verbose: true`.',
150
+ properties: {
151
+ flat_index: { type: 'number' },
152
+ block_name: { type: 'string' },
153
+ attributes: { type: 'object' },
154
+ inner_html: { type: 'string' },
155
+ is_dynamic: { type: 'boolean' },
156
+ ref: { type: 'string' },
157
+ },
158
+ },
159
+ },
160
+ },
161
+ },
162
+ before_revision_id: { type: 'number' },
163
+ revision_id: { type: 'number' },
164
+ },
165
+ },
166
+ inputSchema: {
167
+ type: 'object' as const,
168
+ properties: {
169
+ post_id: { type: 'number', description: 'Post ID.' },
170
+ updates: {
171
+ type: 'array',
172
+ description: 'List of update items (1..50). Each item targets one block; same item shape as update_block.',
173
+ items: {
174
+ type: 'object',
175
+ properties: {
176
+ ref: { type: 'string', description: 'Stable gk_ref. Provide this OR flat_index.' },
177
+ flat_index: { type: 'number', description: 'Flat index from get_page_blocks. Provide this OR ref.' },
178
+ block_name: { type: 'string', description: 'Block type (e.g. "core/paragraph"). Required for enrichers when attributes are provided.' },
179
+ attributes: { type: 'object', description: 'Partial attrs (top-level shallow merge).' },
180
+ innerHTML: { type: 'string', description: 'Replacement innerHTML.' },
181
+ },
182
+ },
183
+ },
184
+ verbose: {
185
+ type: 'boolean',
186
+ description: 'When true, each result includes `saved.inner_html` + `saved.attributes` (the canonical post-save snapshot). Default false to keep batch responses compact.',
187
+ },
188
+ },
189
+ required: ['post_id', 'updates'],
190
+ },
191
+ },
192
+ {
193
+ name: 'insert_blocks',
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.',
196
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Insert blocks' },
197
+ outputSchema: INSERTED_REFS_SCHEMA,
198
+ inputSchema: {
199
+ type: 'object' as const,
200
+ properties: {
201
+ post_id: { type: 'number', description: 'Post ID.' },
202
+ after_top_level: {
203
+ type: ['number', 'string'],
204
+ description: 'top_level_counter to insert AFTER. -1/omit = append, "start" = prepend.',
205
+ },
206
+ before_top_level: {
207
+ type: 'number',
208
+ description: 'top_level_counter to insert BEFORE.',
209
+ },
210
+ after_ref: {
211
+ type: 'string',
212
+ description: 'gk_ref of the top-level block to insert AFTER. Recommended — survives sibling shifts. Takes precedence over after_top_level.',
213
+ },
214
+ before_ref: {
215
+ type: 'string',
216
+ description: 'gk_ref of the top-level block to insert BEFORE. Takes precedence over before_top_level.',
217
+ },
218
+ blocks: {
219
+ type: 'array',
220
+ description: 'Blocks to insert.',
221
+ items: BLOCK_INPUT_SCHEMA,
222
+ },
223
+ },
224
+ required: ['post_id', 'blocks'],
225
+ },
226
+ },
227
+ {
228
+ name: 'delete_block',
229
+ description: 'Remove block(s) by top_level_counter OR by ref. Provide exactly one. For core/block, removes the reference only — not the source pattern.',
230
+ // idempotentHint is false: deleting at counter N twice removes a
231
+ // *different* block the second time (indices shift after the first).
232
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Delete blocks' },
233
+ outputSchema: { type: 'object', properties: { ...REVISION_ONLY_SCHEMA.properties, deleted_index: { type: 'number' }, deleted_count: { type: 'number' } } },
234
+ inputSchema: {
235
+ type: 'object' as const,
236
+ properties: {
237
+ post_id: { type: 'number', description: 'Post ID.' },
238
+ top_level_counter: {
239
+ type: 'number',
240
+ description: 'Zero-based top_level_counter (sequential position among top-level blocks). Provide this OR `ref`.',
241
+ },
242
+ ref: {
243
+ type: 'string',
244
+ description: 'gk_ref of the block to remove (or the leading block if count > 1). Survives sibling shifts. Provide this OR `top_level_counter`.',
245
+ },
246
+ count: { type: 'number', description: 'Consecutive top-level blocks to remove. Default 1.' },
247
+ },
248
+ required: ['post_id'],
249
+ },
250
+ },
251
+ {
252
+ name: 'replace_block_range',
253
+ description:
254
+ 'Atomic single-revision swap of N top-level blocks for M new blocks (M can be 0, 1, or N). Safer than delete+insert (no half-written intermediate state). Distinct from rewrite_post_blocks (which replaces the entire post).',
255
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Replace a range of blocks' },
256
+ outputSchema: { ...INSERTED_REFS_SCHEMA, properties: { ...INSERTED_REFS_SCHEMA.properties, removed: { type: 'number' } } },
257
+ inputSchema: {
258
+ type: 'object' as const,
259
+ properties: {
260
+ post_id: { type: 'number', description: 'Post ID.' },
261
+ start: { type: 'number', description: 'top_level_counter of first block to replace.' },
262
+ 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 },
264
+ },
265
+ required: ['post_id', 'start', 'count', 'blocks'],
266
+ },
267
+ },
268
+ {
269
+ name: 'rewrite_post_blocks',
270
+ description: 'Replace ALL blocks on a page in one revision. Use for major restructuring; prefer update_block / insert_blocks / replace_block_range for edits.',
271
+ // idempotentHint is false for the same reason as update_block: every
272
+ // call creates a revision; history is observable.
273
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Rewrite the entire post' },
274
+ // PUT /posts/{id}/blocks returns a flat { blocks: [{ index, name, attributes }] }
275
+ // summary — not the { inserted: [...] } shape of the insert/replace tools.
276
+ outputSchema: {
277
+ type: 'object',
278
+ properties: {
279
+ success: { type: 'boolean' },
280
+ blocks: {
281
+ type: 'array',
282
+ items: {
283
+ type: 'object',
284
+ properties: {
285
+ index: { type: 'number' },
286
+ name: { type: 'string' },
287
+ attributes: { type: 'object' },
288
+ },
289
+ },
290
+ },
291
+ warnings: { type: 'array' },
292
+ before_revision_id: { type: 'number' },
293
+ revision_id: { type: 'number' },
294
+ },
295
+ },
296
+ inputSchema: {
297
+ type: 'object' as const,
298
+ properties: {
299
+ post_id: { type: 'number', description: 'Post ID.' },
300
+ blocks: { type: 'array', description: 'Complete blocks array (replaces all).', items: BLOCK_INPUT_SCHEMA },
301
+ },
302
+ required: ['post_id', 'blocks'],
303
+ },
304
+ },
305
+ {
306
+ name: 'revert_to_revision',
307
+ description: 'Restore a post to a revision. Pass `before_revision_id` from a prior write to UNDO that write; pass `revision_id` to REDO it.',
308
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true, title: 'Revert post to revision' },
309
+ inputSchema: {
310
+ type: 'object' as const,
311
+ properties: {
312
+ post_id: { type: 'number', description: 'Post ID.' },
313
+ revision_id: { type: 'number', description: 'Revision ID to restore.' },
314
+ },
315
+ required: ['post_id', 'revision_id'],
316
+ },
317
+ },
318
+ ];
319
+
320
+ /**
321
+ * Handle a write tool call.
322
+ */
323
+ export async function handleWriteTool(
324
+ toolName: string,
325
+ args: Record<string, unknown>,
326
+ client: WordPressBlockClient
327
+ ): Promise<unknown> {
328
+ switch (toolName) {
329
+ case 'update_block': {
330
+ const postId = args.post_id as number;
331
+ const flatIndex = args.flat_index as number | undefined;
332
+ const ref = args.ref as string | undefined;
333
+ const blockName = args.block_name as string | undefined;
334
+ let attributes = args.attributes as Record<string, unknown> | undefined;
335
+ let innerHTML = args.innerHTML as string | undefined;
336
+
337
+ if (postId === undefined || postId === null) throw new Error('post_id is required');
338
+ const hasIndex = typeof flatIndex === 'number' && Number.isFinite(flatIndex) && flatIndex >= 0;
339
+ const hasRef = typeof ref === 'string' && ref.length > 0;
340
+ if (!hasIndex && !hasRef) {
341
+ throw new Error('Provide either flat_index (non-negative integer) or ref');
342
+ }
343
+ if (hasIndex && hasRef) {
344
+ throw new Error('Provide flat_index OR ref, not both');
345
+ }
346
+ if (!attributes && !innerHTML) {
347
+ throw new Error('At least one of attributes or innerHTML must be provided');
348
+ }
349
+
350
+ // When block_name is provided, run enrichers so computed fields (e.g.
351
+ // codeHTML for CBP) are derived automatically from the supplied attrs.
352
+ if (blockName && attributes) {
353
+ const blockDef: BlockDef = { name: blockName, attributes, ...(innerHTML ? { innerHTML } : {}) };
354
+ const enriched = await enrichBlock(blockDef);
355
+ attributes = enriched.attributes;
356
+ if (enriched.innerHTML !== undefined) innerHTML = enriched.innerHTML;
357
+ }
358
+
359
+ if (hasRef) {
360
+ return await client.updateBlockByRef(postId, ref as string, { attributes, innerHTML });
361
+ }
362
+ return await client.updateBlock(postId, flatIndex as number, { attributes, innerHTML });
363
+ }
364
+
365
+ case 'update_blocks': {
366
+ const postId = args.post_id as number;
367
+ const updates = args.updates as Array<{
368
+ ref?: string;
369
+ flat_index?: number;
370
+ block_name?: string;
371
+ attributes?: Record<string, unknown>;
372
+ innerHTML?: string;
373
+ }> | undefined;
374
+
375
+ if (postId === undefined || postId === null) throw new Error('post_id is required');
376
+ if (!Array.isArray(updates) || updates.length === 0) {
377
+ throw new Error('updates must be a non-empty array');
378
+ }
379
+
380
+ // Pre-validate every item client-side so an obviously-broken batch
381
+ // surfaces a precise per-item error before paying the network round-trip.
382
+ // Server still re-validates (canonical authority).
383
+ const normalized: Array<{
384
+ ref?: string;
385
+ flat_index?: number;
386
+ attributes?: Record<string, unknown>;
387
+ innerHTML?: string;
388
+ }> = [];
389
+ for (let i = 0; i < updates.length; i++) {
390
+ const item = updates[i];
391
+ if (!item || typeof item !== 'object') {
392
+ throw new Error(`updates[${i}]: each item must be an object`);
393
+ }
394
+ const hasItemRef = typeof item.ref === 'string' && item.ref.length > 0;
395
+ const hasItemIndex = typeof item.flat_index === 'number'
396
+ && Number.isFinite(item.flat_index)
397
+ && item.flat_index >= 0;
398
+ if (hasItemRef === hasItemIndex) {
399
+ throw new Error(`updates[${i}]: provide exactly one of ref or flat_index`);
400
+ }
401
+ const hasAttrs = item.attributes && Object.keys(item.attributes).length > 0;
402
+ const hasHTML = typeof item.innerHTML === 'string';
403
+ if (!hasAttrs && !hasHTML) {
404
+ throw new Error(`updates[${i}]: at least one of attributes or innerHTML is required`);
405
+ }
406
+
407
+ // Run enrichers when block_name + attributes are both supplied so
408
+ // computed fields (e.g. CBP codeHTML) get derived automatically —
409
+ // mirrors update_block's behavior on a per-item basis.
410
+ let attributes = item.attributes;
411
+ let innerHTML = item.innerHTML;
412
+ if (item.block_name && attributes) {
413
+ const enriched = await enrichBlock({
414
+ name: item.block_name,
415
+ attributes,
416
+ ...(innerHTML ? { innerHTML } : {}),
417
+ });
418
+ attributes = enriched.attributes;
419
+ if (enriched.innerHTML !== undefined) innerHTML = enriched.innerHTML;
420
+ }
421
+
422
+ normalized.push({
423
+ ...(hasItemRef ? { ref: item.ref } : {}),
424
+ ...(hasItemIndex ? { flat_index: item.flat_index } : {}),
425
+ ...(attributes ? { attributes } : {}),
426
+ ...(innerHTML !== undefined ? { innerHTML } : {}),
427
+ });
428
+ }
429
+
430
+ if (args.verbose === true) {
431
+ return await client.updateBlocksBatch(postId, normalized, { verbose: true });
432
+ }
433
+ return await client.updateBlocksBatch(postId, normalized);
434
+ }
435
+
436
+ case 'insert_blocks': {
437
+ const postId = args.post_id as number;
438
+ const after = args.after_top_level as number | 'start' | undefined;
439
+ const before = args.before_top_level as number | undefined;
440
+ const afterRef = args.after_ref as string | undefined;
441
+ const beforeRef = args.before_ref as string | undefined;
442
+ const blocks = args.blocks as Array<{
443
+ name: string;
444
+ attributes?: Record<string, unknown>;
445
+ innerHTML?: string;
446
+ }>;
447
+
448
+ if (postId === undefined || postId === null) throw new Error('post_id is required');
449
+ if (!blocks || blocks.length === 0) throw new Error('At least one block is required in the blocks array');
450
+
451
+ const result = await client.insertBlocks(postId, {
452
+ after,
453
+ before,
454
+ ...(afterRef ? { after_ref: afterRef } : {}),
455
+ ...(beforeRef ? { before_ref: beforeRef } : {}),
456
+ blocks: await enrichBlocks(blocks as BlockDef[]),
457
+ });
458
+ if (result.warnings && result.warnings.length > 0) {
459
+ return { ...result, formatted_warnings: result.warnings.map(formatPreferenceWarning) };
460
+ }
461
+ return result;
462
+ }
463
+
464
+ case 'delete_block': {
465
+ const postId = args.post_id as number;
466
+ const topLevelCounter = args.top_level_counter as number | undefined;
467
+ const ref = args.ref as string | undefined;
468
+ const count = args.count as number | undefined;
469
+
470
+ if (postId === undefined || postId === null) throw new Error('post_id is required');
471
+ const hasCounter = typeof topLevelCounter === 'number' && Number.isFinite(topLevelCounter) && topLevelCounter >= 0;
472
+ const hasRef = typeof ref === 'string' && ref.length > 0;
473
+ if (!hasCounter && !hasRef) {
474
+ throw new Error('Provide either top_level_counter (non-negative integer) or ref');
475
+ }
476
+ if (hasCounter && hasRef) {
477
+ throw new Error('Provide top_level_counter OR ref, not both');
478
+ }
479
+ if (count !== undefined && count !== null) {
480
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 1) {
481
+ throw new Error('count must be a positive integer');
482
+ }
483
+ }
484
+
485
+ if (hasRef) {
486
+ return await client.deleteBlockByRef(postId, ref as string, count);
487
+ }
488
+ return await client.deleteBlock(postId, topLevelCounter as number, count);
489
+ }
490
+
491
+ case 'replace_block_range': {
492
+ const postId = args.post_id as number;
493
+ const start = args.start as number;
494
+ const count = args.count as number;
495
+ const blocks = args.blocks as Array<{
496
+ name: string;
497
+ attributes?: Record<string, unknown>;
498
+ innerHTML?: string;
499
+ }>;
500
+
501
+ if (postId === undefined || postId === null) throw new Error('post_id is required');
502
+ if (typeof start !== 'number' || start < 0) throw new Error('start must be a non-negative integer');
503
+ if (typeof count !== 'number' || count < 0) throw new Error('count must be a non-negative integer');
504
+ if (!Array.isArray(blocks)) throw new Error('blocks must be an array (may be empty for a pure delete)');
505
+
506
+ const result = await client.replaceBlocksRange(postId, { start, count, blocks: await enrichBlocks(blocks as BlockDef[]) });
507
+ if (result.warnings && result.warnings.length > 0) {
508
+ return { ...result, formatted_warnings: result.warnings.map(formatPreferenceWarning) };
509
+ }
510
+ return result;
511
+ }
512
+
513
+ case 'rewrite_post_blocks': {
514
+ const postId = args.post_id as number;
515
+ const blocks = args.blocks as Array<{
516
+ name: string;
517
+ attributes?: Record<string, unknown>;
518
+ innerHTML?: string;
519
+ }>;
520
+
521
+ if (postId === undefined || postId === null) throw new Error('post_id is required');
522
+ if (!blocks || blocks.length === 0) throw new Error('At least one block is required for a full page rewrite');
523
+
524
+ const result = await client.replaceAllBlocks(postId, await enrichBlocks(blocks as BlockDef[]));
525
+ if (result.warnings && result.warnings.length > 0) {
526
+ return { ...result, formatted_warnings: result.warnings.map(formatPreferenceWarning) };
527
+ }
528
+ return result;
529
+ }
530
+
531
+ case 'revert_to_revision': {
532
+ const postId = args.post_id as number;
533
+ const revisionId = args.revision_id as number;
534
+ if (postId === undefined || postId === null) throw new Error('post_id is required');
535
+ if (revisionId === undefined || revisionId === null) throw new Error('revision_id is required');
536
+ return await client.revertToRevision(postId, revisionId);
537
+ }
538
+
539
+ default:
540
+ throw new Error(`Unknown write tool: ${toolName}`);
541
+ }
542
+ }