@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.
- package/README.md +34 -7
- package/dist/index.cjs +2556 -967
- package/package.json +12 -4
- package/src/__tests__/coerce.test.ts +57 -0
- package/src/__tests__/fixtures/error-envelopes.ts +19 -7
- package/src/__tests__/integration/dual-storage.test.ts +22 -26
- package/src/__tests__/integration/error-envelopes.test.ts +2 -2
- package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
- package/src/__tests__/integration/read-discovery.test.ts +313 -0
- package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
- package/src/__tests__/integration/write-surface.test.ts +603 -0
- package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
- 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/read/pagination.test.ts +65 -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/client/ref-endpoints.test.ts +3 -6
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
- 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/agent-guide.ts +85 -0
- package/src/client.ts +104 -40
- package/src/coerce.ts +41 -0
- package/src/config.ts +96 -0
- package/src/connect.ts +123 -38
- package/src/enrichers.ts +6 -2
- package/src/error-translator.ts +63 -11
- package/src/index.ts +49 -79
- 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 +21 -20
- package/src/tools/patterns.ts +3 -2
- package/src/tools/posts.ts +26 -26
- package/src/tools/read.ts +23 -6
- package/src/tools/write.ts +37 -36
- package/src/tools/yoast.ts +30 -31
- package/src/types.ts +45 -31
- 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
|
-
* -
|
|
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)
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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('</textarea>');
|
|
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('$&');
|
|
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
|