@gravitykit/block-mcp 2.0.1 → 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 (42) hide show
  1. package/dist/index.cjs +311 -195
  2. package/package.json +8 -4
  3. package/src/__tests__/coerce.test.ts +57 -0
  4. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  5. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  6. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  7. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  8. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  9. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  10. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  11. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  12. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  13. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  14. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  15. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  16. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  17. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  18. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  19. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  20. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  21. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +100 -29
  22. package/src/__tests__/unit/instructions.test.ts +3 -3
  23. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  24. package/src/__tests__/unit/rest-url.test.ts +23 -0
  25. package/src/client.ts +53 -18
  26. package/src/coerce.ts +41 -0
  27. package/src/config.ts +96 -0
  28. package/src/connect.ts +17 -3
  29. package/src/enrichers.ts +6 -2
  30. package/src/error-translator.ts +62 -10
  31. package/src/index.ts +35 -27
  32. package/src/instructions.ts +3 -3
  33. package/src/preferences.ts +56 -43
  34. package/src/rest-url.ts +18 -0
  35. package/src/tools/discovery.ts +10 -14
  36. package/src/tools/mutate.ts +18 -12
  37. package/src/tools/patterns.ts +3 -2
  38. package/src/tools/posts.ts +26 -26
  39. package/src/tools/read.ts +7 -6
  40. package/src/tools/write.ts +26 -31
  41. package/src/tools/yoast.ts +30 -31
  42. package/src/types.ts +23 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravitykit/block-mcp",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "MCP server for WordPress block-level content management with preference-aware editing",
5
5
  "main": "dist/index.cjs",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "test:watch": "vitest",
18
18
  "test:integration": "vitest run --config vitest.integration.config.ts",
19
19
  "test:docs": "playwright test --config tests/docs/playwright.config.ts",
20
+ "test:e2e": "playwright test --config tests/e2e/playwright.config.ts",
20
21
  "eval": "tsx tests/evals/lib/runner.ts",
21
22
  "eval:fixture-refresh": "tsx tests/evals/scripts/fetch-fixture.ts",
22
23
  "prepare": "npm run build",
@@ -42,7 +43,7 @@
42
43
  },
43
44
  "dependencies": {
44
45
  "@modelcontextprotocol/sdk": "^1.0.0",
45
- "axios": "^1.7.9",
46
+ "axios": "^1.16.0",
46
47
  "form-data": "^4.0.1"
47
48
  },
48
49
  "devDependencies": {
@@ -51,12 +52,12 @@
51
52
  "@shikijs/engine-javascript": "^4.0.2",
52
53
  "@shikijs/langs": "^4.0.2",
53
54
  "@types/node": "^22.0.0",
54
- "esbuild": "^0.27.3",
55
+ "esbuild": "^0.28.1",
55
56
  "sharp": "^0.33.5",
56
57
  "shiki": "^4.0.2",
57
58
  "tsx": "^4.21.0",
58
59
  "typescript": "^5.7.0",
59
- "vitest": "^3.2.4"
60
+ "vitest": "^3.2.6"
60
61
  },
61
62
  "engines": {
62
63
  "node": ">=20.0.0"
@@ -70,5 +71,8 @@
70
71
  ],
71
72
  "publishConfig": {
72
73
  "access": "public"
74
+ },
75
+ "overrides": {
76
+ "esbuild": "$esbuild"
73
77
  }
74
78
  }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { coercePostId } from '../coerce.js';
3
+
4
+ /**
5
+ * Unit tests for the shared coercePostId() helper.
6
+ *
7
+ * Every post_id tool (get_post_info, update_post, yoast_*) inherits this
8
+ * helper, so its "positive integer" contract must hold uniformly — a numeric
9
+ * string is accepted identically to the number, and a zero/negative/non-numeric
10
+ * value is rejected identically. The zero-string cases pin the fix for the gap
11
+ * where "0"/"00" parsed to 0 while the number 0 threw.
12
+ */
13
+ describe('coercePostId', () => {
14
+ it('returns undefined for absent values', () => {
15
+ expect(coercePostId(undefined, 't')).toBeUndefined();
16
+ expect(coercePostId(null, 't')).toBeUndefined();
17
+ });
18
+
19
+ it('accepts a positive integer number', () => {
20
+ expect(coercePostId(123, 't')).toBe(123);
21
+ });
22
+
23
+ it('coerces a positive numeric string', () => {
24
+ expect(coercePostId('123', 't')).toBe(123);
25
+ expect(coercePostId('042', 't')).toBe(42);
26
+ });
27
+
28
+ it('rejects the number 0 and negatives', () => {
29
+ expect(() => coercePostId(0, 't')).toThrow('post_id must be a positive integer');
30
+ expect(() => coercePostId(-1, 't')).toThrow('post_id must be a positive integer');
31
+ });
32
+
33
+ it('rejects zero-valued strings identically to the number 0', () => {
34
+ expect(() => coercePostId('0', 't')).toThrow('post_id must be a positive integer');
35
+ expect(() => coercePostId('00', 't')).toThrow('post_id must be a positive integer');
36
+ expect(() => coercePostId('000', 't')).toThrow('post_id must be a positive integer');
37
+ });
38
+
39
+ it('rejects floats and non-numeric strings', () => {
40
+ expect(() => coercePostId(1.5, 't')).toThrow('post_id must be a positive integer');
41
+ expect(() => coercePostId('12.5', 't')).toThrow('post_id must be a positive integer');
42
+ expect(() => coercePostId('abc', 't')).toThrow('post_id must be a positive integer');
43
+ expect(() => coercePostId('12abc', 't')).toThrow('post_id must be a positive integer');
44
+ });
45
+
46
+ it('rejects integers above Number.MAX_SAFE_INTEGER', () => {
47
+ // Beyond 2^53-1 the number can no longer represent the exact id, so a
48
+ // parsed/rounded value would target the wrong post.
49
+ expect(() => coercePostId(Number.MAX_SAFE_INTEGER + 1, 't')).toThrow('post_id must be a positive integer');
50
+ expect(() => coercePostId('9007199254740993', 't')).toThrow('post_id must be a positive integer');
51
+ expect(() => coercePostId('99999999999999999999', 't')).toThrow('post_id must be a positive integer');
52
+ });
53
+
54
+ it('prefixes the thrown error with the caller label', () => {
55
+ expect(() => coercePostId('x', 'update_post')).toThrow('update_post: post_id must be a positive integer');
56
+ });
57
+ });
@@ -43,19 +43,23 @@ export const ERROR_ENVELOPES: ErrorEnvelope[] = [
43
43
  { code: 'not_found', httpStatus: 404, data: null },
44
44
  { code: 'not_found_with_post', httpStatus: 404, data: { post_id: 77 } },
45
45
 
46
- // ── Precondition / concurrency (412) ────────────────────────────────────
47
- { code: 'stale_revision', httpStatus: 412, data: null },
46
+ // ── Precondition / concurrency (409/412) ────────────────────────────────
47
+ { code: 'stale_revision', httpStatus: 412, data: { current_revision: 88 } },
48
+ { code: 'edit_conflict', httpStatus: 409, data: null },
48
49
 
49
50
  // ── Validation (400) ────────────────────────────────────────────────────
50
51
  { code: 'legacy_block', httpStatus: 400, data: { block: 'ugb/text', suggested_replacement: 'core/paragraph' } },
52
+ { code: 'inner_html_required', httpStatus: 400, data: { block: 'core/paragraph', source_bound_attributes: ['content'] } },
53
+ { code: 'static_markup_stale_risk', httpStatus: 400, data: null },
51
54
  { code: 'dual_storage_requires_both', httpStatus: 400, data: { block_name: 'yoast/faq-block' } },
52
55
  { code: 'bound_attribute', httpStatus: 400, data: null },
53
56
  { code: 'batch_too_large', httpStatus: 400, data: null },
54
57
  { code: 'batch_validation_failed', httpStatus: 400, data: { errors: [{ index: 0, code: 'ref_stale' }] } },
55
58
  { code: 'empty_batch', httpStatus: 400, data: null },
56
- { code: 'block_depth_exceeded', httpStatus: 400, data: null },
59
+ { code: 'block_depth_exceeded', httpStatus: 400, data: { max_depth: 32, actual_depth: 33 } },
57
60
  { code: 'invalid_path', httpStatus: 400, data: { path: [0, 99] } },
58
61
  { code: 'invalid_ref', httpStatus: 400, data: null },
62
+ { code: 'invalid_index', httpStatus: 400, data: { flat_index: 12 } },
59
63
  { code: 'ref_not_top_level', httpStatus: 400, data: { ref: 'blk_nested' } },
60
64
  { code: 'invalid_op', httpStatus: 400, data: null },
61
65
  { code: 'invalid_block', httpStatus: 400, data: null },
@@ -70,9 +74,11 @@ export const ERROR_ENVELOPES: ErrorEnvelope[] = [
70
74
  { code: 'file_too_large', httpStatus: 400, data: null },
71
75
  { code: 'invalid_url', httpStatus: 400, data: null },
72
76
  { code: 'empty_pattern', httpStatus: 400, data: null },
77
+ { code: 'trash_disabled', httpStatus: 403, data: null },
73
78
 
74
79
  // ── Rate limit (429) ────────────────────────────────────────────────────
75
80
  { code: 'rate_limit_exceeded', httpStatus: 429, data: { post_id: 42 } },
81
+ { code: 'rate_limit_locked', httpStatus: 429, data: { post_id: 42 } },
76
82
  { code: 'scan_rate_limited', httpStatus: 429, data: null },
77
83
 
78
84
  // ── Upstream (502) ──────────────────────────────────────────────────────
@@ -97,19 +103,25 @@ export const TRANSLATED_CODES = new Set([
97
103
  'rest_cookie_invalid_nonce',
98
104
  'rest_authentication_required',
99
105
  'rest_post_invalid_id',
100
- 'invalid_post_id',
106
+ 'post_not_found',
101
107
  'not_found',
102
- 'gk_block_api_invalid_ref',
103
108
  'invalid_ref',
104
- 'path_not_found',
109
+ 'ref_stale',
105
110
  'invalid_path',
106
- 'path_out_of_bounds',
111
+ 'invalid_index',
107
112
  'legacy_block',
113
+ 'inner_html_required',
108
114
  'static_markup_stale_risk',
115
+ 'dual_storage_requires_both',
116
+ 'block_depth_exceeded',
117
+ 'edit_conflict',
118
+ 'stale_revision',
109
119
  'rate_limit_exceeded',
120
+ 'rate_limit_locked',
110
121
  'mixed_trash_payload',
111
122
  'invalid_post_type',
112
123
  'invalid_status',
124
+ 'trash_disabled',
113
125
  'invalid_url',
114
126
  'disallowed_mime',
115
127
  ]);
@@ -125,14 +125,14 @@ describe.skipIf(skip)('real REST error envelopes (integration)', () => {
125
125
  expect(result.message).toBeTruthy();
126
126
  });
127
127
 
128
- it('invalid_path: mutate with out-of-range path returns 400 + path error code', async () => {
128
+ it('invalid_path: mutate with out-of-range path returns 400 + invalid_path', async () => {
129
129
  const result = await rawRequest('post', `/posts/${livePostId}/mutate`, {
130
130
  op: 'update-attrs',
131
131
  path: [999, 999, 999],
132
132
  attributes: { content: 'never land' },
133
133
  });
134
134
  expect(result.status).toBe(400);
135
- expect(['invalid_path', 'path_not_found', 'path_out_of_bounds']).toContain(result.code);
135
+ expect(result.code).toBe('invalid_path');
136
136
  expect(result.message).toBeTruthy();
137
137
  });
138
138
 
@@ -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)
@@ -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/);