@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravitykit/block-mcp",
3
- "version": "2.0.0-beta",
3
+ "version": "2.0.1",
4
4
  "description": "MCP server for WordPress block-level content management with preference-aware editing",
5
5
  "main": "dist/index.cjs",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "test": "vitest run",
17
17
  "test:watch": "vitest",
18
18
  "test:integration": "vitest run --config vitest.integration.config.ts",
19
+ "test:docs": "playwright test --config tests/docs/playwright.config.ts",
19
20
  "eval": "tsx tests/evals/lib/runner.ts",
20
21
  "eval:fixture-refresh": "tsx tests/evals/scripts/fetch-fixture.ts",
21
22
  "prepare": "npm run build",
@@ -41,14 +42,17 @@
41
42
  },
42
43
  "dependencies": {
43
44
  "@modelcontextprotocol/sdk": "^1.0.0",
44
- "axios": "^1.7.9"
45
+ "axios": "^1.7.9",
46
+ "form-data": "^4.0.1"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@anthropic-ai/sdk": "^0.91.1",
50
+ "@playwright/test": "^1.49.0",
48
51
  "@shikijs/engine-javascript": "^4.0.2",
49
52
  "@shikijs/langs": "^4.0.2",
50
53
  "@types/node": "^22.0.0",
51
54
  "esbuild": "^0.27.3",
55
+ "sharp": "^0.33.5",
52
56
  "shiki": "^4.0.2",
53
57
  "tsx": "^4.21.0",
54
58
  "typescript": "^5.7.0",
@@ -49,7 +49,7 @@ describe.skipIf(skip)('dual-storage enforcement (integration)', () => {
49
49
  }
50
50
  });
51
51
 
52
- it('update with only attributes on a dual-storage block returns 400 or 200 (requires scan)', async () => {
52
+ it('innerHTML-only update on a dual-storage block is rejected; attributes-only is allowed', async () => {
53
53
  if (!yoastInstalled || !dualBlockName) {
54
54
  console.log('[integration] No dual-storage block found (Yoast not installed or no dual blocks) — skipping');
55
55
  return;
@@ -78,46 +78,42 @@ describe.skipIf(skip)('dual-storage enforcement (integration)', () => {
78
78
  const blockIndex = inserted.inserted[0]?.index;
79
79
  expect(blockIndex).toBeGreaterThanOrEqual(0);
80
80
 
81
- // Try to update with ONLY attributes dual enforcement should reject it.
81
+ // The enforced direction (BLOCK-14): innerHTML ALONE desyncs the
82
+ // structured attributes (questions[] et al.) and must be rejected with
83
+ // dual_storage_requires_both. Attributes-only is allowed by design —
84
+ // the documented contract is "innerHTML-only is rejected".
82
85
  const credentials = Buffer.from(
83
86
  `${LIVE_ENV.user}:${LIVE_ENV.password}`
84
87
  ).toString('base64');
85
88
  const url = `${LIVE_ENV.url.replace(/\/+$/, '')}/wp-json/gk-block-api/v1/posts/${postId}/blocks/${blockIndex}`;
86
-
87
- const response = await axios.patch(
88
- url,
89
- { attributes: faqAttributes },
90
- {
89
+ const patch = (body: unknown) =>
90
+ axios.patch(url, body, {
91
91
  headers: {
92
92
  Authorization: `Basic ${credentials}`,
93
93
  'Content-Type': 'application/json',
94
94
  },
95
95
  timeout: 15_000,
96
96
  validateStatus: () => true,
97
- }
98
- );
97
+ });
99
98
 
100
- if (response.status === 400) {
101
- // Dual-storage enforcement is active — verify the error code.
102
- const body = response.data as Record<string, unknown>;
103
- expect(body.code).toBe('dual_storage_requires_both');
104
- expect(body.message).toBeTruthy();
105
- } else if (response.status === 200) {
106
- // The plugin returned a 200 because the storage-mode scan classifying
107
- // this block as `dual` had not been run on this site. Silently
108
- // logging-and-continuing turns the test green even though the
109
- // enforcement path was never exercised — the same outcome a real
110
- // regression that broke enforcement would produce. Fail loudly so
111
- // CI surfaces the missing precondition instead of papering over it.
99
+ const htmlOnly = await patch({ innerHTML: faqInnerHtml });
100
+ if (htmlOnly.status === 200) {
101
+ // A 200 here means the block is not classified dual on this site —
102
+ // the enforcement path was never exercised. Fail loudly so CI
103
+ // surfaces the missing precondition instead of papering over it.
112
104
  throw new Error(
113
105
  `[integration] dual-storage not enforced for ${dualBlockName} ` +
114
- '— call scan_storage_modes to classify the block as dual, then re-run this suite. ' +
115
- 'The 200 response means the precondition for this test is not met, not that the test passed.'
106
+ '— call scan_storage_modes (or add the block to the dual-storage list), then re-run.'
116
107
  );
117
- } else {
118
- // Any other status is unexpected.
119
- throw new Error(`Unexpected status ${response.status} from dual-storage update`);
120
108
  }
109
+ expect(htmlOnly.status).toBe(400);
110
+ const body = htmlOnly.data as Record<string, unknown>;
111
+ expect(body.code).toBe('dual_storage_requires_both');
112
+ expect(body.message).toBeTruthy();
113
+
114
+ // The permitted direction: attributes-only succeeds.
115
+ const attrsOnly = await patch({ attributes: faqAttributes });
116
+ expect(attrsOnly.status).toBe(200);
121
117
  });
122
118
  }, 45_000);
123
119
 
@@ -0,0 +1,271 @@
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, withTestPost, LIVE_ENV } from './setup.js';
6
+ import type { CreatePostRequest, UpdatePostRequest } from '../../types.js';
7
+
8
+ /**
9
+ * Edge-case coverage for the post-lifecycle, terms, and media surfaces
10
+ * against a live WordPress — every accepted argument plus the guard rails
11
+ * (allow-list, status enum, mutual exclusion, trash gate, SSRF, MIME
12
+ * allow-list, filename hygiene).
13
+ */
14
+
15
+ interface WpErr {
16
+ message?: string;
17
+ wpCode?: string;
18
+ wpStatus?: number;
19
+ }
20
+
21
+ async function grab(promise: Promise<unknown>): Promise<WpErr> {
22
+ try {
23
+ await promise;
24
+ return {};
25
+ } catch (e) {
26
+ return e as WpErr;
27
+ }
28
+ }
29
+
30
+ // 1x1 transparent PNG.
31
+ const PNG_B64 =
32
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
33
+
34
+ describe.skipIf(skipUnlessLive())('integration: create_post argument surface', () => {
35
+ it('content and blocks are mutually exclusive', async () => {
36
+ const client = makeLiveClient();
37
+ const err = await grab(
38
+ client.createPost({
39
+ title: `${LIVE_ENV.prefix} conflict`,
40
+ content: '<p>html</p>',
41
+ blocks: [{ name: 'core/paragraph', innerHTML: '<p>blocks</p>' }],
42
+ } as CreatePostRequest),
43
+ );
44
+ expect(err.message).toMatch(/mutually exclusive|content.*blocks/i);
45
+ });
46
+
47
+ it('invalid status is rejected', async () => {
48
+ const client = makeLiveClient();
49
+ const err = await grab(
50
+ client.createPost({
51
+ title: `${LIVE_ENV.prefix} bad status`,
52
+ status: 'banana',
53
+ } as unknown as CreatePostRequest),
54
+ );
55
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
56
+ });
57
+
58
+ it('a post type outside the allow-list is rejected', async () => {
59
+ const client = makeLiveClient();
60
+ const err = await grab(
61
+ client.createPost({
62
+ title: `${LIVE_ENV.prefix} bad type`,
63
+ post_type: 'gk_definitely_not_allowed',
64
+ } as CreatePostRequest),
65
+ );
66
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
67
+ });
68
+
69
+ it('slug + excerpt persist on create', async () => {
70
+ const client = makeLiveClient();
71
+ const slug = `edge-slug-${process.pid}`;
72
+ const created = await client.createPost({
73
+ title: `${LIVE_ENV.prefix} slugged`,
74
+ status: 'draft',
75
+ slug,
76
+ excerpt: 'EDGE-EXCERPT',
77
+ blocks: [{ name: 'core/paragraph', innerHTML: '<p>x</p>' }],
78
+ } as CreatePostRequest);
79
+ try {
80
+ const info = (await client.getPostInfo({ post_id: created.id })) as { slug?: string };
81
+ expect(info.slug).toBe(slug);
82
+ // Slug+post_type lookup resolves back to the same post.
83
+ const bySlug = (await client.getPostInfo({ slug, post_type: 'post' })) as { post_id?: number };
84
+ expect(bySlug.post_id).toBe(created.id);
85
+ } finally {
86
+ // Trash may be gated; draft test content is swept by title prefix.
87
+ await grab(client.updatePost(created.id, { status: 'trash' } as UpdatePostRequest));
88
+ }
89
+ });
90
+ });
91
+
92
+ describe.skipIf(skipUnlessLive())('integration: update_post argument surface', () => {
93
+ it('title / slug / excerpt update together', async () => {
94
+ const client = makeLiveClient();
95
+ await withTestPost(client, async (postId) => {
96
+ const res = await client.updatePost(postId, {
97
+ title: `${LIVE_ENV.prefix} retitled`,
98
+ slug: `edge-retitle-${process.pid}`,
99
+ excerpt: 'EDGE-NEW-EXCERPT',
100
+ });
101
+ expect(res.success).toBe(true);
102
+ const info = (await client.getPostInfo({ post_id: postId })) as {
103
+ title?: string;
104
+ slug?: string;
105
+ };
106
+ expect(info.title).toContain('retitled');
107
+ expect(info.slug).toBe(`edge-retitle-${process.pid}`);
108
+ });
109
+ });
110
+
111
+ it('status transition draft → publish → draft round-trips', async () => {
112
+ const client = makeLiveClient();
113
+ await withTestPost(client, async (postId) => {
114
+ const publish = await client.updatePost(postId, { status: 'publish' });
115
+ expect(publish.success).toBe(true);
116
+
117
+ const back = await client.updatePost(postId, { status: 'draft' });
118
+ expect(back.success).toBe(true);
119
+ });
120
+ });
121
+
122
+ it('trash combined with other fields is rejected (mixed payload or gate)', async () => {
123
+ const client = makeLiveClient();
124
+ await withTestPost(client, async (postId) => {
125
+ const err = await grab(
126
+ client.updatePost(postId, { status: 'trash', title: 'sneaky' } as UpdatePostRequest),
127
+ );
128
+ // Whichever guard fires first — the trash gate (when disabled) or the
129
+ // mixed-payload guard — the write must NOT go through silently.
130
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
131
+ const info = (await client.getPostInfo({ post_id: postId })) as { title?: string };
132
+ expect(info.title).not.toBe('sneaky');
133
+ });
134
+ });
135
+ });
136
+
137
+ describe.skipIf(skipUnlessLive())('integration: list_terms argument surface', () => {
138
+ it('invalid taxonomy fails loudly', async () => {
139
+ const client = makeLiveClient();
140
+ const err = await grab(client.listTerms({ taxonomy: 'gk_no_such_tax' }));
141
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
142
+ });
143
+
144
+ it('per_page is honored and capped', async () => {
145
+ const client = makeLiveClient();
146
+ const res = (await client.listTerms({ taxonomy: 'category', per_page: 1 })) as {
147
+ terms?: unknown[];
148
+ };
149
+ expect((res.terms ?? []).length).toBeLessThanOrEqual(1);
150
+
151
+ const err = await grab(client.listTerms({ taxonomy: 'category', per_page: 500 }));
152
+ // Either rejected outright or clamped — never an unbounded dump.
153
+ if (!err.wpStatus) {
154
+ const all = (await client.listTerms({ taxonomy: 'category', per_page: 500 })) as {
155
+ terms?: unknown[];
156
+ };
157
+ expect((all.terms ?? []).length).toBeLessThanOrEqual(200);
158
+ }
159
+ });
160
+
161
+ it('orderby/order + search + slug filters apply', async () => {
162
+ const client = makeLiveClient();
163
+ const res = (await client.listTerms({
164
+ taxonomy: 'category',
165
+ orderby: 'name',
166
+ order: 'desc',
167
+ per_page: 5,
168
+ } as never)) as { terms?: Array<{ name: string; slug: string }> };
169
+ const names = (res.terms ?? []).map((t) => t.name.toLowerCase());
170
+ const sorted = [...names].sort().reverse();
171
+ expect(names).toEqual(sorted);
172
+
173
+ if (res.terms?.length) {
174
+ const target = res.terms[0];
175
+ const bySlug = (await client.listTerms({ taxonomy: 'category', slug: target.slug } as never)) as {
176
+ terms?: Array<{ slug: string }>;
177
+ };
178
+ expect(bySlug.terms?.every((t) => t.slug === target.slug)).toBe(true);
179
+ }
180
+ });
181
+ });
182
+
183
+ describe.skipIf(skipUnlessLive())('integration: upload_media guard rails', () => {
184
+ it('SSRF: url mode pointing at a private/reserved address is blocked', async () => {
185
+ const client = makeLiveClient();
186
+ for (const url of ['http://127.0.0.1:9/x.png', 'http://169.254.169.254/latest/meta-data.png']) {
187
+ const err = await grab(client.uploadMedia({ url, title: `${LIVE_ENV.prefix} ssrf` }));
188
+ expect(err.wpStatus, url).toBeGreaterThanOrEqual(400);
189
+ expect(`${err.wpCode}`, url).toMatch(/invalid_url|url_fetch_failed/);
190
+ }
191
+ });
192
+
193
+ it('disallowed MIME (a .php "image") is rejected', async () => {
194
+ const client = makeLiveClient();
195
+ const err = await grab(
196
+ client.uploadMedia({
197
+ data_base64: PNG_B64,
198
+ filename: 'evil.php',
199
+ title: `${LIVE_ENV.prefix} mime`,
200
+ }),
201
+ );
202
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
203
+ expect(`${err.wpCode}`).toMatch(/disallowed_mime|invalid_filename|sideload_failed/);
204
+ });
205
+
206
+ it('path traversal in filename does not escape the uploads dir', async () => {
207
+ const client = makeLiveClient();
208
+ const result = await grab(
209
+ client.uploadMedia({
210
+ data_base64: PNG_B64,
211
+ filename: '../../traversal.png',
212
+ title: `${LIVE_ENV.prefix} traversal`,
213
+ }),
214
+ );
215
+ if (!result.wpStatus) {
216
+ // Accepted → the stored file must be sanitized to a basename.
217
+ const ok = (await client.uploadMedia({
218
+ data_base64: PNG_B64,
219
+ filename: '../../traversal.png',
220
+ title: `${LIVE_ENV.prefix} traversal2`,
221
+ })) as { source_url?: string; url?: string };
222
+ const storedUrl = String(ok.source_url ?? ok.url);
223
+ expect(storedUrl).not.toContain('..');
224
+ expect(storedUrl).toMatch(/traversal[^/]*\.png$/);
225
+ } else {
226
+ expect(`${result.wpCode}`).toMatch(/invalid_filename|disallowed_mime/);
227
+ }
228
+ });
229
+
230
+ it('invalid base64 payload fails loudly', async () => {
231
+ const client = makeLiveClient();
232
+ const err = await grab(
233
+ client.uploadMedia({
234
+ data_base64: '!!!not-base64!!!',
235
+ filename: 'bad.png',
236
+ title: `${LIVE_ENV.prefix} badb64`,
237
+ }),
238
+ );
239
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
240
+ });
241
+
242
+ it('path mode with a nonexistent local file throws client-side (nothing sent)', async () => {
243
+ const client = makeLiveClient();
244
+ const err = await grab(
245
+ client.uploadMedia({ path: path.join(os.tmpdir(), 'gk-definitely-missing.png') }),
246
+ );
247
+ expect(err.message).toMatch(/ENOENT|no such file/i);
248
+ });
249
+
250
+ it('metadata args (title, alt_text, caption, description, post_id) ride along', async () => {
251
+ const client = makeLiveClient();
252
+ await withTestPost(client, async (postId) => {
253
+ const tmp = path.join(os.tmpdir(), `gk-edge-meta-${process.pid}.png`);
254
+ fs.writeFileSync(tmp, Buffer.from(PNG_B64, 'base64'));
255
+ try {
256
+ const res = (await client.uploadMedia({
257
+ path: tmp,
258
+ title: `${LIVE_ENV.prefix} meta upload`,
259
+ alt_text: 'EDGE-ALT',
260
+ caption: 'EDGE-CAPTION',
261
+ description: 'EDGE-DESC',
262
+ post_id: postId,
263
+ })) as { id?: number; source_url?: string; url?: string };
264
+ expect(res.id).toBeGreaterThan(0);
265
+ expect(String(res.source_url ?? res.url)).toMatch(/\.png$/);
266
+ } finally {
267
+ fs.rmSync(tmp, { force: true });
268
+ }
269
+ });
270
+ });
271
+ });