@gravitykit/block-mcp 2.0.0-beta

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 (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tool tests: create_post
3
+ *
4
+ * Covers:
5
+ * - Schema: title required
6
+ * - Validation: non-empty title (empty, whitespace-only)
7
+ * - Validation: content + blocks mutually exclusive
8
+ * - Happy path: args forwarded to client
9
+ * - Response shape passthrough
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+ import { POST_TOOLS, handlePostTool } from '../../../tools/posts.js';
14
+ import { makeMockClient } from '../../helpers/mock-client.js';
15
+ import { createPostResponse } from '../../fixtures/rest-responses.js';
16
+
17
+ describe('create_post — schema', () => {
18
+ it('exposes create_post tool', () => {
19
+ expect(POST_TOOLS.map((t) => t.name)).toContain('create_post');
20
+ });
21
+
22
+ it('requires title in inputSchema', () => {
23
+ const tool = POST_TOOLS.find((t) => t.name === 'create_post')!;
24
+ expect(tool.inputSchema.required).toContain('title');
25
+ });
26
+ });
27
+
28
+ describe('create_post — validation', () => {
29
+ let client: ReturnType<typeof makeMockClient>;
30
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
31
+
32
+ it('rejects missing title', async () => {
33
+ await expect(handlePostTool('create_post', {}, client as any)).rejects.toThrow('title');
34
+ expect(client.createPost).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it('rejects empty-string title', async () => {
38
+ await expect(handlePostTool('create_post', { title: '' }, client as any)).rejects.toThrow('title');
39
+ expect(client.createPost).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it('rejects whitespace-only title', async () => {
43
+ await expect(handlePostTool('create_post', { title: ' ' }, client as any)).rejects.toThrow('title');
44
+ expect(client.createPost).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it('rejects content and blocks together (mutually exclusive)', async () => {
48
+ await expect(handlePostTool('create_post', {
49
+ title: 'X', content: 'some text', blocks: [{ name: 'core/paragraph' }],
50
+ }, client as any)).rejects.toThrow('mutually exclusive');
51
+ expect(client.createPost).not.toHaveBeenCalled();
52
+ });
53
+ });
54
+
55
+ describe('create_post — happy path', () => {
56
+ let client: ReturnType<typeof makeMockClient>;
57
+ beforeEach(() => {
58
+ client = makeMockClient();
59
+ client.createPost.mockResolvedValue(createPostResponse);
60
+ vi.clearAllMocks();
61
+ });
62
+
63
+ it('forwards args to client', async () => {
64
+ await handlePostTool('create_post', {
65
+ title: 'Hello', status: 'draft', categories: [12],
66
+ }, client as any);
67
+ expect(client.createPost).toHaveBeenCalledWith({
68
+ title: 'Hello', status: 'draft', categories: [12],
69
+ });
70
+ });
71
+
72
+ it('returns client response unchanged', async () => {
73
+ const result = await handlePostTool('create_post', { title: 'X' }, client as any);
74
+ expect(result).toEqual(createPostResponse);
75
+ });
76
+
77
+ it('response includes success, id, post_type, status', async () => {
78
+ const result = await handlePostTool('create_post', { title: 'X' }, client as any) as any;
79
+ expect(result.success).toBe(true);
80
+ expect(result.id).toBe(9999);
81
+ expect(result.post_type).toBe('post');
82
+ expect(result.status).toBe('draft');
83
+ });
84
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Tool tests: update_post
3
+ *
4
+ * Covers:
5
+ * - Schema: post_id required
6
+ * - Validation: post_id required
7
+ * - Validation: at least one mutating field besides post_id
8
+ * - Request shape: separates post_id from body
9
+ * - Response shape passthrough
10
+ * - Unknown tool throws
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+ import { POST_TOOLS, handlePostTool } from '../../../tools/posts.js';
15
+ import { makeMockClient } from '../../helpers/mock-client.js';
16
+ import { updatePostResponse } from '../../fixtures/rest-responses.js';
17
+
18
+ describe('update_post — schema', () => {
19
+ it('exposes update_post tool', () => {
20
+ expect(POST_TOOLS.map((t) => t.name)).toContain('update_post');
21
+ });
22
+
23
+ it('requires post_id in inputSchema', () => {
24
+ const tool = POST_TOOLS.find((t) => t.name === 'update_post')!;
25
+ expect(tool.inputSchema.required).toContain('post_id');
26
+ });
27
+ });
28
+
29
+ describe('update_post — validation', () => {
30
+ let client: ReturnType<typeof makeMockClient>;
31
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
32
+
33
+ it('requires post_id', async () => {
34
+ await expect(handlePostTool('update_post', { title: 'X' }, client as any))
35
+ .rejects.toThrow('post_id');
36
+ expect(client.updatePost).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it('requires at least one mutating field besides post_id', async () => {
40
+ await expect(handlePostTool('update_post', { post_id: 1 }, client as any))
41
+ .rejects.toThrow('at least one mutating field');
42
+ expect(client.updatePost).not.toHaveBeenCalled();
43
+ });
44
+ });
45
+
46
+ describe('update_post — request shape', () => {
47
+ let client: ReturnType<typeof makeMockClient>;
48
+ beforeEach(() => {
49
+ client = makeMockClient();
50
+ client.updatePost.mockResolvedValue(updatePostResponse);
51
+ vi.clearAllMocks();
52
+ });
53
+
54
+ it('separates post_id from body and forwards remaining fields', async () => {
55
+ await handlePostTool('update_post', { post_id: 99, status: 'publish', title: 'New' }, client as any);
56
+ expect(client.updatePost).toHaveBeenCalledWith(99, { status: 'publish', title: 'New' });
57
+ });
58
+
59
+ it('post_id is not included in the body', async () => {
60
+ await handlePostTool('update_post', { post_id: 5, status: 'draft' }, client as any);
61
+ const body = client.updatePost.mock.calls[0]![1] as Record<string, unknown>;
62
+ expect(body).not.toHaveProperty('post_id');
63
+ });
64
+ });
65
+
66
+ describe('update_post — response shape', () => {
67
+ let client: ReturnType<typeof makeMockClient>;
68
+ beforeEach(() => {
69
+ client = makeMockClient();
70
+ client.updatePost.mockResolvedValue(updatePostResponse);
71
+ vi.clearAllMocks();
72
+ });
73
+
74
+ it('returns client response unchanged', async () => {
75
+ const result = await handlePostTool('update_post', { post_id: 1, status: 'publish' }, client as any);
76
+ expect(result).toEqual(updatePostResponse);
77
+ });
78
+
79
+ it('response includes transitioned_to_publish', async () => {
80
+ const result = await handlePostTool('update_post', { post_id: 1, status: 'publish' }, client as any) as any;
81
+ expect(result.transitioned_to_publish).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe('update_post — unknown tool', () => {
86
+ let client: ReturnType<typeof makeMockClient>;
87
+ beforeEach(() => { client = makeMockClient(); });
88
+
89
+ it('throws on unknown tool name', async () => {
90
+ await expect(handlePostTool('unknown_tool', { post_id: 1 }, client as any))
91
+ .rejects.toThrow('Unknown post tool');
92
+ });
93
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Tool tests: get_block
3
+ *
4
+ * Covers:
5
+ * - Required: post_id
6
+ * - XOR: exactly one of ref or flat_index
7
+ * - Routing: ref → client.getBlock(postId, {ref})
8
+ * - Routing: flat_index → client.getBlock(postId, {flatIndex})
9
+ * - flat_index:0 is valid (Number.isFinite check, not truthy)
10
+ * - Response shape: { success, saved } via assertSavedBlock
11
+ * - Empty string ref treated as missing (XOR with flat_index)
12
+ * - Non-finite flat_index (NaN/Infinity) treated as missing
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach } from 'vitest';
16
+ import { handleReadTool } from '../../../tools/read.js';
17
+ import { makeMockClient } from '../../helpers/mock-client.js';
18
+ import { getBlockResponse } from '../../fixtures/rest-responses.js';
19
+ import { assertSavedBlock } from '../../helpers/schema-asserts.js';
20
+
21
+ describe('get_block — input validation', () => {
22
+ let client: ReturnType<typeof makeMockClient>;
23
+ beforeEach(() => {
24
+ client = makeMockClient();
25
+ client.getBlock.mockResolvedValue(getBlockResponse);
26
+ });
27
+
28
+ it('requires post_id', async () => {
29
+ await expect(
30
+ handleReadTool('get_block', { ref: 'blk_a' }, client as any)
31
+ ).rejects.toThrow(/post_id is required/);
32
+ });
33
+
34
+ it('requires exactly one of ref or flat_index (rejects neither)', async () => {
35
+ await expect(
36
+ handleReadTool('get_block', { post_id: 1 }, client as any)
37
+ ).rejects.toThrow(/exactly one of ref or flat_index/);
38
+ });
39
+
40
+ it('rejects both ref and flat_index together', async () => {
41
+ await expect(
42
+ handleReadTool('get_block', { post_id: 1, ref: 'blk_a', flat_index: 0 }, client as any)
43
+ ).rejects.toThrow(/exactly one of ref or flat_index/);
44
+ });
45
+
46
+ it('treats empty-string ref as missing (still requires flat_index)', async () => {
47
+ await expect(
48
+ handleReadTool('get_block', { post_id: 1, ref: '' }, client as any)
49
+ ).rejects.toThrow(/exactly one of ref or flat_index/);
50
+ });
51
+
52
+ it('treats NaN flat_index as missing (still requires ref)', async () => {
53
+ await expect(
54
+ handleReadTool('get_block', { post_id: 1, flat_index: NaN }, client as any)
55
+ ).rejects.toThrow(/exactly one of ref or flat_index/);
56
+ });
57
+ });
58
+
59
+ describe('get_block — routing', () => {
60
+ let client: ReturnType<typeof makeMockClient>;
61
+ beforeEach(() => {
62
+ client = makeMockClient();
63
+ client.getBlock.mockResolvedValue(getBlockResponse);
64
+ });
65
+
66
+ it('forwards ref to client.getBlock', async () => {
67
+ await handleReadTool('get_block', { post_id: 42, ref: 'blk_a' }, client as any);
68
+ expect(client.getBlock).toHaveBeenCalledWith(42, { ref: 'blk_a' });
69
+ });
70
+
71
+ it('forwards flat_index to client.getBlock as flatIndex (camelCase)', async () => {
72
+ await handleReadTool('get_block', { post_id: 42, flat_index: 3 }, client as any);
73
+ expect(client.getBlock).toHaveBeenCalledWith(42, { flatIndex: 3 });
74
+ });
75
+
76
+ it('flat_index:0 is a valid lookup (not falsy-rejected)', async () => {
77
+ await handleReadTool('get_block', { post_id: 1, flat_index: 0 }, client as any);
78
+ expect(client.getBlock).toHaveBeenCalledWith(1, { flatIndex: 0 });
79
+ });
80
+ });
81
+
82
+ describe('get_block — response shape', () => {
83
+ let client: ReturnType<typeof makeMockClient>;
84
+ beforeEach(() => {
85
+ client = makeMockClient();
86
+ client.getBlock.mockResolvedValue(getBlockResponse);
87
+ });
88
+
89
+ it('returns { success, saved } with a valid saved block', async () => {
90
+ const result = await handleReadTool('get_block', { post_id: 1, ref: 'blk_a' }, client as any) as {
91
+ success: boolean; saved: unknown;
92
+ };
93
+ expect(result.success).toBe(true);
94
+ assertSavedBlock(result.saved);
95
+ });
96
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Tool tests: get_page_blocks
3
+ *
4
+ * Covers:
5
+ * - Input requirement: either post_id or url
6
+ * - URL resolution path (when only url is provided)
7
+ * - Direct post_id path (skips resolveUrl)
8
+ * - All query-mode flag forwarding (fields, render, search, block_name,
9
+ * outline, summary_only, include_legacy_paths, persist_refs)
10
+ * - summary_only short-circuits enrichment
11
+ * - Response shape: post_id + summary + blocks + block_count + warnings
12
+ * - Enrichment adds preference warnings for legacy/avoid blocks
13
+ * - persist_refs only forwarded when provided (omitted otherwise)
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach } from 'vitest';
17
+ import { handleReadTool } from '../../../tools/read.js';
18
+ import { makeMockClient } from '../../helpers/mock-client.js';
19
+ import { pageBlocksResponse, resolveUrlResponse } from '../../fixtures/rest-responses.js';
20
+ import { mixedPageBlocks, legacyPageBlocks } from '../../fixtures/block-trees.js';
21
+
22
+ describe('get_page_blocks — input validation', () => {
23
+ let client: ReturnType<typeof makeMockClient>;
24
+ beforeEach(() => {
25
+ client = makeMockClient();
26
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
27
+ });
28
+
29
+ it('throws when neither post_id nor url is supplied', async () => {
30
+ await expect(
31
+ handleReadTool('get_page_blocks', {}, client as any)
32
+ ).rejects.toThrow(/post_id or url/);
33
+ });
34
+
35
+ it('accepts post_id alone', async () => {
36
+ await expect(
37
+ handleReadTool('get_page_blocks', { post_id: 42 }, client as any)
38
+ ).resolves.toBeDefined();
39
+ });
40
+
41
+ it('accepts url alone', async () => {
42
+ client.resolveUrl.mockResolvedValueOnce(resolveUrlResponse);
43
+ await expect(
44
+ handleReadTool('get_page_blocks', { url: '/path/' }, client as any)
45
+ ).resolves.toBeDefined();
46
+ });
47
+ });
48
+
49
+ describe('get_page_blocks — URL resolution', () => {
50
+ let client: ReturnType<typeof makeMockClient>;
51
+ beforeEach(() => {
52
+ client = makeMockClient();
53
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
54
+ });
55
+
56
+ it('resolves url to post_id via client.resolveUrl', async () => {
57
+ client.resolveUrl.mockResolvedValueOnce({ ...resolveUrlResponse, post_id: 555 });
58
+ await handleReadTool('get_page_blocks', { url: '/some/path/' }, client as any);
59
+ expect(client.resolveUrl).toHaveBeenCalledWith('/some/path/');
60
+ expect(client.getPageBlocks).toHaveBeenCalledWith(555, expect.any(Object));
61
+ });
62
+
63
+ it('skips resolveUrl when post_id is provided', async () => {
64
+ await handleReadTool('get_page_blocks', { post_id: 99 }, client as any);
65
+ expect(client.resolveUrl).not.toHaveBeenCalled();
66
+ expect(client.getPageBlocks).toHaveBeenCalledWith(99, expect.any(Object));
67
+ });
68
+
69
+ it('returns the resolved post_id in the response (url path)', async () => {
70
+ client.resolveUrl.mockResolvedValueOnce({ ...resolveUrlResponse, post_id: 1234 });
71
+ const result = await handleReadTool('get_page_blocks', { url: '/x' }, client as any);
72
+ expect((result as { post_id: number }).post_id).toBe(1234);
73
+ });
74
+ });
75
+
76
+ describe('get_page_blocks — option forwarding', () => {
77
+ let client: ReturnType<typeof makeMockClient>;
78
+ beforeEach(() => {
79
+ client = makeMockClient();
80
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
81
+ });
82
+
83
+ it('forwards fields, render, search, block_name, outline, summary_only, include_legacy_paths', async () => {
84
+ await handleReadTool('get_page_blocks', {
85
+ post_id: 1,
86
+ fields: 'path,name', render: true, search: 'foo', block_name: 'core/heading',
87
+ outline: true, include_legacy_paths: true,
88
+ }, client as any);
89
+ expect(client.getPageBlocks).toHaveBeenCalledWith(1, expect.objectContaining({
90
+ fields: 'path,name', render: true, search: 'foo',
91
+ block_name: 'core/heading', outline: true, include_legacy_paths: true,
92
+ }));
93
+ });
94
+
95
+ it('omits persist_refs when not provided', async () => {
96
+ await handleReadTool('get_page_blocks', { post_id: 1 }, client as any);
97
+ const opts = client.getPageBlocks.mock.calls[0]![1] as Record<string, unknown>;
98
+ expect('persist_refs' in opts).toBe(false);
99
+ });
100
+
101
+ it('forwards persist_refs:false when explicitly set', async () => {
102
+ await handleReadTool('get_page_blocks', { post_id: 1, persist_refs: false }, client as any);
103
+ expect(client.getPageBlocks).toHaveBeenCalledWith(1, expect.objectContaining({ persist_refs: false }));
104
+ });
105
+
106
+ it('forwards persist_refs:true when explicitly set', async () => {
107
+ await handleReadTool('get_page_blocks', { post_id: 1, persist_refs: true }, client as any);
108
+ expect(client.getPageBlocks).toHaveBeenCalledWith(1, expect.objectContaining({ persist_refs: true }));
109
+ });
110
+ });
111
+
112
+ describe('get_page_blocks — summary_only short-circuit', () => {
113
+ let client: ReturnType<typeof makeMockClient>;
114
+ beforeEach(() => {
115
+ client = makeMockClient();
116
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
117
+ });
118
+
119
+ it('returns only post_id + summary when summary_only:true', async () => {
120
+ const result = await handleReadTool(
121
+ 'get_page_blocks',
122
+ { post_id: 1, summary_only: true },
123
+ client as any
124
+ ) as Record<string, unknown>;
125
+ expect(Object.keys(result).sort()).toEqual(['post_id', 'summary']);
126
+ expect(result.post_id).toBe(1);
127
+ expect(result.summary).toBeDefined();
128
+ });
129
+
130
+ it('does not include blocks/warnings keys in summary_only mode', async () => {
131
+ const result = await handleReadTool(
132
+ 'get_page_blocks', { post_id: 1, summary_only: true }, client as any
133
+ ) as Record<string, unknown>;
134
+ expect('blocks' in result).toBe(false);
135
+ expect('warnings' in result).toBe(false);
136
+ expect('block_count' in result).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe('get_page_blocks — response shape (default mode)', () => {
141
+ let client: ReturnType<typeof makeMockClient>;
142
+ beforeEach(() => {
143
+ client = makeMockClient();
144
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
145
+ });
146
+
147
+ it('returns post_id, summary, blocks, block_count, warnings', async () => {
148
+ const result = await handleReadTool('get_page_blocks', { post_id: 7 }, client as any) as Record<string, unknown>;
149
+ expect(result.post_id).toBe(7);
150
+ expect(result.summary).toBeDefined();
151
+ expect(Array.isArray(result.blocks)).toBe(true);
152
+ expect(typeof result.block_count).toBe('number');
153
+ expect(Array.isArray(result.warnings)).toBe(true);
154
+ });
155
+
156
+ it('block_count matches blocks.length', async () => {
157
+ const result = await handleReadTool('get_page_blocks', { post_id: 1 }, client as any) as Record<string, unknown>;
158
+ expect(result.block_count).toBe((result.blocks as unknown[]).length);
159
+ });
160
+
161
+ it('warnings is empty when content has no legacy/avoid blocks', async () => {
162
+ client.getPageBlocks.mockResolvedValueOnce({ ...pageBlocksResponse, blocks: mixedPageBlocks });
163
+ const result = await handleReadTool('get_page_blocks', { post_id: 1 }, client as any) as { warnings: unknown[] };
164
+ expect(result.warnings).toEqual([]);
165
+ });
166
+
167
+ it('handles missing blocks array (treats as empty)', async () => {
168
+ client.getPageBlocks.mockResolvedValueOnce({ summary: pageBlocksResponse.summary } as any);
169
+ const result = await handleReadTool('get_page_blocks', { post_id: 1 }, client as any) as Record<string, unknown>;
170
+ expect(result.blocks).toEqual([]);
171
+ expect(result.block_count).toBe(0);
172
+ });
173
+ });
174
+
175
+ describe('get_page_blocks — enrichment with legacy blocks', () => {
176
+ let client: ReturnType<typeof makeMockClient>;
177
+ beforeEach(() => { client = makeMockClient(); });
178
+
179
+ it('produces warnings for legacy ugb/* blocks', async () => {
180
+ client.getPageBlocks.mockResolvedValueOnce({ blocks: legacyPageBlocks, summary: {} });
181
+ const result = await handleReadTool('get_page_blocks', { post_id: 1 }, client as any) as { warnings: unknown[] };
182
+ expect(result.warnings.length).toBeGreaterThan(0);
183
+ });
184
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tool tests: get_page_blocks — persist_refs option
3
+ *
4
+ * Covers:
5
+ * - persist_refs not forwarded when not specified (server default applies)
6
+ * - persist_refs:false forwarded when explicitly set
7
+ * - persist_refs:true forwarded when explicitly set
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { handleReadTool } from '../../../tools/read.js';
12
+ import { makeMockClient } from '../../helpers/mock-client.js';
13
+
14
+ describe('get_page_blocks — persist_refs option', () => {
15
+ let client: ReturnType<typeof makeMockClient>;
16
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
17
+
18
+ it('does not pass persist_refs when not specified (server default applies)', async () => {
19
+ await handleReadTool('get_page_blocks', { post_id: 1 }, client as any);
20
+ const opts = client.getPageBlocks.mock.calls[0]![1] as Record<string, unknown>;
21
+ expect(opts).not.toHaveProperty('persist_refs');
22
+ });
23
+
24
+ it('forwards persist_refs:false when explicitly set', async () => {
25
+ await handleReadTool('get_page_blocks', { post_id: 1, persist_refs: false }, client as any);
26
+ const opts = client.getPageBlocks.mock.calls[0]![1] as Record<string, unknown>;
27
+ expect(opts.persist_refs).toBe(false);
28
+ });
29
+
30
+ it('forwards persist_refs:true when explicitly set', async () => {
31
+ await handleReadTool('get_page_blocks', { post_id: 1, persist_refs: true }, client as any);
32
+ const opts = client.getPageBlocks.mock.calls[0]![1] as Record<string, unknown>;
33
+ expect(opts.persist_refs).toBe(true);
34
+ });
35
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tool tests: list_terms
3
+ *
4
+ * Covers:
5
+ * - Schema: list_terms exposed
6
+ * - Empty args: passes through (taxonomy default server-side)
7
+ * - Filter forwarding: taxonomy, search, per_page, page
8
+ * - Response shape: taxonomy, total, page, per_page, terms
9
+ * - Unknown tool throws
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+ import { TERM_TOOLS, handleTermTool } from '../../../tools/terms.js';
14
+ import { makeMockClient } from '../../helpers/mock-client.js';
15
+
16
+ describe('list_terms — schema', () => {
17
+ it('exposes list_terms tool', () => {
18
+ expect(TERM_TOOLS.map((t) => t.name)).toContain('list_terms');
19
+ });
20
+ });
21
+
22
+ describe('list_terms — request shape', () => {
23
+ let client: ReturnType<typeof makeMockClient>;
24
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
25
+
26
+ it('passes empty args to client (server applies default taxonomy)', async () => {
27
+ await handleTermTool('list_terms', {}, client as any);
28
+ expect(client.listTerms).toHaveBeenCalledWith({});
29
+ });
30
+
31
+ it('forwards taxonomy filter', async () => {
32
+ await handleTermTool('list_terms', { taxonomy: 'post_tag' }, client as any);
33
+ expect(client.listTerms).toHaveBeenCalledWith(expect.objectContaining({ taxonomy: 'post_tag' }));
34
+ });
35
+
36
+ it('forwards search filter', async () => {
37
+ await handleTermTool('list_terms', { taxonomy: 'category', search: 'wp' }, client as any);
38
+ expect(client.listTerms).toHaveBeenCalledWith(expect.objectContaining({ search: 'wp' }));
39
+ });
40
+
41
+ it('forwards per_page and page', async () => {
42
+ await handleTermTool('list_terms', { taxonomy: 'post_tag', per_page: 25, page: 2 }, client as any);
43
+ expect(client.listTerms).toHaveBeenCalledWith({
44
+ taxonomy: 'post_tag', per_page: 25, page: 2,
45
+ });
46
+ });
47
+
48
+ it('forwards all filters together', async () => {
49
+ await handleTermTool('list_terms', {
50
+ taxonomy: 'category', search: 'news', per_page: 50, page: 3,
51
+ }, client as any);
52
+ expect(client.listTerms).toHaveBeenCalledWith({
53
+ taxonomy: 'category', search: 'news', per_page: 50, page: 3,
54
+ });
55
+ });
56
+ });
57
+
58
+ describe('list_terms — response shape', () => {
59
+ let client: ReturnType<typeof makeMockClient>;
60
+ beforeEach(() => {
61
+ client = makeMockClient();
62
+ vi.clearAllMocks();
63
+ });
64
+
65
+ it('returns taxonomy, total, page, per_page, terms', async () => {
66
+ client.listTerms.mockResolvedValue({
67
+ taxonomy: 'category', total: 5, page: 1, per_page: 100,
68
+ terms: [{ id: 1, name: 'Uncategorized', slug: 'uncategorized', count: 10 }],
69
+ });
70
+ const result = await handleTermTool('list_terms', {}, client as any) as any;
71
+ expect(result.taxonomy).toBe('category');
72
+ expect(result.total).toBe(5);
73
+ expect(result.terms).toHaveLength(1);
74
+ });
75
+
76
+ it('handles empty terms array', async () => {
77
+ const result = await handleTermTool('list_terms', {}, client as any) as any;
78
+ expect(result.terms).toEqual([]);
79
+ expect(result.total).toBe(0);
80
+ });
81
+ });
82
+
83
+ describe('list_terms — unknown tool', () => {
84
+ let client: ReturnType<typeof makeMockClient>;
85
+ beforeEach(() => { client = makeMockClient(); });
86
+
87
+ it('throws on unknown tool name', async () => {
88
+ await expect(handleTermTool('unknown_tool', {}, client as any))
89
+ .rejects.toThrow('Unknown term tool');
90
+ });
91
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tool tests: delete_block
3
+ *
4
+ * Covers:
5
+ * - Input validation (post_id, top_level_counter/ref XOR, count bounds)
6
+ * - Index path → client.deleteBlock
7
+ * - Ref path → client.deleteBlockByRef
8
+ * - count forwarding
9
+ * - Edge cases: counter=0, count=1
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+ import { handleWriteTool } from '../../../tools/write.js';
14
+ import { makeMockClient } from '../../helpers/mock-client.js';
15
+
16
+ describe('delete_block — validation', () => {
17
+ let client: ReturnType<typeof makeMockClient>;
18
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
19
+
20
+ it('requires post_id', async () => {
21
+ await expect(
22
+ handleWriteTool('delete_block', { top_level_counter: 0 }, client as any)
23
+ ).rejects.toThrow('post_id');
24
+ });
25
+
26
+ it('requires top_level_counter OR ref (not neither)', async () => {
27
+ await expect(
28
+ handleWriteTool('delete_block', { post_id: 1 }, client as any)
29
+ ).rejects.toThrow(/Provide either top_level_counter/);
30
+ });
31
+
32
+ it('rejects when both top_level_counter and ref provided', async () => {
33
+ await expect(
34
+ handleWriteTool('delete_block', { post_id: 1, top_level_counter: 0, ref: 'blk_x' }, client as any)
35
+ ).rejects.toThrow(/top_level_counter OR ref, not both/);
36
+ });
37
+
38
+ it('rejects NaN top_level_counter', async () => {
39
+ await expect(
40
+ handleWriteTool('delete_block', { post_id: 1, top_level_counter: NaN }, client as any)
41
+ ).rejects.toThrow(/Provide either top_level_counter/);
42
+ });
43
+
44
+ it('rejects count=0 (must be positive)', async () => {
45
+ await expect(
46
+ handleWriteTool('delete_block', { post_id: 1, top_level_counter: 0, count: 0 }, client as any)
47
+ ).rejects.toThrow(/count must be a positive integer/);
48
+ });
49
+ });
50
+
51
+ describe('delete_block — index path', () => {
52
+ let client: ReturnType<typeof makeMockClient>;
53
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
54
+
55
+ it('routes to deleteBlock (not deleteBlockByRef)', async () => {
56
+ await handleWriteTool('delete_block', { post_id: 1, top_level_counter: 3 }, client as any);
57
+ expect(client.deleteBlock).toHaveBeenCalledWith(1, 3, undefined);
58
+ expect(client.deleteBlockByRef).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('top_level_counter=0 is a valid target', async () => {
62
+ await handleWriteTool('delete_block', { post_id: 1, top_level_counter: 0 }, client as any);
63
+ expect(client.deleteBlock).toHaveBeenCalledWith(1, 0, undefined);
64
+ });
65
+
66
+ it('forwards count to deleteBlock', async () => {
67
+ await handleWriteTool('delete_block', { post_id: 1, top_level_counter: 2, count: 3 }, client as any);
68
+ expect(client.deleteBlock).toHaveBeenCalledWith(1, 2, 3);
69
+ });
70
+
71
+ it('count=1 is accepted', async () => {
72
+ await handleWriteTool('delete_block', { post_id: 1, top_level_counter: 0, count: 1 }, client as any);
73
+ expect(client.deleteBlock).toHaveBeenCalledWith(1, 0, 1);
74
+ });
75
+ });
76
+
77
+ describe('delete_block — ref path', () => {
78
+ let client: ReturnType<typeof makeMockClient>;
79
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
80
+
81
+ it('routes to deleteBlockByRef (not deleteBlock)', async () => {
82
+ await handleWriteTool('delete_block', { post_id: 1, ref: 'blk_target' }, client as any);
83
+ expect(client.deleteBlockByRef).toHaveBeenCalledWith(1, 'blk_target', undefined);
84
+ expect(client.deleteBlock).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('forwards count through ref path', async () => {
88
+ await handleWriteTool('delete_block', { post_id: 1, ref: 'blk_x', count: 3 }, client as any);
89
+ expect(client.deleteBlockByRef).toHaveBeenCalledWith(1, 'blk_x', 3);
90
+ });
91
+ });