@gravitykit/block-mcp 2.0.0-beta → 2.0.1
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 +2247 -774
- package/package.json +6 -2
- package/src/__tests__/integration/dual-storage.test.ts +22 -26
- 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/read/pagination.test.ts +65 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +1 -1
- package/src/agent-guide.ts +85 -0
- package/src/client.ts +51 -22
- package/src/connect.ts +106 -35
- package/src/error-translator.ts +1 -1
- package/src/index.ts +14 -52
- package/src/tools/mutate.ts +3 -8
- package/src/tools/read.ts +16 -0
- package/src/tools/write.ts +11 -5
- package/src/types.ts +22 -18
- package/src/validate-args.ts +109 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { skipUnlessLive, makeLiveClient, withTestPost, LIVE_ENV } from './setup.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Edge-case coverage for the READ + discovery surface against a live
|
|
7
|
+
* WordPress — every accepted query parameter on get_page_blocks plus the
|
|
8
|
+
* discovery endpoints (block-types, patterns, site-usage, resolve,
|
|
9
|
+
* find-posts, post-info, instructions), exercised for shape and filtering
|
|
10
|
+
* behavior rather than just "returns 200".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
describe.skipIf(skipUnlessLive())('integration: get_page_blocks query params', () => {
|
|
14
|
+
it('fields= returns only the requested fields', async () => {
|
|
15
|
+
const client = makeLiveClient();
|
|
16
|
+
await withTestPost(client, async (postId) => {
|
|
17
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,ref' });
|
|
18
|
+
const block = (read.blocks as unknown as Array<Record<string, unknown>>)[0];
|
|
19
|
+
expect(block.name).toBeTruthy();
|
|
20
|
+
expect(block.innerHTML ?? block.inner_html).toBeUndefined();
|
|
21
|
+
expect(block.attributes).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('search= filters to blocks containing the text', async () => {
|
|
26
|
+
const client = makeLiveClient();
|
|
27
|
+
await withTestPost(client, async (postId) => {
|
|
28
|
+
const read = await client.getPageBlocks(postId, {
|
|
29
|
+
fields: 'name,innerHTML',
|
|
30
|
+
search: 'Integration test paragraph',
|
|
31
|
+
});
|
|
32
|
+
const blocks = read.blocks as Array<{ name: string }>;
|
|
33
|
+
expect(blocks.length).toBe(1);
|
|
34
|
+
expect(blocks[0].name).toBe('core/paragraph');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('block_name= filters to a single block type', async () => {
|
|
39
|
+
const client = makeLiveClient();
|
|
40
|
+
await withTestPost(client, async (postId) => {
|
|
41
|
+
const read = await client.getPageBlocks(postId, {
|
|
42
|
+
fields: 'name',
|
|
43
|
+
block_name: 'core/heading',
|
|
44
|
+
});
|
|
45
|
+
const blocks = read.blocks as Array<{ name: string }>;
|
|
46
|
+
expect(blocks.length).toBeGreaterThanOrEqual(1);
|
|
47
|
+
expect(blocks.every((b) => b.name === 'core/heading')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('summary_only returns the summary without block bodies', async () => {
|
|
52
|
+
const client = makeLiveClient();
|
|
53
|
+
await withTestPost(client, async (postId) => {
|
|
54
|
+
const read = (await client.getPageBlocks(postId, { summary_only: true })) as {
|
|
55
|
+
summary?: { total_blocks?: number };
|
|
56
|
+
blocks?: unknown[];
|
|
57
|
+
};
|
|
58
|
+
expect(read.summary?.total_blocks).toBeGreaterThanOrEqual(2);
|
|
59
|
+
expect(read.blocks === undefined || read.blocks.length === 0).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('render=true serves rendered output without corrupting the canonical tree', async () => {
|
|
64
|
+
const client = makeLiveClient();
|
|
65
|
+
await withTestPost(client, async (postId) => {
|
|
66
|
+
const read = await client.getPageBlocks(postId, { render: true });
|
|
67
|
+
const blocks = read.blocks as Array<{ name: string }>;
|
|
68
|
+
expect(blocks.length).toBeGreaterThanOrEqual(2);
|
|
69
|
+
expect(blocks[0].name).toBe('core/heading');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('resolve_url maps a real permalink back to the post', async () => {
|
|
74
|
+
const client = makeLiveClient();
|
|
75
|
+
await withTestPost(client, async (postId) => {
|
|
76
|
+
// Drafts have no stable public URL (resolution falls through to other
|
|
77
|
+
// posts) — publish first so the permalink is a real contract.
|
|
78
|
+
await client.updatePost(postId, { status: 'publish' });
|
|
79
|
+
const info = (await client.getPostInfo({ post_id: postId })) as { post_url?: string };
|
|
80
|
+
expect(info.post_url).toBeTruthy();
|
|
81
|
+
|
|
82
|
+
const resolved = await client.resolveUrl(info.post_url as string);
|
|
83
|
+
expect(resolved.post_id).toBe(postId);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe.skipIf(skipUnlessLive())('integration: discovery endpoints', () => {
|
|
89
|
+
it('block-types: namespace filter returns only that namespace', async () => {
|
|
90
|
+
const client = makeLiveClient();
|
|
91
|
+
const res = (await client.getBlockTypes({ namespace: 'core' })) as {
|
|
92
|
+
block_types?: Array<{ name: string }>;
|
|
93
|
+
blocks?: Array<{ name: string }>;
|
|
94
|
+
};
|
|
95
|
+
const list = res.block_types ?? res.blocks ?? [];
|
|
96
|
+
expect(list.length).toBeGreaterThan(0);
|
|
97
|
+
expect(list.every((b) => b.name.startsWith('core/'))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('block-types: search finds core/paragraph', async () => {
|
|
101
|
+
const client = makeLiveClient();
|
|
102
|
+
const res = (await client.getBlockTypes({ search: 'paragraph' })) as {
|
|
103
|
+
block_types?: Array<{ name: string }>;
|
|
104
|
+
blocks?: Array<{ name: string }>;
|
|
105
|
+
};
|
|
106
|
+
const list = res.block_types ?? res.blocks ?? [];
|
|
107
|
+
expect(JSON.stringify(list)).toContain('core/paragraph');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('patterns: list + search respond with arrays; bogus id is a 404', async () => {
|
|
111
|
+
const client = makeLiveClient();
|
|
112
|
+
const list = (await client.getPatterns()) as { patterns?: unknown[] };
|
|
113
|
+
expect(Array.isArray(list.patterns)).toBe(true);
|
|
114
|
+
|
|
115
|
+
const search = (await client.searchPatterns('a')) as { patterns?: unknown[] };
|
|
116
|
+
expect(Array.isArray(search.patterns)).toBe(true);
|
|
117
|
+
|
|
118
|
+
let status = 0;
|
|
119
|
+
try {
|
|
120
|
+
await client.getPattern(999999999);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
status = (e as { wpStatus?: number }).wpStatus ?? 0;
|
|
123
|
+
}
|
|
124
|
+
expect(status).toBe(404);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('site-usage responds with usage stats', async () => {
|
|
128
|
+
const client = makeLiveClient();
|
|
129
|
+
const usage = (await client.getSiteUsage()) as unknown as Record<string, unknown>;
|
|
130
|
+
expect(Object.keys(usage).length).toBeGreaterThan(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('find-posts: search + post_type + per_page paging contract', async () => {
|
|
134
|
+
const client = makeLiveClient();
|
|
135
|
+
await withTestPost(client, async (postId) => {
|
|
136
|
+
const info = (await client.getPostInfo({ post_id: postId })) as { title?: string };
|
|
137
|
+
const found = (await client.findPosts({
|
|
138
|
+
search: info.title ?? LIVE_ENV.prefix,
|
|
139
|
+
post_type: 'post',
|
|
140
|
+
post_status: 'draft',
|
|
141
|
+
per_page: 1,
|
|
142
|
+
page: 1,
|
|
143
|
+
} as never)) as { posts?: Array<{ post_id?: number; id?: number }> };
|
|
144
|
+
expect(found.posts?.length).toBeLessThanOrEqual(1);
|
|
145
|
+
const ids = (found.posts ?? []).map((p) => p.post_id ?? p.id);
|
|
146
|
+
expect(ids).toContain(postId);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('post-info: post_id and slug+post_type modes agree', async () => {
|
|
151
|
+
const client = makeLiveClient();
|
|
152
|
+
await withTestPost(client, async (postId) => {
|
|
153
|
+
const byId = (await client.getPostInfo({ post_id: postId })) as {
|
|
154
|
+
post_id?: number;
|
|
155
|
+
slug?: string;
|
|
156
|
+
post_type?: string;
|
|
157
|
+
};
|
|
158
|
+
expect(byId.post_id).toBe(postId);
|
|
159
|
+
|
|
160
|
+
// Drafts only have a slug when explicitly set — skip the slug round-trip if empty.
|
|
161
|
+
if (byId.slug) {
|
|
162
|
+
const bySlug = (await client.getPostInfo({
|
|
163
|
+
slug: byId.slug,
|
|
164
|
+
post_type: byId.post_type ?? 'post',
|
|
165
|
+
})) as { post_id?: number };
|
|
166
|
+
expect(bySlug.post_id).toBe(postId);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('instructions endpoint serves unauthenticated', async () => {
|
|
172
|
+
const base = LIVE_ENV.url.replace(/\/+$/, '');
|
|
173
|
+
const res = await axios.get(`${base}/wp-json/gk-block-api/v1/instructions`, {
|
|
174
|
+
validateStatus: () => true,
|
|
175
|
+
});
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/** Decorated error fields attached by the client's response interceptor. */
|
|
181
|
+
interface WpErr {
|
|
182
|
+
message?: string;
|
|
183
|
+
wpCode?: string;
|
|
184
|
+
wpStatus?: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function grab(promise: Promise<unknown>): Promise<WpErr> {
|
|
188
|
+
try {
|
|
189
|
+
await promise;
|
|
190
|
+
return {};
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return e as WpErr;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const P = (text: string) => ({
|
|
197
|
+
name: 'core/paragraph',
|
|
198
|
+
attributes: { content: text },
|
|
199
|
+
innerHTML: `<p>${text}</p>`,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe.skipIf(skipUnlessLive())('integration: read variants and pagination', () => {
|
|
203
|
+
it('cursor + limit paginate top-level blocks without gaps or overlap', async () => {
|
|
204
|
+
const client = makeLiveClient();
|
|
205
|
+
await withTestPost(client, async (postId) => {
|
|
206
|
+
await client.insertBlocks(postId, { blocks: [P('EDGE-PG-3'), P('EDGE-PG-4')] }); // 4 total
|
|
207
|
+
|
|
208
|
+
const page1 = await client.getPageBlocks(postId, { fields: 'name,innerHTML', limit: 2 });
|
|
209
|
+
expect(page1.blocks).toHaveLength(2);
|
|
210
|
+
expect(page1.pagination?.next_cursor).toBeTruthy();
|
|
211
|
+
|
|
212
|
+
const page2 = await client.getPageBlocks(postId, {
|
|
213
|
+
fields: 'name,innerHTML',
|
|
214
|
+
limit: 2,
|
|
215
|
+
cursor: page1.pagination?.next_cursor as string,
|
|
216
|
+
});
|
|
217
|
+
expect(page2.blocks).toHaveLength(2);
|
|
218
|
+
|
|
219
|
+
const all = JSON.stringify([...page1.blocks, ...page2.blocks]);
|
|
220
|
+
expect(all).toContain('Integration test heading');
|
|
221
|
+
expect(all).toContain('EDGE-PG-3');
|
|
222
|
+
expect(all).toContain('EDGE-PG-4');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('outline and include_legacy_paths respond without altering the tree', async () => {
|
|
227
|
+
const client = makeLiveClient();
|
|
228
|
+
await withTestPost(client, async (postId) => {
|
|
229
|
+
const outline = await client.getPageBlocks(postId, { outline: true });
|
|
230
|
+
expect(outline.post_id ?? postId).toBe(postId);
|
|
231
|
+
|
|
232
|
+
const legacy = await client.getPageBlocks(postId, {
|
|
233
|
+
fields: 'name',
|
|
234
|
+
include_legacy_paths: true,
|
|
235
|
+
});
|
|
236
|
+
expect((legacy.blocks as unknown[]).length).toBeGreaterThanOrEqual(2);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('persist_refs: false reads without writing refs back', async () => {
|
|
241
|
+
const client = makeLiveClient();
|
|
242
|
+
await withTestPost(client, async (postId) => {
|
|
243
|
+
const read = await client.getPageBlocks(postId, {
|
|
244
|
+
fields: 'name,ref',
|
|
245
|
+
persist_refs: false,
|
|
246
|
+
});
|
|
247
|
+
expect((read.blocks as unknown[]).length).toBeGreaterThanOrEqual(2);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('getBlock targets by flatIndex as well as by ref', async () => {
|
|
252
|
+
const client = makeLiveClient();
|
|
253
|
+
await withTestPost(client, async (postId) => {
|
|
254
|
+
const byIndex = await client.getBlock(postId, { flatIndex: 0 });
|
|
255
|
+
expect(byIndex.saved?.block_name).toBe('core/heading');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe.skipIf(skipUnlessLive())('integration: block-types filter matrix', () => {
|
|
261
|
+
it('tier filter returns only blocks of that tier', async () => {
|
|
262
|
+
const client = makeLiveClient();
|
|
263
|
+
const res = (await client.getBlockTypes({ tier: 'preferred' })) as unknown as {
|
|
264
|
+
block_types?: Array<{ name: string; preference?: { tier?: string }; tier?: string }>;
|
|
265
|
+
blocks?: Array<{ name: string; preference?: { tier?: string }; tier?: string }>;
|
|
266
|
+
};
|
|
267
|
+
const list = res.block_types ?? res.blocks ?? [];
|
|
268
|
+
for (const b of list.slice(0, 10)) {
|
|
269
|
+
const tier = b.preference?.tier ?? b.tier;
|
|
270
|
+
if (tier) expect(String(tier).toLowerCase()).toBe('preferred');
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('preferred_only / usage_only / category / storage_mode are accepted', async () => {
|
|
275
|
+
const client = makeLiveClient();
|
|
276
|
+
await expect(client.getBlockTypes({ preferred_only: true })).resolves.toBeTruthy();
|
|
277
|
+
await expect(client.getBlockTypes({ usage_only: true })).resolves.toBeTruthy();
|
|
278
|
+
await expect(client.getBlockTypes({ category: 'text' })).resolves.toBeTruthy();
|
|
279
|
+
await expect(client.getBlockTypes({ storage_mode: 'static' })).resolves.toBeTruthy();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe.skipIf(skipUnlessLive())('integration: patterns insert', () => {
|
|
284
|
+
it('inserts a registered or synced pattern (inlined) into the post', async () => {
|
|
285
|
+
const client = makeLiveClient();
|
|
286
|
+
const list = (await client.getPatterns()) as {
|
|
287
|
+
patterns?: Array<{ id?: number | string; name?: string; pattern_id?: number | string }>;
|
|
288
|
+
};
|
|
289
|
+
const candidate = (list.patterns ?? [])[0];
|
|
290
|
+
if (!candidate) {
|
|
291
|
+
// A site with zero patterns is valid — nothing to exercise.
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const patternId = (candidate.id ?? candidate.pattern_id ?? candidate.name) as number | string;
|
|
295
|
+
|
|
296
|
+
await withTestPost(client, async (postId) => {
|
|
297
|
+
const before = await client.getPageBlocks(postId, { fields: 'name' });
|
|
298
|
+
const res = await grab(
|
|
299
|
+
client.insertPattern(postId, { pattern_id: patternId, synced: false }),
|
|
300
|
+
);
|
|
301
|
+
if (res.wpStatus) {
|
|
302
|
+
// Some patterns are rejected by site policy (legacy-tier contents) —
|
|
303
|
+
// that's a structured outcome, not a silent failure.
|
|
304
|
+
expect(res.wpStatus).toBeGreaterThanOrEqual(400);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const after = await client.getPageBlocks(postId, { fields: 'name' });
|
|
308
|
+
expect((after.blocks as unknown[]).length).toBeGreaterThan(
|
|
309
|
+
(before.blocks as unknown[]).length,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { skipUnlessLive, makeLiveClient, LIVE_ENV } from './setup.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Connector ↔ plugin media-upload round-trip.
|
|
9
|
+
*
|
|
10
|
+
* The upload multipart bug passed every other suite because nothing ran the
|
|
11
|
+
* real connector against a real WordPress: the TS tests mocked the client, the
|
|
12
|
+
* PHP tests dispatched synthetic WP_REST_Requests. This exercises both ends
|
|
13
|
+
* together — the connector serializes a real request, WordPress's /media route
|
|
14
|
+
* receives and stores it — for both upload modes.
|
|
15
|
+
*
|
|
16
|
+
* Skips cleanly offline (no WORDPRESS_URL/USER/APP_PASSWORD); the CI "integration"
|
|
17
|
+
* job boots a real WordPress and sets those so this runs there.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// 1x1 transparent PNG.
|
|
21
|
+
const PNG_B64 =
|
|
22
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
|
23
|
+
|
|
24
|
+
describe.skipIf(skipUnlessLive())('integration: connector ↔ plugin media upload', () => {
|
|
25
|
+
it('uploads a local file via path mode (real multipart round-trip)', async () => {
|
|
26
|
+
const client = makeLiveClient();
|
|
27
|
+
const tmp = path.join(os.tmpdir(), `gk-int-upload-${process.pid}.png`);
|
|
28
|
+
fs.writeFileSync(tmp, Buffer.from(PNG_B64, 'base64'));
|
|
29
|
+
try {
|
|
30
|
+
const r = (await client.uploadMedia({
|
|
31
|
+
path: tmp,
|
|
32
|
+
title: `${LIVE_ENV.prefix} path upload`,
|
|
33
|
+
alt_text: 'integration path upload',
|
|
34
|
+
})) as { id: number; source_url?: string; url?: string };
|
|
35
|
+
expect(typeof r.id).toBe('number');
|
|
36
|
+
expect(r.id).toBeGreaterThan(0);
|
|
37
|
+
expect(String(r.source_url ?? r.url)).toMatch(/\.png$/i);
|
|
38
|
+
} finally {
|
|
39
|
+
fs.rmSync(tmp, { force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('uploads via data_base64 mode', async () => {
|
|
44
|
+
const client = makeLiveClient();
|
|
45
|
+
const r = (await client.uploadMedia({
|
|
46
|
+
data_base64: PNG_B64,
|
|
47
|
+
filename: 'gk-int-upload.png',
|
|
48
|
+
title: `${LIVE_ENV.prefix} base64 upload`,
|
|
49
|
+
alt_text: 'integration base64 upload',
|
|
50
|
+
})) as { id: number; source_url?: string; url?: string };
|
|
51
|
+
expect(typeof r.id).toBe('number');
|
|
52
|
+
expect(r.id).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
});
|