@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.
- package/dist/index.cjs +311 -195
- package/package.json +8 -4
- package/src/__tests__/coerce.test.ts +57 -0
- package/src/__tests__/fixtures/error-envelopes.ts +19 -7
- package/src/__tests__/integration/error-envelopes.test.ts +2 -2
- package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
- package/src/__tests__/tools/posts/update_post.test.ts +14 -0
- package/src/__tests__/tools/read/get_block.test.ts +35 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
- package/src/__tests__/tools/write/delete_block.test.ts +18 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
- package/src/__tests__/tools/write/update_block.test.ts +16 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +100 -29
- package/src/__tests__/unit/instructions.test.ts +3 -3
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
- package/src/__tests__/unit/rest-url.test.ts +23 -0
- package/src/client.ts +53 -18
- package/src/coerce.ts +41 -0
- package/src/config.ts +96 -0
- package/src/connect.ts +17 -3
- package/src/enrichers.ts +6 -2
- package/src/error-translator.ts +62 -10
- package/src/index.ts +35 -27
- package/src/instructions.ts +3 -3
- package/src/preferences.ts +56 -43
- package/src/rest-url.ts +18 -0
- package/src/tools/discovery.ts +10 -14
- package/src/tools/mutate.ts +18 -12
- package/src/tools/patterns.ts +3 -2
- package/src/tools/posts.ts +26 -26
- package/src/tools/read.ts +7 -6
- package/src/tools/write.ts +26 -31
- package/src/tools/yoast.ts +30 -31
- 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
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
'
|
|
106
|
+
'post_not_found',
|
|
101
107
|
'not_found',
|
|
102
|
-
'gk_block_api_invalid_ref',
|
|
103
108
|
'invalid_ref',
|
|
104
|
-
'
|
|
109
|
+
'ref_stale',
|
|
105
110
|
'invalid_path',
|
|
106
|
-
'
|
|
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 +
|
|
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(
|
|
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
|
-
* -
|
|
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('
|
|
50
|
+
it('fetches the full set (no truncating server-side limit)', async () => {
|
|
50
51
|
await handleDiscoveryTool('list_patterns', {}, client as any);
|
|
51
|
-
//
|
|
52
|
-
|
|
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('
|
|
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
|
-
|
|
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/);
|