@gravitykit/block-mcp 2.0.0-beta → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +34 -7
  2. package/dist/index.cjs +2556 -967
  3. package/package.json +12 -4
  4. package/src/__tests__/coerce.test.ts +57 -0
  5. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  6. package/src/__tests__/integration/dual-storage.test.ts +22 -26
  7. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  8. package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
  9. package/src/__tests__/integration/read-discovery.test.ts +313 -0
  10. package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
  11. package/src/__tests__/integration/write-surface.test.ts +603 -0
  12. package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
  13. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  14. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  15. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  16. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  17. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  18. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  19. package/src/__tests__/tools/read/pagination.test.ts +65 -0
  20. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  21. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  22. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  23. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  24. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  25. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  26. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  27. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  28. package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
  29. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  30. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
  31. package/src/__tests__/unit/instructions.test.ts +3 -3
  32. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  33. package/src/__tests__/unit/rest-url.test.ts +23 -0
  34. package/src/agent-guide.ts +85 -0
  35. package/src/client.ts +104 -40
  36. package/src/coerce.ts +41 -0
  37. package/src/config.ts +96 -0
  38. package/src/connect.ts +123 -38
  39. package/src/enrichers.ts +6 -2
  40. package/src/error-translator.ts +63 -11
  41. package/src/index.ts +49 -79
  42. package/src/instructions.ts +3 -3
  43. package/src/preferences.ts +56 -43
  44. package/src/rest-url.ts +18 -0
  45. package/src/tools/discovery.ts +10 -14
  46. package/src/tools/mutate.ts +21 -20
  47. package/src/tools/patterns.ts +3 -2
  48. package/src/tools/posts.ts +26 -26
  49. package/src/tools/read.ts +23 -6
  50. package/src/tools/write.ts +37 -36
  51. package/src/tools/yoast.ts +30 -31
  52. package/src/types.ts +45 -31
  53. package/src/validate-args.ts +109 -0
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { WordPressBlockClient } from '../client.js';
12
- import { formatPreferenceWarning } from '../preferences.js';
12
+ import { formatPreferenceWarning, withFormattedWarnings } from '../preferences.js';
13
13
  import { enrichBlock, enrichBlocks, type BlockDef } from '../enrichers.js';
14
+ import { coercePostId } from '../coerce.js';
14
15
 
15
16
  /** Shape shared by every block-input arg in this module. */
16
17
  export const BLOCK_INPUT_SCHEMA = {
@@ -75,7 +76,9 @@ export const WRITE_TOOLS = [
75
76
  // idempotentHint is false: every call creates a new revision, and
76
77
  // revision history is observable to other readers. Same-input/same-state
77
78
  // 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
+ // destructiveHint is true: attributes/innerHTML overwrite the block's
80
+ // existing content rather than merging additively.
81
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Update one block' },
79
82
  outputSchema: {
80
83
  type: 'object',
81
84
  properties: {
@@ -123,7 +126,8 @@ export const WRITE_TOOLS = [
123
126
  name: 'update_blocks',
124
127
  description:
125
128
  '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' },
129
+ // destructiveHint is true: same overwrite semantics as update_block, applied per item.
130
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Batch-update blocks' },
127
131
  outputSchema: {
128
132
  type: 'object',
129
133
  properties: {
@@ -192,7 +196,13 @@ export const WRITE_TOOLS = [
192
196
  {
193
197
  name: 'insert_blocks',
194
198
  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.',
199
+ 'Insert top-level blocks into a post.\n\n' +
200
+ 'POSITIONING — use exactly ONE anchor (any other key, e.g. `after`, `before`, `position`, is rejected):\n' +
201
+ '- `before_ref` / `after_ref` — gk_ref from get_page_blocks. Recommended: refs survive sibling shifts.\n' +
202
+ '- `before_top_level` / `after_top_level` — top_level_counter position.\n' +
203
+ '- Prepend at the very top: `before_top_level: 0`. Append at the end: omit all anchors.\n\n' +
204
+ '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' +
205
+ '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
206
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Insert blocks' },
197
207
  outputSchema: INSERTED_REFS_SCHEMA,
198
208
  inputSchema: {
@@ -201,7 +211,7 @@ export const WRITE_TOOLS = [
201
211
  post_id: { type: 'number', description: 'Post ID.' },
202
212
  after_top_level: {
203
213
  type: ['number', 'string'],
204
- description: 'top_level_counter to insert AFTER. -1/omit = append, "start" = prepend.',
214
+ description: 'top_level_counter to insert AFTER (omit or -1 = append). To prepend at the very top, prefer `before_top_level: 0`.',
205
215
  },
206
216
  before_top_level: {
207
217
  type: 'number',
@@ -217,7 +227,7 @@ export const WRITE_TOOLS = [
217
227
  },
218
228
  blocks: {
219
229
  type: 'array',
220
- description: 'Blocks to insert.',
230
+ 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
231
  items: BLOCK_INPUT_SCHEMA,
222
232
  },
223
233
  },
@@ -260,7 +270,7 @@ export const WRITE_TOOLS = [
260
270
  post_id: { type: 'number', description: 'Post ID.' },
261
271
  start: { type: 'number', description: 'top_level_counter of first block to replace.' },
262
272
  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 },
273
+ 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
274
  },
265
275
  required: ['post_id', 'start', 'count', 'blocks'],
266
276
  },
@@ -297,7 +307,7 @@ export const WRITE_TOOLS = [
297
307
  type: 'object' as const,
298
308
  properties: {
299
309
  post_id: { type: 'number', description: 'Post ID.' },
300
- blocks: { type: 'array', description: 'Complete blocks array (replaces all).', items: BLOCK_INPUT_SCHEMA },
310
+ 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
311
  },
302
312
  required: ['post_id', 'blocks'],
303
313
  },
@@ -327,14 +337,14 @@ export async function handleWriteTool(
327
337
  ): Promise<unknown> {
328
338
  switch (toolName) {
329
339
  case 'update_block': {
330
- const postId = args.post_id as number;
340
+ const postId = coercePostId(args.post_id, 'update_block');
331
341
  const flatIndex = args.flat_index as number | undefined;
332
342
  const ref = args.ref as string | undefined;
333
343
  const blockName = args.block_name as string | undefined;
334
344
  let attributes = args.attributes as Record<string, unknown> | undefined;
335
345
  let innerHTML = args.innerHTML as string | undefined;
336
346
 
337
- if (postId === undefined || postId === null) throw new Error('post_id is required');
347
+ if (postId === undefined) throw new Error('post_id is required');
338
348
  const hasIndex = typeof flatIndex === 'number' && Number.isFinite(flatIndex) && flatIndex >= 0;
339
349
  const hasRef = typeof ref === 'string' && ref.length > 0;
340
350
  if (!hasIndex && !hasRef) {
@@ -363,7 +373,7 @@ export async function handleWriteTool(
363
373
  }
364
374
 
365
375
  case 'update_blocks': {
366
- const postId = args.post_id as number;
376
+ const postId = coercePostId(args.post_id, 'update_blocks');
367
377
  const updates = args.updates as Array<{
368
378
  ref?: string;
369
379
  flat_index?: number;
@@ -372,7 +382,7 @@ export async function handleWriteTool(
372
382
  innerHTML?: string;
373
383
  }> | undefined;
374
384
 
375
- if (postId === undefined || postId === null) throw new Error('post_id is required');
385
+ if (postId === undefined) throw new Error('post_id is required');
376
386
  if (!Array.isArray(updates) || updates.length === 0) {
377
387
  throw new Error('updates must be a non-empty array');
378
388
  }
@@ -434,7 +444,7 @@ export async function handleWriteTool(
434
444
  }
435
445
 
436
446
  case 'insert_blocks': {
437
- const postId = args.post_id as number;
447
+ const postId = coercePostId(args.post_id, 'insert_blocks');
438
448
  const after = args.after_top_level as number | 'start' | undefined;
439
449
  const before = args.before_top_level as number | undefined;
440
450
  const afterRef = args.after_ref as string | undefined;
@@ -445,7 +455,7 @@ export async function handleWriteTool(
445
455
  innerHTML?: string;
446
456
  }>;
447
457
 
448
- if (postId === undefined || postId === null) throw new Error('post_id is required');
458
+ if (postId === undefined) throw new Error('post_id is required');
449
459
  if (!blocks || blocks.length === 0) throw new Error('At least one block is required in the blocks array');
450
460
 
451
461
  const result = await client.insertBlocks(postId, {
@@ -455,19 +465,16 @@ export async function handleWriteTool(
455
465
  ...(beforeRef ? { before_ref: beforeRef } : {}),
456
466
  blocks: await enrichBlocks(blocks as BlockDef[]),
457
467
  });
458
- if (result.warnings && result.warnings.length > 0) {
459
- return { ...result, formatted_warnings: result.warnings.map(formatPreferenceWarning) };
460
- }
461
- return result;
468
+ return withFormattedWarnings(result, formatPreferenceWarning);
462
469
  }
463
470
 
464
471
  case 'delete_block': {
465
- const postId = args.post_id as number;
472
+ const postId = coercePostId(args.post_id, 'delete_block');
466
473
  const topLevelCounter = args.top_level_counter as number | undefined;
467
474
  const ref = args.ref as string | undefined;
468
475
  const count = args.count as number | undefined;
469
476
 
470
- if (postId === undefined || postId === null) throw new Error('post_id is required');
477
+ if (postId === undefined) throw new Error('post_id is required');
471
478
  const hasCounter = typeof topLevelCounter === 'number' && Number.isFinite(topLevelCounter) && topLevelCounter >= 0;
472
479
  const hasRef = typeof ref === 'string' && ref.length > 0;
473
480
  if (!hasCounter && !hasRef) {
@@ -489,7 +496,7 @@ export async function handleWriteTool(
489
496
  }
490
497
 
491
498
  case 'replace_block_range': {
492
- const postId = args.post_id as number;
499
+ const postId = coercePostId(args.post_id, 'replace_block_range');
493
500
  const start = args.start as number;
494
501
  const count = args.count as number;
495
502
  const blocks = args.blocks as Array<{
@@ -498,40 +505,34 @@ export async function handleWriteTool(
498
505
  innerHTML?: string;
499
506
  }>;
500
507
 
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');
508
+ if (postId === undefined) throw new Error('post_id is required');
509
+ if (!Number.isSafeInteger(start) || start < 0) throw new Error('start must be a non-negative integer');
510
+ if (!Number.isSafeInteger(count) || count < 0) throw new Error('count must be a non-negative integer');
504
511
  if (!Array.isArray(blocks)) throw new Error('blocks must be an array (may be empty for a pure delete)');
505
512
 
506
513
  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;
514
+ return withFormattedWarnings(result, formatPreferenceWarning);
511
515
  }
512
516
 
513
517
  case 'rewrite_post_blocks': {
514
- const postId = args.post_id as number;
518
+ const postId = coercePostId(args.post_id, 'rewrite_post_blocks');
515
519
  const blocks = args.blocks as Array<{
516
520
  name: string;
517
521
  attributes?: Record<string, unknown>;
518
522
  innerHTML?: string;
519
523
  }>;
520
524
 
521
- if (postId === undefined || postId === null) throw new Error('post_id is required');
525
+ if (postId === undefined) throw new Error('post_id is required');
522
526
  if (!blocks || blocks.length === 0) throw new Error('At least one block is required for a full page rewrite');
523
527
 
524
528
  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
+ return withFormattedWarnings(result, formatPreferenceWarning);
529
530
  }
530
531
 
531
532
  case 'revert_to_revision': {
532
- const postId = args.post_id as number;
533
+ const postId = coercePostId(args.post_id, 'revert_to_revision');
533
534
  const revisionId = args.revision_id as number;
534
- if (postId === undefined || postId === null) throw new Error('post_id is required');
535
+ if (postId === undefined) throw new Error('post_id is required');
535
536
  if (revisionId === undefined || revisionId === null) throw new Error('revision_id is required');
536
537
  return await client.revertToRevision(postId, revisionId);
537
538
  }
@@ -11,27 +11,24 @@
11
11
  */
12
12
 
13
13
  import type { WordPressBlockClient } from '../client.js';
14
- import type {
15
- YoastSchemaPageType,
16
- YoastSchemaArticleType,
17
- YoastRobotsAdvanced,
18
- YoastUpdateRequest,
19
- YoastBulkUpdateItem,
14
+ import { coercePostId } from '../coerce.js';
15
+ import {
16
+ YOAST_SCHEMA_PAGE_TYPES,
17
+ YOAST_SCHEMA_ARTICLE_TYPES,
18
+ YOAST_ROBOTS_ADVANCED,
19
+ type YoastSchemaPageType,
20
+ type YoastSchemaArticleType,
21
+ type YoastRobotsAdvanced,
22
+ type YoastUpdateRequest,
23
+ type YoastBulkUpdateItem,
20
24
  } from '../types.js';
21
25
 
22
- const SCHEMA_PAGE_TYPES: YoastSchemaPageType[] = [
23
- 'WebPage', 'ItemPage', 'AboutPage', 'FAQPage', 'QAPage',
24
- 'ProfilePage', 'ContactPage', 'MedicalWebPage', 'CollectionPage',
25
- 'CheckoutPage', 'RealEstateListing', 'SearchResultsPage',
26
- ];
27
-
28
- const SCHEMA_ARTICLE_TYPES: YoastSchemaArticleType[] = [
29
- 'Article', 'BlogPosting', 'SocialMediaPosting', 'NewsArticle',
30
- 'AdvertiserContentArticle', 'SatiricalArticle', 'ScholarlyArticle',
31
- 'TechArticle', 'Report', 'None',
32
- ];
33
-
34
- const ROBOTS_ADVANCED: YoastRobotsAdvanced[] = ['noimageindex', 'noarchive', 'nosnippet'];
26
+ // Local aliases: the enum values themselves live in types.ts (single source
27
+ // shared with the TS union types); these names keep the rest of this file
28
+ // unchanged.
29
+ const SCHEMA_PAGE_TYPES = YOAST_SCHEMA_PAGE_TYPES;
30
+ const SCHEMA_ARTICLE_TYPES = YOAST_SCHEMA_ARTICLE_TYPES;
31
+ const ROBOTS_ADVANCED = YOAST_ROBOTS_ADVANCED;
35
32
 
36
33
  /** Field-level schema reused by yoast_update_seo and yoast_bulk_update_seo. */
37
34
  const YOAST_FIELD_PROPERTIES = {
@@ -40,8 +37,8 @@ const YOAST_FIELD_PROPERTIES = {
40
37
  canonical: { type: 'string', description: 'Canonical URL override.' },
41
38
  focus_keyword: { type: 'string', description: 'Focus keyphrase.' },
42
39
  noindex: {
43
- type: ['boolean', 'null'],
44
- description: 'Tri-state: true=noindex, false=explicit index, null=post-type default.',
40
+ type: 'boolean',
41
+ description: 'true=noindex, false=explicit index. Pass null to reset to the post-type default; omit to leave the current value unchanged. (Advertised as a single boolean type because some AI clients — e.g. Google Gemini — reject a "null" member in a type array; the handler still accepts an explicit null.)',
45
42
  },
46
43
  nofollow: { type: 'boolean', description: 'true=nofollow, false=follow.' },
47
44
  robots_advanced: {
@@ -132,17 +129,17 @@ export async function handleYoastTool(
132
129
  ): Promise<unknown> {
133
130
  switch (toolName) {
134
131
  case 'yoast_get_seo': {
135
- const postId = args.post_id;
136
- if (typeof postId !== 'number') {
137
- throw new Error('yoast_get_seo: "post_id" (number) is required');
132
+ const postId = coercePostId(args.post_id, 'yoast_get_seo');
133
+ if (postId === undefined) {
134
+ throw new Error('yoast_get_seo: "post_id" is required');
138
135
  }
139
136
  return client.getYoastSEO(postId);
140
137
  }
141
138
 
142
139
  case 'yoast_update_seo': {
143
- const postId = args.post_id;
144
- if (typeof postId !== 'number') {
145
- throw new Error('yoast_update_seo: "post_id" (number) is required');
140
+ const postId = coercePostId(args.post_id, 'yoast_update_seo');
141
+ if (postId === undefined) {
142
+ throw new Error('yoast_update_seo: "post_id" is required');
146
143
  }
147
144
  const { post_id: _omit, ...rest } = args;
148
145
  void _omit;
@@ -163,11 +160,13 @@ export async function handleYoastTool(
163
160
  throw new Error('yoast_bulk_update_seo: each item in `posts` must be an object');
164
161
  }
165
162
  const obj = raw as Record<string, unknown>;
166
- if (typeof obj.post_id !== 'number') {
167
- throw new Error('yoast_bulk_update_seo: each item requires `post_id` (number)');
163
+ const id = coercePostId(obj.post_id, 'yoast_bulk_update_seo');
164
+ if (id === undefined) {
165
+ throw new Error('yoast_bulk_update_seo: each item requires `post_id`');
168
166
  }
169
- const { post_id: id, ...rest } = obj;
170
- items.push({ post_id: id as number, ...narrowYoastFields(rest) });
167
+ const { post_id: _omit, ...rest } = obj;
168
+ void _omit;
169
+ items.push({ post_id: id, ...narrowYoastFields(rest) });
171
170
  }
172
171
  return client.bulkUpdateYoastSEO(items);
173
172
  }
package/src/types.ts CHANGED
@@ -83,8 +83,8 @@ export interface BlockType {
83
83
  export interface PatternPreference {
84
84
  /** Computed preference score */
85
85
  score: number;
86
- /** Human-readable tier */
87
- tier: 'recommended' | 'acceptable' | 'avoid' | 'legacy';
86
+ /** Human-readable tier (matches the server: preferred/acceptable/avoid/legacy) */
87
+ tier: 'preferred' | 'acceptable' | 'avoid' | 'legacy';
88
88
  /** Reasons contributing to the score */
89
89
  reasons: string[];
90
90
  }
@@ -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';
@@ -795,17 +799,27 @@ export interface UploadMediaResponse {
795
799
  // Routes only register when Yoast SEO is active on the target site.
796
800
  // ============================================
797
801
 
798
- export type YoastSchemaPageType =
799
- | 'WebPage' | 'ItemPage' | 'AboutPage' | 'FAQPage' | 'QAPage'
800
- | 'ProfilePage' | 'ContactPage' | 'MedicalWebPage' | 'CollectionPage'
801
- | 'CheckoutPage' | 'RealEstateListing' | 'SearchResultsPage';
802
-
803
- export type YoastSchemaArticleType =
804
- | 'Article' | 'BlogPosting' | 'SocialMediaPosting' | 'NewsArticle'
805
- | 'AdvertiserContentArticle' | 'SatiricalArticle' | 'ScholarlyArticle'
806
- | 'TechArticle' | 'Report' | 'None';
807
-
808
- export type YoastRobotsAdvanced = 'noimageindex' | 'noarchive' | 'nosnippet';
802
+ /**
803
+ * Single source of truth for the Yoast schema-page-type enum — the const
804
+ * array backs both the runtime value (JSON Schema `enum`, narrowing filters
805
+ * in src/tools/yoast.ts) and the TS union type below, so the two can't drift.
806
+ */
807
+ export const YOAST_SCHEMA_PAGE_TYPES = [
808
+ 'WebPage', 'ItemPage', 'AboutPage', 'FAQPage', 'QAPage',
809
+ 'ProfilePage', 'ContactPage', 'MedicalWebPage', 'CollectionPage',
810
+ 'CheckoutPage', 'RealEstateListing', 'SearchResultsPage',
811
+ ] as const;
812
+ export type YoastSchemaPageType = typeof YOAST_SCHEMA_PAGE_TYPES[number];
813
+
814
+ export const YOAST_SCHEMA_ARTICLE_TYPES = [
815
+ 'Article', 'BlogPosting', 'SocialMediaPosting', 'NewsArticle',
816
+ 'AdvertiserContentArticle', 'SatiricalArticle', 'ScholarlyArticle',
817
+ 'TechArticle', 'Report', 'None',
818
+ ] as const;
819
+ export type YoastSchemaArticleType = typeof YOAST_SCHEMA_ARTICLE_TYPES[number];
820
+
821
+ export const YOAST_ROBOTS_ADVANCED = ['noimageindex', 'noarchive', 'nosnippet'] as const;
822
+ export type YoastRobotsAdvanced = typeof YOAST_ROBOTS_ADVANCED[number];
809
823
 
810
824
  /**
811
825
  * Writable Yoast SEO fields. All optional in update payloads — only the
@@ -857,6 +871,6 @@ export interface YoastBulkUpdateItem extends YoastSEOFields {
857
871
  }
858
872
 
859
873
  export type YoastBulkUpdateResponse = Array<
860
- | { post_id: number; success: true; seo: YoastSEOMeta }
861
- | { post_id: number; success: false; error: string }
874
+ | YoastSEOMeta
875
+ | { post_id?: number; error: string }
862
876
  >;
@@ -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
+ }