@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.
Files changed (53) hide show
  1. package/README.md +34 -7
  2. package/dist/index.cjs +2556 -967
  3. package/package.json +12 -4
  4. package/src/__tests__/coerce.test.ts +57 -0
  5. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  6. package/src/__tests__/integration/dual-storage.test.ts +22 -26
  7. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  8. package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
  9. package/src/__tests__/integration/read-discovery.test.ts +313 -0
  10. package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
  11. package/src/__tests__/integration/write-surface.test.ts +603 -0
  12. package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
  13. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  14. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  15. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  16. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  17. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  18. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  19. package/src/__tests__/tools/read/pagination.test.ts +65 -0
  20. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  21. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  22. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  23. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  24. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  25. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  26. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  27. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  28. package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
  29. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  30. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
  31. package/src/__tests__/unit/instructions.test.ts +3 -3
  32. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  33. package/src/__tests__/unit/rest-url.test.ts +23 -0
  34. package/src/agent-guide.ts +85 -0
  35. package/src/client.ts +104 -40
  36. package/src/coerce.ts +41 -0
  37. package/src/config.ts +96 -0
  38. package/src/connect.ts +123 -38
  39. package/src/enrichers.ts +6 -2
  40. package/src/error-translator.ts +63 -11
  41. package/src/index.ts +49 -79
  42. package/src/instructions.ts +3 -3
  43. package/src/preferences.ts +56 -43
  44. package/src/rest-url.ts +18 -0
  45. package/src/tools/discovery.ts +10 -14
  46. package/src/tools/mutate.ts +21 -20
  47. package/src/tools/patterns.ts +3 -2
  48. package/src/tools/posts.ts +26 -26
  49. package/src/tools/read.ts +23 -6
  50. package/src/tools/write.ts +37 -36
  51. package/src/tools/yoast.ts +30 -31
  52. package/src/types.ts +45 -31
  53. package/src/validate-args.ts +109 -0
@@ -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 ─────────────────────────────────────────────
@@ -44,7 +55,7 @@ describe('translateWpError — null contract', () => {
44
55
  describe('translateWpError — routing & auth', () => {
45
56
  it('rest_no_route mentions the plugin name', () => {
46
57
  const msg = translateWpError('rest_no_route', null)!;
47
- expect(msg).toMatch(/gk-block-api/);
58
+ expect(msg).toMatch(/Block MCP/);
48
59
  expect(msg).toMatch(/WORDPRESS_URL/);
49
60
  });
50
61
 
@@ -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
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The agent-guide resource content (block-mcp://agent-guide).
3
+ *
4
+ * Workflow guidance served to MCP clients as a resource and prompt seed.
5
+ * Lives in its own module (not index.ts, which boots the server on import)
6
+ * so the discoverability contract is testable: everything an agent needs to
7
+ * author content — including container nesting — must be learnable from the
8
+ * tool surface + this guide, never from reading this repo's source.
9
+ */
10
+
11
+ export const AGENT_GUIDE_CONTENT = `# Block MCP — Agent Guide
12
+
13
+ ## URL → post ID resolution
14
+
15
+ NEVER run curl, wget, or any bash/shell command to hit wp-json or resolve a URL to a post ID.
16
+ The MCP does this for you:
17
+
18
+ - \`get_page_blocks\` accepts \`url\` as an alternative to \`post_id\`. Pass the full URL or path; the server resolves it via \`url_to_postid\`.
19
+ - For explicit resolution (title, post_type, edit_url before editing), call \`resolve_url\`.
20
+
21
+ If the user says "change X on https://example.com/some-page/", your first tool call should be \`get_page_blocks({ url: "...", search: "keyword" })\` or \`resolve_url({ url: "..." })\` — not a shell command.
22
+
23
+ ## Moving / reordering blocks
24
+
25
+ NEVER do a move as separate \`insert_blocks\` + \`delete_block\` calls — if the delete is skipped or fails, the page ends up with an orphaned clone of the original. The atomic primitive is the \`move\` op on \`edit_block_tree\`:
26
+
27
+ - Target the source with \`ref\` (the \`gk_ref\` from \`get_page_blocks\`) or \`path\`. Prefer \`ref\` — it survives sibling shifts; paths go stale the moment any earlier block is inserted or removed.
28
+ - Express the destination with \`destination_ref\` or \`destination\` (path). For path destinations, use **pre-move** indexing — write the path as if the source were still in place; the server adjusts indices after the removal.
29
+ - Use \`count\` to move N consecutive siblings in a single op.
30
+ - The server rejects moves into the source itself or any of its descendants.
31
+ - The whole \`edit_block_tree\` call is one revision, reversible via \`revert_to_revision\`.
32
+
33
+ If you must fall back to the flat-index tools, do \`insert_blocks\` + \`delete_block\` in the same turn and re-fetch \`get_page_blocks\` afterward to confirm exactly one copy remains.
34
+
35
+ ## Building container blocks (groups, columns, callouts)
36
+
37
+ Every block def accepted by \`insert_blocks\`, \`replace_block_range\`, and \`rewrite_post_blocks\` nests recursively via \`innerBlocks\` — build a whole container (and its children) in ONE call instead of inserting pieces and moving them around.
38
+
39
+ - The container's \`innerHTML\` is its wrapper element only — an empty wrapper; children render inside it. E.g. \`core/group\` → \`<div class="wp-block-group"></div>\`, \`core/list\` → \`<ul class="wp-block-list"></ul>\`.
40
+ - Each entry in \`innerBlocks\` is a full block def and may nest further (columns → column → content).
41
+ - Include any style class in BOTH the \`className\` attribute and the wrapper \`innerHTML\`.
42
+
43
+ Example — a styled callout (a \`core/group\` wrapping a paragraph):
44
+
45
+ \`\`\`json
46
+ {
47
+ "name": "core/group",
48
+ "attributes": { "className": "is-style-callout-info", "layout": { "type": "constrained" } },
49
+ "innerHTML": "<div class=\\"wp-block-group is-style-callout-info\\"></div>",
50
+ "innerBlocks": [
51
+ { "name": "core/paragraph", "innerHTML": "<p>Tip text here.</p>" }
52
+ ]
53
+ }
54
+ \`\`\`
55
+
56
+ Site-specific style conventions (e.g. callout class names) come from this site's instructions addendum — prefer those over inventing classes.
57
+
58
+ ## Verifying writes
59
+
60
+ Every write echoes the canonical post-save snapshot. Use it. Do not fetch the public page to verify what saved.
61
+
62
+ - \`update_block\` always returns \`saved.inner_html\` + \`saved.attributes\` — the exact content that just landed in post_content. The write call IS the verification round-trip.
63
+ - \`update_blocks\` returns per-result \`saved\` only when called with \`verbose: true\` (default false to keep batch responses compact). Pass \`verbose: true\` if you need to confirm each item without a re-read.
64
+ - For after-the-fact re-reads of a single known block, use \`get_block({ post_id, ref })\` — returns the same \`saved\` shape, lighter than \`get_page_blocks\`.
65
+
66
+ For dynamic blocks (\`saved.is_dynamic: true\`, e.g. shortcodes, query loops, latest-posts), \`saved.inner_html\` is the stored template that runs at render time — not the rendered HTML the visitor sees. That's expected; the canonical state is the template.
67
+
68
+ ## Block preferences (site-defined)
69
+
70
+ Block preference policy is configured per-site in the WordPress admin (the
71
+ gk-block-api Preferences option) and exposed dynamically. There is no
72
+ client-side hardcoded list of "good" vs "bad" namespaces.
73
+
74
+ How to discover the policy at runtime:
75
+
76
+ 1. \`list_block_types\` returns blocks grouped by tier (PREFERRED / ACCEPTABLE / AVOID / LEGACY) for the current site. Use this when you need the full picture.
77
+ 2. \`get_page_blocks\` annotates non-preferred blocks inline with \`preference.tier\` and (when configured) \`preference.suggested_replacement\`. Trust those fields — they reflect the live config.
78
+ 3. \`insert_blocks\` rejects legacy-tier blocks with a \`legacy_block\` error that includes the rejected namespace, the suggested replacement, and a pointer back to this resource.
79
+
80
+ How to behave:
81
+
82
+ - Prefer the highest-tier blocks for new content. Defer to the server's classification rather than guessing from a namespace prefix.
83
+ - Reuse existing patterns before building from scratch — call \`list_patterns\` first.
84
+ - For patterns that need per-page customization, use \`synced: false\` to inline them.
85
+ - When you encounter legacy blocks on a page during a read, note them but do not replace unless asked.`;