@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
+ * Unit tests for enrichBlockList().
3
+ *
4
+ * The function is purely synchronous and side-effect-free: feed it a Block
5
+ * array, get back { blocks, warnings, summary }. No HTTP, no client.
6
+ *
7
+ * Key invariants tested:
8
+ * - Only blocks with server-attached `preference` field generate warnings
9
+ * - Any tier (legacy OR avoid) generates a warning
10
+ * - Nested innerBlocks are walked (legacy blocks inside groups surface)
11
+ * - Empty input returns clean state
12
+ * - Summary text varies by warning count
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import { enrichBlockList } from '../../../preferences.js';
17
+ import type { Block } from '../../../types.js';
18
+ import {
19
+ paragraphBlock,
20
+ headingBlock,
21
+ legacyHeadingBlock,
22
+ avoidBlock,
23
+ groupBlock,
24
+ } from '../../fixtures/block-trees.js';
25
+ import { assertPreferenceWarning } from '../../helpers/schema-asserts.js';
26
+
27
+ // ── Empty input ───────────────────────────────────────────────────────────────
28
+
29
+ describe('enrichBlockList — empty input', () => {
30
+ it('returns empty warnings array', () => {
31
+ const result = enrichBlockList([]);
32
+ expect(result.warnings).toHaveLength(0);
33
+ });
34
+
35
+ it('returns empty blocks array', () => {
36
+ expect(enrichBlockList([]).blocks).toHaveLength(0);
37
+ });
38
+
39
+ it('returns clean summary when no blocks', () => {
40
+ const result = enrichBlockList([]);
41
+ expect(result.summary).toMatch(/preferred or acceptable/);
42
+ });
43
+ });
44
+
45
+ // ── Preferred/acceptable blocks (no preference field attached) ─────────────
46
+
47
+ describe('enrichBlockList — clean blocks', () => {
48
+ it('returns no warnings for core blocks', () => {
49
+ const blocks = [paragraphBlock, headingBlock] as unknown as Block[];
50
+ const result = enrichBlockList(blocks);
51
+ expect(result.warnings).toHaveLength(0);
52
+ });
53
+
54
+ it('returns the original blocks array reference', () => {
55
+ const blocks = [paragraphBlock] as unknown as Block[];
56
+ const result = enrichBlockList(blocks);
57
+ expect(result.blocks).toBe(blocks);
58
+ });
59
+
60
+ it('summary says preferred/acceptable', () => {
61
+ const blocks = [paragraphBlock] as unknown as Block[];
62
+ expect(enrichBlockList(blocks).summary).toMatch(/preferred or acceptable/);
63
+ });
64
+
65
+ it('does not invent warnings for unrecognised namespaces without preference field', () => {
66
+ const customBlock: Block = {
67
+ index: 0, name: 'customplugin/widget', attributes: {},
68
+ };
69
+ expect(enrichBlockList([customBlock]).warnings).toHaveLength(0);
70
+ });
71
+ });
72
+
73
+ // ── Non-preferred blocks (server attaches preference field) ───────────────
74
+
75
+ describe('enrichBlockList — non-preferred blocks', () => {
76
+ it('warns about a legacy-tier block', () => {
77
+ const blocks = [legacyHeadingBlock] as unknown as Block[];
78
+ const result = enrichBlockList(blocks);
79
+ expect(result.warnings).toHaveLength(1);
80
+ assertPreferenceWarning(result.warnings[0]);
81
+ expect(result.warnings[0].block).toBe('ugb/heading');
82
+ });
83
+
84
+ it('warns about an avoid-tier block', () => {
85
+ const blocks = [avoidBlock] as unknown as Block[];
86
+ const result = enrichBlockList(blocks);
87
+ expect(result.warnings).toHaveLength(1);
88
+ expect(result.warnings[0].block).toBe('stackable/heading');
89
+ });
90
+
91
+ it('warns about multiple non-preferred blocks', () => {
92
+ const blocks = [legacyHeadingBlock, avoidBlock] as unknown as Block[];
93
+ const result = enrichBlockList(blocks);
94
+ expect(result.warnings).toHaveLength(2);
95
+ const names = result.warnings.map((w) => w.block);
96
+ expect(names).toContain('ugb/heading');
97
+ expect(names).toContain('stackable/heading');
98
+ });
99
+
100
+ it('includes suggested_replacement when provided', () => {
101
+ const blocks = [legacyHeadingBlock] as unknown as Block[];
102
+ const result = enrichBlockList(blocks);
103
+ expect(result.warnings[0].suggested_replacement).toBe('core/heading');
104
+ });
105
+
106
+ it('summary mentions block names', () => {
107
+ const blocks = [legacyHeadingBlock] as unknown as Block[];
108
+ const result = enrichBlockList(blocks);
109
+ expect(result.summary).toContain('ugb/heading');
110
+ expect(result.summary).toMatch(/1 non-preferred/);
111
+ });
112
+
113
+ it('summary mentions count when multiple warnings', () => {
114
+ const blocks = [legacyHeadingBlock, avoidBlock] as unknown as Block[];
115
+ const result = enrichBlockList(blocks);
116
+ expect(result.summary).toMatch(/2 non-preferred/);
117
+ });
118
+ });
119
+
120
+ // ── Nested innerBlocks walk ───────────────────────────────────────────────
121
+
122
+ describe('enrichBlockList — nested innerBlocks', () => {
123
+ it('finds legacy block inside a core/group', () => {
124
+ // groupBlock has two preferred innerBlocks — no warnings expected
125
+ const clean = enrichBlockList([groupBlock as unknown as Block]);
126
+ expect(clean.warnings).toHaveLength(0);
127
+ });
128
+
129
+ it('surfaces legacy block nested inside a container', () => {
130
+ const containerWithLegacy: Block = {
131
+ index: 0,
132
+ name: 'core/group',
133
+ attributes: {},
134
+ innerBlocks: [
135
+ legacyHeadingBlock as unknown as Block,
136
+ { index: 2, name: 'core/paragraph', attributes: {} },
137
+ ],
138
+ };
139
+ const result = enrichBlockList([containerWithLegacy]);
140
+ expect(result.warnings).toHaveLength(1);
141
+ expect(result.warnings[0].block).toBe('ugb/heading');
142
+ });
143
+
144
+ it('surfaces avoid block two levels deep', () => {
145
+ const deeplyNested: Block = {
146
+ index: 0,
147
+ name: 'core/columns',
148
+ attributes: {},
149
+ innerBlocks: [
150
+ {
151
+ index: 1,
152
+ name: 'core/column',
153
+ attributes: {},
154
+ innerBlocks: [avoidBlock as unknown as Block],
155
+ },
156
+ ],
157
+ };
158
+ const result = enrichBlockList([deeplyNested]);
159
+ expect(result.warnings).toHaveLength(1);
160
+ expect(result.warnings[0].block).toBe('stackable/heading');
161
+ });
162
+ });
163
+
164
+ // ── Mixed pages ───────────────────────────────────────────────────────────
165
+
166
+ describe('enrichBlockList — mixed preferred + non-preferred', () => {
167
+ it('only warns about non-preferred blocks', () => {
168
+ const blocks = [paragraphBlock, legacyHeadingBlock, headingBlock, avoidBlock] as unknown as Block[];
169
+ const result = enrichBlockList(blocks);
170
+ expect(result.warnings).toHaveLength(2);
171
+ const warnedNames = result.warnings.map((w) => w.block);
172
+ expect(warnedNames).not.toContain('core/paragraph');
173
+ expect(warnedNames).not.toContain('core/heading');
174
+ });
175
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Unit tests for enrichPatternList() and enrichBlockTypes().
3
+ *
4
+ * Both functions transform API arrays into { items, summary/guidance } objects.
5
+ * No HTTP, no client, no mocks needed.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { enrichPatternList, enrichBlockTypes } from '../../../preferences.js';
10
+ import type { Pattern, BlockType } from '../../../types.js';
11
+
12
+ // ── helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ function makePattern(overrides: Partial<Pattern> & { id: number; name: string }): Pattern {
15
+ return {
16
+ type: 'synced',
17
+ created: '2026-01-01',
18
+ modified: '2026-01-01',
19
+ reference_count: 0,
20
+ preference: { score: 80, tier: 'recommended', reasons: [] },
21
+ contains_blocks: [],
22
+ has_legacy_blocks: false,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function makeBlockType(
28
+ name: string,
29
+ tier: BlockType['preference']['tier'],
30
+ score = 80
31
+ ): BlockType {
32
+ return {
33
+ name,
34
+ title: name.split('/')[1] ?? name,
35
+ category: 'text',
36
+ preference: { score, tier },
37
+ };
38
+ }
39
+
40
+ // ── enrichPatternList ─────────────────────────────────────────────────────────
41
+
42
+ describe('enrichPatternList — empty input', () => {
43
+ it('returns empty patterns array', () => {
44
+ expect(enrichPatternList([]).patterns).toHaveLength(0);
45
+ });
46
+
47
+ it('returns "No patterns found" summary', () => {
48
+ expect(enrichPatternList([]).summary).toMatch(/No patterns found/);
49
+ });
50
+ });
51
+
52
+ describe('enrichPatternList — sorting', () => {
53
+ it('sorts by score descending', () => {
54
+ const patterns = [
55
+ 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: [] } }),
58
+ ];
59
+ const result = enrichPatternList(patterns);
60
+ expect(result.patterns[0].name).toBe('High');
61
+ expect(result.patterns[1].name).toBe('Mid');
62
+ expect(result.patterns[2].name).toBe('Low');
63
+ });
64
+
65
+ it('preserves equal-score order (stable sort is not required, just non-crash)', () => {
66
+ 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: [] } }),
69
+ ];
70
+ const result = enrichPatternList(patterns);
71
+ expect(result.patterns).toHaveLength(2);
72
+ });
73
+
74
+ it('does not mutate the input array', () => {
75
+ const patterns = [
76
+ makePattern({ id: 1, name: 'A', preference: { score: 10, tier: 'avoid', reasons: [] } }),
77
+ makePattern({ id: 2, name: 'B', preference: { score: 95, tier: 'recommended', reasons: [] } }),
78
+ ];
79
+ const original = [...patterns];
80
+ enrichPatternList(patterns);
81
+ expect(patterns[0].name).toBe(original[0].name);
82
+ });
83
+ });
84
+
85
+ describe('enrichPatternList — summary generation', () => {
86
+ it('includes RECOMMENDED label for recommended patterns', () => {
87
+ const patterns = [
88
+ makePattern({ id: 1, name: 'Hero', preference: { score: 85, tier: 'recommended', reasons: [] } }),
89
+ ];
90
+ const result = enrichPatternList(patterns);
91
+ expect(result.summary).toMatch(/RECOMMENDED/);
92
+ expect(result.summary).toContain('Hero');
93
+ });
94
+
95
+ it('includes AVOID label for avoid-tier patterns', () => {
96
+ const patterns = [
97
+ makePattern({
98
+ id: 2, name: 'Old Legacy',
99
+ preference: { score: -80, tier: 'legacy', reasons: ['has_legacy_blocks'] },
100
+ has_legacy_blocks: true,
101
+ legacy_blocks: ['ugb/text'],
102
+ }),
103
+ ];
104
+ const result = enrichPatternList(patterns);
105
+ expect(result.summary).toMatch(/AVOID/);
106
+ expect(result.summary).toContain('Old Legacy');
107
+ });
108
+
109
+ it('includes legacy block names in the avoid section', () => {
110
+ const patterns = [
111
+ makePattern({
112
+ id: 3, name: 'Old Pattern',
113
+ preference: { score: -80, tier: 'legacy', reasons: [] },
114
+ has_legacy_blocks: true,
115
+ legacy_blocks: ['ugb/text', 'ugb/heading'],
116
+ }),
117
+ ];
118
+ const result = enrichPatternList(patterns);
119
+ expect(result.summary).toContain('ugb/text');
120
+ });
121
+
122
+ it('does not include AVOID label when all patterns are recommended', () => {
123
+ const patterns = [
124
+ makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'recommended', reasons: [] } }),
125
+ ];
126
+ expect(enrichPatternList(patterns).summary).not.toMatch(/AVOID/);
127
+ });
128
+
129
+ it('shows both sections when mixed tiers', () => {
130
+ const patterns = [
131
+ makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'recommended', reasons: [] } }),
132
+ makePattern({ id: 2, name: 'Bad', preference: { score: 0, tier: 'legacy', reasons: [] }, has_legacy_blocks: true }),
133
+ ];
134
+ const { summary } = enrichPatternList(patterns);
135
+ expect(summary).toMatch(/RECOMMENDED/);
136
+ expect(summary).toMatch(/AVOID/);
137
+ });
138
+
139
+ it('includes block namespace info for recommended patterns with contains_blocks', () => {
140
+ const patterns = [
141
+ makePattern({
142
+ id: 1, name: 'Hero',
143
+ preference: { score: 80, tier: 'recommended', reasons: [] },
144
+ contains_blocks: ['core/heading', 'core/paragraph', 'core/image'],
145
+ }),
146
+ ];
147
+ const { summary } = enrichPatternList(patterns);
148
+ // Should mention "core" namespace
149
+ expect(summary).toContain('core');
150
+ });
151
+ });
152
+
153
+ // ── enrichBlockTypes ──────────────────────────────────────────────────────────
154
+
155
+ describe('enrichBlockTypes — empty input', () => {
156
+ it('returns empty block_types array', () => {
157
+ expect(enrichBlockTypes([]).block_types).toHaveLength(0);
158
+ });
159
+
160
+ it('returns guidance string (may be minimal)', () => {
161
+ const result = enrichBlockTypes([]);
162
+ expect(typeof result.guidance).toBe('string');
163
+ });
164
+ });
165
+
166
+ describe('enrichBlockTypes — tier grouping', () => {
167
+ it('all preferred types appear in guidance', () => {
168
+ const types = [makeBlockType('core/paragraph', 'preferred', 90)];
169
+ const result = enrichBlockTypes(types);
170
+ expect(result.guidance).toMatch(/PREFERRED|preferred/i);
171
+ expect(result.block_types).toContain(types[0]);
172
+ });
173
+
174
+ it('avoid-tier blocks appear in guidance with warning', () => {
175
+ const types = [makeBlockType('stackable/heading', 'avoid', 10)];
176
+ const result = enrichBlockTypes(types);
177
+ expect(result.guidance).toMatch(/avoid|AVOID/i);
178
+ });
179
+
180
+ it('legacy-tier blocks appear in guidance', () => {
181
+ const types = [makeBlockType('ugb/text', 'legacy', 0)];
182
+ const result = enrichBlockTypes(types);
183
+ expect(result.guidance).toMatch(/legacy|LEGACY/i);
184
+ });
185
+
186
+ it('groups all four tiers when all are present', () => {
187
+ const types = [
188
+ makeBlockType('core/paragraph', 'preferred', 90),
189
+ makeBlockType('outermost/icon', 'acceptable', 60),
190
+ makeBlockType('stackable/heading', 'avoid', 10),
191
+ makeBlockType('ugb/text', 'legacy', 0),
192
+ ];
193
+ const result = enrichBlockTypes(types);
194
+ expect(result.block_types).toHaveLength(4);
195
+ const guidance = result.guidance;
196
+ // All four tiers should have some representation in the guidance text
197
+ expect(guidance.length).toBeGreaterThan(20);
198
+ });
199
+
200
+ it('returns original types in block_types (not copies)', () => {
201
+ const types = [makeBlockType('core/heading', 'preferred', 90)];
202
+ const result = enrichBlockTypes(types);
203
+ expect(result.block_types).toContain(types[0]);
204
+ });
205
+
206
+ it('unknown tier falls through to acceptable bucket without crashing', () => {
207
+ const weirdType = {
208
+ ...makeBlockType('unknown/block', 'acceptable', 30),
209
+ preference: { score: 30, tier: 'unknown-tier' as any },
210
+ };
211
+ expect(() => enrichBlockTypes([weirdType])).not.toThrow();
212
+ });
213
+ });
214
+
215
+ describe('enrichBlockTypes — inline snapshot for stable guidance format', () => {
216
+ it('single preferred block produces expected guidance shape', () => {
217
+ const types = [makeBlockType('core/paragraph', 'preferred', 90)];
218
+ const { guidance } = enrichBlockTypes(types);
219
+ expect(guidance).toMatchInlineSnapshot(`"PREFERRED (core/): paragraph"`);
220
+ });
221
+
222
+ it('single legacy block produces expected guidance shape', () => {
223
+ const types = [makeBlockType('ugb/text', 'legacy', 0)];
224
+ const { guidance } = enrichBlockTypes(types);
225
+ expect(guidance).toMatchInlineSnapshot(`"LEGACY — DO NOT USE (ugb/): text"`);
226
+ });
227
+ });