@gravitykit/block-mcp 2.0.0-beta → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -7
- package/dist/index.cjs +2556 -967
- package/package.json +12 -4
- package/src/__tests__/coerce.test.ts +57 -0
- package/src/__tests__/fixtures/error-envelopes.ts +19 -7
- package/src/__tests__/integration/dual-storage.test.ts +22 -26
- package/src/__tests__/integration/error-envelopes.test.ts +2 -2
- package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
- package/src/__tests__/integration/read-discovery.test.ts +313 -0
- package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
- package/src/__tests__/integration/write-surface.test.ts +603 -0
- package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
- package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
- package/src/__tests__/tools/posts/update_post.test.ts +14 -0
- package/src/__tests__/tools/read/get_block.test.ts +35 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
- package/src/__tests__/tools/read/pagination.test.ts +65 -0
- package/src/__tests__/tools/write/delete_block.test.ts +18 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
- package/src/__tests__/tools/write/update_block.test.ts +16 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
- package/src/__tests__/unit/instructions.test.ts +3 -3
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
- package/src/__tests__/unit/rest-url.test.ts +23 -0
- package/src/agent-guide.ts +85 -0
- package/src/client.ts +104 -40
- package/src/coerce.ts +41 -0
- package/src/config.ts +96 -0
- package/src/connect.ts +123 -38
- package/src/enrichers.ts +6 -2
- package/src/error-translator.ts +63 -11
- package/src/index.ts +49 -79
- package/src/instructions.ts +3 -3
- package/src/preferences.ts +56 -43
- package/src/rest-url.ts +18 -0
- package/src/tools/discovery.ts +10 -14
- package/src/tools/mutate.ts +21 -20
- package/src/tools/patterns.ts +3 -2
- package/src/tools/posts.ts +26 -26
- package/src/tools/read.ts +23 -6
- package/src/tools/write.ts +37 -36
- package/src/tools/yoast.ts +30 -31
- package/src/types.ts +45 -31
- package/src/validate-args.ts +109 -0
|
@@ -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(/
|
|
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('
|
|
94
|
-
const withData = translateWpError('
|
|
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('
|
|
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('
|
|
111
|
-
const msg = translateWpError('
|
|
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('
|
|
121
|
-
const msg = translateWpError('
|
|
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('
|
|
127
|
-
const msg = translateWpError('
|
|
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('
|
|
138
|
-
expect(translateWpError('
|
|
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('
|
|
142
|
-
const msg = translateWpError('
|
|
143
|
-
expect(msg).toMatch(
|
|
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('
|
|
147
|
-
const msg = translateWpError('
|
|
148
|
-
expect(msg).toMatch(
|
|
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('
|
|
360
|
+
expect(() => translateWpError('ref_stale', 'oops')).not.toThrow();
|
|
290
361
|
});
|
|
291
362
|
|
|
292
363
|
it('ignores array data payload', () => {
|
|
293
|
-
expect(() => translateWpError('
|
|
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('
|
|
301
|
-
const msg = translateWpError('
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
57
|
-
makePattern({ id: 3, name: 'Mid', preference: { score: 50, tier: '
|
|
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: '
|
|
68
|
-
makePattern({ id: 2, name: 'B', preference: { score: 80, tier: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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.`;
|