@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.
Files changed (53) hide show
  1. package/README.md +34 -7
  2. package/dist/index.cjs +2556 -967
  3. package/package.json +12 -4
  4. package/src/__tests__/coerce.test.ts +57 -0
  5. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  6. package/src/__tests__/integration/dual-storage.test.ts +22 -26
  7. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  8. package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
  9. package/src/__tests__/integration/read-discovery.test.ts +313 -0
  10. package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
  11. package/src/__tests__/integration/write-surface.test.ts +603 -0
  12. package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
  13. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  14. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  15. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  16. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  17. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  18. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  19. package/src/__tests__/tools/read/pagination.test.ts +65 -0
  20. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  21. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  22. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  23. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  24. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  25. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  26. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  27. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  28. package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
  29. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  30. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
  31. package/src/__tests__/unit/instructions.test.ts +3 -3
  32. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  33. package/src/__tests__/unit/rest-url.test.ts +23 -0
  34. package/src/agent-guide.ts +85 -0
  35. package/src/client.ts +104 -40
  36. package/src/coerce.ts +41 -0
  37. package/src/config.ts +96 -0
  38. package/src/connect.ts +123 -38
  39. package/src/enrichers.ts +6 -2
  40. package/src/error-translator.ts +63 -11
  41. package/src/index.ts +49 -79
  42. package/src/instructions.ts +3 -3
  43. package/src/preferences.ts +56 -43
  44. package/src/rest-url.ts +18 -0
  45. package/src/tools/discovery.ts +10 -14
  46. package/src/tools/mutate.ts +21 -20
  47. package/src/tools/patterns.ts +3 -2
  48. package/src/tools/posts.ts +26 -26
  49. package/src/tools/read.ts +23 -6
  50. package/src/tools/write.ts +37 -36
  51. package/src/tools/yoast.ts +30 -31
  52. package/src/types.ts +45 -31
  53. package/src/validate-args.ts +109 -0
@@ -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,7 +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
+ import { BLOCK_INPUT_SCHEMA } from './write.js';
16
+ import { coercePostId } from '../coerce.js';
15
17
 
16
18
  const OPS: readonly MutationOp[] = [
17
19
  'update-attrs',
@@ -61,14 +63,8 @@ export const MUTATE_TOOLS = [{
61
63
  attributes: { type: 'object', description: 'update-attrs: attributes to merge.' },
62
64
  innerHTML: { type: 'string', description: 'update-html: replacement innerHTML.' },
63
65
  block: {
64
- type: 'object',
65
- description: 'replace-block / insert-child: { name, attributes?, innerHTML?, innerBlocks? }.',
66
- properties: {
67
- name: { type: 'string', description: 'Fully-qualified block name.' },
68
- attributes: { type: 'object' },
69
- innerHTML: { type: 'string' },
70
- innerBlocks: { type: 'array' },
71
- },
66
+ ...BLOCK_INPUT_SCHEMA,
67
+ description: 'replace-block / insert-child: a block def. Nests recursively via `innerBlocks`; containers need their empty wrapper `innerHTML`.',
72
68
  },
73
69
  wrapper: {
74
70
  type: 'object',
@@ -120,12 +116,12 @@ export async function handleMutateTool(
120
116
  throw new Error(`Unknown mutate tool: ${toolName}`);
121
117
  }
122
118
 
123
- const postId = args.post_id as number;
119
+ const postId = coercePostId(args.post_id, 'edit_block_tree');
124
120
  const op = args.op as string;
125
121
  const path = args.path;
126
122
  const ref = args.ref as string | undefined;
127
123
 
128
- if (postId === undefined || postId === null) throw new Error('post_id is required');
124
+ if (postId === undefined) throw new Error('post_id is required');
129
125
  // Op validation comes from the schema enum at request time; this guard
130
126
  // exists for direct programmatic callers that bypass the MCP transport.
131
127
  if (!op || !(OPS as readonly string[]).includes(op)) {
@@ -180,10 +176,20 @@ export async function handleMutateTool(
180
176
  requestBody.block = block as MutationRequest['block'];
181
177
  if (op === 'insert-child' && args.position !== undefined) {
182
178
  const position = args.position;
183
- if (typeof position === 'number' && Number.isInteger(position)) {
179
+ if (typeof position === 'number' && Number.isInteger(position) && position >= -1) {
184
180
  requestBody.position = position;
185
181
  } else if (position === 'start' || position === 'end') {
186
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;
187
193
  } else {
188
194
  throw new Error('position must be an integer, "start", or "end"');
189
195
  }
@@ -232,12 +238,7 @@ export async function handleMutateTool(
232
238
 
233
239
  const result: MutationResponse = await client.mutateBlockTree(postId, requestBody);
234
240
 
235
- if (result.warnings && result.warnings.length > 0) {
236
- const formattedWarnings = result.warnings.map((warning) => {
237
- if (isStaticBlockWarning(warning)) return formatStaticBlockWarning(warning);
238
- return formatPreferenceWarning(warning);
239
- });
240
- return { ...result, formatted_warnings: formattedWarnings };
241
- }
242
- return result;
241
+ return withFormattedWarnings(result, (warning) =>
242
+ isStaticBlockWarning(warning) ? formatStaticBlockWarning(warning) : formatPreferenceWarning(warning)
243
+ );
243
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.
@@ -71,6 +72,14 @@ export const READ_TOOLS = [
71
72
  type: 'boolean',
72
73
  description: 'Default true. When true, missing block refs (attrs.metadata.gk_ref) are assigned and persisted silently (no revision created). Set false for read-only callers that don\'t want write side effects — refs in the response will not resolve in subsequent mutation calls.',
73
74
  },
75
+ limit: {
76
+ type: 'number',
77
+ description: 'Page size: top-level blocks per response. When more remain, the response carries `pagination.next_cursor` — pass it back via `cursor` to fetch the next page.',
78
+ },
79
+ cursor: {
80
+ type: 'string',
81
+ description: "Opaque cursor from a previous response's `pagination.next_cursor`. Omit on the first request.",
82
+ },
74
83
  },
75
84
  },
76
85
  },
@@ -129,7 +138,7 @@ export async function handleReadTool(
129
138
  ): Promise<unknown> {
130
139
  switch (toolName) {
131
140
  case 'get_page_blocks': {
132
- let postId = args.post_id as number | undefined;
141
+ let postId = coercePostId(args.post_id, 'get_page_blocks');
133
142
  const url = args.url as string | undefined;
134
143
  const fields = args.fields as string | undefined;
135
144
  const render = args.render as boolean | undefined;
@@ -139,12 +148,14 @@ export async function handleReadTool(
139
148
  const summaryOnly = args.summary_only as boolean | undefined;
140
149
  const includeLegacyPaths = args.include_legacy_paths as boolean | undefined;
141
150
  const persistRefs = args.persist_refs as boolean | undefined;
151
+ const limit = args.limit as number | undefined;
152
+ const cursor = args.cursor as string | undefined;
142
153
 
143
- if ((postId === undefined || postId === null) && !url) {
154
+ if (postId === undefined && !url) {
144
155
  throw new Error('Either post_id or url is required');
145
156
  }
146
157
 
147
- if (postId === undefined || postId === null) {
158
+ if (postId === undefined) {
148
159
  const resolved = await client.resolveUrl(url as string);
149
160
  postId = resolved.post_id;
150
161
  }
@@ -154,6 +165,8 @@ export async function handleReadTool(
154
165
  summary_only: summaryOnly,
155
166
  include_legacy_paths: includeLegacyPaths,
156
167
  ...(persistRefs !== undefined ? { persist_refs: persistRefs } : {}),
168
+ ...(limit !== undefined ? { limit } : {}),
169
+ ...(cursor !== undefined ? { cursor } : {}),
157
170
  });
158
171
 
159
172
  // summary_only mode: return server summary as-is.
@@ -166,24 +179,28 @@ export async function handleReadTool(
166
179
 
167
180
  const enriched = enrichBlockList(response.blocks || []);
168
181
 
182
+ // Surface pagination meta (incl. next_cursor) so the agent can page.
183
+ const pagination = (response as { pagination?: unknown }).pagination;
184
+
169
185
  return {
170
186
  post_id: postId,
171
187
  summary: (response as { summary?: unknown }).summary,
172
188
  blocks: enriched.blocks,
173
189
  block_count: enriched.blocks.length,
174
190
  warnings: enriched.warnings,
191
+ ...(pagination !== undefined ? { pagination } : {}),
175
192
  };
176
193
  }
177
194
 
178
195
  case 'get_block': {
179
- const postId = args.post_id as number;
196
+ const postId = coercePostId(args.post_id, 'get_block');
180
197
  const ref = typeof args.ref === 'string' && args.ref.length > 0 ? (args.ref as string) : undefined;
181
198
  const flatIndex =
182
- 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
183
200
  ? (args.flat_index as number)
184
201
  : undefined;
185
202
 
186
- if (postId === undefined || postId === null) {
203
+ if (postId === undefined) {
187
204
  throw new Error('post_id is required');
188
205
  }
189
206
  const hasRef = ref !== undefined;