@gravitykit/block-mcp 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.cjs +311 -195
  2. package/package.json +8 -4
  3. package/src/__tests__/coerce.test.ts +57 -0
  4. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  5. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  6. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  7. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  8. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  9. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  10. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  11. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  12. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  13. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  14. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  15. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  16. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  17. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  18. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  19. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  20. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  21. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +100 -29
  22. package/src/__tests__/unit/instructions.test.ts +3 -3
  23. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  24. package/src/__tests__/unit/rest-url.test.ts +23 -0
  25. package/src/client.ts +53 -18
  26. package/src/coerce.ts +41 -0
  27. package/src/config.ts +96 -0
  28. package/src/connect.ts +17 -3
  29. package/src/enrichers.ts +6 -2
  30. package/src/error-translator.ts +62 -10
  31. package/src/index.ts +35 -27
  32. package/src/instructions.ts +3 -3
  33. package/src/preferences.ts +56 -43
  34. package/src/rest-url.ts +18 -0
  35. package/src/tools/discovery.ts +10 -14
  36. package/src/tools/mutate.ts +18 -12
  37. package/src/tools/patterns.ts +3 -2
  38. package/src/tools/posts.ts +26 -26
  39. package/src/tools/read.ts +7 -6
  40. package/src/tools/write.ts +26 -31
  41. package/src/tools/yoast.ts +30 -31
  42. package/src/types.ts +23 -13
@@ -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
- it('rejects string post_id (must be number)', async () => {
37
- await expect(handleYoastTool('yoast_get_seo', { post_id: '42' }, client as any))
38
- .rejects.toThrow('post_id');
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', () => {
@@ -185,6 +185,27 @@ describe('enrichBlock — Code Block Pro', () => {
185
185
  expect(textareaContent).toContain('&lt;/textarea&gt;');
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('$&#x26;');
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
@@ -37,6 +37,17 @@ describe('translateWpError — null contract', () => {
37
37
  expect(typeof result, `Expected string translation for "${code}"`).toBe('string');
38
38
  }
39
39
  });
40
+
41
+ it('returns null for codes the plugin never emits (no phantom translations)', () => {
42
+ // These names look plausible but the PHP plugin does not construct them —
43
+ // the real codes are post_not_found, invalid_ref, invalid_path. A
44
+ // translation here would confidently mislead an agent pattern-matching
45
+ // on `wpCode`.
46
+ expect(translateWpError('invalid_post_id', { post_id: 7 })).toBeNull();
47
+ expect(translateWpError('gk_block_api_invalid_ref', { ref: 'blk_x' })).toBeNull();
48
+ expect(translateWpError('path_not_found', { path: [0] })).toBeNull();
49
+ expect(translateWpError('path_out_of_bounds', { path: [0] })).toBeNull();
50
+ });
40
51
  });
41
52
 
42
53
  // ── 2. Per-group describe blocks ─────────────────────────────────────────────
@@ -90,10 +101,10 @@ describe('translateWpError — post lookup', () => {
90
101
  expect(msg).toBe('Post not found. List pages with `list_posts` to find the right ID.');
91
102
  });
92
103
 
93
- it('invalid_post_id is an alias of rest_post_invalid_id', () => {
94
- const withData = translateWpError('invalid_post_id', { post_id: 7 })!;
104
+ it('post_not_found the code the plugin actually emits — embeds post_id', () => {
105
+ const withData = translateWpError('post_not_found', { post_id: 7 })!;
95
106
  expect(withData).toMatch(/Post 7 not found/);
96
- const bare = translateWpError('invalid_post_id', null)!;
107
+ const bare = translateWpError('post_not_found', null)!;
97
108
  expect(bare).toMatch(/Post not found/);
98
109
  });
99
110
 
@@ -107,8 +118,14 @@ describe('translateWpError — post lookup', () => {
107
118
  });
108
119
 
109
120
  describe('translateWpError — block ref / path resolution', () => {
110
- it('gk_block_api_invalid_ref embeds ref and post_id', () => {
111
- const msg = translateWpError('gk_block_api_invalid_ref', {
121
+ it('invalid_ref explains the malformed-ref case (empty/non-string ref)', () => {
122
+ const msg = translateWpError('invalid_ref', null)!;
123
+ expect(msg).toMatch(/non-empty string/);
124
+ expect(msg).toMatch(/get_page_blocks/);
125
+ });
126
+
127
+ it('ref_stale embeds ref and post_id and points at get_page_blocks', () => {
128
+ const msg = translateWpError('ref_stale', {
112
129
  ref: 'blk_abc123',
113
130
  post_id: 42,
114
131
  })!;
@@ -117,40 +134,35 @@ describe('translateWpError — block ref / path resolution', () => {
117
134
  expect(msg).toMatch(/get_page_blocks/);
118
135
  });
119
136
 
120
- it('gk_block_api_invalid_ref uses ? placeholders when data is null', () => {
121
- const msg = translateWpError('gk_block_api_invalid_ref', null)!;
137
+ it('ref_stale uses ? placeholder and drops the post clause when data is null', () => {
138
+ const msg = translateWpError('ref_stale', null)!;
122
139
  expect(msg).toMatch(/Block ref `\?`/);
123
- expect(msg).toMatch(/post \?/);
140
+ expect(msg).not.toMatch(/ in post/);
124
141
  });
125
142
 
126
- it('invalid_ref is an alias', () => {
127
- const msg = translateWpError('invalid_ref', { ref: 'blk_xyz', post_id: 5 })!;
128
- expect(msg).toContain('blk_xyz');
129
- });
130
-
131
- it('path_not_found formats path array correctly', () => {
132
- const msg = translateWpError('path_not_found', { path: [0, 2, 1] })!;
143
+ it('invalid_path formats the path array and mentions re-fetching', () => {
144
+ const msg = translateWpError('invalid_path', { path: [0, 2, 1] })!;
133
145
  expect(msg).toMatch(/\[0, 2, 1\]/);
134
146
  expect(msg).toMatch(/Re-fetch the post/);
135
147
  });
136
148
 
137
- it('path_not_found uses ? when path is absent', () => {
138
- expect(translateWpError('path_not_found', null)).toMatch(/path \? doesn't address/);
149
+ it('invalid_path uses ? when path is absent', () => {
150
+ expect(translateWpError('invalid_path', null)).toMatch(/path \? doesn't address/);
139
151
  });
140
152
 
141
- it('invalid_path is an alias', () => {
142
- const msg = translateWpError('invalid_path', { path: [1] })!;
143
- expect(msg).toMatch(/\[1\]/);
153
+ it('invalid_index embeds flat_index and mentions re-fetching', () => {
154
+ const msg = translateWpError('invalid_index', { flat_index: 12 })!;
155
+ expect(msg).toMatch(/Block index 12 out of range/);
156
+ expect(msg).toMatch(/get_page_blocks/);
144
157
  });
145
158
 
146
- it('path_out_of_bounds mentions re-fetching', () => {
147
- const msg = translateWpError('path_out_of_bounds', { path: [99] })!;
148
- expect(msg).toMatch(/out of bounds/);
149
- expect(msg).toMatch(/get_page_blocks/);
159
+ it('invalid_index survives a missing flat_index hint', () => {
160
+ const msg = translateWpError('invalid_index', null)!;
161
+ expect(msg).toMatch(/^Block index out of range/);
150
162
  });
151
163
  });
152
164
 
153
- describe('translateWpError — preference enforcement', () => {
165
+ describe('translateWpError — preference / storage enforcement', () => {
154
166
  it('legacy_block embeds block name and replacement', () => {
155
167
  const msg = translateWpError('legacy_block', {
156
168
  block: 'example/heading',
@@ -207,6 +219,47 @@ describe('translateWpError — preference enforcement', () => {
207
219
  expect(msg).toMatch(/innerHTML/);
208
220
  expect(msg).toMatch(/static block/);
209
221
  });
222
+
223
+ it('dual_storage_requires_both names the block and tells the agent to send both fields', () => {
224
+ const msg = translateWpError('dual_storage_requires_both', { block_name: 'yoast/faq-block' })!;
225
+ expect(msg).toMatch(/yoast\/faq-block is dual-storage/);
226
+ expect(msg).toMatch(/attributes.*innerHTML.*together/);
227
+ });
228
+
229
+ it('dual_storage_requires_both falls back gracefully without a block name', () => {
230
+ const msg = translateWpError('dual_storage_requires_both', null)!;
231
+ expect(msg).toMatch(/This block is dual-storage/);
232
+ });
233
+
234
+ it('block_depth_exceeded embeds max/actual depth when present', () => {
235
+ const msg = translateWpError('block_depth_exceeded', { max_depth: 32, actual_depth: 33 })!;
236
+ expect(msg).toMatch(/max 32, got 33/);
237
+ expect(msg).toMatch(/nesting depth/);
238
+ });
239
+
240
+ it('block_depth_exceeded survives missing depth hints', () => {
241
+ const msg = translateWpError('block_depth_exceeded', null)!;
242
+ expect(msg).toMatch(/^Block tree exceeds the maximum nesting depth/);
243
+ });
244
+ });
245
+
246
+ describe('translateWpError — concurrency / staleness', () => {
247
+ it('edit_conflict tells the agent to re-fetch and retry', () => {
248
+ const msg = translateWpError('edit_conflict', null)!;
249
+ expect(msg).toMatch(/changed since it was read/);
250
+ expect(msg).toMatch(/get_page_blocks/);
251
+ });
252
+
253
+ it('stale_revision embeds the current revision when present', () => {
254
+ const msg = translateWpError('stale_revision', { current_revision: 88 })!;
255
+ expect(msg).toMatch(/current revision: 88/);
256
+ expect(msg).toMatch(/get_page_blocks/);
257
+ });
258
+
259
+ it('stale_revision survives a missing current_revision hint', () => {
260
+ const msg = translateWpError('stale_revision', null)!;
261
+ expect(msg).toMatch(/^The post has changed since you fetched it\. Re-fetch/);
262
+ });
210
263
  });
211
264
 
212
265
  describe('translateWpError — rate limiting', () => {
@@ -221,6 +274,18 @@ describe('translateWpError — rate limiting', () => {
221
274
  expect(msg).toMatch(/^Too many writes in the last minute/);
222
275
  expect(msg).not.toMatch(/on post/);
223
276
  });
277
+
278
+ it('rate_limit_locked advises a brief retry and embeds post_id', () => {
279
+ const msg = translateWpError('rate_limit_locked', { post_id: 7 })!;
280
+ expect(msg).toMatch(/on post 7/);
281
+ expect(msg).toMatch(/Retry in a moment/);
282
+ });
283
+
284
+ it('rate_limit_locked drops post_id clause when absent', () => {
285
+ const msg = translateWpError('rate_limit_locked', null)!;
286
+ expect(msg).toMatch(/^Another write is in progress/);
287
+ expect(msg).not.toMatch(/on post/);
288
+ });
224
289
  });
225
290
 
226
291
  describe('translateWpError — v1.2 post lifecycle', () => {
@@ -239,6 +304,12 @@ describe('translateWpError — v1.2 post lifecycle', () => {
239
304
  const msg = translateWpError('invalid_status', null)!;
240
305
  expect(msg).toMatch(/draft/);
241
306
  });
307
+
308
+ it('trash_disabled points at the site setting', () => {
309
+ const msg = translateWpError('trash_disabled', null)!;
310
+ expect(msg).toMatch(/trash is turned off/);
311
+ expect(msg).toMatch(/Settings/);
312
+ });
242
313
  });
243
314
 
244
315
  describe('translateWpError — media uploads', () => {
@@ -286,19 +357,19 @@ describe('translateWpError — full error-code coverage matrix', () => {
286
357
  describe('translateWpError — data extraction edge cases', () => {
287
358
  it('ignores non-object data payload', () => {
288
359
  // String data: should not throw, fall back to ? placeholders
289
- expect(() => translateWpError('gk_block_api_invalid_ref', 'oops')).not.toThrow();
360
+ expect(() => translateWpError('ref_stale', 'oops')).not.toThrow();
290
361
  });
291
362
 
292
363
  it('ignores array data payload', () => {
293
- expect(() => translateWpError('gk_block_api_invalid_ref', [1, 2, 3])).not.toThrow();
364
+ expect(() => translateWpError('ref_stale', [1, 2, 3])).not.toThrow();
294
365
  });
295
366
 
296
367
  it('handles path array with non-integer values — does not throw', () => {
297
368
  // extractHints validates each element is a finite number; non-integers
298
369
  // are filtered out, so the path hint falls back to ? placeholder.
299
370
  // The translator must not throw regardless of payload shape.
300
- expect(() => translateWpError('path_not_found', { path: ['a', 'b'] })).not.toThrow();
301
- const msg = translateWpError('path_not_found', { path: ['a', 'b'] });
371
+ expect(() => translateWpError('invalid_path', { path: ['a', 'b'] })).not.toThrow();
372
+ const msg = translateWpError('invalid_path', { path: ['a', 'b'] });
302
373
  expect(msg).not.toBeNull();
303
374
  });
304
375
 
@@ -202,19 +202,19 @@ describe('fetchAddendum', () => {
202
202
  expect(result).toBe('hi');
203
203
  });
204
204
 
205
- it('issues GET to /wp-json/gk-block-api/v1/instructions', async () => {
205
+ it('issues GET on the permalink-independent ?rest_route= form', async () => {
206
206
  vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'use info callout' } });
207
207
  await fetchAddendum('https://example.com');
208
208
  expect(vi.mocked(axios.get)).toHaveBeenCalledOnce();
209
209
  const [url] = vi.mocked(axios.get).mock.calls[0]!;
210
- expect(url).toBe('https://example.com/wp-json/gk-block-api/v1/instructions');
210
+ expect(url).toBe('https://example.com/?rest_route=/gk-block-api/v1/instructions');
211
211
  });
212
212
 
213
213
  it('normalizes trailing slashes in the base URL', async () => {
214
214
  vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } });
215
215
  await fetchAddendum('https://example.com///');
216
216
  const [url] = vi.mocked(axios.get).mock.calls[0]!;
217
- expect(url).toBe('https://example.com/wp-json/gk-block-api/v1/instructions');
217
+ expect(url).toBe('https://example.com/?rest_route=/gk-block-api/v1/instructions');
218
218
  });
219
219
 
220
220
  it('passes a short timeout and JSON Accept header', async () => {
@@ -17,7 +17,7 @@ function makePattern(overrides: Partial<Pattern> & { id: number; name: string })
17
17
  created: '2026-01-01',
18
18
  modified: '2026-01-01',
19
19
  reference_count: 0,
20
- preference: { score: 80, tier: 'recommended', reasons: [] },
20
+ preference: { score: 80, tier: 'preferred', reasons: [] },
21
21
  contains_blocks: [],
22
22
  has_legacy_blocks: false,
23
23
  ...overrides,
@@ -49,12 +49,28 @@ describe('enrichPatternList — empty input', () => {
49
49
  });
50
50
  });
51
51
 
52
+ describe('enrichPatternList — tier buckets match the tiers the server emits', () => {
53
+ it('lists preferred and acceptable patterns instead of reporting none', () => {
54
+ // The server emits preferred/acceptable/avoid/legacy — never "recommended".
55
+ // Filtering the usable bucket on a non-existent tier hid every pattern.
56
+ const patterns = [
57
+ makePattern({ id: 1, name: 'Hero', preference: { score: 90, tier: 'preferred', reasons: [] } }),
58
+ makePattern({ id: 2, name: 'Card', preference: { score: 60, tier: 'acceptable', reasons: [] } }),
59
+ ];
60
+ const { summary } = enrichPatternList(patterns);
61
+ expect(summary).toContain('RECOMMENDED patterns');
62
+ expect(summary).toContain('Hero');
63
+ expect(summary).toContain('Card');
64
+ expect(summary).not.toMatch(/No patterns found/);
65
+ });
66
+ });
67
+
52
68
  describe('enrichPatternList — sorting', () => {
53
69
  it('sorts by score descending', () => {
54
70
  const patterns = [
55
71
  makePattern({ id: 1, name: 'Low', preference: { score: 10, tier: 'avoid', reasons: [] } }),
56
- makePattern({ id: 2, name: 'High', preference: { score: 95, tier: 'recommended', reasons: [] } }),
57
- makePattern({ id: 3, name: 'Mid', preference: { score: 50, tier: 'recommended', reasons: [] } }),
72
+ makePattern({ id: 2, name: 'High', preference: { score: 95, tier: 'preferred', reasons: [] } }),
73
+ makePattern({ id: 3, name: 'Mid', preference: { score: 50, tier: 'preferred', reasons: [] } }),
58
74
  ];
59
75
  const result = enrichPatternList(patterns);
60
76
  expect(result.patterns[0].name).toBe('High');
@@ -64,8 +80,8 @@ describe('enrichPatternList — sorting', () => {
64
80
 
65
81
  it('preserves equal-score order (stable sort is not required, just non-crash)', () => {
66
82
  const patterns = [
67
- makePattern({ id: 1, name: 'A', preference: { score: 80, tier: 'recommended', reasons: [] } }),
68
- makePattern({ id: 2, name: 'B', preference: { score: 80, tier: 'recommended', reasons: [] } }),
83
+ makePattern({ id: 1, name: 'A', preference: { score: 80, tier: 'preferred', reasons: [] } }),
84
+ makePattern({ id: 2, name: 'B', preference: { score: 80, tier: 'preferred', reasons: [] } }),
69
85
  ];
70
86
  const result = enrichPatternList(patterns);
71
87
  expect(result.patterns).toHaveLength(2);
@@ -74,7 +90,7 @@ describe('enrichPatternList — sorting', () => {
74
90
  it('does not mutate the input array', () => {
75
91
  const patterns = [
76
92
  makePattern({ id: 1, name: 'A', preference: { score: 10, tier: 'avoid', reasons: [] } }),
77
- makePattern({ id: 2, name: 'B', preference: { score: 95, tier: 'recommended', reasons: [] } }),
93
+ makePattern({ id: 2, name: 'B', preference: { score: 95, tier: 'preferred', reasons: [] } }),
78
94
  ];
79
95
  const original = [...patterns];
80
96
  enrichPatternList(patterns);
@@ -85,7 +101,7 @@ describe('enrichPatternList — sorting', () => {
85
101
  describe('enrichPatternList — summary generation', () => {
86
102
  it('includes RECOMMENDED label for recommended patterns', () => {
87
103
  const patterns = [
88
- makePattern({ id: 1, name: 'Hero', preference: { score: 85, tier: 'recommended', reasons: [] } }),
104
+ makePattern({ id: 1, name: 'Hero', preference: { score: 85, tier: 'preferred', reasons: [] } }),
89
105
  ];
90
106
  const result = enrichPatternList(patterns);
91
107
  expect(result.summary).toMatch(/RECOMMENDED/);
@@ -121,14 +137,14 @@ describe('enrichPatternList — summary generation', () => {
121
137
 
122
138
  it('does not include AVOID label when all patterns are recommended', () => {
123
139
  const patterns = [
124
- makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'recommended', reasons: [] } }),
140
+ makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'preferred', reasons: [] } }),
125
141
  ];
126
142
  expect(enrichPatternList(patterns).summary).not.toMatch(/AVOID/);
127
143
  });
128
144
 
129
145
  it('shows both sections when mixed tiers', () => {
130
146
  const patterns = [
131
- makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'recommended', reasons: [] } }),
147
+ makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'preferred', reasons: [] } }),
132
148
  makePattern({ id: 2, name: 'Bad', preference: { score: 0, tier: 'legacy', reasons: [] }, has_legacy_blocks: true }),
133
149
  ];
134
150
  const { summary } = enrichPatternList(patterns);
@@ -140,7 +156,7 @@ describe('enrichPatternList — summary generation', () => {
140
156
  const patterns = [
141
157
  makePattern({
142
158
  id: 1, name: 'Hero',
143
- preference: { score: 80, tier: 'recommended', reasons: [] },
159
+ preference: { score: 80, tier: 'preferred', reasons: [] },
144
160
  contains_blocks: ['core/heading', 'core/paragraph', 'core/image'],
145
161
  }),
146
162
  ];
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { restRouteUrl } from '../../rest-url.js';
3
+
4
+ // The client and the instructions fetch must reach WordPress on the
5
+ // permalink-independent ?rest_route= form. Hardcoding /wp-json/ let the
6
+ // connector succeed and then every tool call 404 on plain-permalink sites.
7
+ describe('restRouteUrl', () => {
8
+ it('uses the ?rest_route= form, never a hardcoded /wp-json/ path', () => {
9
+ expect(restRouteUrl('https://site.test')).toBe('https://site.test/?rest_route=/gk-block-api/v1');
10
+ expect(restRouteUrl('https://site.test')).not.toContain('/wp-json/');
11
+ });
12
+
13
+ it('trims trailing slashes on the site URL', () => {
14
+ expect(restRouteUrl('https://site.test/')).toBe('https://site.test/?rest_route=/gk-block-api/v1');
15
+ expect(restRouteUrl('https://site.test///')).toBe('https://site.test/?rest_route=/gk-block-api/v1');
16
+ });
17
+
18
+ it('appends a route suffix after the namespace', () => {
19
+ expect(restRouteUrl('https://site.test', '/instructions')).toBe(
20
+ 'https://site.test/?rest_route=/gk-block-api/v1/instructions'
21
+ );
22
+ });
23
+ });