@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
package/src/connect.ts
CHANGED
|
@@ -27,7 +27,6 @@ import * as cp from 'node:child_process';
|
|
|
27
27
|
export type ClientTarget =
|
|
28
28
|
| 'claude-code'
|
|
29
29
|
| 'cursor'
|
|
30
|
-
| 'chatgpt-desktop'
|
|
31
30
|
| 'claude-desktop'
|
|
32
31
|
| 'print';
|
|
33
32
|
|
|
@@ -35,7 +34,6 @@ export type ClientTarget =
|
|
|
35
34
|
const VALID_CLIENTS: ClientTarget[] = [
|
|
36
35
|
'claude-code',
|
|
37
36
|
'cursor',
|
|
38
|
-
'chatgpt-desktop',
|
|
39
37
|
'claude-desktop',
|
|
40
38
|
'print',
|
|
41
39
|
];
|
|
@@ -256,7 +254,7 @@ export function buildAuthorizeUrl(params: AuthorizeUrlParams): string {
|
|
|
256
254
|
const { site, callback, state, client } = params;
|
|
257
255
|
const base = `${site}/wp-admin/options-general.php`;
|
|
258
256
|
const query = new URLSearchParams({
|
|
259
|
-
page: 'gk-block-
|
|
257
|
+
page: 'gk-block-mcp-settings',
|
|
260
258
|
tab: 'connect',
|
|
261
259
|
gk_authorize: '1',
|
|
262
260
|
callback,
|
|
@@ -337,6 +335,50 @@ export function parseExchangeResponse(json: unknown): Credentials {
|
|
|
337
335
|
/** Default timeout (ms) for the credential-exchange request. */
|
|
338
336
|
export const EXCHANGE_FETCH_TIMEOUT_MS = 15_000;
|
|
339
337
|
|
|
338
|
+
/**
|
|
339
|
+
* TLS failure codes meaning the site's certificate chain is not trusted by
|
|
340
|
+
* Node's bundled CA store. Node ignores the operating system's trust store
|
|
341
|
+
* (macOS Keychain / Windows certificate store), so a local dev site whose CA
|
|
342
|
+
* the browser trusts — Laravel Herd/Valet, Local, OrbStack, mkcert — still
|
|
343
|
+
* fails here unless NODE_EXTRA_CA_CERTS points at that CA's .pem.
|
|
344
|
+
*/
|
|
345
|
+
const TLS_TRUST_ERROR_CODES = new Set([
|
|
346
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
347
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
348
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
349
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
350
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
351
|
+
'CERT_UNTRUSTED',
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
/** Actionable guidance appended when the exchange fails on an untrusted certificate. */
|
|
355
|
+
const CA_TRUST_HINT =
|
|
356
|
+
"The site's TLS certificate is not trusted by Node.js, which uses its own CA bundle rather than " +
|
|
357
|
+
"the operating system's trust store. If this is a local development site, re-run with " +
|
|
358
|
+
'NODE_EXTRA_CA_CERTS=<path to the root CA certificate (.pem) of the tool serving the site> — ' +
|
|
359
|
+
'Laravel Herd/Valet, Local, OrbStack, and mkcert each keep one in their config directory. ' +
|
|
360
|
+
'The variable is also copied into the generated MCP config so the server can reach the site too.';
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Render a network-level fetch failure as an actionable error message.
|
|
364
|
+
*
|
|
365
|
+
* `fetch` reports every network failure as a bare "fetch failed" TypeError and
|
|
366
|
+
* hides the real reason (TLS, DNS, refused connection) in `cause`. Surface the
|
|
367
|
+
* cause's code so the failure is diagnosable, and append the CA-trust hint when
|
|
368
|
+
* the code means Node rejected the site's certificate.
|
|
369
|
+
*/
|
|
370
|
+
function describeExchangeFetchError(url: string, err: unknown): string {
|
|
371
|
+
const error = err as Error & { cause?: { code?: unknown; message?: unknown } };
|
|
372
|
+
const causeCode = typeof error.cause?.code === 'string' ? error.cause.code : '';
|
|
373
|
+
const causeMessage = typeof error.cause?.message === 'string' ? error.cause.message : '';
|
|
374
|
+
const detail = causeCode || causeMessage;
|
|
375
|
+
const reason = detail ? `${error.message}: ${detail}` : error.message;
|
|
376
|
+
|
|
377
|
+
const isTrustFailure = TLS_TRUST_ERROR_CODES.has(causeCode);
|
|
378
|
+
const hint = isTrustFailure ? ` ${CA_TRUST_HINT}` : '';
|
|
379
|
+
return `Exchange failed: could not reach ${url} (${reason}).${hint}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
340
382
|
/**
|
|
341
383
|
* Exchange a single-use code for the credential set.
|
|
342
384
|
*
|
|
@@ -366,7 +408,6 @@ export async function exchangeCode(
|
|
|
366
408
|
fetchFn: typeof fetch = fetch,
|
|
367
409
|
timeoutMs: number = EXCHANGE_FETCH_TIMEOUT_MS
|
|
368
410
|
): Promise<Credentials> {
|
|
369
|
-
const origin = new URL(site).origin;
|
|
370
411
|
let url = `${site}/?rest_route=/gk-block-api/v1/connect/exchange`;
|
|
371
412
|
|
|
372
413
|
const controller = new AbortController();
|
|
@@ -384,7 +425,7 @@ export async function exchangeCode(
|
|
|
384
425
|
signal: controller.signal,
|
|
385
426
|
});
|
|
386
427
|
} catch (err) {
|
|
387
|
-
throw new Error(
|
|
428
|
+
throw new Error(describeExchangeFetchError(url, err));
|
|
388
429
|
}
|
|
389
430
|
|
|
390
431
|
// 2xx (or any non-redirect) → done.
|
|
@@ -392,15 +433,30 @@ export async function exchangeCode(
|
|
|
392
433
|
break;
|
|
393
434
|
}
|
|
394
435
|
|
|
395
|
-
// Follow
|
|
436
|
+
// Follow same-origin redirects, re-POSTing the body. Also follow a
|
|
437
|
+
// same-host http->https upgrade: a site that forces HTTPS is a security
|
|
438
|
+
// upgrade to the same site, not an off-site leak, and refusing it bombed
|
|
439
|
+
// the connector for anyone who passed --site http://. Any other
|
|
440
|
+
// cross-origin redirect (different host, or an https->http downgrade) is
|
|
441
|
+
// still refused so the credential is never sent off the target site.
|
|
396
442
|
const location = res.headers.get('location');
|
|
397
443
|
if (!location || hops >= 3) {
|
|
398
444
|
throw new Error(
|
|
399
445
|
`Exchange failed: the site redirected the request (HTTP ${res.status}); ensure --site is the canonical site URL.`
|
|
400
446
|
);
|
|
401
447
|
}
|
|
448
|
+
const current = new URL(url);
|
|
402
449
|
const next = new URL(location, url);
|
|
403
|
-
|
|
450
|
+
// Only the standard HTTPS port counts as a "force HTTPS" upgrade. A
|
|
451
|
+
// redirect to https on a non-standard port is not ordinary HSTS behavior,
|
|
452
|
+
// so the credential must not follow it even when the host matches.
|
|
453
|
+
const upgradesToStandardHttpsPort = next.port === '' || next.port === '443';
|
|
454
|
+
const sameHostHttpsUpgrade =
|
|
455
|
+
current.protocol === 'http:' &&
|
|
456
|
+
next.protocol === 'https:' &&
|
|
457
|
+
next.hostname === current.hostname &&
|
|
458
|
+
upgradesToStandardHttpsPort;
|
|
459
|
+
if (next.origin !== current.origin && !sameHostHttpsUpgrade) {
|
|
404
460
|
throw new Error(
|
|
405
461
|
`Exchange failed: the site redirected to a different origin (${next.origin}); refusing to send the credential off-site.`
|
|
406
462
|
);
|
|
@@ -423,16 +479,31 @@ export async function exchangeCode(
|
|
|
423
479
|
|
|
424
480
|
// ── MCP config builders ───────────────────────────────────────────────────────
|
|
425
481
|
|
|
426
|
-
/**
|
|
427
|
-
|
|
482
|
+
/**
|
|
483
|
+
* Build the mcpServers entry for @gravitykit/block-mcp.
|
|
484
|
+
*
|
|
485
|
+
* When NODE_EXTRA_CA_CERTS is set (required to reach a local dev site whose
|
|
486
|
+
* certificate Node's bundled CA store rejects), it is copied into the entry's
|
|
487
|
+
* env: the MCP server talks to the same site over the same Node TLS stack, so
|
|
488
|
+
* it needs the same trust anchor the connector did.
|
|
489
|
+
*/
|
|
490
|
+
export function buildMcpEntry(
|
|
491
|
+
creds: Credentials,
|
|
492
|
+
extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
|
|
493
|
+
): McpServerEntry {
|
|
494
|
+
const env: Record<string, string> = {
|
|
495
|
+
WORDPRESS_URL: creds.site,
|
|
496
|
+
WORDPRESS_USER: creds.user,
|
|
497
|
+
WORDPRESS_APP_PASSWORD: creds.password,
|
|
498
|
+
};
|
|
499
|
+
const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
|
|
500
|
+
if (hasCaCerts) {
|
|
501
|
+
env.NODE_EXTRA_CA_CERTS = extraCaCerts;
|
|
502
|
+
}
|
|
428
503
|
return {
|
|
429
504
|
command: 'npx',
|
|
430
505
|
args: ['-y', '@gravitykit/block-mcp'],
|
|
431
|
-
env
|
|
432
|
-
WORDPRESS_URL: creds.site,
|
|
433
|
-
WORDPRESS_USER: creds.user,
|
|
434
|
-
WORDPRESS_APP_PASSWORD: creds.password,
|
|
435
|
-
},
|
|
506
|
+
env,
|
|
436
507
|
};
|
|
437
508
|
}
|
|
438
509
|
|
|
@@ -507,35 +578,57 @@ export function cursorConfigPath(): string {
|
|
|
507
578
|
* That residual exposure is inherent to the `claude mcp add` interface; the
|
|
508
579
|
* config it then writes is owned and protected by Claude Code.
|
|
509
580
|
*/
|
|
510
|
-
export function claudeCodeAddArgs(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
'user',
|
|
581
|
+
export function claudeCodeAddArgs(
|
|
582
|
+
creds: Credentials,
|
|
583
|
+
name: string = 'block-mcp',
|
|
584
|
+
extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
|
|
585
|
+
): string[] {
|
|
586
|
+
const envArgs = [
|
|
517
587
|
'--env',
|
|
518
588
|
`WORDPRESS_URL=${creds.site}`,
|
|
519
589
|
'--env',
|
|
520
590
|
`WORDPRESS_USER=${creds.user}`,
|
|
521
591
|
'--env',
|
|
522
592
|
`WORDPRESS_APP_PASSWORD=${creds.password}`,
|
|
523
|
-
'--',
|
|
524
|
-
'npx',
|
|
525
|
-
'-y',
|
|
526
|
-
'@gravitykit/block-mcp',
|
|
527
593
|
];
|
|
594
|
+
// Same propagation as buildMcpEntry: the server needs the CA bundle that the
|
|
595
|
+
// connector needed to reach the site.
|
|
596
|
+
const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
|
|
597
|
+
if (hasCaCerts) {
|
|
598
|
+
envArgs.push('--env', `NODE_EXTRA_CA_CERTS=${extraCaCerts}`);
|
|
599
|
+
}
|
|
600
|
+
return ['mcp', 'add', name, '--scope', 'user', ...envArgs, '--', 'npx', '-y', '@gravitykit/block-mcp'];
|
|
528
601
|
}
|
|
529
602
|
|
|
530
603
|
// ── Config file writers ───────────────────────────────────────────────────────
|
|
531
604
|
|
|
532
|
-
/**
|
|
533
|
-
|
|
605
|
+
/**
|
|
606
|
+
* Read a JSON file, returning the default ONLY when the file does not exist.
|
|
607
|
+
*
|
|
608
|
+
* A missing config (ENOENT) is the normal first-run case, so fall back to the
|
|
609
|
+
* default. Any other failure — an unreadable file or, critically, malformed
|
|
610
|
+
* JSON — is rethrown so the caller surfaces it instead of silently treating a
|
|
611
|
+
* corrupt config as empty and overwriting it (which would clobber every other
|
|
612
|
+
* MCP server the user had configured).
|
|
613
|
+
*/
|
|
614
|
+
export function readJsonFile(filePath: string, defaultValue: McpConfig): McpConfig {
|
|
615
|
+
let raw: string;
|
|
616
|
+
try {
|
|
617
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
618
|
+
} catch (err) {
|
|
619
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
620
|
+
return defaultValue;
|
|
621
|
+
}
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
|
|
534
625
|
try {
|
|
535
|
-
const raw = fs.readFileSync(filePath, 'utf8');
|
|
536
626
|
return JSON.parse(raw) as McpConfig;
|
|
537
|
-
} catch {
|
|
538
|
-
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`Could not parse the existing MCP config at ${filePath}: ${(err as Error).message}. ` +
|
|
630
|
+
'Fix or remove the file, then re-run connect — refusing to overwrite it.'
|
|
631
|
+
);
|
|
539
632
|
}
|
|
540
633
|
}
|
|
541
634
|
|
|
@@ -857,14 +950,6 @@ export async function runConnect(
|
|
|
857
950
|
break;
|
|
858
951
|
}
|
|
859
952
|
|
|
860
|
-
case 'chatgpt-desktop': {
|
|
861
|
-
// ChatGPT Desktop does not have a standardised config path yet.
|
|
862
|
-
// Print the JSON block so the user can paste it (secret needed here).
|
|
863
|
-
console.log(`\n✓ Authorized! Paste the following into ChatGPT Desktop's MCP config:\n`);
|
|
864
|
-
printConfig(creds, true, args.name);
|
|
865
|
-
break;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
953
|
case 'print':
|
|
869
954
|
default:
|
|
870
955
|
printConfig(creds, args.reveal, args.name);
|
package/src/enrichers.ts
CHANGED
|
@@ -180,7 +180,8 @@ export async function shikiHighlight(code: string, language: string, themeName?:
|
|
|
180
180
|
.replace(/var\(--shiki-foreground\)/g, 'var(--shiki-color-text)')
|
|
181
181
|
.replace(/var\(--shiki-background\)/g, 'var(--shiki-color-background)');
|
|
182
182
|
if (themeName && themeName !== 'css-variables') {
|
|
183
|
-
|
|
183
|
+
// Replacer function so a `$` in themeName is not read as a replacement token.
|
|
184
|
+
html = html.replace(/(<pre[^>]*class="shiki) css-variables(")/, (_m, p1, p2) => `${p1} ${themeName}${p2}`);
|
|
184
185
|
}
|
|
185
186
|
return html;
|
|
186
187
|
}
|
|
@@ -305,9 +306,12 @@ registerBlockEnricher('kevinbatdorf/code-block-pro', async (block) => {
|
|
|
305
306
|
// post renders a blank gap where the code should be.
|
|
306
307
|
let updatedInnerHTML: string;
|
|
307
308
|
if (incomingInnerHTML !== '') {
|
|
309
|
+
// Replacer function, not a string: codeHTML is arbitrary content and a
|
|
310
|
+
// `$`-sequence in it ($$, $&, $`) would otherwise be interpreted as a
|
|
311
|
+
// String.replace pattern and corrupt the rendered code.
|
|
308
312
|
updatedInnerHTML = incomingInnerHTML.replace(
|
|
309
313
|
/<pre class="shiki[\s\S]*?<\/pre>/,
|
|
310
|
-
codeHTML,
|
|
314
|
+
() => codeHTML,
|
|
311
315
|
);
|
|
312
316
|
updatedInnerHTML = updatedInnerHTML.replace(
|
|
313
317
|
/(<textarea[^>]*>)([\s\S]*?)(<\/textarea>)/,
|
package/src/error-translator.ts
CHANGED
|
@@ -18,14 +18,24 @@ interface ErrorContextHints {
|
|
|
18
18
|
ref?: string;
|
|
19
19
|
/** Block path (e.g. [0, 2, 1]) that was rejected, when applicable. */
|
|
20
20
|
path?: number[];
|
|
21
|
-
/** Block name involved in a legacy/avoid rejection. */
|
|
21
|
+
/** Block name involved in a legacy/avoid/dual-storage rejection. */
|
|
22
22
|
block?: string;
|
|
23
|
-
/**
|
|
23
|
+
/** Suggested replacement block name suggested by the preference engine. */
|
|
24
24
|
suggested_replacement?: string;
|
|
25
25
|
/** Status from the data envelope, sometimes present even on success. */
|
|
26
26
|
status?: number;
|
|
27
27
|
/** Some endpoints return `block_name` instead of `block`. */
|
|
28
28
|
block_name?: string;
|
|
29
|
+
/** flat_index that was rejected as out of range (invalid_index). */
|
|
30
|
+
flat_index?: number;
|
|
31
|
+
/** Revision ID the caller's If-Match expected (stale_revision). */
|
|
32
|
+
expected_revision?: number;
|
|
33
|
+
/** Revision ID currently stored (stale_revision). */
|
|
34
|
+
current_revision?: number;
|
|
35
|
+
/** Configured max nesting depth (block_depth_exceeded). */
|
|
36
|
+
max_depth?: number;
|
|
37
|
+
/** Actual nesting depth that exceeded max_depth (block_depth_exceeded). */
|
|
38
|
+
actual_depth?: number;
|
|
29
39
|
/** Allow extra fields without typing every one. */
|
|
30
40
|
[key: string]: unknown;
|
|
31
41
|
}
|
|
@@ -49,6 +59,11 @@ function extractHints(data: unknown): ErrorContextHints {
|
|
|
49
59
|
if (typeof d.block_name === 'string') hints.block_name = d.block_name;
|
|
50
60
|
if (typeof d.suggested_replacement === 'string') hints.suggested_replacement = d.suggested_replacement;
|
|
51
61
|
if (typeof d.status === 'number') hints.status = d.status;
|
|
62
|
+
if (typeof d.flat_index === 'number') hints.flat_index = d.flat_index;
|
|
63
|
+
if (typeof d.expected_revision === 'number') hints.expected_revision = d.expected_revision;
|
|
64
|
+
if (typeof d.current_revision === 'number') hints.current_revision = d.current_revision;
|
|
65
|
+
if (typeof d.max_depth === 'number') hints.max_depth = d.max_depth;
|
|
66
|
+
if (typeof d.actual_depth === 'number') hints.actual_depth = d.actual_depth;
|
|
52
67
|
|
|
53
68
|
return hints;
|
|
54
69
|
}
|
|
@@ -67,7 +82,7 @@ export function translateWpError(code: string | undefined, data: unknown): strin
|
|
|
67
82
|
switch (code) {
|
|
68
83
|
// ── Routing / auth ─────────────────────────────────────────────
|
|
69
84
|
case 'rest_no_route':
|
|
70
|
-
return 'REST route not found at this site. Confirm the
|
|
85
|
+
return 'REST route not found at this site. Confirm the Block MCP plugin is active and the WORDPRESS_URL is correct.';
|
|
71
86
|
|
|
72
87
|
case 'rest_forbidden':
|
|
73
88
|
case 'rest_cannot_edit':
|
|
@@ -79,8 +94,11 @@ export function translateWpError(code: string | undefined, data: unknown): strin
|
|
|
79
94
|
return 'Authentication failed. Confirm WORDPRESS_USER and WORDPRESS_APP_PASSWORD are set to a valid Application Password (not a regular login password).';
|
|
80
95
|
|
|
81
96
|
// ── Post lookup ────────────────────────────────────────────────
|
|
97
|
+
// `post_not_found` is the code the plugin actually emits;
|
|
98
|
+
// `rest_post_invalid_id` is WordPress core's equivalent, kept as an
|
|
99
|
+
// alias in case a core route ever surfaces it.
|
|
82
100
|
case 'rest_post_invalid_id':
|
|
83
|
-
case '
|
|
101
|
+
case 'post_not_found': {
|
|
84
102
|
const target = hints.post_id !== undefined ? `Post ${hints.post_id}` : 'Post';
|
|
85
103
|
return `${target} not found. List pages with \`list_posts\` to find the right ID.`;
|
|
86
104
|
}
|
|
@@ -92,18 +110,23 @@ export function translateWpError(code: string | undefined, data: unknown): strin
|
|
|
92
110
|
: 'Resource not found. It may have been deleted, or the ID is wrong.';
|
|
93
111
|
|
|
94
112
|
// ── Block ref / path resolution ────────────────────────────────
|
|
95
|
-
case 'gk_block_api_invalid_ref':
|
|
96
113
|
case 'invalid_ref':
|
|
97
|
-
return
|
|
114
|
+
return 'Ref must be a non-empty string. Use the `ref` value returned by `get_page_blocks` — not a made-up ID.';
|
|
115
|
+
|
|
116
|
+
case 'ref_stale': {
|
|
117
|
+
const where = hints.post_id !== undefined ? ` in post ${hints.post_id}` : '';
|
|
118
|
+
return `Block ref \`${hints.ref ?? '?'}\`${where} no longer resolves to a block. It may have been deleted, or the ref is from an older snapshot — call \`get_page_blocks\` again to get current refs.`;
|
|
119
|
+
}
|
|
98
120
|
|
|
99
|
-
case 'path_not_found':
|
|
100
121
|
case 'invalid_path':
|
|
101
|
-
return `Block path ${formatPath(hints.path)} doesn't address an existing block. Re-fetch the post with \`get_page_blocks\` to get current paths — paths shift when blocks are added or removed.`;
|
|
122
|
+
return `Block path ${formatPath(hints.path)} doesn't address an existing block (or isn't a valid array of non-negative integers). Re-fetch the post with \`get_page_blocks\` to get current paths — paths shift when blocks are added or removed.`;
|
|
102
123
|
|
|
103
|
-
case '
|
|
104
|
-
|
|
124
|
+
case 'invalid_index': {
|
|
125
|
+
const idx = typeof hints.flat_index === 'number' ? ` ${hints.flat_index}` : '';
|
|
126
|
+
return `Block index${idx} out of range. Re-fetch the post with \`get_page_blocks\` to get current indices — they shift when blocks are added or removed.`;
|
|
127
|
+
}
|
|
105
128
|
|
|
106
|
-
// ── Block tier / preference enforcement
|
|
129
|
+
// ── Block tier / preference / storage enforcement ───────────────
|
|
107
130
|
case 'legacy_block':
|
|
108
131
|
return blockName
|
|
109
132
|
? `${blockName} is in a namespace this site has configured as legacy. Use ${hints.suggested_replacement ?? 'a core block instead'}.`
|
|
@@ -121,12 +144,38 @@ export function translateWpError(code: string | undefined, data: unknown): strin
|
|
|
121
144
|
case 'static_markup_stale_risk':
|
|
122
145
|
return 'Updating attributes on a static block without new innerHTML may leave its rendered markup stale. Pass `innerHTML` alongside `attributes`, or use a dynamic block.';
|
|
123
146
|
|
|
147
|
+
case 'dual_storage_requires_both':
|
|
148
|
+
return blockName
|
|
149
|
+
? `${blockName} is dual-storage: \`attributes\` and \`innerHTML\` carry the same data and must be sent together — sending only one silently desyncs the other. Pass both fields in the same call.`
|
|
150
|
+
: 'This block is dual-storage: `attributes` and `innerHTML` carry the same data and must be sent together — sending only one silently desyncs the other. Pass both fields in the same call.';
|
|
151
|
+
|
|
152
|
+
case 'block_depth_exceeded': {
|
|
153
|
+
const depth = typeof hints.max_depth === 'number' && typeof hints.actual_depth === 'number'
|
|
154
|
+
? ` (max ${hints.max_depth}, got ${hints.actual_depth})`
|
|
155
|
+
: '';
|
|
156
|
+
return `Block tree exceeds the maximum nesting depth${depth}. Flatten the structure — split deeply nested groups into separate top-level blocks.`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Concurrency / staleness ──────────────────────────────────────
|
|
160
|
+
case 'edit_conflict':
|
|
161
|
+
return 'The post content changed since it was read (a concurrent write raced this one). Re-fetch the page with `get_page_blocks` and retry your edit against the current content.';
|
|
162
|
+
|
|
163
|
+
case 'stale_revision': {
|
|
164
|
+
const rev = typeof hints.current_revision === 'number' ? ` (current revision: ${hints.current_revision})` : '';
|
|
165
|
+
return `The post has changed since you fetched it${rev}. Re-fetch with \`get_page_blocks\` and retry.`;
|
|
166
|
+
}
|
|
167
|
+
|
|
124
168
|
// ── Rate limiting ──────────────────────────────────────────────
|
|
125
169
|
case 'rate_limit_exceeded': {
|
|
126
170
|
const where = hints.post_id !== undefined ? `on post ${hints.post_id} ` : '';
|
|
127
171
|
return `Too many writes ${where}in the last minute. Wait ~60s before retrying, or batch your edits into a single \`edit_block_tree\` call.`;
|
|
128
172
|
}
|
|
129
173
|
|
|
174
|
+
case 'rate_limit_locked': {
|
|
175
|
+
const where = hints.post_id !== undefined ? ` on post ${hints.post_id}` : '';
|
|
176
|
+
return `Another write${where} is in progress. Retry in a moment; the lock clears within a second. To avoid contention, batch edits into a single \`edit_block_tree\` call.`;
|
|
177
|
+
}
|
|
178
|
+
|
|
130
179
|
// ── v1.2 post lifecycle ────────────────────────────────────────
|
|
131
180
|
case 'mixed_trash_payload':
|
|
132
181
|
return '`status: "trash"` cannot be combined with other fields. Trash the post in one call, then update other fields after.';
|
|
@@ -137,6 +186,9 @@ export function translateWpError(code: string | undefined, data: unknown): strin
|
|
|
137
186
|
case 'invalid_status':
|
|
138
187
|
return 'Post status not allowed. Valid values: draft, pending, publish, future, private. To trash, call update_post with status:"trash" (on its own, not combined with other fields).';
|
|
139
188
|
|
|
189
|
+
case 'trash_disabled':
|
|
190
|
+
return 'Moving posts to trash is turned off for this site. A site administrator can enable it under Block MCP → Settings, or use update_post with a different status.';
|
|
191
|
+
|
|
140
192
|
// ── Media uploads ──────────────────────────────────────────────
|
|
141
193
|
case 'invalid_url':
|
|
142
194
|
return 'URL rejected by SSRF guard. Hostnames pointing at private/loopback/cloud-metadata IPs are blocked. Use a publicly reachable URL.';
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
GetPromptRequestSchema,
|
|
39
39
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
40
40
|
import { WordPressBlockClient } from './client.js';
|
|
41
|
+
import { resolveWordPressConfig, buildNotConfiguredResult } from './config.js';
|
|
41
42
|
import { getInstructions } from './instructions.js';
|
|
42
43
|
import { DISCOVERY_TOOLS, handleDiscoveryTool } from './tools/discovery.js';
|
|
43
44
|
import { READ_TOOLS, handleReadTool } from './tools/read.js';
|
|
@@ -48,6 +49,8 @@ import { POST_TOOLS, handlePostTool } from './tools/posts.js';
|
|
|
48
49
|
import { TERM_TOOLS, handleTermTool } from './tools/terms.js';
|
|
49
50
|
import { MEDIA_TOOLS, handleMediaTool } from './tools/media.js';
|
|
50
51
|
import { YOAST_TOOLS, handleYoastTool } from './tools/yoast.js';
|
|
52
|
+
import { validateToolArgs } from './validate-args.js';
|
|
53
|
+
import { AGENT_GUIDE_CONTENT } from './agent-guide.js';
|
|
51
54
|
import { runConnect } from './connect.js';
|
|
52
55
|
|
|
53
56
|
// Environment variables are passed by the parent process (Claude Code, Hermes, etc.)
|
|
@@ -56,21 +59,8 @@ import { runConnect } from './connect.js';
|
|
|
56
59
|
// ============================================
|
|
57
60
|
// Initialize WordPress client
|
|
58
61
|
// ============================================
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
// Fall back to the legacy GK_-prefixed names so existing configs keep working;
|
|
62
|
-
// emit a deprecation notice to stderr when they're used. The legacy names will
|
|
63
|
-
// be removed in a future minor release.
|
|
64
|
-
function readEnv(primary: string, legacy: string): string | undefined {
|
|
65
|
-
const fromPrimary = process.env[primary];
|
|
66
|
-
if (fromPrimary) return fromPrimary;
|
|
67
|
-
const fromLegacy = process.env[legacy];
|
|
68
|
-
if (fromLegacy) {
|
|
69
|
-
console.error(`[block-mcp] DEPRECATED: ${legacy} is deprecated; rename to ${primary} in your MCP client config.`);
|
|
70
|
-
return fromLegacy;
|
|
71
|
-
}
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
62
|
+
// Config resolution (WORDPRESS_* with legacy GK_* fallback) lives in
|
|
63
|
+
// ./config.ts so it stays side-effect free and unit-testable.
|
|
74
64
|
|
|
75
65
|
// ============================================
|
|
76
66
|
// Aggregate all tool definitions
|
|
@@ -137,58 +127,8 @@ const AGENT_GUIDE_RESOURCE_URI = 'block-mcp://agent-guide';
|
|
|
137
127
|
// integrations resolving the old URI don't 404.
|
|
138
128
|
const LEGACY_PREFERENCES_RESOURCE_URI = 'block-mcp://block-preferences';
|
|
139
129
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
## URL → post ID resolution
|
|
143
|
-
|
|
144
|
-
NEVER run curl, wget, or any bash/shell command to hit wp-json or resolve a URL to a post ID.
|
|
145
|
-
The MCP does this for you:
|
|
146
|
-
|
|
147
|
-
- \`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\`.
|
|
148
|
-
- For explicit resolution (title, post_type, edit_url before editing), call \`resolve_url\`.
|
|
149
|
-
|
|
150
|
-
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.
|
|
151
|
-
|
|
152
|
-
## Moving / reordering blocks
|
|
153
|
-
|
|
154
|
-
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\`:
|
|
155
|
-
|
|
156
|
-
- 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.
|
|
157
|
-
- 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.
|
|
158
|
-
- Use \`count\` to move N consecutive siblings in a single op.
|
|
159
|
-
- The server rejects moves into the source itself or any of its descendants.
|
|
160
|
-
- The whole \`edit_block_tree\` call is one revision, reversible via \`revert_to_revision\`.
|
|
161
|
-
|
|
162
|
-
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.
|
|
163
|
-
|
|
164
|
-
## Verifying writes
|
|
165
|
-
|
|
166
|
-
Every write echoes the canonical post-save snapshot. Use it. Do not fetch the public page to verify what saved.
|
|
167
|
-
|
|
168
|
-
- \`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.
|
|
169
|
-
- \`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.
|
|
170
|
-
- 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\`.
|
|
171
|
-
|
|
172
|
-
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.
|
|
173
|
-
|
|
174
|
-
## Block preferences (site-defined)
|
|
175
|
-
|
|
176
|
-
Block preference policy is configured per-site in the WordPress admin (the
|
|
177
|
-
gk-block-api Preferences option) and exposed dynamically. There is no
|
|
178
|
-
client-side hardcoded list of "good" vs "bad" namespaces.
|
|
179
|
-
|
|
180
|
-
How to discover the policy at runtime:
|
|
181
|
-
|
|
182
|
-
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.
|
|
183
|
-
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.
|
|
184
|
-
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.
|
|
185
|
-
|
|
186
|
-
How to behave:
|
|
187
|
-
|
|
188
|
-
- Prefer the highest-tier blocks for new content. Defer to the server's classification rather than guessing from a namespace prefix.
|
|
189
|
-
- Reuse existing patterns before building from scratch — call \`list_patterns\` first.
|
|
190
|
-
- For patterns that need per-page customization, use \`synced: false\` to inline them.
|
|
191
|
-
- When you encounter legacy blocks on a page during a read, note them but do not replace unless asked.`;
|
|
130
|
+
// AGENT_GUIDE_CONTENT lives in src/agent-guide.ts so the discoverability
|
|
131
|
+
// contract is testable (index.ts boots the server on import).
|
|
192
132
|
|
|
193
133
|
// ============================================
|
|
194
134
|
// Handler registration
|
|
@@ -200,7 +140,11 @@ How to behave:
|
|
|
200
140
|
// `_instructions` field — see the construction note above).
|
|
201
141
|
// ============================================
|
|
202
142
|
|
|
203
|
-
function registerHandlers(
|
|
143
|
+
function registerHandlers(
|
|
144
|
+
server: McpServer,
|
|
145
|
+
client: WordPressBlockClient | null,
|
|
146
|
+
notConfiguredMessage?: string
|
|
147
|
+
): void {
|
|
204
148
|
|
|
205
149
|
server.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
206
150
|
return { tools: ALL_TOOLS };
|
|
@@ -214,11 +158,28 @@ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
214
158
|
const { name, arguments: args } = request.params;
|
|
215
159
|
const toolArgs = (args ?? {}) as Record<string, unknown>;
|
|
216
160
|
|
|
161
|
+
// Degraded mode: the server started without a valid WordPress config, so it
|
|
162
|
+
// can list tools (the client connects and sees them) but cannot run any —
|
|
163
|
+
// return a clear reason instead of a crash the client shows as -32000.
|
|
164
|
+
if (!client) {
|
|
165
|
+
return buildNotConfiguredResult(notConfiguredMessage ?? 'Block MCP is not configured.');
|
|
166
|
+
}
|
|
167
|
+
|
|
217
168
|
try {
|
|
218
169
|
const handle = TOOL_DISPATCH.get(name);
|
|
219
170
|
if (!handle) {
|
|
220
171
|
throw new Error(`Unknown tool: ${name}`);
|
|
221
172
|
}
|
|
173
|
+
|
|
174
|
+
// Reject unknown / misnamed arguments before the handler runs. The MCP SDK
|
|
175
|
+
// does not validate against inputSchema, so without this an unrecognized key
|
|
176
|
+
// (e.g. `after` instead of `after_top_level`) is silently dropped and the
|
|
177
|
+
// tool runs with defaults — a silent wrong-place write rather than an error.
|
|
178
|
+
const def = ALL_TOOLS.find((t) => t.name === name) as
|
|
179
|
+
| { inputSchema?: { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: unknown } }
|
|
180
|
+
| undefined;
|
|
181
|
+
validateToolArgs(name, def?.inputSchema, toolArgs);
|
|
182
|
+
|
|
222
183
|
const result = await handle(name, toolArgs, client);
|
|
223
184
|
|
|
224
185
|
// Emit `structuredContent` alongside the text fallback when the tool
|
|
@@ -396,22 +357,31 @@ async function main(): Promise<void> {
|
|
|
396
357
|
}
|
|
397
358
|
|
|
398
359
|
// ── env-var validation ──────────────────────────────────────────────────
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
360
|
+
const cfg = resolveWordPressConfig(process.env);
|
|
361
|
+
|
|
362
|
+
// Start degraded rather than exiting on a missing/invalid config: exiting
|
|
363
|
+
// kills the stdio session before the handshake, so the MCP client only shows
|
|
364
|
+
// an opaque -32000. A running server lists its tools and returns cfg.message
|
|
365
|
+
// (which names the missing vars) on any tool call — a diagnosable error.
|
|
366
|
+
if (!cfg.ok) {
|
|
367
|
+
console.error(`Block MCP: ${cfg.message}`);
|
|
368
|
+
const degraded = new McpServer(
|
|
369
|
+
{ name: 'block-mcp', version: pkg.version },
|
|
370
|
+
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
|
371
|
+
);
|
|
372
|
+
registerHandlers(degraded, null, cfg.message);
|
|
373
|
+
await degraded.connect(new StdioServerTransport());
|
|
404
374
|
console.error(
|
|
405
|
-
'
|
|
375
|
+
'Block MCP Server running on stdio (unconfigured — tool calls return a configuration error)'
|
|
406
376
|
);
|
|
407
|
-
|
|
377
|
+
return;
|
|
408
378
|
}
|
|
409
379
|
|
|
410
380
|
const client = new WordPressBlockClient({
|
|
411
|
-
wordpress_url:
|
|
381
|
+
wordpress_url: cfg.config.url,
|
|
412
382
|
auth: {
|
|
413
|
-
username:
|
|
414
|
-
application_password:
|
|
383
|
+
username: cfg.config.user,
|
|
384
|
+
application_password: cfg.config.password,
|
|
415
385
|
},
|
|
416
386
|
});
|
|
417
387
|
|
|
@@ -420,7 +390,7 @@ async function main(): Promise<void> {
|
|
|
420
390
|
// from the start — no post-construction mutation of SDK internals.
|
|
421
391
|
// `getInstructions` never throws: on any failure it logs to stderr
|
|
422
392
|
// and returns the baseline only.
|
|
423
|
-
const instructions = await getInstructions(
|
|
393
|
+
const instructions = await getInstructions(cfg.config.url);
|
|
424
394
|
|
|
425
395
|
const server = new McpServer(
|
|
426
396
|
{
|
package/src/instructions.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import axios, { AxiosError } from 'axios';
|
|
28
|
+
import { restRouteUrl } from './rest-url.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The baseline instructions string.
|
|
@@ -188,9 +189,8 @@ export async function fetchAddendum(wordpressUrl: string): Promise<string> {
|
|
|
188
189
|
return '';
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
//
|
|
192
|
-
const
|
|
193
|
-
const url = `${base}/wp-json/gk-block-api/v1/instructions`;
|
|
192
|
+
// Permalink-independent ?rest_route= form: /wp-json/ 404s on plain permalinks.
|
|
193
|
+
const url = restRouteUrl(wordpressUrl, '/instructions');
|
|
194
194
|
|
|
195
195
|
try {
|
|
196
196
|
const response = await axios.get<InstructionsResponse>(url, {
|