@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
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Covers:
5
5
  * - Filter forwarding (search → q, synced, min_score)
6
- * - Server-side limit = offset + limit (so client slice still works)
6
+ * - Full fetch (no server-side limit) + client-side pagination, so `total`
7
+ * and `next_offset` reflect the true count (matches list_block_types)
7
8
  * - Client-side slicing via offset
8
9
  * - Defaults: limit 20, offset 0
9
10
  * - Response shape: patterns, count, total, offset, next_offset, summary
@@ -46,15 +47,33 @@ describe('list_patterns — pagination', () => {
46
47
  client.getPatterns.mockResolvedValue(patternsResponse);
47
48
  });
48
49
 
49
- it('defaults to limit 20, offset 0', async () => {
50
+ it('fetches the full set (no truncating server-side limit)', async () => {
50
51
  await handleDiscoveryTool('list_patterns', {}, client as any);
51
- // server limit is offset + limit = 0 + 20 = 20
52
- expect(client.getPatterns).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
52
+ // No `limit` is sent: the handler paginates locally so `total` and
53
+ // `next_offset` reflect the true count instead of the fetched window.
54
+ const args = client.getPatterns.mock.calls[0][0];
55
+ expect(args?.limit).toBeUndefined();
53
56
  });
54
57
 
55
- it('server limit accounts for offset (offset+limit)', async () => {
58
+ it('does not cap the fetch by offset+limit', async () => {
56
59
  await handleDiscoveryTool('list_patterns', { limit: 10, offset: 30 }, client as any);
57
- expect(client.getPatterns).toHaveBeenCalledWith(expect.objectContaining({ limit: 40 }));
60
+ const args = client.getPatterns.mock.calls[0][0];
61
+ expect(args?.limit).toBeUndefined();
62
+ });
63
+
64
+ it('reports true total and non-null next_offset when more remain', async () => {
65
+ const many = Array.from({ length: 30 }, (_, i) => ({
66
+ id: i + 1, name: `pattern-${i}`,
67
+ type: 'registered' as const,
68
+ created: '2026-01-01', modified: '2026-01-01',
69
+ reference_count: 0,
70
+ preference: { score: 80, tier: 'recommended' as const, reasons: [] },
71
+ contains_blocks: [], has_legacy_blocks: false,
72
+ }));
73
+ client.getPatterns.mockResolvedValueOnce({ patterns: many } as any);
74
+ const result = await handleDiscoveryTool('list_patterns', { limit: 20, offset: 0 }, client as any) as Record<string, unknown>;
75
+ expect(result.total).toBe(30);
76
+ expect(result.next_offset).toBe(20);
58
77
  });
59
78
 
60
79
  it('client-side slice honors offset', async () => {
@@ -40,6 +40,26 @@ describe('edit_block_tree — common validation', () => {
40
40
  ).rejects.toThrow(/post_id is required/);
41
41
  });
42
42
 
43
+ it('rejects a float post_id', async () => {
44
+ await expect(
45
+ handleMutateTool('edit_block_tree', { post_id: 1.5, op: 'update-attrs', path: [0], attributes: {} }, client as any)
46
+ ).rejects.toThrow('post_id must be a positive integer');
47
+ });
48
+
49
+ it('rejects a negative post_id', async () => {
50
+ await expect(
51
+ handleMutateTool('edit_block_tree', { post_id: -1, op: 'update-attrs', path: [0], attributes: {} }, client as any)
52
+ ).rejects.toThrow('post_id must be a positive integer');
53
+ });
54
+
55
+ it('rejects an overflow post_id', async () => {
56
+ await expect(
57
+ handleMutateTool('edit_block_tree', {
58
+ post_id: Number.MAX_SAFE_INTEGER + 1, op: 'update-attrs', path: [0], attributes: {},
59
+ }, client as any)
60
+ ).rejects.toThrow('post_id must be a positive integer');
61
+ });
62
+
43
63
  it('rejects unknown op', async () => {
44
64
  await expect(
45
65
  handleMutateTool('edit_block_tree', { post_id: 1, op: 'frobnicate', path: [0] }, client as any)
@@ -247,6 +267,39 @@ describe('edit_block_tree — insert-child', () => {
247
267
  }));
248
268
  });
249
269
 
270
+ it('coerces a numeric-string position to an integer', async () => {
271
+ // The inputSchema advertises position as integer|string, so a numeric
272
+ // string ("3") is schema-conformant and must be accepted as the index.
273
+ await handleMutateTool('edit_block_tree', {
274
+ post_id: 1, op: 'insert-child', path: [0],
275
+ block: { name: 'core/paragraph' }, position: '3',
276
+ }, client as any);
277
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
278
+ position: 3,
279
+ }));
280
+ });
281
+
282
+ it('accepts the "-1" append sentinel as a string', async () => {
283
+ // -1 is the documented append sentinel; its string form must behave like
284
+ // the number -1, not be rejected as a non-numeric position.
285
+ await handleMutateTool('edit_block_tree', {
286
+ post_id: 1, op: 'insert-child', path: [0],
287
+ block: { name: 'core/paragraph' }, position: '-1',
288
+ }, client as any);
289
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
290
+ position: -1,
291
+ }));
292
+ });
293
+
294
+ it('rejects positions below the -1 append sentinel', async () => {
295
+ await expect(
296
+ handleMutateTool('edit_block_tree', {
297
+ post_id: 1, op: 'insert-child', path: [0],
298
+ block: { name: 'core/paragraph' }, position: '-2',
299
+ }, client as any)
300
+ ).rejects.toThrow(/position must be/);
301
+ });
302
+
250
303
  it('rejects invalid position string', async () => {
251
304
  await expect(
252
305
  handleMutateTool('edit_block_tree', {
@@ -27,6 +27,22 @@ describe('insert_pattern — validation', () => {
27
27
  .rejects.toThrow('post_id');
28
28
  });
29
29
 
30
+ it('rejects a float post_id', async () => {
31
+ await expect(handlePatternTool('insert_pattern', { post_id: 1.5, pattern_id: 1 }, client as any))
32
+ .rejects.toThrow('post_id must be a positive integer');
33
+ });
34
+
35
+ it('rejects a negative post_id', async () => {
36
+ await expect(handlePatternTool('insert_pattern', { post_id: -1, pattern_id: 1 }, client as any))
37
+ .rejects.toThrow('post_id must be a positive integer');
38
+ });
39
+
40
+ it('rejects an overflow post_id', async () => {
41
+ await expect(
42
+ handlePatternTool('insert_pattern', { post_id: Number.MAX_SAFE_INTEGER + 1, pattern_id: 1 }, client as any)
43
+ ).rejects.toThrow('post_id must be a positive integer');
44
+ });
45
+
30
46
  it('requires pattern_id', async () => {
31
47
  await expect(handlePatternTool('insert_pattern', { post_id: 1 }, client as any))
32
48
  .rejects.toThrow('pattern_id');
@@ -61,6 +61,20 @@ describe('update_post — request shape', () => {
61
61
  const body = client.updatePost.mock.calls[0]![1] as Record<string, unknown>;
62
62
  expect(body).not.toHaveProperty('post_id');
63
63
  });
64
+
65
+ // Some MCP clients / untyped JSON send numeric IDs as strings. get_post_info
66
+ // already coerces them; update_post must accept the same, or the same post is
67
+ // editable via one tool and rejected by another in one session.
68
+ it('coerces a numeric-string post_id to a number', async () => {
69
+ await handlePostTool('update_post', { post_id: '42', status: 'draft' }, client as any);
70
+ expect(client.updatePost).toHaveBeenCalledWith(42, { status: 'draft' });
71
+ });
72
+
73
+ it('rejects a non-numeric post_id string', async () => {
74
+ await expect(handlePostTool('update_post', { post_id: 'abc', status: 'draft' }, client as any))
75
+ .rejects.toThrow('post_id must be a positive integer');
76
+ expect(client.updatePost).not.toHaveBeenCalled();
77
+ });
64
78
  });
65
79
 
66
80
  describe('update_post — response shape', () => {
@@ -31,6 +31,24 @@ describe('get_block — input validation', () => {
31
31
  ).rejects.toThrow(/post_id is required/);
32
32
  });
33
33
 
34
+ it('rejects a float post_id', async () => {
35
+ await expect(
36
+ handleReadTool('get_block', { post_id: 1.5, ref: 'blk_a' }, client as any)
37
+ ).rejects.toThrow('post_id must be a positive integer');
38
+ });
39
+
40
+ it('rejects a negative post_id', async () => {
41
+ await expect(
42
+ handleReadTool('get_block', { post_id: -1, ref: 'blk_a' }, client as any)
43
+ ).rejects.toThrow('post_id must be a positive integer');
44
+ });
45
+
46
+ it('rejects an overflow post_id', async () => {
47
+ await expect(
48
+ handleReadTool('get_block', { post_id: Number.MAX_SAFE_INTEGER + 1, ref: 'blk_a' }, client as any)
49
+ ).rejects.toThrow('post_id must be a positive integer');
50
+ });
51
+
34
52
  it('requires exactly one of ref or flat_index (rejects neither)', async () => {
35
53
  await expect(
36
54
  handleReadTool('get_block', { post_id: 1 }, client as any)
@@ -54,6 +72,23 @@ describe('get_block — input validation', () => {
54
72
  handleReadTool('get_block', { post_id: 1, flat_index: NaN }, client as any)
55
73
  ).rejects.toThrow(/exactly one of ref or flat_index/);
56
74
  });
75
+
76
+ it('treats a fractional flat_index as missing (must be an integer)', async () => {
77
+ await expect(
78
+ handleReadTool('get_block', { post_id: 1, flat_index: 1.5 }, client as any)
79
+ ).rejects.toThrow(/exactly one of ref or flat_index/);
80
+ expect(client.getBlock).not.toHaveBeenCalled();
81
+ });
82
+
83
+ // update_block / delete_block reject a negative flat_index client-side;
84
+ // get_block must match so a negative index is treated as absent, not sent
85
+ // to the server as a valid target.
86
+ it('treats a negative flat_index as missing (still requires ref)', async () => {
87
+ await expect(
88
+ handleReadTool('get_block', { post_id: 1, flat_index: -1 }, client as any)
89
+ ).rejects.toThrow(/exactly one of ref or flat_index/);
90
+ expect(client.getBlock).not.toHaveBeenCalled();
91
+ });
57
92
  });
58
93
 
59
94
  describe('get_block — routing', () => {
@@ -32,6 +32,24 @@ describe('get_page_blocks — input validation', () => {
32
32
  ).rejects.toThrow(/post_id or url/);
33
33
  });
34
34
 
35
+ it('rejects a float post_id (does not silently fall through to url resolution)', async () => {
36
+ await expect(
37
+ handleReadTool('get_page_blocks', { post_id: 1.5 }, client as any)
38
+ ).rejects.toThrow('post_id must be a positive integer');
39
+ });
40
+
41
+ it('rejects a negative post_id', async () => {
42
+ await expect(
43
+ handleReadTool('get_page_blocks', { post_id: -1 }, client as any)
44
+ ).rejects.toThrow('post_id must be a positive integer');
45
+ });
46
+
47
+ it('rejects an overflow post_id', async () => {
48
+ await expect(
49
+ handleReadTool('get_page_blocks', { post_id: Number.MAX_SAFE_INTEGER + 1 }, client as any)
50
+ ).rejects.toThrow('post_id must be a positive integer');
51
+ });
52
+
35
53
  it('accepts post_id alone', async () => {
36
54
  await expect(
37
55
  handleReadTool('get_page_blocks', { post_id: 42 }, client as any)
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tool tests: get_page_blocks pagination (limit + cursor).
3
+ *
4
+ * The REST route and the client both support `limit`/`cursor` pagination,
5
+ * but the MCP tool never exposed them — agents had no way to page through a
6
+ * huge post. These pin the tool-layer contract:
7
+ * - limit/cursor are declared on the inputSchema (dispatch validation
8
+ * rejects undeclared keys, so omission = unusable);
9
+ * - both forward to client.getPageBlocks;
10
+ * - the response's `pagination` (incl. next_cursor) passes through so the
11
+ * agent can fetch the next page.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach } from 'vitest';
15
+ import { handleReadTool, READ_TOOLS } from '../../../tools/read.js';
16
+ import { makeMockClient } from '../../helpers/mock-client.js';
17
+ import { pageBlocksResponse } from '../../fixtures/rest-responses.js';
18
+
19
+ describe('get_page_blocks — pagination', () => {
20
+ let client: ReturnType<typeof makeMockClient>;
21
+ beforeEach(() => {
22
+ client = makeMockClient();
23
+ });
24
+
25
+ it('declares limit and cursor on the inputSchema', () => {
26
+ const tool = READ_TOOLS.find((t) => t.name === 'get_page_blocks');
27
+ const props = (tool?.inputSchema as { properties?: Record<string, unknown> })?.properties ?? {};
28
+ expect(props.limit).toBeDefined();
29
+ expect(props.cursor).toBeDefined();
30
+ });
31
+
32
+ it('forwards limit and cursor to the client', async () => {
33
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
34
+ await handleReadTool(
35
+ 'get_page_blocks',
36
+ { post_id: 42, limit: 2, cursor: 'idx_2' },
37
+ client as never,
38
+ );
39
+ expect(client.getPageBlocks).toHaveBeenCalledWith(
40
+ 42,
41
+ expect.objectContaining({ limit: 2, cursor: 'idx_2' }),
42
+ );
43
+ });
44
+
45
+ it('passes the pagination meta (next_cursor) through to the agent', async () => {
46
+ client.getPageBlocks.mockResolvedValue({
47
+ ...pageBlocksResponse,
48
+ pagination: { limit: 2, offset: 0, total: 4, next_cursor: 'idx_2' },
49
+ });
50
+ const result = (await handleReadTool(
51
+ 'get_page_blocks',
52
+ { post_id: 42, limit: 2 },
53
+ client as never,
54
+ )) as { pagination?: { next_cursor?: string | null } };
55
+ expect(result.pagination?.next_cursor).toBe('idx_2');
56
+ });
57
+
58
+ it('omits pagination from the response when not paginating', async () => {
59
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
60
+ const result = (await handleReadTool('get_page_blocks', { post_id: 42 }, client as never)) as {
61
+ pagination?: unknown;
62
+ };
63
+ expect(result.pagination).toBeUndefined();
64
+ });
65
+ });
@@ -23,6 +23,24 @@ describe('delete_block — validation', () => {
23
23
  ).rejects.toThrow('post_id');
24
24
  });
25
25
 
26
+ it('rejects a float post_id', async () => {
27
+ await expect(
28
+ handleWriteTool('delete_block', { post_id: 1.5, top_level_counter: 0 }, client as any)
29
+ ).rejects.toThrow('post_id must be a positive integer');
30
+ });
31
+
32
+ it('rejects a negative post_id', async () => {
33
+ await expect(
34
+ handleWriteTool('delete_block', { post_id: -1, top_level_counter: 0 }, client as any)
35
+ ).rejects.toThrow('post_id must be a positive integer');
36
+ });
37
+
38
+ it('rejects an overflow post_id', async () => {
39
+ await expect(
40
+ handleWriteTool('delete_block', { post_id: Number.MAX_SAFE_INTEGER + 1, top_level_counter: 0 }, client as any)
41
+ ).rejects.toThrow('post_id must be a positive integer');
42
+ });
43
+
26
44
  it('requires top_level_counter OR ref (not neither)', async () => {
27
45
  await expect(
28
46
  handleWriteTool('delete_block', { post_id: 1 }, client as any)
@@ -32,6 +32,27 @@ describe('insert_blocks — validation', () => {
32
32
  ).rejects.toThrow('post_id');
33
33
  });
34
34
 
35
+ it('rejects a float post_id', async () => {
36
+ await expect(
37
+ handleWriteTool('insert_blocks', { post_id: 1.5, blocks: [{ name: 'core/paragraph' }] }, client as any)
38
+ ).rejects.toThrow('post_id must be a positive integer');
39
+ });
40
+
41
+ it('rejects a negative post_id', async () => {
42
+ await expect(
43
+ handleWriteTool('insert_blocks', { post_id: -1, blocks: [{ name: 'core/paragraph' }] }, client as any)
44
+ ).rejects.toThrow('post_id must be a positive integer');
45
+ });
46
+
47
+ it('rejects an overflow post_id', async () => {
48
+ await expect(
49
+ handleWriteTool('insert_blocks', {
50
+ post_id: Number.MAX_SAFE_INTEGER + 1,
51
+ blocks: [{ name: 'core/paragraph' }],
52
+ }, client as any)
53
+ ).rejects.toThrow('post_id must be a positive integer');
54
+ });
55
+
35
56
  it('requires at least one block', async () => {
36
57
  await expect(
37
58
  handleWriteTool('insert_blocks', { post_id: 1 }, client as any)
@@ -11,6 +11,7 @@
11
11
  import { describe, it, expect, vi, beforeEach } from 'vitest';
12
12
  import { handleWriteTool } from '../../../tools/write.js';
13
13
  import { makeMockClient } from '../../helpers/mock-client.js';
14
+ import { assertHasFormattedWarning, assertNoFormattedWarnings } from '../../helpers/request-matchers.js';
14
15
 
15
16
  vi.mock('../../../enrichers.js', () => ({
16
17
  enrichBlock: vi.fn(async (block: any) => block),
@@ -29,6 +30,26 @@ describe('replace_block_range — validation', () => {
29
30
  ).rejects.toThrow('post_id');
30
31
  });
31
32
 
33
+ it('rejects a float post_id', async () => {
34
+ await expect(
35
+ handleWriteTool('replace_block_range', { post_id: 1.5, start: 0, count: 1, blocks: [] }, client as any)
36
+ ).rejects.toThrow('post_id must be a positive integer');
37
+ });
38
+
39
+ it('rejects a negative post_id', async () => {
40
+ await expect(
41
+ handleWriteTool('replace_block_range', { post_id: -1, start: 0, count: 1, blocks: [] }, client as any)
42
+ ).rejects.toThrow('post_id must be a positive integer');
43
+ });
44
+
45
+ it('rejects an overflow post_id', async () => {
46
+ await expect(
47
+ handleWriteTool('replace_block_range', {
48
+ post_id: Number.MAX_SAFE_INTEGER + 1, start: 0, count: 1, blocks: [],
49
+ }, client as any)
50
+ ).rejects.toThrow('post_id must be a positive integer');
51
+ });
52
+
32
53
  it('requires start', async () => {
33
54
  await expect(
34
55
  handleWriteTool('replace_block_range', { post_id: 1, count: 1, blocks: [] }, client as any)
@@ -46,6 +67,24 @@ describe('replace_block_range — validation', () => {
46
67
  handleWriteTool('replace_block_range', { post_id: 1, start: 0, count: 1 }, client as any)
47
68
  ).rejects.toThrow('block');
48
69
  });
70
+
71
+ it('rejects a fractional start', async () => {
72
+ await expect(
73
+ handleWriteTool('replace_block_range', { post_id: 1, start: 1.5, count: 1, blocks: [] }, client as any)
74
+ ).rejects.toThrow('start must be a non-negative integer');
75
+ });
76
+
77
+ it('rejects a NaN count', async () => {
78
+ await expect(
79
+ handleWriteTool('replace_block_range', { post_id: 1, start: 0, count: NaN, blocks: [] }, client as any)
80
+ ).rejects.toThrow('count must be a non-negative integer');
81
+ });
82
+
83
+ it('rejects an Infinity start', async () => {
84
+ await expect(
85
+ handleWriteTool('replace_block_range', { post_id: 1, start: Infinity, count: 1, blocks: [] }, client as any)
86
+ ).rejects.toThrow('start must be a non-negative integer');
87
+ });
49
88
  });
50
89
 
51
90
  describe('replace_block_range — forwarding', () => {
@@ -88,3 +127,35 @@ describe('replace_block_range — forwarding', () => {
88
127
  expect(client.replaceBlocksRange).toHaveBeenCalled();
89
128
  });
90
129
  });
130
+
131
+ describe('replace_block_range — warning enrichment', () => {
132
+ let client: ReturnType<typeof makeMockClient>;
133
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
134
+
135
+ it('adds formatted_warnings when response has warnings', async () => {
136
+ client.replaceBlocksRange = vi.fn().mockResolvedValue({
137
+ success: true, removed: 1,
138
+ inserted: [{ index: 0, name: 'oldns/heading' }],
139
+ warnings: [{ block: 'oldns/heading', message: 'AVOID', suggested_replacement: 'core/heading' }],
140
+ before_revision_id: 1, revision_id: 2,
141
+ }) as any;
142
+ const result = await handleWriteTool('replace_block_range', {
143
+ post_id: 1, start: 0, count: 1, blocks: [{ name: 'oldns/heading' }],
144
+ }, client as any);
145
+ assertHasFormattedWarning(result, 'WARNING');
146
+ assertHasFormattedWarning(result, 'oldns/heading');
147
+ });
148
+
149
+ it('no formatted_warnings when response has none', async () => {
150
+ client.replaceBlocksRange = vi.fn().mockResolvedValue({
151
+ success: true, removed: 1,
152
+ inserted: [{ index: 0, name: 'core/heading' }],
153
+ warnings: [],
154
+ before_revision_id: 1, revision_id: 2,
155
+ }) as any;
156
+ const result = await handleWriteTool('replace_block_range', {
157
+ post_id: 1, start: 0, count: 1, blocks: [{ name: 'core/heading' }],
158
+ }, client as any);
159
+ assertNoFormattedWarnings(result);
160
+ });
161
+ });
@@ -29,6 +29,27 @@ describe('rewrite_post_blocks — validation', () => {
29
29
  ).rejects.toThrow('post_id');
30
30
  });
31
31
 
32
+ it('rejects a float post_id', async () => {
33
+ await expect(
34
+ handleWriteTool('rewrite_post_blocks', { post_id: 1.5, blocks: [{ name: 'core/paragraph' }] }, client as any)
35
+ ).rejects.toThrow('post_id must be a positive integer');
36
+ });
37
+
38
+ it('rejects a negative post_id', async () => {
39
+ await expect(
40
+ handleWriteTool('rewrite_post_blocks', { post_id: -1, blocks: [{ name: 'core/paragraph' }] }, client as any)
41
+ ).rejects.toThrow('post_id must be a positive integer');
42
+ });
43
+
44
+ it('rejects an overflow post_id', async () => {
45
+ await expect(
46
+ handleWriteTool('rewrite_post_blocks', {
47
+ post_id: Number.MAX_SAFE_INTEGER + 1,
48
+ blocks: [{ name: 'core/paragraph' }],
49
+ }, client as any)
50
+ ).rejects.toThrow('post_id must be a positive integer');
51
+ });
52
+
32
53
  it('requires at least one block', async () => {
33
54
  await expect(
34
55
  handleWriteTool('rewrite_post_blocks', { post_id: 1 }, client as any)
@@ -108,6 +129,24 @@ describe('revert_to_revision — validation', () => {
108
129
  ).rejects.toThrow('post_id');
109
130
  });
110
131
 
132
+ it('rejects a float post_id', async () => {
133
+ await expect(
134
+ handleWriteTool('revert_to_revision', { post_id: 1.5, revision_id: 1 }, client as any)
135
+ ).rejects.toThrow('post_id must be a positive integer');
136
+ });
137
+
138
+ it('rejects a negative post_id', async () => {
139
+ await expect(
140
+ handleWriteTool('revert_to_revision', { post_id: -1, revision_id: 1 }, client as any)
141
+ ).rejects.toThrow('post_id must be a positive integer');
142
+ });
143
+
144
+ it('rejects an overflow post_id', async () => {
145
+ await expect(
146
+ handleWriteTool('revert_to_revision', { post_id: Number.MAX_SAFE_INTEGER + 1, revision_id: 1 }, client as any)
147
+ ).rejects.toThrow('post_id must be a positive integer');
148
+ });
149
+
111
150
  it('requires revision_id', async () => {
112
151
  await expect(
113
152
  handleWriteTool('revert_to_revision', { post_id: 1 }, client as any)
@@ -48,6 +48,22 @@ describe('update_block — validation', () => {
48
48
  .rejects.toThrow('post_id');
49
49
  });
50
50
 
51
+ it('rejects a float post_id', async () => {
52
+ await expect(handleWriteTool('update_block', { post_id: 1.5, flat_index: 0, attributes: {} }, client as any))
53
+ .rejects.toThrow('post_id must be a positive integer');
54
+ });
55
+
56
+ it('rejects a negative post_id', async () => {
57
+ await expect(handleWriteTool('update_block', { post_id: -1, flat_index: 0, attributes: {} }, client as any))
58
+ .rejects.toThrow('post_id must be a positive integer');
59
+ });
60
+
61
+ it('rejects an overflow post_id', async () => {
62
+ await expect(
63
+ handleWriteTool('update_block', { post_id: Number.MAX_SAFE_INTEGER + 1, flat_index: 0, attributes: {} }, client as any)
64
+ ).rejects.toThrow('post_id must be a positive integer');
65
+ });
66
+
51
67
  it('requires flat_index OR ref (not neither)', async () => {
52
68
  await expect(handleWriteTool('update_block', { post_id: 1, attributes: {} }, client as any))
53
69
  .rejects.toThrow(/Provide either flat_index/);
@@ -33,6 +33,27 @@ describe('update_blocks — validation: post_id', () => {
33
33
  handleWriteTool('update_blocks', { updates: [{ ref: 'blk_a', innerHTML: 'x' }] }, client as any)
34
34
  ).rejects.toThrow('post_id');
35
35
  });
36
+
37
+ it('rejects a float post_id', async () => {
38
+ await expect(
39
+ handleWriteTool('update_blocks', { post_id: 1.5, updates: [{ ref: 'blk_a', innerHTML: 'x' }] }, client as any)
40
+ ).rejects.toThrow('post_id must be a positive integer');
41
+ });
42
+
43
+ it('rejects a negative post_id', async () => {
44
+ await expect(
45
+ handleWriteTool('update_blocks', { post_id: -1, updates: [{ ref: 'blk_a', innerHTML: 'x' }] }, client as any)
46
+ ).rejects.toThrow('post_id must be a positive integer');
47
+ });
48
+
49
+ it('rejects an overflow post_id', async () => {
50
+ await expect(
51
+ handleWriteTool('update_blocks', {
52
+ post_id: Number.MAX_SAFE_INTEGER + 1,
53
+ updates: [{ ref: 'blk_a', innerHTML: 'x' }],
54
+ }, client as any)
55
+ ).rejects.toThrow('post_id must be a positive integer');
56
+ });
36
57
  });
37
58
 
38
59
  describe('update_blocks — validation: updates array', () => {
@@ -33,9 +33,17 @@ describe('yoast_get_seo — validation', () => {
33
33
  expect(client.getYoastSEO).not.toHaveBeenCalled();
34
34
  });
35
35
 
36
- it('rejects string post_id (must be number)', async () => {
37
- await expect(handleYoastTool('yoast_get_seo', { post_id: '42' }, client as any))
38
- .rejects.toThrow('post_id');
36
+ // Coerces a numeric-string post_id (parity with get_post_info / update_post),
37
+ // rejecting only a genuinely non-numeric value.
38
+ it('coerces a numeric-string post_id', async () => {
39
+ client.getYoastSEO.mockResolvedValue(yoastSEOResponse);
40
+ await handleYoastTool('yoast_get_seo', { post_id: '42' }, client as any);
41
+ expect(client.getYoastSEO).toHaveBeenCalledWith(42);
42
+ });
43
+
44
+ it('rejects a non-numeric post_id string', async () => {
45
+ await expect(handleYoastTool('yoast_get_seo', { post_id: 'abc' }, client as any))
46
+ .rejects.toThrow('post_id must be a positive integer');
39
47
  expect(client.getYoastSEO).not.toHaveBeenCalled();
40
48
  });
41
49
  });
@@ -26,6 +26,22 @@ describe('yoast_update_seo — schema', () => {
26
26
  const tool = YOAST_TOOLS.find((t) => t.name === 'yoast_update_seo')!;
27
27
  expect(tool.inputSchema.required).toContain('post_id');
28
28
  });
29
+
30
+ // Regression: some AI clients (e.g. Google Gemini) reject a "null" member in a
31
+ // JSON Schema `type` array and 400 the entire tools/list request, taking down
32
+ // every tool on the server. noindex must advertise a single scalar type; the
33
+ // handler still accepts an explicit null (see "noindex tri-state" tests below).
34
+ it('advertises noindex as a single boolean type, never a ["boolean","null"] array', () => {
35
+ const tool = YOAST_TOOLS.find((t) => t.name === 'yoast_update_seo')!;
36
+ const props = tool.inputSchema.properties as unknown as Record<string, { type?: unknown }>;
37
+ expect(props.noindex.type).toBe('boolean');
38
+ // Guard the whole shared field set: no property may use a type array containing "null".
39
+ for (const [name, schema] of Object.entries(props)) {
40
+ if (Array.isArray(schema.type)) {
41
+ expect(schema.type, `${name} uses a type array containing "null"`).not.toContain('null');
42
+ }
43
+ }
44
+ });
29
45
  });
30
46
 
31
47
  describe('yoast_update_seo — validation', () => {
@@ -191,12 +191,9 @@ describe('Client — mutateBlockTree ref/path body', () => {
191
191
  expect(body).not.toHaveProperty('ref');
192
192
  });
193
193
 
194
- it('forwards before_ref in move body', async () => {
195
- const client = makeClient();
196
- await client.mutateBlockTree(42, { op: 'move', ref: 'blk_source', before_ref: 'blk_anchor' });
197
- const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/mutate');
198
- expect((req!.data as Record<string, unknown>).before_ref).toBe('blk_anchor');
199
- });
194
+ // NOTE: `before_ref`/`before` were removed from MutationRequest the REST
195
+ // route only accepts `destination`/`destination_ref`, so forwarding the
196
+ // aliases pinned a param the server silently dropped.
200
197
 
201
198
  it('forwards destination_ref in move body', async () => {
202
199
  const client = makeClient();
@@ -185,6 +185,27 @@ describe('enrichBlock — Code Block Pro', () => {
185
185
  expect(textareaContent).toContain('&lt;/textarea&gt;');
186
186
  });
187
187
 
188
+ /**
189
+ * Regression: when the enricher replaces an existing <pre class="shiki"> in
190
+ * innerHTML, the highlighted codeHTML was passed as the String.replace
191
+ * REPLACEMENT string, so a `$`-sequence in the source ($$ -> $, $& -> the
192
+ * whole matched <pre>) was interpreted as a replacement pattern and corrupted
193
+ * the rendered code. The replacement must be literal.
194
+ */
195
+ it('preserves $-sequences in code when replacing existing innerHTML', async () => {
196
+ const block: BlockDef = {
197
+ name: 'kevinbatdorf/code-block-pro',
198
+ attributes: { code: 'A $$MONEY$$ B $& C', language: 'plaintext', copyButton: false },
199
+ innerHTML: '<div class="wp-block-kevinbatdorf-code-block-pro"><pre class="shiki css-variables"><code>OLD</code></pre></div>',
200
+ };
201
+ const result = await enrichBlock(block);
202
+ expect(result.innerHTML).toContain('$$MONEY$$');
203
+ // $& would otherwise splice the whole old <pre> back in; the old content
204
+ // must be gone and the literal $& (shiki escapes the ampersand) preserved.
205
+ expect(result.innerHTML).not.toContain('OLD');
206
+ expect(result.innerHTML).toContain('$&#x26;');
207
+ });
208
+
188
209
  /**
189
210
  * Wrapper-style values come straight from caller-supplied attributes. A
190
211
  * value containing `"` (whether malicious or just a quoted font-name like