@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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tool tests: get_page_blocks pagination (limit + cursor).
3
+ *
4
+ * The REST route and the client both support `limit`/`cursor` pagination,
5
+ * but the MCP tool never exposed them — agents had no way to page through a
6
+ * huge post. These pin the tool-layer contract:
7
+ * - limit/cursor are declared on the inputSchema (dispatch validation
8
+ * rejects undeclared keys, so omission = unusable);
9
+ * - both forward to client.getPageBlocks;
10
+ * - the response's `pagination` (incl. next_cursor) passes through so the
11
+ * agent can fetch the next page.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach } from 'vitest';
15
+ import { handleReadTool, READ_TOOLS } from '../../../tools/read.js';
16
+ import { makeMockClient } from '../../helpers/mock-client.js';
17
+ import { pageBlocksResponse } from '../../fixtures/rest-responses.js';
18
+
19
+ describe('get_page_blocks — pagination', () => {
20
+ let client: ReturnType<typeof makeMockClient>;
21
+ beforeEach(() => {
22
+ client = makeMockClient();
23
+ });
24
+
25
+ it('declares limit and cursor on the inputSchema', () => {
26
+ const tool = READ_TOOLS.find((t) => t.name === 'get_page_blocks');
27
+ const props = (tool?.inputSchema as { properties?: Record<string, unknown> })?.properties ?? {};
28
+ expect(props.limit).toBeDefined();
29
+ expect(props.cursor).toBeDefined();
30
+ });
31
+
32
+ it('forwards limit and cursor to the client', async () => {
33
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
34
+ await handleReadTool(
35
+ 'get_page_blocks',
36
+ { post_id: 42, limit: 2, cursor: 'idx_2' },
37
+ client as never,
38
+ );
39
+ expect(client.getPageBlocks).toHaveBeenCalledWith(
40
+ 42,
41
+ expect.objectContaining({ limit: 2, cursor: 'idx_2' }),
42
+ );
43
+ });
44
+
45
+ it('passes the pagination meta (next_cursor) through to the agent', async () => {
46
+ client.getPageBlocks.mockResolvedValue({
47
+ ...pageBlocksResponse,
48
+ pagination: { limit: 2, offset: 0, total: 4, next_cursor: 'idx_2' },
49
+ });
50
+ const result = (await handleReadTool(
51
+ 'get_page_blocks',
52
+ { post_id: 42, limit: 2 },
53
+ client as never,
54
+ )) as { pagination?: { next_cursor?: string | null } };
55
+ expect(result.pagination?.next_cursor).toBe('idx_2');
56
+ });
57
+
58
+ it('omits pagination from the response when not paginating', async () => {
59
+ client.getPageBlocks.mockResolvedValue(pageBlocksResponse);
60
+ const result = (await handleReadTool('get_page_blocks', { post_id: 42 }, client as never)) as {
61
+ pagination?: unknown;
62
+ };
63
+ expect(result.pagination).toBeUndefined();
64
+ });
65
+ });
@@ -191,12 +191,9 @@ describe('Client — mutateBlockTree ref/path body', () => {
191
191
  expect(body).not.toHaveProperty('ref');
192
192
  });
193
193
 
194
- it('forwards before_ref in move body', async () => {
195
- const client = makeClient();
196
- await client.mutateBlockTree(42, { op: 'move', ref: 'blk_source', before_ref: 'blk_anchor' });
197
- const req = captured.find((r) => r.method === 'POST' && r.url === '/posts/42/mutate');
198
- expect((req!.data as Record<string, unknown>).before_ref).toBe('blk_anchor');
199
- });
194
+ // NOTE: `before_ref`/`before` were removed from MutationRequest the REST
195
+ // route only accepts `destination`/`destination_ref`, so forwarding the
196
+ // aliases pinned a param the server silently dropped.
200
197
 
201
198
  it('forwards destination_ref in move body', async () => {
202
199
  const client = makeClient();
@@ -44,7 +44,7 @@ describe('translateWpError — null contract', () => {
44
44
  describe('translateWpError — routing & auth', () => {
45
45
  it('rest_no_route mentions the plugin name', () => {
46
46
  const msg = translateWpError('rest_no_route', null)!;
47
- expect(msg).toMatch(/gk-block-api/);
47
+ expect(msg).toMatch(/Block MCP/);
48
48
  expect(msg).toMatch(/WORDPRESS_URL/);
49
49
  });
50
50
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The agent-guide resource content (block-mcp://agent-guide).
3
+ *
4
+ * Workflow guidance served to MCP clients as a resource and prompt seed.
5
+ * Lives in its own module (not index.ts, which boots the server on import)
6
+ * so the discoverability contract is testable: everything an agent needs to
7
+ * author content — including container nesting — must be learnable from the
8
+ * tool surface + this guide, never from reading this repo's source.
9
+ */
10
+
11
+ export const AGENT_GUIDE_CONTENT = `# Block MCP — Agent Guide
12
+
13
+ ## URL → post ID resolution
14
+
15
+ NEVER run curl, wget, or any bash/shell command to hit wp-json or resolve a URL to a post ID.
16
+ The MCP does this for you:
17
+
18
+ - \`get_page_blocks\` accepts \`url\` as an alternative to \`post_id\`. Pass the full URL or path; the server resolves it via \`url_to_postid\`.
19
+ - For explicit resolution (title, post_type, edit_url before editing), call \`resolve_url\`.
20
+
21
+ If the user says "change X on https://example.com/some-page/", your first tool call should be \`get_page_blocks({ url: "...", search: "keyword" })\` or \`resolve_url({ url: "..." })\` — not a shell command.
22
+
23
+ ## Moving / reordering blocks
24
+
25
+ NEVER do a move as separate \`insert_blocks\` + \`delete_block\` calls — if the delete is skipped or fails, the page ends up with an orphaned clone of the original. The atomic primitive is the \`move\` op on \`edit_block_tree\`:
26
+
27
+ - Target the source with \`ref\` (the \`gk_ref\` from \`get_page_blocks\`) or \`path\`. Prefer \`ref\` — it survives sibling shifts; paths go stale the moment any earlier block is inserted or removed.
28
+ - Express the destination with \`destination_ref\` or \`destination\` (path). For path destinations, use **pre-move** indexing — write the path as if the source were still in place; the server adjusts indices after the removal.
29
+ - Use \`count\` to move N consecutive siblings in a single op.
30
+ - The server rejects moves into the source itself or any of its descendants.
31
+ - The whole \`edit_block_tree\` call is one revision, reversible via \`revert_to_revision\`.
32
+
33
+ If you must fall back to the flat-index tools, do \`insert_blocks\` + \`delete_block\` in the same turn and re-fetch \`get_page_blocks\` afterward to confirm exactly one copy remains.
34
+
35
+ ## Building container blocks (groups, columns, callouts)
36
+
37
+ Every block def accepted by \`insert_blocks\`, \`replace_block_range\`, and \`rewrite_post_blocks\` nests recursively via \`innerBlocks\` — build a whole container (and its children) in ONE call instead of inserting pieces and moving them around.
38
+
39
+ - The container's \`innerHTML\` is its wrapper element only — an empty wrapper; children render inside it. E.g. \`core/group\` → \`<div class="wp-block-group"></div>\`, \`core/list\` → \`<ul class="wp-block-list"></ul>\`.
40
+ - Each entry in \`innerBlocks\` is a full block def and may nest further (columns → column → content).
41
+ - Include any style class in BOTH the \`className\` attribute and the wrapper \`innerHTML\`.
42
+
43
+ Example — a styled callout (a \`core/group\` wrapping a paragraph):
44
+
45
+ \`\`\`json
46
+ {
47
+ "name": "core/group",
48
+ "attributes": { "className": "is-style-callout-info", "layout": { "type": "constrained" } },
49
+ "innerHTML": "<div class=\\"wp-block-group is-style-callout-info\\"></div>",
50
+ "innerBlocks": [
51
+ { "name": "core/paragraph", "innerHTML": "<p>Tip text here.</p>" }
52
+ ]
53
+ }
54
+ \`\`\`
55
+
56
+ Site-specific style conventions (e.g. callout class names) come from this site's instructions addendum — prefer those over inventing classes.
57
+
58
+ ## Verifying writes
59
+
60
+ Every write echoes the canonical post-save snapshot. Use it. Do not fetch the public page to verify what saved.
61
+
62
+ - \`update_block\` always returns \`saved.inner_html\` + \`saved.attributes\` — the exact content that just landed in post_content. The write call IS the verification round-trip.
63
+ - \`update_blocks\` returns per-result \`saved\` only when called with \`verbose: true\` (default false to keep batch responses compact). Pass \`verbose: true\` if you need to confirm each item without a re-read.
64
+ - For after-the-fact re-reads of a single known block, use \`get_block({ post_id, ref })\` — returns the same \`saved\` shape, lighter than \`get_page_blocks\`.
65
+
66
+ For dynamic blocks (\`saved.is_dynamic: true\`, e.g. shortcodes, query loops, latest-posts), \`saved.inner_html\` is the stored template that runs at render time — not the rendered HTML the visitor sees. That's expected; the canonical state is the template.
67
+
68
+ ## Block preferences (site-defined)
69
+
70
+ Block preference policy is configured per-site in the WordPress admin (the
71
+ gk-block-api Preferences option) and exposed dynamically. There is no
72
+ client-side hardcoded list of "good" vs "bad" namespaces.
73
+
74
+ How to discover the policy at runtime:
75
+
76
+ 1. \`list_block_types\` returns blocks grouped by tier (PREFERRED / ACCEPTABLE / AVOID / LEGACY) for the current site. Use this when you need the full picture.
77
+ 2. \`get_page_blocks\` annotates non-preferred blocks inline with \`preference.tier\` and (when configured) \`preference.suggested_replacement\`. Trust those fields — they reflect the live config.
78
+ 3. \`insert_blocks\` rejects legacy-tier blocks with a \`legacy_block\` error that includes the rejected namespace, the suggested replacement, and a pointer back to this resource.
79
+
80
+ How to behave:
81
+
82
+ - Prefer the highest-tier blocks for new content. Defer to the server's classification rather than guessing from a namespace prefix.
83
+ - Reuse existing patterns before building from scratch — call \`list_patterns\` first.
84
+ - For patterns that need per-page customization, use \`synced: false\` to inline them.
85
+ - When you encounter legacy blocks on a page during a read, note them but do not replace unless asked.`;
package/src/client.ts CHANGED
@@ -9,6 +9,21 @@
9
9
  import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios';
10
10
  import { translateWpError } from './error-translator.js';
11
11
 
12
+ /** Best-effort MIME type from a filename extension, for multipart uploads. */
13
+ function mimeForFilename(filename: string): string {
14
+ const ext = filename.toLowerCase().split('.').pop() ?? '';
15
+ const map: Record<string, string> = {
16
+ png: 'image/png',
17
+ jpg: 'image/jpeg',
18
+ jpeg: 'image/jpeg',
19
+ gif: 'image/gif',
20
+ webp: 'image/webp',
21
+ svg: 'image/svg+xml',
22
+ pdf: 'application/pdf',
23
+ };
24
+ return map[ext] ?? 'application/octet-stream';
25
+ }
26
+
12
27
  /** Max retry attempts for transient server / network errors. */
13
28
  const MAX_RETRIES = 2;
14
29
 
@@ -91,6 +106,8 @@ import type {
91
106
  MutationRequest,
92
107
  MutationResponse,
93
108
  ResolveUrlResponse,
109
+ BlockInput,
110
+ BlockPatch,
94
111
  CreatePostRequest,
95
112
  UpdatePostRequest,
96
113
  PostMutationResponse,
@@ -116,7 +133,17 @@ interface PatternsResponse {
116
133
 
117
134
  /** Response wrapper for page blocks. */
118
135
  interface PageBlocksResponse {
136
+ post_id?: number;
137
+ summary?: Record<string, unknown>;
119
138
  blocks: Block[];
139
+ /** Present when `limit` pagination is in play. */
140
+ pagination?: {
141
+ limit?: number;
142
+ offset?: number;
143
+ total?: number;
144
+ /** Pass back as `cursor` to fetch the next page; null on the last page. */
145
+ next_cursor?: string | null;
146
+ };
120
147
  }
121
148
 
122
149
  /**
@@ -415,6 +442,10 @@ export class WordPressBlockClient {
415
442
  include_legacy_paths?: boolean;
416
443
  /** When true (default), missing gk_refs are assigned and persisted. Pass false to skip the silent write side effect. */
417
444
  persist_refs?: boolean;
445
+ /** Page size: top-level blocks per response. Pairs with `cursor`. */
446
+ limit?: number;
447
+ /** Opaque pagination cursor from a prior response's `next_cursor`. */
448
+ cursor?: string;
418
449
  }
419
450
  ): Promise<PageBlocksResponse> {
420
451
  if (postId === undefined || postId === null) {
@@ -434,6 +465,8 @@ export class WordPressBlockClient {
434
465
  // when the param is omitted entirely.
435
466
  if (params?.persist_refs === false) queryParams.persist_refs = 'false';
436
467
  else if (params?.persist_refs === true) queryParams.persist_refs = 'true';
468
+ if (typeof params?.limit === 'number') queryParams.limit = String(params.limit);
469
+ if (params?.cursor) queryParams.cursor = params.cursor;
437
470
 
438
471
  const response = await this.client.get<PageBlocksResponse>(
439
472
  `/posts/${postId}/blocks`,
@@ -505,7 +538,7 @@ export class WordPressBlockClient {
505
538
  async updateBlock(
506
539
  postId: number,
507
540
  index: number,
508
- data: { attributes?: Record<string, unknown>; innerHTML?: string }
541
+ data: BlockPatch
509
542
  ): Promise<BlockUpdateResponse> {
510
543
  if (postId === undefined || postId === null) throw new Error('Post ID is required');
511
544
  if (index < 0) throw new Error('Block index must be non-negative');
@@ -532,7 +565,7 @@ export class WordPressBlockClient {
532
565
  async updateBlockByRef(
533
566
  postId: number,
534
567
  ref: string,
535
- data: { attributes?: Record<string, unknown>; innerHTML?: string }
568
+ data: BlockPatch
536
569
  ): Promise<BlockUpdateResponse> {
537
570
  if (postId === undefined || postId === null) throw new Error('Post ID is required');
538
571
  if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
@@ -629,11 +662,8 @@ export class WordPressBlockClient {
629
662
  before?: number;
630
663
  after_ref?: string;
631
664
  before_ref?: string;
632
- blocks: Array<{
633
- name: string;
634
- attributes?: Record<string, unknown>;
635
- innerHTML?: string;
636
- }>;
665
+ // Recursive: containers (groups/columns) nest children via innerBlocks.
666
+ blocks: BlockInput[];
637
667
  }
638
668
  ): Promise<BlockWriteResponse> {
639
669
  if (postId === undefined || postId === null) throw new Error('Post ID is required');
@@ -713,11 +743,8 @@ export class WordPressBlockClient {
713
743
  data: {
714
744
  start: number;
715
745
  count: number;
716
- blocks: Array<{
717
- name: string;
718
- attributes?: Record<string, unknown>;
719
- innerHTML?: string;
720
- }>;
746
+ // Recursive: containers (groups/columns) nest children via innerBlocks.
747
+ blocks: BlockInput[];
721
748
  }
722
749
  ): Promise<BlockReplaceRangeResponse> {
723
750
  if (postId === undefined || postId === null) throw new Error('Post ID is required');
@@ -750,11 +777,7 @@ export class WordPressBlockClient {
750
777
  */
751
778
  async replaceAllBlocks(
752
779
  postId: number,
753
- blocks: Array<{
754
- name: string;
755
- attributes?: Record<string, unknown>;
756
- innerHTML?: string;
757
- }>
780
+ blocks: BlockInput[]
758
781
  ): Promise<BlockWriteResponse> {
759
782
  if (postId === undefined || postId === null) throw new Error('Post ID is required');
760
783
  if (!blocks || blocks.length === 0) {
@@ -907,19 +930,25 @@ export class WordPressBlockClient {
907
930
 
908
931
  if (args.path) {
909
932
  const fs = await import('node:fs/promises');
910
- const path = await import('node:path');
933
+ const nodePath = await import('node:path');
934
+ const { default: FormData } = await import('form-data');
911
935
  const data = await fs.readFile(args.path);
912
- const filename = args.filename ?? path.basename(args.path);
936
+ const filename = args.filename ?? nodePath.basename(args.path);
913
937
  const form = new FormData();
914
- form.append('file', new Blob([new Uint8Array(data)]), filename);
938
+ form.append('file', data, { filename, contentType: mimeForFilename(filename) });
915
939
  if (args.title) form.append('title', args.title);
916
940
  if (args.alt_text) form.append('alt_text', args.alt_text);
917
941
  if (args.caption) form.append('caption', args.caption);
918
942
  if (args.description) form.append('description', args.description);
919
943
  if (typeof args.post_id === 'number') form.append('post_id', String(args.post_id));
920
944
 
921
- // axios sets the multipart Content-Type and boundary automatically.
922
- const response = await this.client.post<UploadMediaResponse>('/media', form);
945
+ // The shared axios instance defaults Content-Type to application/json,
946
+ // which makes axios JSON-serialize the form and send NO file. The
947
+ // form-data package's getHeaders() supplies multipart/form-data WITH the
948
+ // boundary, overriding that default so this is a real multipart upload.
949
+ const response = await this.client.post<UploadMediaResponse>('/media', form, {
950
+ headers: form.getHeaders(),
951
+ });
923
952
  return response.data;
924
953
  }
925
954
 
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-api-settings',
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
  *
@@ -384,7 +426,7 @@ export async function exchangeCode(
384
426
  signal: controller.signal,
385
427
  });
386
428
  } catch (err) {
387
- throw new Error(`Exchange failed: could not reach ${url} (${(err as Error).message}).`);
429
+ throw new Error(describeExchangeFetchError(url, err));
388
430
  }
389
431
 
390
432
  // 2xx (or any non-redirect) → done.
@@ -423,16 +465,31 @@ export async function exchangeCode(
423
465
 
424
466
  // ── MCP config builders ───────────────────────────────────────────────────────
425
467
 
426
- /** Build the mcpServers entry for @gravitykit/block-mcp. */
427
- export function buildMcpEntry(creds: Credentials): McpServerEntry {
468
+ /**
469
+ * Build the mcpServers entry for @gravitykit/block-mcp.
470
+ *
471
+ * When NODE_EXTRA_CA_CERTS is set (required to reach a local dev site whose
472
+ * certificate Node's bundled CA store rejects), it is copied into the entry's
473
+ * env: the MCP server talks to the same site over the same Node TLS stack, so
474
+ * it needs the same trust anchor the connector did.
475
+ */
476
+ export function buildMcpEntry(
477
+ creds: Credentials,
478
+ extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
479
+ ): McpServerEntry {
480
+ const env: Record<string, string> = {
481
+ WORDPRESS_URL: creds.site,
482
+ WORDPRESS_USER: creds.user,
483
+ WORDPRESS_APP_PASSWORD: creds.password,
484
+ };
485
+ const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
486
+ if (hasCaCerts) {
487
+ env.NODE_EXTRA_CA_CERTS = extraCaCerts;
488
+ }
428
489
  return {
429
490
  command: 'npx',
430
491
  args: ['-y', '@gravitykit/block-mcp'],
431
- env: {
432
- WORDPRESS_URL: creds.site,
433
- WORDPRESS_USER: creds.user,
434
- WORDPRESS_APP_PASSWORD: creds.password,
435
- },
492
+ env,
436
493
  };
437
494
  }
438
495
 
@@ -507,35 +564,57 @@ export function cursorConfigPath(): string {
507
564
  * That residual exposure is inherent to the `claude mcp add` interface; the
508
565
  * config it then writes is owned and protected by Claude Code.
509
566
  */
510
- export function claudeCodeAddArgs(creds: Credentials, name: string = 'block-mcp'): string[] {
511
- return [
512
- 'mcp',
513
- 'add',
514
- name,
515
- '--scope',
516
- 'user',
567
+ export function claudeCodeAddArgs(
568
+ creds: Credentials,
569
+ name: string = 'block-mcp',
570
+ extraCaCerts: string | undefined = process.env.NODE_EXTRA_CA_CERTS
571
+ ): string[] {
572
+ const envArgs = [
517
573
  '--env',
518
574
  `WORDPRESS_URL=${creds.site}`,
519
575
  '--env',
520
576
  `WORDPRESS_USER=${creds.user}`,
521
577
  '--env',
522
578
  `WORDPRESS_APP_PASSWORD=${creds.password}`,
523
- '--',
524
- 'npx',
525
- '-y',
526
- '@gravitykit/block-mcp',
527
579
  ];
580
+ // Same propagation as buildMcpEntry: the server needs the CA bundle that the
581
+ // connector needed to reach the site.
582
+ const hasCaCerts = typeof extraCaCerts === 'string' && extraCaCerts.trim() !== '';
583
+ if (hasCaCerts) {
584
+ envArgs.push('--env', `NODE_EXTRA_CA_CERTS=${extraCaCerts}`);
585
+ }
586
+ return ['mcp', 'add', name, '--scope', 'user', ...envArgs, '--', 'npx', '-y', '@gravitykit/block-mcp'];
528
587
  }
529
588
 
530
589
  // ── Config file writers ───────────────────────────────────────────────────────
531
590
 
532
- /** Read a JSON file, return default if missing or unparseable. */
533
- function readJsonFile(filePath: string, defaultValue: McpConfig): McpConfig {
591
+ /**
592
+ * Read a JSON file, returning the default ONLY when the file does not exist.
593
+ *
594
+ * A missing config (ENOENT) is the normal first-run case, so fall back to the
595
+ * default. Any other failure — an unreadable file or, critically, malformed
596
+ * JSON — is rethrown so the caller surfaces it instead of silently treating a
597
+ * corrupt config as empty and overwriting it (which would clobber every other
598
+ * MCP server the user had configured).
599
+ */
600
+ export function readJsonFile(filePath: string, defaultValue: McpConfig): McpConfig {
601
+ let raw: string;
602
+ try {
603
+ raw = fs.readFileSync(filePath, 'utf8');
604
+ } catch (err) {
605
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
606
+ return defaultValue;
607
+ }
608
+ throw err;
609
+ }
610
+
534
611
  try {
535
- const raw = fs.readFileSync(filePath, 'utf8');
536
612
  return JSON.parse(raw) as McpConfig;
537
- } catch {
538
- return defaultValue;
613
+ } catch (err) {
614
+ throw new Error(
615
+ `Could not parse the existing MCP config at ${filePath}: ${(err as Error).message}. ` +
616
+ 'Fix or remove the file, then re-run connect — refusing to overwrite it.'
617
+ );
539
618
  }
540
619
  }
541
620
 
@@ -857,14 +936,6 @@ export async function runConnect(
857
936
  break;
858
937
  }
859
938
 
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
939
  case 'print':
869
940
  default:
870
941
  printConfig(creds, args.reveal, args.name);
@@ -67,7 +67,7 @@ export function translateWpError(code: string | undefined, data: unknown): strin
67
67
  switch (code) {
68
68
  // ── Routing / auth ─────────────────────────────────────────────
69
69
  case 'rest_no_route':
70
- return 'REST route not found at this site. Confirm the gk-block-api plugin is active and the WORDPRESS_URL is correct.';
70
+ return 'REST route not found at this site. Confirm the Block MCP plugin is active and the WORDPRESS_URL is correct.';
71
71
 
72
72
  case 'rest_forbidden':
73
73
  case 'rest_cannot_edit':
package/src/index.ts CHANGED
@@ -48,6 +48,8 @@ import { POST_TOOLS, handlePostTool } from './tools/posts.js';
48
48
  import { TERM_TOOLS, handleTermTool } from './tools/terms.js';
49
49
  import { MEDIA_TOOLS, handleMediaTool } from './tools/media.js';
50
50
  import { YOAST_TOOLS, handleYoastTool } from './tools/yoast.js';
51
+ import { validateToolArgs } from './validate-args.js';
52
+ import { AGENT_GUIDE_CONTENT } from './agent-guide.js';
51
53
  import { runConnect } from './connect.js';
52
54
 
53
55
  // Environment variables are passed by the parent process (Claude Code, Hermes, etc.)
@@ -137,58 +139,8 @@ const AGENT_GUIDE_RESOURCE_URI = 'block-mcp://agent-guide';
137
139
  // integrations resolving the old URI don't 404.
138
140
  const LEGACY_PREFERENCES_RESOURCE_URI = 'block-mcp://block-preferences';
139
141
 
140
- const AGENT_GUIDE_CONTENT = `# Block MCP Agent Guide
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.`;
142
+ // AGENT_GUIDE_CONTENT lives in src/agent-guide.ts so the discoverability
143
+ // contract is testable (index.ts boots the server on import).
192
144
 
193
145
  // ============================================
194
146
  // Handler registration
@@ -219,6 +171,16 @@ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
219
171
  if (!handle) {
220
172
  throw new Error(`Unknown tool: ${name}`);
221
173
  }
174
+
175
+ // Reject unknown / misnamed arguments before the handler runs. The MCP SDK
176
+ // does not validate against inputSchema, so without this an unrecognized key
177
+ // (e.g. `after` instead of `after_top_level`) is silently dropped and the
178
+ // tool runs with defaults — a silent wrong-place write rather than an error.
179
+ const def = ALL_TOOLS.find((t) => t.name === name) as
180
+ | { inputSchema?: { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: unknown } }
181
+ | undefined;
182
+ validateToolArgs(name, def?.inputSchema, toolArgs);
183
+
222
184
  const result = await handle(name, toolArgs, client);
223
185
 
224
186
  // Emit `structuredContent` alongside the text fallback when the tool