@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,175 @@
1
+ /**
2
+ * Integration: ref stability across sibling mutations
3
+ *
4
+ * Verifies that stable gk_refs survive the two most disruptive sibling
5
+ * operations:
6
+ * 1. Inserting a new block BEFORE a known ref — ref must still resolve.
7
+ * 2. Deleting a block ABOVE another known ref — ref must still resolve.
8
+ *
9
+ * These are the exact scenarios that motivate refs over flat indices.
10
+ *
11
+ * If the live plugin build does not assign refs (older versions), these tests
12
+ * fall back to verifying the simpler property that flat indices shift correctly
13
+ * (the observable behaviour that refs are designed to hide).
14
+ *
15
+ * Batch-update tests require the /batch-update route — they skip gracefully
16
+ * when it is absent.
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { makeLiveClient, skipUnlessLive, withTestPost, hasRoute } from './setup.js';
21
+
22
+ const skip = skipUnlessLive();
23
+
24
+ describe.skipIf(skip)('ref stability across sibling mutations (integration)', () => {
25
+ it('block at a given position is addressable after a block is inserted before it', async () => {
26
+ const client = makeLiveClient();
27
+
28
+ await withTestPost(client, async (postId) => {
29
+ // Read initial state: heading (0) + paragraph (1)
30
+ const initial = await client.getPageBlocks(postId);
31
+ const para = initial.blocks.find((b) => b.name === 'core/paragraph');
32
+ expect(para).toBeDefined();
33
+ const paraIndexBefore = para!.index;
34
+ const paraRef = para!.ref; // undefined on older plugin builds
35
+
36
+ // Insert a new block after the heading (before the paragraph).
37
+ const heading = initial.blocks.find((b) => b.name === 'core/heading');
38
+ expect(heading).toBeDefined();
39
+
40
+ const insertData = heading!.ref
41
+ ? { after_ref: heading!.ref }
42
+ : { after: heading!.top_level_counter ?? heading!.index };
43
+
44
+ await client.insertBlocks(postId, {
45
+ ...insertData,
46
+ blocks: [
47
+ {
48
+ name: 'core/paragraph',
49
+ attributes: { content: 'Inserted between heading and paragraph.' },
50
+ innerHTML: '<p>Inserted between heading and paragraph.</p>',
51
+ },
52
+ ],
53
+ });
54
+
55
+ // Re-read and confirm the original paragraph has shifted.
56
+ const after = await client.getPageBlocks(postId);
57
+
58
+ if (paraRef) {
59
+ // Ref-capable plugin: find by ref and verify the index shifted.
60
+ const paraAfter = after.blocks.find((b) => b.ref === paraRef);
61
+ expect(paraAfter).toBeDefined();
62
+ expect(paraAfter!.index).toBeGreaterThan(paraIndexBefore);
63
+
64
+ // Confirm the ref is still directly resolvable via getBlock.
65
+ const singleBlockAvailable = await hasRoute('/block$');
66
+ if (singleBlockAvailable) {
67
+ const single = await client.getBlock(postId, { ref: paraRef });
68
+ expect(single.success).toBe(true);
69
+ expect(single.saved.ref).toBe(paraRef);
70
+ }
71
+ } else {
72
+ // Older plugin without refs: verify there are now 3 blocks (heading +
73
+ // inserted + original paragraph) and the paragraph content is still
74
+ // present at a higher flat index.
75
+ const nonHeadingParas = after.blocks.filter((b) => b.name === 'core/paragraph');
76
+ expect(nonHeadingParas.length).toBeGreaterThanOrEqual(2);
77
+ // One of them must still have the original content.
78
+ const original = nonHeadingParas.find(
79
+ (b) => (b.attributes.content as string | undefined)?.includes('Integration test paragraph')
80
+ );
81
+ expect(original).toBeDefined();
82
+ // Its index must be higher than the inserted block (inserted block took
83
+ // the position right after the heading).
84
+ const inserted = nonHeadingParas.find(
85
+ (b) => (b.attributes.content as string | undefined)?.includes('Inserted between')
86
+ );
87
+ expect(inserted).toBeDefined();
88
+ expect(original!.index).toBeGreaterThan(inserted!.index);
89
+ }
90
+ });
91
+ }, 45_000);
92
+
93
+ it('block at a given position survives deletion of a block above it', async () => {
94
+ const client = makeLiveClient();
95
+
96
+ await withTestPost(client, async (postId) => {
97
+ // Initial: heading (0) + paragraph (1).
98
+ const initial = await client.getPageBlocks(postId);
99
+ const heading = initial.blocks.find((b) => b.name === 'core/heading');
100
+ const para = initial.blocks.find((b) => b.name === 'core/paragraph');
101
+ expect(heading).toBeDefined();
102
+ expect(para).toBeDefined();
103
+ const paraRef = para!.ref;
104
+
105
+ // Delete the heading.
106
+ await client.deleteBlock(postId, heading!.index);
107
+
108
+ // After deletion, the paragraph should be the first block.
109
+ const after = await client.getPageBlocks(postId);
110
+
111
+ if (paraRef) {
112
+ const paraAfter = after.blocks.find((b) => b.ref === paraRef);
113
+ expect(paraAfter).toBeDefined();
114
+ // Flat index must be lower (heading is gone).
115
+ expect(paraAfter!.index).toBeLessThan(para!.index);
116
+
117
+ const singleBlockAvailable = await hasRoute('/block$');
118
+ if (singleBlockAvailable) {
119
+ const single = await client.getBlock(postId, { ref: paraRef });
120
+ expect(single.success).toBe(true);
121
+ expect(single.saved.ref).toBe(paraRef);
122
+ }
123
+ } else {
124
+ // Older plugin: heading must be gone, paragraph still present.
125
+ const headingAfter = after.blocks.find((b) => b.name === 'core/heading');
126
+ expect(headingAfter).toBeUndefined();
127
+ const paraAfter = after.blocks.find((b) => b.name === 'core/paragraph');
128
+ expect(paraAfter).toBeDefined();
129
+ }
130
+ });
131
+ }, 45_000);
132
+
133
+ it('batch update applies two writes in a single revision', async () => {
134
+ const batchRouteExists = await hasRoute('batch-update');
135
+ if (!batchRouteExists) {
136
+ console.log('[integration] batch-update route not present — skipping batch test');
137
+ return;
138
+ }
139
+
140
+ const client = makeLiveClient();
141
+
142
+ await withTestPost(client, async (postId) => {
143
+ const initial = await client.getPageBlocks(postId);
144
+ const heading = initial.blocks.find((b) => b.name === 'core/heading');
145
+ const para = initial.blocks.find((b) => b.name === 'core/paragraph');
146
+ expect(heading?.ref).toBeDefined();
147
+ expect(para?.ref).toBeDefined();
148
+
149
+ const batchResult = await client.updateBlocksBatch(postId, [
150
+ {
151
+ ref: heading!.ref,
152
+ attributes: { content: 'Batch heading', level: 2 },
153
+ innerHTML: '<h2 class="wp-block-heading">Batch heading</h2>',
154
+ },
155
+ {
156
+ ref: para!.ref,
157
+ attributes: { content: 'Batch paragraph.' },
158
+ innerHTML: '<p>Batch paragraph.</p>',
159
+ },
160
+ ], { verbose: true });
161
+
162
+ expect(batchResult.success).toBe(true);
163
+ expect(batchResult.count).toBe(2);
164
+ // ONE revision for both changes.
165
+ expect(batchResult.revision_id).toBeGreaterThan(0);
166
+
167
+ // Verify on disk.
168
+ const after = await client.getPageBlocks(postId);
169
+ const h = after.blocks.find((b) => b.ref === heading!.ref);
170
+ const p = after.blocks.find((b) => b.ref === para!.ref);
171
+ expect(h?.attributes.content).toBe('Batch heading');
172
+ expect(p?.attributes.content).toBe('Batch paragraph.');
173
+ });
174
+ }, 45_000);
175
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Integration test setup helpers.
3
+ *
4
+ * Everything in this file is tree-shaken at unit-test time — nothing here
5
+ * imports from the test-runner directly, so it is safe to import in any
6
+ * integration test file even before `skipUnlessLive()` gates the describe
7
+ * block.
8
+ *
9
+ * Required env vars (all absent → every integration test skips cleanly):
10
+ * WORDPRESS_URL — e.g. http://localhost:7701
11
+ * WORDPRESS_USER — e.g. admin
12
+ * WORDPRESS_APP_PASSWORD — Application Password (spaces OK)
13
+ *
14
+ * Optional:
15
+ * INTEGRATION_POST_TITLE_PREFIX — defaults to "[integration-test]"
16
+ * Used to namespace throwaway posts for cleanup safety.
17
+ */
18
+
19
+ import { WordPressBlockClient } from '../../client.js';
20
+ import type { BlockMCPConfig } from '../../types.js';
21
+ import axios from 'axios';
22
+
23
+ // ── Environment probe ──────────────────────────────────────────────────────
24
+
25
+ export const LIVE_ENV = {
26
+ url: process.env.WORDPRESS_URL ?? '',
27
+ user: process.env.WORDPRESS_USER ?? '',
28
+ password: process.env.WORDPRESS_APP_PASSWORD ?? '',
29
+ prefix: process.env.INTEGRATION_POST_TITLE_PREFIX ?? '[integration-test]',
30
+ };
31
+
32
+ /** True when all required env vars are present. */
33
+ export const isLive: boolean =
34
+ Boolean(LIVE_ENV.url) && Boolean(LIVE_ENV.user) && Boolean(LIVE_ENV.password);
35
+
36
+ /**
37
+ * Returns the boolean skip flag for `describe.skipIf(skip)(...)`.
38
+ *
39
+ * Usage in every integration test file:
40
+ *
41
+ * const skip = skipUnlessLive();
42
+ * describe.skipIf(skip)('my suite', () => { ... });
43
+ */
44
+ export function skipUnlessLive(): boolean {
45
+ return !isLive;
46
+ }
47
+
48
+ // ── Plugin capability detection ───────────────────────────────────────────
49
+
50
+ /**
51
+ * Cached set of route prefixes registered on the live plugin.
52
+ * Populated lazily on first call to `getRegisteredRoutes()`.
53
+ */
54
+ let _routeCache: string[] | null = null;
55
+
56
+ /**
57
+ * Fetch the route list from the live WP instance (cached for the process).
58
+ * Returns an empty array when the site is unreachable.
59
+ */
60
+ export async function getRegisteredRoutes(): Promise<string[]> {
61
+ if (_routeCache !== null) return _routeCache;
62
+ if (!isLive) return (_routeCache = []);
63
+
64
+ try {
65
+ const creds = Buffer.from(`${LIVE_ENV.user}:${LIVE_ENV.password}`).toString('base64');
66
+ const base = LIVE_ENV.url.replace(/\/+$/, '');
67
+ const resp = await axios.get(`${base}/wp-json/gk-block-api/v1`, {
68
+ headers: { Authorization: `Basic ${creds}` },
69
+ timeout: 8000,
70
+ });
71
+ _routeCache = Object.keys((resp.data as { routes?: Record<string, unknown> }).routes ?? {});
72
+ } catch {
73
+ _routeCache = [];
74
+ }
75
+ return _routeCache;
76
+ }
77
+
78
+ /**
79
+ * True when the live plugin has a specific route.
80
+ * `pattern` is treated as a substring when it's a plain string, or
81
+ * as a regex when it starts with `/`. Pass a regex string like `'/block$'`
82
+ * to avoid false positives where 'block' matches 'blocks'.
83
+ */
84
+ export async function hasRoute(pattern: string): Promise<boolean> {
85
+ const routes = await getRegisteredRoutes();
86
+ if (pattern.startsWith('/') && pattern.endsWith('$')) {
87
+ // Treat as a regex anchored at the end of the route segment.
88
+ const re = new RegExp(pattern.slice(1)); // remove leading /
89
+ return routes.some((r) => re.test(r));
90
+ }
91
+ return routes.some((r) => r.includes(pattern));
92
+ }
93
+
94
+ // ── Client factory ─────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Build a real WordPressBlockClient pointed at the live instance.
98
+ * Throws (and the test file will error out) if called without env vars — but
99
+ * callers should always guard with `describe.skipIf(skipUnlessLive())` so
100
+ * this never executes in offline runs.
101
+ */
102
+ export function makeLiveClient(): WordPressBlockClient {
103
+ if (!isLive) {
104
+ throw new Error(
105
+ 'makeLiveClient() called without WORDPRESS_URL/WORDPRESS_USER/WORDPRESS_APP_PASSWORD. ' +
106
+ 'Guard with describe.skipIf(skipUnlessLive()).'
107
+ );
108
+ }
109
+ const config: BlockMCPConfig = {
110
+ wordpress_url: LIVE_ENV.url,
111
+ auth: {
112
+ username: LIVE_ENV.user,
113
+ application_password: LIVE_ENV.password,
114
+ },
115
+ };
116
+ return new WordPressBlockClient(config);
117
+ }
118
+
119
+ // ── Throwaway post lifecycle ───────────────────────────────────────────────
120
+
121
+ /** IDs of posts created during this test run, for emergency globalTeardown sweeps. */
122
+ const createdPostIds = new Set<number>();
123
+
124
+ /**
125
+ * Create a throwaway draft post, run the callback, then delete it — even on
126
+ * failure. Returns whatever the callback returns so tests can use it as their
127
+ * assertion value.
128
+ *
129
+ * @param client Live WordPressBlockClient
130
+ * @param callback Async function receiving the new post_id
131
+ */
132
+ export async function withTestPost<T>(
133
+ client: WordPressBlockClient,
134
+ callback: (postId: number) => Promise<T>
135
+ ): Promise<T> {
136
+ const title = `${LIVE_ENV.prefix} ${Date.now()}`;
137
+ const created = await client.createPost({
138
+ title,
139
+ status: 'draft',
140
+ // Seed with a minimal block so we always have something to read/mutate.
141
+ blocks: [
142
+ {
143
+ name: 'core/heading',
144
+ attributes: { level: 2, content: 'Integration test heading' },
145
+ innerHTML: '<h2 class="wp-block-heading">Integration test heading</h2>',
146
+ },
147
+ {
148
+ name: 'core/paragraph',
149
+ attributes: { content: 'Integration test paragraph.' },
150
+ innerHTML: '<p>Integration test paragraph.</p>',
151
+ },
152
+ ],
153
+ });
154
+
155
+ const postId = created.id;
156
+ createdPostIds.add(postId);
157
+
158
+ try {
159
+ return await callback(postId);
160
+ } finally {
161
+ await deleteTestPost(client, postId);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Delete a throwaway post, tolerating 404s and 429s (already gone or rate
167
+ * limited). Removes the post from the in-process tracker.
168
+ */
169
+ export async function deleteTestPost(
170
+ client: WordPressBlockClient,
171
+ postId: number
172
+ ): Promise<void> {
173
+ try {
174
+ await client.updatePost(postId, { status: 'trash' });
175
+ } catch (err: unknown) {
176
+ // 404 / not found → already gone, fine.
177
+ // 429 → rate limited — log and move on so cleanup doesn't mask test failure.
178
+ const msg = err instanceof Error ? err.message : String(err);
179
+ if (!msg.includes('404') && !msg.includes('not found') && !msg.includes('429') && !msg.includes('rate_limit')) {
180
+ console.warn(`[integration] Failed to trash post ${postId}: ${msg}`);
181
+ }
182
+ } finally {
183
+ createdPostIds.delete(postId);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Sweep any posts that leaked (e.g. afterEach didn't fire because the process
189
+ * crashed). Designed to be called from Vitest globalTeardown.
190
+ *
191
+ * Safe to call even when `isLive` is false — returns immediately.
192
+ */
193
+ export async function cleanupTestPosts(): Promise<void> {
194
+ if (!isLive) return;
195
+ if (createdPostIds.size === 0) return;
196
+
197
+ const client = makeLiveClient();
198
+ const ids = Array.from(createdPostIds);
199
+ console.warn(`[integration] globalTeardown: sweeping ${ids.length} leaked post(s): ${ids.join(', ')}`);
200
+ await Promise.allSettled(ids.map((id) => deleteTestPost(client, id)));
201
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tool tests: get_pattern
3
+ *
4
+ * Covers:
5
+ * - Requires pattern_id (numeric or string)
6
+ * - Forwards numeric ID
7
+ * - Forwards string name
8
+ * - Returns the raw client response
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach } from 'vitest';
12
+ import { handleDiscoveryTool } from '../../../tools/discovery.js';
13
+ import { makeMockClient } from '../../helpers/mock-client.js';
14
+
15
+ describe('get_pattern — validation', () => {
16
+ let client: ReturnType<typeof makeMockClient>;
17
+ beforeEach(() => {
18
+ client = makeMockClient();
19
+ client.getPattern.mockResolvedValue({ id: 1, name: 'Pattern', content: '' } as any);
20
+ });
21
+
22
+ it('requires pattern_id', async () => {
23
+ await expect(
24
+ handleDiscoveryTool('get_pattern', {}, client as any)
25
+ ).rejects.toThrow(/pattern_id is required/);
26
+ });
27
+
28
+ it('rejects null pattern_id', async () => {
29
+ await expect(
30
+ handleDiscoveryTool('get_pattern', { pattern_id: null }, client as any)
31
+ ).rejects.toThrow(/pattern_id is required/);
32
+ });
33
+ });
34
+
35
+ describe('get_pattern — routing', () => {
36
+ let client: ReturnType<typeof makeMockClient>;
37
+ beforeEach(() => {
38
+ client = makeMockClient();
39
+ client.getPattern.mockResolvedValue({ id: 1, name: 'Pattern', content: '' } as any);
40
+ });
41
+
42
+ it('forwards numeric ID', async () => {
43
+ await handleDiscoveryTool('get_pattern', { pattern_id: 42 }, client as any);
44
+ expect(client.getPattern).toHaveBeenCalledWith(42);
45
+ });
46
+
47
+ it('forwards string name (registered pattern)', async () => {
48
+ await handleDiscoveryTool('get_pattern', { pattern_id: 'twentytwentyfive/hero' }, client as any);
49
+ expect(client.getPattern).toHaveBeenCalledWith('twentytwentyfive/hero');
50
+ });
51
+
52
+ it('returns the raw client response', async () => {
53
+ const fake = { id: 9, name: 'X', content: '<!-- wp:paragraph /-->' };
54
+ client.getPattern.mockResolvedValueOnce(fake as any);
55
+ const result = await handleDiscoveryTool('get_pattern', { pattern_id: 9 }, client as any);
56
+ expect(result).toBe(fake);
57
+ });
58
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Tool tests: get_post_info
3
+ *
4
+ * Covers:
5
+ * - Requires one of: post_id, url, slug (rejects when all three missing)
6
+ * - Numeric post_id passes through
7
+ * - String post_id matching /^[0-9]+$/ coerced to integer
8
+ * - Non-numeric string post_id rejected
9
+ * - url alone accepted
10
+ * - slug alone accepted (with optional post_type scope)
11
+ * - Forwarding includes post_type when supplied
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach } from 'vitest';
15
+ import { handleDiscoveryTool } from '../../../tools/discovery.js';
16
+ import { makeMockClient } from '../../helpers/mock-client.js';
17
+
18
+ describe('get_post_info — validation', () => {
19
+ let client: ReturnType<typeof makeMockClient>;
20
+ beforeEach(() => {
21
+ client = makeMockClient();
22
+ client.getPostInfo.mockResolvedValue({ post_id: 1 } as any);
23
+ });
24
+
25
+ it('throws when none of post_id, url, slug is provided', async () => {
26
+ await expect(
27
+ handleDiscoveryTool('get_post_info', {}, client as any)
28
+ ).rejects.toThrow(/post_id, url, or slug/);
29
+ });
30
+
31
+ it('throws on non-numeric string post_id', async () => {
32
+ await expect(
33
+ handleDiscoveryTool('get_post_info', { post_id: 'abc' }, client as any)
34
+ ).rejects.toThrow(/post_id must be a positive integer/);
35
+ });
36
+
37
+ it('throws on float post_id and does NOT call the client', async () => {
38
+ // post_id is documented as a positive integer. A float input must
39
+ // throw the documented error AND short-circuit before reaching the
40
+ // client — otherwise validation drift would only surface server-side.
41
+ await expect(
42
+ handleDiscoveryTool('get_post_info', { post_id: 1.5 }, client as any)
43
+ ).rejects.toThrow(/post_id must be a positive integer/);
44
+ expect(client.getPostInfo).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it('accepts a positive integer post_id and forwards it', async () => {
48
+ await handleDiscoveryTool('get_post_info', { post_id: 1 }, client as any);
49
+ expect(client.getPostInfo).toHaveBeenCalledWith(expect.objectContaining({ post_id: 1 }));
50
+ });
51
+
52
+ it('throws on object post_id', async () => {
53
+ await expect(
54
+ handleDiscoveryTool('get_post_info', { post_id: {} }, client as any)
55
+ ).rejects.toThrow(/post_id must be a positive integer/);
56
+ });
57
+ });
58
+
59
+ describe('get_post_info — post_id resolution', () => {
60
+ let client: ReturnType<typeof makeMockClient>;
61
+ beforeEach(() => {
62
+ client = makeMockClient();
63
+ client.getPostInfo.mockResolvedValue({ post_id: 1 } as any);
64
+ });
65
+
66
+ it('forwards numeric post_id', async () => {
67
+ await handleDiscoveryTool('get_post_info', { post_id: 42 }, client as any);
68
+ expect(client.getPostInfo).toHaveBeenCalledWith(expect.objectContaining({ post_id: 42 }));
69
+ });
70
+
71
+ it('coerces well-formed integer-string post_id', async () => {
72
+ await handleDiscoveryTool('get_post_info', { post_id: '99' }, client as any);
73
+ expect(client.getPostInfo).toHaveBeenCalledWith(expect.objectContaining({ post_id: 99 }));
74
+ });
75
+ });
76
+
77
+ describe('get_post_info — url and slug routing', () => {
78
+ let client: ReturnType<typeof makeMockClient>;
79
+ beforeEach(() => {
80
+ client = makeMockClient();
81
+ client.getPostInfo.mockResolvedValue({ post_id: 1 } as any);
82
+ });
83
+
84
+ it('forwards url alone', async () => {
85
+ await handleDiscoveryTool('get_post_info', { url: '/about/' }, client as any);
86
+ expect(client.getPostInfo).toHaveBeenCalledWith(expect.objectContaining({ url: '/about/' }));
87
+ });
88
+
89
+ it('forwards slug alone', async () => {
90
+ await handleDiscoveryTool('get_post_info', { slug: 'about-us' }, client as any);
91
+ expect(client.getPostInfo).toHaveBeenCalledWith(expect.objectContaining({ slug: 'about-us' }));
92
+ });
93
+
94
+ it('forwards slug + post_type', async () => {
95
+ await handleDiscoveryTool('get_post_info', { slug: 'about-us', post_type: 'page' }, client as any);
96
+ expect(client.getPostInfo).toHaveBeenCalledWith({
97
+ post_id: undefined, url: undefined, slug: 'about-us', post_type: 'page',
98
+ });
99
+ });
100
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tool tests: get_site_usage
3
+ *
4
+ * Covers:
5
+ * - Forwards refresh:undefined when not set
6
+ * - Forwards refresh:true to bust the transient cache
7
+ * - Returns raw client response (no MCP-side enrichment)
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from 'vitest';
11
+ import { handleDiscoveryTool } from '../../../tools/discovery.js';
12
+ import { makeMockClient } from '../../helpers/mock-client.js';
13
+ import { siteUsageResponse } from '../../fixtures/rest-responses.js';
14
+
15
+ describe('get_site_usage', () => {
16
+ let client: ReturnType<typeof makeMockClient>;
17
+ beforeEach(() => {
18
+ client = makeMockClient();
19
+ client.getSiteUsage.mockResolvedValue(siteUsageResponse);
20
+ });
21
+
22
+ it('forwards undefined refresh when not set', async () => {
23
+ await handleDiscoveryTool('get_site_usage', {}, client as any);
24
+ expect(client.getSiteUsage).toHaveBeenCalledWith(undefined);
25
+ });
26
+
27
+ it('forwards refresh:true', async () => {
28
+ await handleDiscoveryTool('get_site_usage', { refresh: true }, client as any);
29
+ expect(client.getSiteUsage).toHaveBeenCalledWith(true);
30
+ });
31
+
32
+ it('forwards refresh:false', async () => {
33
+ await handleDiscoveryTool('get_site_usage', { refresh: false }, client as any);
34
+ expect(client.getSiteUsage).toHaveBeenCalledWith(false);
35
+ });
36
+
37
+ it('returns the raw response', async () => {
38
+ const result = await handleDiscoveryTool('get_site_usage', {}, client as any);
39
+ expect(result).toBe(siteUsageResponse);
40
+ });
41
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tool tests: list_block_types
3
+ *
4
+ * Covers:
5
+ * - Filter forwarding (namespace, category, tier, storage_mode, search,
6
+ * preferred_only, usage_only)
7
+ * - Pagination (limit defaults 50, offset defaults 0, next_offset math)
8
+ * - Enrichment: response includes `guidance` string
9
+ * - Response shape: block_types, count, total, offset, next_offset, guidance
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach } from 'vitest';
13
+ import { handleDiscoveryTool } from '../../../tools/discovery.js';
14
+ import { makeMockClient } from '../../helpers/mock-client.js';
15
+ import { blockTypesResponse } from '../../fixtures/rest-responses.js';
16
+
17
+ describe('list_block_types — filter forwarding', () => {
18
+ let client: ReturnType<typeof makeMockClient>;
19
+ beforeEach(() => {
20
+ client = makeMockClient();
21
+ client.getBlockTypes.mockResolvedValue(blockTypesResponse);
22
+ });
23
+
24
+ it('forwards every documented filter', async () => {
25
+ await handleDiscoveryTool('list_block_types', {
26
+ namespace: 'core', category: 'text', tier: 'preferred',
27
+ storage_mode: 'static', search: 'para', preferred_only: true, usage_only: true,
28
+ }, client as any);
29
+ expect(client.getBlockTypes).toHaveBeenCalledWith({
30
+ namespace: 'core', category: 'text', tier: 'preferred',
31
+ storage_mode: 'static', search: 'para', preferred_only: true, usage_only: true,
32
+ });
33
+ });
34
+
35
+ it('forwards no filters when none are provided (all undefined)', async () => {
36
+ await handleDiscoveryTool('list_block_types', {}, client as any);
37
+ expect(client.getBlockTypes).toHaveBeenCalledWith({
38
+ namespace: undefined, category: undefined, tier: undefined,
39
+ storage_mode: undefined, search: undefined, preferred_only: undefined, usage_only: undefined,
40
+ });
41
+ });
42
+ });
43
+
44
+ describe('list_block_types — pagination', () => {
45
+ let client: ReturnType<typeof makeMockClient>;
46
+ beforeEach(() => {
47
+ client = makeMockClient();
48
+ });
49
+
50
+ it('defaults to limit 50 and offset 0', async () => {
51
+ // Build 60 fake block types so pagination matters
52
+ const many = Array.from({ length: 60 }, (_, i) => ({
53
+ name: `core/block-${i}`, title: `Block ${i}`,
54
+ preference: { tier: 'preferred', score: 90 },
55
+ }));
56
+ client.getBlockTypes.mockResolvedValueOnce({ block_types: many } as any);
57
+ const result = await handleDiscoveryTool('list_block_types', {}, client as any) as Record<string, unknown>;
58
+ expect(result.count).toBe(50);
59
+ expect(result.offset).toBe(0);
60
+ expect(result.next_offset).toBe(50);
61
+ expect(result.total).toBe(60);
62
+ });
63
+
64
+ it('honors explicit limit and offset', async () => {
65
+ const many = Array.from({ length: 30 }, (_, i) => ({
66
+ name: `core/block-${i}`, title: `Block ${i}`,
67
+ preference: { tier: 'preferred', score: 90 },
68
+ }));
69
+ client.getBlockTypes.mockResolvedValueOnce({ block_types: many } as any);
70
+ const result = await handleDiscoveryTool('list_block_types', { limit: 10, offset: 20 }, client as any) as Record<string, unknown>;
71
+ expect(result.count).toBe(10);
72
+ expect(result.offset).toBe(20);
73
+ expect(result.next_offset).toBeNull();
74
+ });
75
+
76
+ it('next_offset is null when the page ends at total', async () => {
77
+ client.getBlockTypes.mockResolvedValueOnce(blockTypesResponse);
78
+ const result = await handleDiscoveryTool('list_block_types', { limit: 100 }, client as any) as Record<string, unknown>;
79
+ expect(result.next_offset).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('list_block_types — response shape', () => {
84
+ let client: ReturnType<typeof makeMockClient>;
85
+ beforeEach(() => {
86
+ client = makeMockClient();
87
+ client.getBlockTypes.mockResolvedValue(blockTypesResponse);
88
+ });
89
+
90
+ it('returns block_types, count, total, offset, next_offset, guidance', async () => {
91
+ const result = await handleDiscoveryTool('list_block_types', {}, client as any) as Record<string, unknown>;
92
+ expect(Array.isArray(result.block_types)).toBe(true);
93
+ expect(typeof result.count).toBe('number');
94
+ expect(typeof result.total).toBe('number');
95
+ expect(typeof result.offset).toBe('number');
96
+ expect(typeof result.guidance).toBe('string');
97
+ });
98
+
99
+ it('guidance is a non-empty string when results exist', async () => {
100
+ const result = await handleDiscoveryTool('list_block_types', {}, client as any) as Record<string, unknown>;
101
+ expect((result.guidance as string).length).toBeGreaterThan(0);
102
+ });
103
+ });