@gravitykit/block-mcp 2.0.1 → 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.
Files changed (42) hide show
  1. package/dist/index.cjs +311 -195
  2. package/package.json +8 -4
  3. package/src/__tests__/coerce.test.ts +57 -0
  4. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  5. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  6. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  7. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  8. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  9. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  10. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  11. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  12. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  13. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  14. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  15. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  16. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  17. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  18. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  19. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  20. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  21. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +100 -29
  22. package/src/__tests__/unit/instructions.test.ts +3 -3
  23. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  24. package/src/__tests__/unit/rest-url.test.ts +23 -0
  25. package/src/client.ts +53 -18
  26. package/src/coerce.ts +41 -0
  27. package/src/config.ts +96 -0
  28. package/src/connect.ts +17 -3
  29. package/src/enrichers.ts +6 -2
  30. package/src/error-translator.ts +62 -10
  31. package/src/index.ts +35 -27
  32. package/src/instructions.ts +3 -3
  33. package/src/preferences.ts +56 -43
  34. package/src/rest-url.ts +18 -0
  35. package/src/tools/discovery.ts +10 -14
  36. package/src/tools/mutate.ts +18 -12
  37. package/src/tools/patterns.ts +3 -2
  38. package/src/tools/posts.ts +26 -26
  39. package/src/tools/read.ts +7 -6
  40. package/src/tools/write.ts +26 -31
  41. package/src/tools/yoast.ts +30 -31
  42. package/src/types.ts +23 -13
@@ -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
- // Normalize trailing slash and resolve relative to wp-json.
192
- const base = wordpressUrl.replace(/\/+$/, '');
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, {
@@ -117,12 +117,13 @@ export function enrichPatternList(patterns: Pattern[]): {
117
117
  (a, b) => b.preference.score - a.preference.score
118
118
  );
119
119
 
120
- // Classify by tier only — the server is the source of truth and already
121
- // applied policy. Mixing in score-based fallbacks (the old logic) caused
122
- // mis-bucketing: an `avoid`-tier pattern with score 5 leaked into the
123
- // recommended bucket; a `recommended`-tier pattern with negative score
124
- // was double-counted. Trust the tier.
125
- const recommended = sorted.filter((p) => p.preference.tier === 'recommended');
120
+ // Classify by the tier the server emits (preferred/acceptable/avoid/legacy),
121
+ // which is the source of truth and has already applied policy. The
122
+ // "recommended" display bucket is the usable set (preferred + acceptable);
123
+ // avoid + legacy are the ones to steer away from.
124
+ const recommended = sorted.filter(
125
+ (p) => p.preference.tier === 'preferred' || p.preference.tier === 'acceptable'
126
+ );
126
127
  const avoid = sorted.filter((p) => p.preference.tier === 'avoid' || p.preference.tier === 'legacy');
127
128
 
128
129
  const lines: string[] = [];
@@ -196,49 +197,41 @@ export function enrichBlockTypes(types: BlockType[]): {
196
197
  }
197
198
 
198
199
  const lines: string[] = [];
199
-
200
- if (preferred.length > 0) {
201
- const grouped = groupByNamespace(preferred);
202
- for (const [ns, blocks] of Object.entries(grouped)) {
203
- const names = blocks.map((t) => getShortName(t.name)).join(', ');
204
- lines.push(`PREFERRED (${ns}/): ${names}`);
205
- }
206
- }
207
- if (acceptable.length > 0) {
208
- const grouped = groupByNamespace(acceptable);
209
- for (const [ns, blocks] of Object.entries(grouped)) {
210
- const names = blocks.map((t) => getShortName(t.name)).join(', ');
211
- lines.push(`ACCEPTABLE (${ns}/): ${names}`);
212
- }
213
- }
214
- if (avoid.length > 0) {
215
- const grouped = groupByNamespace(avoid);
216
- for (const [ns, blocks] of Object.entries(grouped)) {
217
- const mappings = blocks.map((t) => {
218
- const replacement = t.preference.replacement;
219
- const shortName = getShortName(t.name);
220
- return replacement ? `${shortName} -> use ${replacement}` : shortName;
221
- });
222
- lines.push(`AVOID (${ns}/): ${mappings.join(', ')}`);
223
- }
224
- }
225
- if (legacy.length > 0) {
226
- const grouped = groupByNamespace(legacy);
227
- for (const [ns, blocks] of Object.entries(grouped)) {
228
- const mappings = blocks.map((t) => {
229
- const replacement = t.preference.replacement;
230
- const shortName = getShortName(t.name);
231
- return replacement ? `${shortName} -> use ${replacement}` : shortName;
232
- });
233
- lines.push(`LEGACY — DO NOT USE (${ns}/): ${mappings.join(', ')}`);
234
- }
235
- }
200
+ pushTierLines(lines, preferred, 'PREFERRED', false);
201
+ pushTierLines(lines, acceptable, 'ACCEPTABLE', false);
202
+ pushTierLines(lines, avoid, 'AVOID', true);
203
+ pushTierLines(lines, legacy, 'LEGACY DO NOT USE', true);
236
204
 
237
205
  const guidance = lines.join('\n');
238
206
 
239
207
  return { block_types: types, guidance };
240
208
  }
241
209
 
210
+ /**
211
+ * Append one guidance line per namespace group within a tier bucket.
212
+ * No-ops when the bucket is empty, so callers don't need their own length
213
+ * check. `withReplacement` switches between a plain name list (preferred /
214
+ * acceptable) and a `name -> use replacement` mapping (avoid / legacy).
215
+ */
216
+ function pushTierLines(
217
+ lines: string[],
218
+ bucket: BlockType[],
219
+ label: string,
220
+ withReplacement: boolean
221
+ ): void {
222
+ if (bucket.length === 0) return;
223
+ const grouped = groupByNamespace(bucket);
224
+ for (const [ns, blocks] of Object.entries(grouped)) {
225
+ const names = blocks.map((t) => {
226
+ const shortName = getShortName(t.name);
227
+ if (!withReplacement) return shortName;
228
+ const replacement = t.preference.replacement;
229
+ return replacement ? `${shortName} -> use ${replacement}` : shortName;
230
+ });
231
+ lines.push(`${label} (${ns}/): ${names.join(', ')}`);
232
+ }
233
+ }
234
+
242
235
  /**
243
236
  * Format a preference warning into a single human-readable line.
244
237
  *
@@ -252,6 +245,26 @@ export function formatPreferenceWarning(warning: PreferenceWarning): string {
252
245
  return `WARNING: ${warning.message}`;
253
246
  }
254
247
 
248
+ /**
249
+ * Decorate a write-tool result with `formatted_warnings` when its `warnings`
250
+ * array is non-empty. Returns the result unchanged (no `formatted_warnings`
251
+ * key) when there are none, so callers don't leak an empty array into every
252
+ * clean response.
253
+ *
254
+ * @param result - A write-tool response shape carrying an optional `warnings` array
255
+ * @param formatWarning - Per-warning formatter (e.g. formatPreferenceWarning)
256
+ */
257
+ export function withFormattedWarnings<T extends { warnings?: unknown[] }>(
258
+ result: T,
259
+ formatWarning: (warning: NonNullable<T['warnings']>[number]) => string
260
+ ): T & { formatted_warnings?: string[] } {
261
+ if (result.warnings && result.warnings.length > 0) {
262
+ const warnings = result.warnings as NonNullable<T['warnings']>;
263
+ return { ...result, formatted_warnings: warnings.map(formatWarning) };
264
+ }
265
+ return result;
266
+ }
267
+
255
268
  // ============================================
256
269
  // Helpers
257
270
  // ============================================
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Build a REST URL on the permalink-independent `?rest_route=` form.
3
+ *
4
+ * Hardcoding `/wp-json/` breaks on plain-permalink sites (and any custom
5
+ * `rest_url_prefix`): the connector can complete the handshake, then every tool
6
+ * call 404s because the pretty REST route does not exist. The `?rest_route=`
7
+ * form works on every permalink configuration, matching the connector's own
8
+ * exchange URL (see src/connect.ts).
9
+ *
10
+ * @param siteUrl - WordPress site URL; trailing slashes are trimmed.
11
+ * @param routeSuffix - Route path after the namespace, e.g. `/instructions`.
12
+ * Empty (default) yields the axios base URL.
13
+ * @returns e.g. `https://site.test/?rest_route=/gk-block-api/v1/instructions`
14
+ */
15
+ export function restRouteUrl(siteUrl: string, routeSuffix = ''): string {
16
+ const trimmed = siteUrl.replace(/\/+$/, '');
17
+ return `${trimmed}/?rest_route=/gk-block-api/v1${routeSuffix}`;
18
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { WordPressBlockClient } from '../client.js';
10
10
  import { enrichBlockTypes, enrichPatternList } from '../preferences.js';
11
+ import { coercePostId } from '../coerce.js';
11
12
 
12
13
  const READ_ANNOT = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } as const;
13
14
 
@@ -166,12 +167,14 @@ export async function handleDiscoveryTool(
166
167
  case 'list_patterns': {
167
168
  const limit = (args.limit as number | undefined) ?? 20;
168
169
  const offset = (args.offset as number | undefined) ?? 0;
170
+ // Fetch the full set (no server-side limit) and paginate locally, matching
171
+ // list_block_types. Passing limit: offset+limit truncated `total` to the
172
+ // fetched window and made next_offset always null on a full page — the
173
+ // agent then never saw that more patterns existed.
169
174
  const response = await client.getPatterns({
170
175
  q: args.search as string | undefined,
171
176
  synced: args.synced as boolean | undefined,
172
177
  min_score: args.min_score as number | undefined,
173
- // Fetch enough to honor offset+limit. Server caps respond too.
174
- limit: offset + limit,
175
178
  refresh: args.refresh as boolean | undefined,
176
179
  });
177
180
  const enriched = enrichPatternList(response.patterns);
@@ -225,18 +228,11 @@ export async function handleDiscoveryTool(
225
228
  ) {
226
229
  throw new Error('get_post_info requires one of: post_id, url, or slug');
227
230
  }
228
- // Coerce well-formed integer strings (some MCP clients and untyped
229
- // JSON send numeric IDs as strings) but reject everything else with
230
- // the same "post_id must be a positive integer" error the schema
231
- // documents. Floats are rejected because the contract is integer.
232
- let normalizedPostId: number | undefined;
233
- if (typeof postId === 'number' && Number.isInteger(postId) && postId > 0) {
234
- normalizedPostId = postId;
235
- } else if (typeof postId === 'string' && /^[0-9]+$/.test(postId)) {
236
- normalizedPostId = parseInt(postId, 10);
237
- } else if (postId !== undefined && postId !== null) {
238
- throw new Error('get_post_info: post_id must be a positive integer');
239
- }
231
+ // Coerce well-formed integer strings (some MCP clients and untyped JSON
232
+ // send numeric IDs as strings); undefined is allowed here because url/slug
233
+ // are alternate selectors. Shared with update_post / yoast_* so the whole
234
+ // tool surface accepts a post_id identically.
235
+ const normalizedPostId = coercePostId(postId, 'get_post_info');
240
236
  return await client.getPostInfo({
241
237
  post_id: normalizedPostId,
242
238
  url: typeof url === 'string' ? url : undefined,
@@ -11,8 +11,9 @@
11
11
 
12
12
  import type { WordPressBlockClient } from '../client.js';
13
13
  import type { MutationOp, MutationRequest, MutationResponse, StaticBlockWarning } from '../types.js';
14
- import { formatPreferenceWarning } from '../preferences.js';
14
+ import { formatPreferenceWarning, withFormattedWarnings } from '../preferences.js';
15
15
  import { BLOCK_INPUT_SCHEMA } from './write.js';
16
+ import { coercePostId } from '../coerce.js';
16
17
 
17
18
  const OPS: readonly MutationOp[] = [
18
19
  'update-attrs',
@@ -115,12 +116,12 @@ export async function handleMutateTool(
115
116
  throw new Error(`Unknown mutate tool: ${toolName}`);
116
117
  }
117
118
 
118
- const postId = args.post_id as number;
119
+ const postId = coercePostId(args.post_id, 'edit_block_tree');
119
120
  const op = args.op as string;
120
121
  const path = args.path;
121
122
  const ref = args.ref as string | undefined;
122
123
 
123
- if (postId === undefined || postId === null) throw new Error('post_id is required');
124
+ if (postId === undefined) throw new Error('post_id is required');
124
125
  // Op validation comes from the schema enum at request time; this guard
125
126
  // exists for direct programmatic callers that bypass the MCP transport.
126
127
  if (!op || !(OPS as readonly string[]).includes(op)) {
@@ -175,10 +176,20 @@ export async function handleMutateTool(
175
176
  requestBody.block = block as MutationRequest['block'];
176
177
  if (op === 'insert-child' && args.position !== undefined) {
177
178
  const position = args.position;
178
- if (typeof position === 'number' && Number.isInteger(position)) {
179
+ if (typeof position === 'number' && Number.isInteger(position) && position >= -1) {
179
180
  requestBody.position = position;
180
181
  } else if (position === 'start' || position === 'end') {
181
182
  requestBody.position = position;
183
+ } else if (typeof position === 'string' && /^-?\d+$/.test(position)) {
184
+ // The schema advertises `position` as integer|string; accept a
185
+ // numeric string ("3", or the "-1" append sentinel) as the index it
186
+ // plainly denotes rather than rejecting a schema-conformant value.
187
+ // -1 appends; anything below -1 is out of contract.
188
+ const parsedPosition = parseInt(position, 10);
189
+ if (parsedPosition < -1) {
190
+ throw new Error('position must be an integer >= -1, "start", or "end"');
191
+ }
192
+ requestBody.position = parsedPosition;
182
193
  } else {
183
194
  throw new Error('position must be an integer, "start", or "end"');
184
195
  }
@@ -227,12 +238,7 @@ export async function handleMutateTool(
227
238
 
228
239
  const result: MutationResponse = await client.mutateBlockTree(postId, requestBody);
229
240
 
230
- if (result.warnings && result.warnings.length > 0) {
231
- const formattedWarnings = result.warnings.map((warning) => {
232
- if (isStaticBlockWarning(warning)) return formatStaticBlockWarning(warning);
233
- return formatPreferenceWarning(warning);
234
- });
235
- return { ...result, formatted_warnings: formattedWarnings };
236
- }
237
- return result;
241
+ return withFormattedWarnings(result, (warning) =>
242
+ isStaticBlockWarning(warning) ? formatStaticBlockWarning(warning) : formatPreferenceWarning(warning)
243
+ );
238
244
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { WordPressBlockClient } from '../client.js';
10
+ import { coercePostId } from '../coerce.js';
10
11
 
11
12
  /**
12
13
  * Tool definitions for the pattern category.
@@ -61,13 +62,13 @@ export async function handlePatternTool(
61
62
  ): Promise<unknown> {
62
63
  switch (toolName) {
63
64
  case 'insert_pattern': {
64
- const postId = args.post_id as number;
65
+ const postId = coercePostId(args.post_id, 'insert_pattern');
65
66
  const patternId = args.pattern_id as number | string;
66
67
  const after = args.after_top_level as number | undefined;
67
68
  const before = args.before_top_level as number | undefined;
68
69
  const synced = args.synced as boolean | undefined;
69
70
 
70
- if (postId === undefined || postId === null) throw new Error('post_id is required');
71
+ if (postId === undefined) throw new Error('post_id is required');
71
72
  if (patternId === undefined || patternId === null) throw new Error('pattern_id is required');
72
73
 
73
74
  const result = await client.insertPattern(postId, {
@@ -12,6 +12,7 @@
12
12
  import type { WordPressBlockClient } from '../client.js';
13
13
  import type { CreatePostRequest, UpdatePostRequest } from '../types.js';
14
14
  import { BLOCK_INPUT_SCHEMA } from './write.js';
15
+ import { coercePostId } from '../coerce.js';
15
16
 
16
17
  const POST_STATUS_CREATE = ['draft', 'pending', 'private', 'publish', 'future'] as const;
17
18
  const POST_STATUS_UPDATE = ['draft', 'pending', 'private', 'publish', 'future', 'trash'] as const;
@@ -128,15 +129,17 @@ export async function handlePostTool(
128
129
  }
129
130
 
130
131
  case 'update_post': {
131
- if (typeof args.post_id !== 'number') {
132
- throw new Error('update_post: "post_id" (number) is required');
132
+ const postId = coercePostId(args.post_id, 'update_post');
133
+ if (postId === undefined) {
134
+ throw new Error('update_post: "post_id" is required');
133
135
  }
134
- const { post_id: postId, ...rest } = args;
136
+ const { post_id: _omit, ...rest } = args;
137
+ void _omit;
135
138
  if (Object.keys(rest).length === 0) {
136
139
  throw new Error('update_post: provide at least one mutating field besides post_id');
137
140
  }
138
141
  const update = narrowUpdatePost(rest);
139
- return client.updatePost(postId as number, update);
142
+ return client.updatePost(postId, update);
140
143
  }
141
144
 
142
145
  default:
@@ -144,13 +147,14 @@ export async function handlePostTool(
144
147
  }
145
148
  }
146
149
 
147
- function narrowCreatePost(input: Record<string, unknown>): CreatePostRequest {
148
- // input.title is already validated as a non-empty string by the caller.
149
- const out: CreatePostRequest = { title: input.title as string };
150
- if (typeof input.post_type === 'string') out.post_type = input.post_type;
151
- if (typeof input.status === 'string') out.status = input.status as CreatePostRequest['status'];
152
- if (typeof input.content === 'string') out.content = input.content;
153
- if (Array.isArray(input.blocks)) out.blocks = input.blocks as CreatePostRequest['blocks'];
150
+ /** Fields narrowed identically for create_post and update_post (everything except title/status/content/blocks/post_type — those differ in requiredness or are create-only). */
151
+ type CommonPostFields = Pick<
152
+ UpdatePostRequest,
153
+ 'slug' | 'parent' | 'excerpt' | 'featured_media' | 'categories' | 'tags' | 'terms' | 'date' | 'menu_order' | 'comment_status' | 'ping_status' | 'author'
154
+ >;
155
+
156
+ function narrowCommonPostFields(input: Record<string, unknown>): CommonPostFields {
157
+ const out: CommonPostFields = {};
154
158
  if (typeof input.slug === 'string') out.slug = input.slug;
155
159
  if (typeof input.parent === 'number') out.parent = input.parent;
156
160
  if (typeof input.excerpt === 'string') out.excerpt = input.excerpt;
@@ -168,24 +172,20 @@ function narrowCreatePost(input: Record<string, unknown>): CreatePostRequest {
168
172
  return out;
169
173
  }
170
174
 
175
+ function narrowCreatePost(input: Record<string, unknown>): CreatePostRequest {
176
+ // input.title is already validated as a non-empty string by the caller.
177
+ const out: CreatePostRequest = { title: input.title as string, ...narrowCommonPostFields(input) };
178
+ if (typeof input.post_type === 'string') out.post_type = input.post_type;
179
+ if (typeof input.status === 'string') out.status = input.status as CreatePostRequest['status'];
180
+ if (typeof input.content === 'string') out.content = input.content;
181
+ if (Array.isArray(input.blocks)) out.blocks = input.blocks as CreatePostRequest['blocks'];
182
+ return out;
183
+ }
184
+
171
185
  function narrowUpdatePost(input: Record<string, unknown>): UpdatePostRequest {
172
- const out: UpdatePostRequest = {};
186
+ const out: UpdatePostRequest = narrowCommonPostFields(input);
173
187
  if (typeof input.title === 'string') out.title = input.title;
174
188
  if (typeof input.status === 'string') out.status = input.status as UpdatePostRequest['status'];
175
- if (typeof input.slug === 'string') out.slug = input.slug;
176
- if (typeof input.parent === 'number') out.parent = input.parent;
177
- if (typeof input.excerpt === 'string') out.excerpt = input.excerpt;
178
- if (typeof input.featured_media === 'number') out.featured_media = input.featured_media;
179
- if (Array.isArray(input.categories)) out.categories = (input.categories as unknown[]).filter((n) => typeof n === 'number') as number[];
180
- if (Array.isArray(input.tags)) out.tags = (input.tags as unknown[]).filter((n) => typeof n === 'number') as number[];
181
- if (input.terms && typeof input.terms === 'object' && !Array.isArray(input.terms)) {
182
- out.terms = narrowTermsMap(input.terms as Record<string, unknown>);
183
- }
184
- if (typeof input.date === 'string') out.date = input.date;
185
- if (typeof input.menu_order === 'number') out.menu_order = input.menu_order;
186
- if (input.comment_status === 'open' || input.comment_status === 'closed') out.comment_status = input.comment_status;
187
- if (input.ping_status === 'open' || input.ping_status === 'closed') out.ping_status = input.ping_status;
188
- if (typeof input.author === 'number') out.author = input.author;
189
189
  return out;
190
190
  }
191
191
 
package/src/tools/read.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { WordPressBlockClient } from '../client.js';
10
10
  import { enrichBlockList } from '../preferences.js';
11
+ import { coercePostId } from '../coerce.js';
11
12
 
12
13
  /**
13
14
  * Tool definitions for the read category.
@@ -137,7 +138,7 @@ export async function handleReadTool(
137
138
  ): Promise<unknown> {
138
139
  switch (toolName) {
139
140
  case 'get_page_blocks': {
140
- let postId = args.post_id as number | undefined;
141
+ let postId = coercePostId(args.post_id, 'get_page_blocks');
141
142
  const url = args.url as string | undefined;
142
143
  const fields = args.fields as string | undefined;
143
144
  const render = args.render as boolean | undefined;
@@ -150,11 +151,11 @@ export async function handleReadTool(
150
151
  const limit = args.limit as number | undefined;
151
152
  const cursor = args.cursor as string | undefined;
152
153
 
153
- if ((postId === undefined || postId === null) && !url) {
154
+ if (postId === undefined && !url) {
154
155
  throw new Error('Either post_id or url is required');
155
156
  }
156
157
 
157
- if (postId === undefined || postId === null) {
158
+ if (postId === undefined) {
158
159
  const resolved = await client.resolveUrl(url as string);
159
160
  postId = resolved.post_id;
160
161
  }
@@ -192,14 +193,14 @@ export async function handleReadTool(
192
193
  }
193
194
 
194
195
  case 'get_block': {
195
- const postId = args.post_id as number;
196
+ const postId = coercePostId(args.post_id, 'get_block');
196
197
  const ref = typeof args.ref === 'string' && args.ref.length > 0 ? (args.ref as string) : undefined;
197
198
  const flatIndex =
198
- typeof args.flat_index === 'number' && Number.isFinite(args.flat_index)
199
+ typeof args.flat_index === 'number' && Number.isInteger(args.flat_index) && args.flat_index >= 0
199
200
  ? (args.flat_index as number)
200
201
  : undefined;
201
202
 
202
- if (postId === undefined || postId === null) {
203
+ if (postId === undefined) {
203
204
  throw new Error('post_id is required');
204
205
  }
205
206
  const hasRef = ref !== undefined;
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { WordPressBlockClient } from '../client.js';
12
- import { formatPreferenceWarning } from '../preferences.js';
12
+ import { formatPreferenceWarning, withFormattedWarnings } from '../preferences.js';
13
13
  import { enrichBlock, enrichBlocks, type BlockDef } from '../enrichers.js';
14
+ import { coercePostId } from '../coerce.js';
14
15
 
15
16
  /** Shape shared by every block-input arg in this module. */
16
17
  export const BLOCK_INPUT_SCHEMA = {
@@ -75,7 +76,9 @@ export const WRITE_TOOLS = [
75
76
  // idempotentHint is false: every call creates a new revision, and
76
77
  // revision history is observable to other readers. Same-input/same-state
77
78
  // is true at the block level but not at the post level.
78
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Update one block' },
79
+ // destructiveHint is true: attributes/innerHTML overwrite the block's
80
+ // existing content rather than merging additively.
81
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Update one block' },
79
82
  outputSchema: {
80
83
  type: 'object',
81
84
  properties: {
@@ -123,7 +126,8 @@ export const WRITE_TOOLS = [
123
126
  name: 'update_blocks',
124
127
  description:
125
128
  'Update N independent blocks atomically in ONE revision. Each item targets one block by `ref` (recommended) or `flat_index`, with `attributes` and/or `innerHTML`. Validation is all-or-nothing: any stale ref / out-of-range index / dual-storage rejection / duplicate target aborts the batch with itemized errors — no partial writes hit disk. Max 50 items per call. Counts as ONE write against the per-post rate limit. Use this instead of looping update_block when fixing multiple blocks on the same post — keeps revision history clean. Pass `verbose: true` to include `saved.inner_html` + `saved.attributes` per result for per-item verification without a re-read.',
126
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Batch-update blocks' },
129
+ // destructiveHint is true: same overwrite semantics as update_block, applied per item.
130
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Batch-update blocks' },
127
131
  outputSchema: {
128
132
  type: 'object',
129
133
  properties: {
@@ -333,14 +337,14 @@ export async function handleWriteTool(
333
337
  ): Promise<unknown> {
334
338
  switch (toolName) {
335
339
  case 'update_block': {
336
- const postId = args.post_id as number;
340
+ const postId = coercePostId(args.post_id, 'update_block');
337
341
  const flatIndex = args.flat_index as number | undefined;
338
342
  const ref = args.ref as string | undefined;
339
343
  const blockName = args.block_name as string | undefined;
340
344
  let attributes = args.attributes as Record<string, unknown> | undefined;
341
345
  let innerHTML = args.innerHTML as string | undefined;
342
346
 
343
- if (postId === undefined || postId === null) throw new Error('post_id is required');
347
+ if (postId === undefined) throw new Error('post_id is required');
344
348
  const hasIndex = typeof flatIndex === 'number' && Number.isFinite(flatIndex) && flatIndex >= 0;
345
349
  const hasRef = typeof ref === 'string' && ref.length > 0;
346
350
  if (!hasIndex && !hasRef) {
@@ -369,7 +373,7 @@ export async function handleWriteTool(
369
373
  }
370
374
 
371
375
  case 'update_blocks': {
372
- const postId = args.post_id as number;
376
+ const postId = coercePostId(args.post_id, 'update_blocks');
373
377
  const updates = args.updates as Array<{
374
378
  ref?: string;
375
379
  flat_index?: number;
@@ -378,7 +382,7 @@ export async function handleWriteTool(
378
382
  innerHTML?: string;
379
383
  }> | undefined;
380
384
 
381
- if (postId === undefined || postId === null) throw new Error('post_id is required');
385
+ if (postId === undefined) throw new Error('post_id is required');
382
386
  if (!Array.isArray(updates) || updates.length === 0) {
383
387
  throw new Error('updates must be a non-empty array');
384
388
  }
@@ -440,7 +444,7 @@ export async function handleWriteTool(
440
444
  }
441
445
 
442
446
  case 'insert_blocks': {
443
- const postId = args.post_id as number;
447
+ const postId = coercePostId(args.post_id, 'insert_blocks');
444
448
  const after = args.after_top_level as number | 'start' | undefined;
445
449
  const before = args.before_top_level as number | undefined;
446
450
  const afterRef = args.after_ref as string | undefined;
@@ -451,7 +455,7 @@ export async function handleWriteTool(
451
455
  innerHTML?: string;
452
456
  }>;
453
457
 
454
- if (postId === undefined || postId === null) throw new Error('post_id is required');
458
+ if (postId === undefined) throw new Error('post_id is required');
455
459
  if (!blocks || blocks.length === 0) throw new Error('At least one block is required in the blocks array');
456
460
 
457
461
  const result = await client.insertBlocks(postId, {
@@ -461,19 +465,16 @@ export async function handleWriteTool(
461
465
  ...(beforeRef ? { before_ref: beforeRef } : {}),
462
466
  blocks: await enrichBlocks(blocks as BlockDef[]),
463
467
  });
464
- if (result.warnings && result.warnings.length > 0) {
465
- return { ...result, formatted_warnings: result.warnings.map(formatPreferenceWarning) };
466
- }
467
- return result;
468
+ return withFormattedWarnings(result, formatPreferenceWarning);
468
469
  }
469
470
 
470
471
  case 'delete_block': {
471
- const postId = args.post_id as number;
472
+ const postId = coercePostId(args.post_id, 'delete_block');
472
473
  const topLevelCounter = args.top_level_counter as number | undefined;
473
474
  const ref = args.ref as string | undefined;
474
475
  const count = args.count as number | undefined;
475
476
 
476
- if (postId === undefined || postId === null) throw new Error('post_id is required');
477
+ if (postId === undefined) throw new Error('post_id is required');
477
478
  const hasCounter = typeof topLevelCounter === 'number' && Number.isFinite(topLevelCounter) && topLevelCounter >= 0;
478
479
  const hasRef = typeof ref === 'string' && ref.length > 0;
479
480
  if (!hasCounter && !hasRef) {
@@ -495,7 +496,7 @@ export async function handleWriteTool(
495
496
  }
496
497
 
497
498
  case 'replace_block_range': {
498
- const postId = args.post_id as number;
499
+ const postId = coercePostId(args.post_id, 'replace_block_range');
499
500
  const start = args.start as number;
500
501
  const count = args.count as number;
501
502
  const blocks = args.blocks as Array<{
@@ -504,40 +505,34 @@ export async function handleWriteTool(
504
505
  innerHTML?: string;
505
506
  }>;
506
507
 
507
- if (postId === undefined || postId === null) throw new Error('post_id is required');
508
- if (typeof start !== 'number' || start < 0) throw new Error('start must be a non-negative integer');
509
- if (typeof count !== 'number' || count < 0) throw new Error('count must be a non-negative integer');
508
+ if (postId === undefined) throw new Error('post_id is required');
509
+ if (!Number.isSafeInteger(start) || start < 0) throw new Error('start must be a non-negative integer');
510
+ if (!Number.isSafeInteger(count) || count < 0) throw new Error('count must be a non-negative integer');
510
511
  if (!Array.isArray(blocks)) throw new Error('blocks must be an array (may be empty for a pure delete)');
511
512
 
512
513
  const result = await client.replaceBlocksRange(postId, { start, count, blocks: await enrichBlocks(blocks as BlockDef[]) });
513
- if (result.warnings && result.warnings.length > 0) {
514
- return { ...result, formatted_warnings: result.warnings.map(formatPreferenceWarning) };
515
- }
516
- return result;
514
+ return withFormattedWarnings(result, formatPreferenceWarning);
517
515
  }
518
516
 
519
517
  case 'rewrite_post_blocks': {
520
- const postId = args.post_id as number;
518
+ const postId = coercePostId(args.post_id, 'rewrite_post_blocks');
521
519
  const blocks = args.blocks as Array<{
522
520
  name: string;
523
521
  attributes?: Record<string, unknown>;
524
522
  innerHTML?: string;
525
523
  }>;
526
524
 
527
- if (postId === undefined || postId === null) throw new Error('post_id is required');
525
+ if (postId === undefined) throw new Error('post_id is required');
528
526
  if (!blocks || blocks.length === 0) throw new Error('At least one block is required for a full page rewrite');
529
527
 
530
528
  const result = await client.replaceAllBlocks(postId, await enrichBlocks(blocks as BlockDef[]));
531
- if (result.warnings && result.warnings.length > 0) {
532
- return { ...result, formatted_warnings: result.warnings.map(formatPreferenceWarning) };
533
- }
534
- return result;
529
+ return withFormattedWarnings(result, formatPreferenceWarning);
535
530
  }
536
531
 
537
532
  case 'revert_to_revision': {
538
- const postId = args.post_id as number;
533
+ const postId = coercePostId(args.post_id, 'revert_to_revision');
539
534
  const revisionId = args.revision_id as number;
540
- if (postId === undefined || postId === null) throw new Error('post_id is required');
535
+ if (postId === undefined) throw new Error('post_id is required');
541
536
  if (revisionId === undefined || revisionId === null) throw new Error('revision_id is required');
542
537
  return await client.revertToRevision(postId, revisionId);
543
538
  }