@gravitykit/block-mcp 2.0.0-beta

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 (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
package/src/client.ts ADDED
@@ -0,0 +1,964 @@
1
+ /**
2
+ * WordPress Block API Client
3
+ *
4
+ * HTTP client for the gk-block-api WordPress REST plugin.
5
+ * Handles authentication via Application Passwords and provides
6
+ * typed methods for every REST endpoint.
7
+ */
8
+
9
+ import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios';
10
+ import { translateWpError } from './error-translator.js';
11
+
12
+ /** Max retry attempts for transient server / network errors. */
13
+ const MAX_RETRIES = 2;
14
+
15
+ /**
16
+ * Verbs safe to retry without risking duplicate or wrong work.
17
+ *
18
+ * DELETE is intentionally NOT here: `delete_block` deletes by flat index, so if
19
+ * the server commits the delete but the response is lost (timeout / 502), a
20
+ * replay removes the *next* block (indices shift after the first delete). The
21
+ * delete_block tool mirrors this with `idempotentHint: false`.
22
+ */
23
+ const IDEMPOTENT_METHODS = new Set(['get', 'head', 'options']);
24
+
25
+ /** Sleep for `ms` milliseconds. */
26
+ function sleep(ms: number): Promise<void> {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+
30
+ /**
31
+ * Backoff delay (ms) for retry `attempt` (1-indexed). Exponential with jitter:
32
+ * attempt 1 → ~500ms ± 25%
33
+ * attempt 2 → ~1500ms ± 25%
34
+ * Jitter avoids thundering-herd retries from many MCP clients hitting the
35
+ * same WP host simultaneously.
36
+ */
37
+ function backoffMs(attempt: number): number {
38
+ const base = 500 * 3 ** (attempt - 1);
39
+ const jitter = base * 0.25 * (Math.random() * 2 - 1);
40
+ return Math.round(base + jitter);
41
+ }
42
+
43
+ /**
44
+ * Decide whether an axios error is retryable.
45
+ *
46
+ * Policy:
47
+ * - 429 (rate limited) → retry on any method. The WP plugin returns 429
48
+ * BEFORE doing any work, so a retry is safe even for writes.
49
+ * - 502 / 503 / 504 (server overload / gateway issues) → retry only
50
+ * idempotent methods. The server may or may not have processed the
51
+ * request; replaying a write could double-apply.
52
+ * - Network errors with no response (ECONNRESET, ETIMEDOUT, ENETUNREACH)
53
+ * → retry idempotent methods only.
54
+ *
55
+ * Anything else is a real error that the caller needs to see.
56
+ */
57
+ export function isRetryable(error: AxiosError): boolean {
58
+ const method = (error.config?.method ?? 'get').toLowerCase();
59
+ const idempotent = IDEMPOTENT_METHODS.has(method);
60
+
61
+ if (error.response) {
62
+ const status = error.response.status;
63
+ if (status === 429) return true;
64
+ if ((status === 502 || status === 503 || status === 504) && idempotent) return true;
65
+ return false;
66
+ }
67
+
68
+ // No response — network-level failure.
69
+ const code = error.code;
70
+ if (code === 'ECONNREFUSED') return false; // wrong URL / WP down — don't retry blindly
71
+ if (code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'ENETUNREACH' || code === 'EAI_AGAIN') {
72
+ return idempotent;
73
+ }
74
+ return false;
75
+ }
76
+ import type {
77
+ BlockMCPConfig,
78
+ BlockType,
79
+ Pattern,
80
+ Block,
81
+ SiteUsage,
82
+ BlockUpdateResponse,
83
+ BlockWriteResponse,
84
+ BlockDeleteResponse,
85
+ BlockReplaceRangeResponse,
86
+ BlockBatchUpdateItem,
87
+ BlockBatchUpdateResponse,
88
+ GetBlockResponse,
89
+ StorageModeScanResult,
90
+ PatternInsertResponse,
91
+ MutationRequest,
92
+ MutationResponse,
93
+ ResolveUrlResponse,
94
+ CreatePostRequest,
95
+ UpdatePostRequest,
96
+ PostMutationResponse,
97
+ ListTermsRequest,
98
+ ListTermsResponse,
99
+ UploadMediaRequest,
100
+ UploadMediaResponse,
101
+ YoastSEOMeta,
102
+ YoastUpdateRequest,
103
+ YoastBulkUpdateItem,
104
+ YoastBulkUpdateResponse,
105
+ } from './types.js';
106
+
107
+ /** Response wrapper for block type listing. */
108
+ interface BlockTypesResponse {
109
+ block_types: BlockType[];
110
+ }
111
+
112
+ /** Response wrapper for pattern listing. */
113
+ interface PatternsResponse {
114
+ patterns: Pattern[];
115
+ }
116
+
117
+ /** Response wrapper for page blocks. */
118
+ interface PageBlocksResponse {
119
+ blocks: Block[];
120
+ }
121
+
122
+ /**
123
+ * WordPress Block API client.
124
+ *
125
+ * Wraps the gk-block-api/v1 REST endpoints with typed methods,
126
+ * Basic Auth via Application Passwords, and meaningful error handling.
127
+ */
128
+ export class WordPressBlockClient {
129
+ private client: AxiosInstance;
130
+
131
+ /**
132
+ * Create a new WordPress Block API client.
133
+ *
134
+ * @param config - MCP server configuration with URL and credentials
135
+ */
136
+ constructor(config: BlockMCPConfig) {
137
+ const { wordpress_url, auth } = config;
138
+
139
+ if (!wordpress_url) {
140
+ throw new Error('WordPress site URL is required (WORDPRESS_URL)');
141
+ }
142
+ if (!auth) {
143
+ throw new Error('WordPress authentication credentials are required (WORDPRESS_USER, WORDPRESS_APP_PASSWORD)');
144
+ }
145
+ if (!auth.username) {
146
+ throw new Error('WordPress API username is required (WORDPRESS_USER)');
147
+ }
148
+ if (!auth.application_password) {
149
+ throw new Error('WordPress Application Password is required (WORDPRESS_APP_PASSWORD)');
150
+ }
151
+
152
+ // Build base64-encoded Basic Auth header
153
+ const credentials = Buffer.from(
154
+ `${auth.username}:${auth.application_password}`
155
+ ).toString('base64');
156
+
157
+ const trimmed = wordpress_url.replace(/\/+$/, '');
158
+ const baseURL = `${trimmed}/wp-json/gk-block-api/v1`;
159
+
160
+ this.client = axios.create({
161
+ baseURL,
162
+ headers: {
163
+ Authorization: `Basic ${credentials}`,
164
+ Accept: 'application/json',
165
+ 'Content-Type': 'application/json',
166
+ 'User-Agent': 'GravityKit Block MCP Server (https://github.com/GravityKit/block-mcp)',
167
+ },
168
+ timeout: 30000,
169
+ });
170
+
171
+ // Response interceptor: retry transient errors with exponential backoff,
172
+ // then format any final error so it carries wpCode/wpData/wpStatus for
173
+ // the server-level catch in src/index.ts.
174
+ this.client.interceptors.response.use(
175
+ (r) => r,
176
+ async (error: AxiosError) => {
177
+ const config = error.config as (AxiosRequestConfig & { __retryCount?: number }) | undefined;
178
+ if (config && isRetryable(error)) {
179
+ const attempt = (config.__retryCount ?? 0) + 1;
180
+ if (attempt <= MAX_RETRIES) {
181
+ config.__retryCount = attempt;
182
+ await sleep(backoffMs(attempt));
183
+ return this.client.request(config);
184
+ }
185
+ }
186
+ throw this.formatError(error);
187
+ },
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Format an Axios error into a thrown Error that carries the full
193
+ * WordPress `{ code, message, data }` payload, not just the message.
194
+ *
195
+ * Without this, agents lose the actionable hints the PHP plugin attaches
196
+ * to errors (e.g. `legacy_block` carries `suggested_replacement`,
197
+ * `policy_resource`, `namespace`; `dual_storage_requires_both` carries
198
+ * `block`, `storage_mode`). The Error message is human-readable; the
199
+ * `wpCode` and `wpData` properties expose the structured payload to the
200
+ * server-level catch in src/index.ts so it can surface them in the
201
+ * tool response.
202
+ *
203
+ * @param error - The Axios error to format
204
+ * @returns Error to throw
205
+ */
206
+ private formatError(error: AxiosError): Error {
207
+ if (error.response) {
208
+ const { status, data } = error.response;
209
+ const body = data as Record<string, unknown> | string | undefined;
210
+
211
+ let detail = 'Unknown error';
212
+ let code: string | undefined;
213
+ let wpData: unknown = null;
214
+
215
+ if (body && typeof body === 'object') {
216
+ if ('message' in body) detail = String(body.message);
217
+ else if ('error' in body) detail = String(body.error);
218
+ if ('code' in body && typeof body.code === 'string') code = body.code;
219
+ if ('data' in body) wpData = body.data;
220
+ } else if (typeof body === 'string') {
221
+ detail = body;
222
+ }
223
+
224
+ // Replace HTTP-shaped detail with an agent-actionable hint when we
225
+ // recognize the code. Original code/data still flow through wpCode /
226
+ // wpData so callers can pattern-match the raw form if they want to.
227
+ const hint = translateWpError(code, wpData);
228
+ const message = hint
229
+ ? `Block API Error (${status}): ${hint}${code ? ` (${code})` : ''}`
230
+ : `Block API Error (${status}): ${detail}`;
231
+
232
+ const err = new Error(message) as Error & {
233
+ wpCode?: string;
234
+ wpData?: unknown;
235
+ wpStatus?: number;
236
+ };
237
+ err.wpCode = code;
238
+ err.wpData = wpData;
239
+ err.wpStatus = status;
240
+ return err;
241
+ }
242
+
243
+ if (error.code === 'ECONNREFUSED') {
244
+ return new Error('Block API Error: Connection refused. Is the WordPress site reachable?');
245
+ }
246
+ if (error.code === 'ETIMEDOUT') {
247
+ return new Error('Block API Error: Request timed out after 30 seconds.');
248
+ }
249
+ return new Error(`Block API Error: ${error.message}`);
250
+ }
251
+
252
+ // ============================================
253
+ // Registry & Discovery
254
+ // ============================================
255
+
256
+ /**
257
+ * Get registered block types with preference + storage_mode metadata.
258
+ *
259
+ * @param params - Optional filters (namespace, category, tier, storage_mode,
260
+ * preferred_only, search, usage_only).
261
+ * @returns Array of block types
262
+ */
263
+ async getBlockTypes(params?: {
264
+ namespace?: string;
265
+ category?: string;
266
+ preferred_only?: boolean;
267
+ tier?: 'preferred' | 'acceptable' | 'avoid' | 'legacy';
268
+ storage_mode?: 'static' | 'dynamic' | 'dual';
269
+ search?: string;
270
+ usage_only?: boolean;
271
+ }): Promise<BlockTypesResponse> {
272
+ const queryParams: Record<string, string> = {};
273
+ if (params?.namespace) queryParams.namespace = params.namespace;
274
+ if (params?.category) queryParams.category = params.category;
275
+ if (params?.preferred_only) queryParams.preferred_only = 'true';
276
+ if (params?.tier) queryParams.tier = params.tier;
277
+ if (params?.storage_mode) queryParams.storage_mode = params.storage_mode;
278
+ if (params?.search) queryParams.search = params.search;
279
+ if (params?.usage_only) queryParams.usage_only = 'true';
280
+
281
+ const response = await this.client.get<BlockTypesResponse>('/block-types', {
282
+ params: queryParams,
283
+ });
284
+ return response.data;
285
+ }
286
+
287
+ /**
288
+ * Get all patterns with preference scoring.
289
+ *
290
+ * @param params - Optional filters and pagination
291
+ * @returns Array of patterns
292
+ */
293
+ async getPatterns(params?: {
294
+ q?: string;
295
+ synced?: boolean;
296
+ min_score?: number;
297
+ category?: string;
298
+ limit?: number;
299
+ order_by?: string;
300
+ refresh?: boolean;
301
+ }): Promise<PatternsResponse> {
302
+ const queryParams: Record<string, string> = {};
303
+ if (params?.q) queryParams.q = params.q;
304
+ if (params?.synced !== undefined) queryParams.synced = String(params.synced);
305
+ if (params?.min_score !== undefined) queryParams.min_score = String(params.min_score);
306
+ if (params?.category) queryParams.category = params.category;
307
+ if (params?.limit !== undefined) queryParams.limit = String(params.limit);
308
+ if (params?.order_by) queryParams.order_by = params.order_by;
309
+ if (params?.refresh) queryParams.refresh = 'true';
310
+
311
+ const response = await this.client.get<PatternsResponse>('/patterns', {
312
+ params: queryParams,
313
+ });
314
+ return response.data;
315
+ }
316
+
317
+ /**
318
+ * Get a single pattern by ID with its full parsed block content.
319
+ *
320
+ * @param id - Pattern ID (post ID for synced, name for registered)
321
+ * @returns Pattern details
322
+ */
323
+ async getPattern(id: number | string): Promise<Pattern> {
324
+ const response = await this.client.get<Pattern>(
325
+ `/patterns/${encodeURIComponent(String(id))}`
326
+ );
327
+ return response.data;
328
+ }
329
+
330
+ /**
331
+ * Search patterns by name or keyword.
332
+ *
333
+ * @param query - Search term
334
+ * @returns Matching patterns
335
+ */
336
+ async searchPatterns(query: string): Promise<PatternsResponse> {
337
+ if (!query) {
338
+ throw new Error('Search query is required');
339
+ }
340
+
341
+ const response = await this.client.get<PatternsResponse>('/patterns/search', {
342
+ params: { q: query },
343
+ });
344
+ return response.data;
345
+ }
346
+
347
+ /**
348
+ * Resolve a URL or path to a WordPress post ID.
349
+ *
350
+ * Accepts any URL on the site (full URL or path). Handles all post types,
351
+ * permalinks, and pretty URLs via url_to_postid().
352
+ *
353
+ * @param url - Full URL or path (e.g. "/products/gravityedit/")
354
+ * @returns Post ID, type, title, status, slug, and edit URL
355
+ */
356
+ async resolveUrl(url: string): Promise<ResolveUrlResponse> {
357
+ if (!url) {
358
+ throw new Error('URL is required');
359
+ }
360
+
361
+ const response = await this.client.get<ResolveUrlResponse>('/resolve', {
362
+ params: { url },
363
+ });
364
+ return response.data;
365
+ }
366
+
367
+ /**
368
+ * Scan all published content and classify every distinct block name as
369
+ * static / dynamic / dual. Persists results to a WP option so subsequent
370
+ * `get_page_blocks` annotations and dual-storage enforcement use the
371
+ * live classification instead of the filter defaults.
372
+ *
373
+ * Slow (walks every published post). Run once after install or when
374
+ * significantly changing the site's block-using content.
375
+ */
376
+ async scanStorageModes(): Promise<StorageModeScanResult> {
377
+ const response = await this.client.post<StorageModeScanResult>('/storage-modes/scan', {});
378
+ return response.data;
379
+ }
380
+
381
+ /**
382
+ * Get site-wide block and pattern usage statistics.
383
+ *
384
+ * @param refresh - If true, bust the transient cache and regenerate stats
385
+ * @returns Usage statistics
386
+ */
387
+ async getSiteUsage(refresh?: boolean): Promise<SiteUsage> {
388
+ const params: Record<string, string> = {};
389
+ if (refresh) params.refresh = 'true';
390
+
391
+ const response = await this.client.get<SiteUsage>('/site-usage', { params });
392
+ return response.data;
393
+ }
394
+
395
+ // ============================================
396
+ // Page Block CRUD
397
+ // ============================================
398
+
399
+ /**
400
+ * Get all blocks on a page as structured JSON.
401
+ *
402
+ * @param postId - WordPress post/page ID
403
+ * @param params - Optional query parameters (fields, render, search, block_name)
404
+ * @returns Array of parsed blocks
405
+ */
406
+ async getPageBlocks(
407
+ postId: number,
408
+ params?: {
409
+ fields?: string;
410
+ render?: boolean;
411
+ search?: string;
412
+ block_name?: string;
413
+ outline?: boolean;
414
+ summary_only?: boolean;
415
+ include_legacy_paths?: boolean;
416
+ /** When true (default), missing gk_refs are assigned and persisted. Pass false to skip the silent write side effect. */
417
+ persist_refs?: boolean;
418
+ }
419
+ ): Promise<PageBlocksResponse> {
420
+ if (postId === undefined || postId === null) {
421
+ throw new Error('Post ID is required');
422
+ }
423
+
424
+ const queryParams: Record<string, string> = {};
425
+ if (params?.fields) queryParams.fields = params.fields;
426
+ if (params?.render) queryParams.render = 'true';
427
+ if (params?.search) queryParams.search = params.search;
428
+ if (params?.block_name) queryParams.block_name = params.block_name;
429
+ if (params?.outline) queryParams.outline = 'true';
430
+ if (params?.summary_only) queryParams.summary_only = 'true';
431
+ if (params?.include_legacy_paths) queryParams.include_legacy_paths = 'true';
432
+ // Forward both true and false explicitly when set, so tool-layer intent
433
+ // matches what reaches the server. The server default (true) only kicks in
434
+ // when the param is omitted entirely.
435
+ if (params?.persist_refs === false) queryParams.persist_refs = 'false';
436
+ else if (params?.persist_refs === true) queryParams.persist_refs = 'true';
437
+
438
+ const response = await this.client.get<PageBlocksResponse>(
439
+ `/posts/${postId}/blocks`,
440
+ { params: queryParams }
441
+ );
442
+ return response.data;
443
+ }
444
+
445
+ /**
446
+ * Search posts by title/content with filters. Cheap WP_Query lookup —
447
+ * returns post stubs with no block parsing.
448
+ */
449
+ async findPosts(
450
+ params?: import('./types.js').FindPostsParams
451
+ ): Promise<import('./types.js').FindPostsResponse> {
452
+ const queryParams: Record<string, string> = {};
453
+ if (params?.search) queryParams.search = params.search;
454
+ if (params?.post_type) queryParams.post_type = params.post_type;
455
+ if (params?.post_status) queryParams.post_status = params.post_status;
456
+ if (params?.per_page !== undefined) queryParams.per_page = String(params.per_page);
457
+ if (params?.page !== undefined) queryParams.page = String(params.page);
458
+
459
+ const response = await this.client.get<import('./types.js').FindPostsResponse>(
460
+ '/find-posts',
461
+ { params: queryParams }
462
+ );
463
+ return response.data;
464
+ }
465
+
466
+ /**
467
+ * Look up a single post's metadata by post_id, url, or slug+post_type.
468
+ * Returns title, status, permalink, modified, parent, author, etc.
469
+ * No block parsing — cheap.
470
+ */
471
+ async getPostInfo(
472
+ params: import('./types.js').PostInfoParams
473
+ ): Promise<import('./types.js').PostInfoResponse> {
474
+ if (
475
+ (params.post_id === undefined || params.post_id === null) &&
476
+ !params.url &&
477
+ !params.slug
478
+ ) {
479
+ throw new Error('post_info requires one of: post_id, url, or slug');
480
+ }
481
+
482
+ const queryParams: Record<string, string> = {};
483
+ if (params.post_id !== undefined && params.post_id !== null) {
484
+ queryParams.post_id = String(params.post_id);
485
+ }
486
+ if (params.url) queryParams.url = params.url;
487
+ if (params.slug) queryParams.slug = params.slug;
488
+ if (params.post_type) queryParams.post_type = params.post_type;
489
+
490
+ const response = await this.client.get<import('./types.js').PostInfoResponse>(
491
+ '/post-info',
492
+ { params: queryParams }
493
+ );
494
+ return response.data;
495
+ }
496
+
497
+ /**
498
+ * Update a single block's attributes and/or innerHTML.
499
+ *
500
+ * @param postId - WordPress post/page ID
501
+ * @param index - Zero-based block index
502
+ * @param data - Partial attributes and/or innerHTML to update
503
+ * @returns Updated block details with revision ID
504
+ */
505
+ async updateBlock(
506
+ postId: number,
507
+ index: number,
508
+ data: { attributes?: Record<string, unknown>; innerHTML?: string }
509
+ ): Promise<BlockUpdateResponse> {
510
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
511
+ if (index < 0) throw new Error('Block index must be non-negative');
512
+ if (!data.attributes && !data.innerHTML) {
513
+ throw new Error('At least one of attributes or innerHTML must be provided');
514
+ }
515
+
516
+ const response = await this.client.patch<BlockUpdateResponse>(
517
+ `/posts/${postId}/blocks/${index}`,
518
+ data
519
+ );
520
+ return response.data;
521
+ }
522
+
523
+ /**
524
+ * Update a single block by its stable gk_ref instead of a flat index.
525
+ * Refs survive sibling shifts so chained mutations don't go stale.
526
+ *
527
+ * @param postId - WordPress post/page ID
528
+ * @param ref - Stable ref (e.g. "blk_a3f2c1q9") from get_page_blocks
529
+ * @param data - Partial attributes and/or innerHTML
530
+ * @returns 404 ref_stale if the ref no longer matches any block.
531
+ */
532
+ async updateBlockByRef(
533
+ postId: number,
534
+ ref: string,
535
+ data: { attributes?: Record<string, unknown>; innerHTML?: string }
536
+ ): Promise<BlockUpdateResponse> {
537
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
538
+ if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
539
+ if (!data.attributes && !data.innerHTML) {
540
+ throw new Error('At least one of attributes or innerHTML must be provided');
541
+ }
542
+
543
+ const response = await this.client.patch<BlockUpdateResponse>(
544
+ `/posts/${postId}/blocks/by-ref/${encodeURIComponent(ref)}`,
545
+ data
546
+ );
547
+ return response.data;
548
+ }
549
+
550
+ /**
551
+ * Apply N independent block updates atomically in ONE WordPress revision.
552
+ *
553
+ * Each item targets one block by stable `ref` (recommended) or `flat_index`,
554
+ * with `attributes` and/or `innerHTML` to apply. Validation is all-or-nothing:
555
+ * if any item is invalid (stale ref, out-of-range index, dual-storage
556
+ * rejection, duplicate target), the whole batch fails with HTTP 400 and an
557
+ * itemized `errors` payload — no partial writes ever hit disk.
558
+ *
559
+ * Counts as ONE write against the per-post rate limit. Server caps batch
560
+ * size to prevent the rate-limit exemption from being abused.
561
+ *
562
+ * @param postId WordPress post/page ID.
563
+ * @param updates Update items (1..MAX_BATCH_SIZE).
564
+ * @returns Per-item results plus the single revision ID.
565
+ */
566
+ async updateBlocksBatch(
567
+ postId: number,
568
+ updates: BlockBatchUpdateItem[],
569
+ options: { verbose?: boolean } = {}
570
+ ): Promise<BlockBatchUpdateResponse> {
571
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
572
+ if (!Array.isArray(updates) || updates.length === 0) {
573
+ throw new Error('updates must be a non-empty array');
574
+ }
575
+
576
+ const body: { updates: BlockBatchUpdateItem[]; verbose?: boolean } = { updates };
577
+ if (options.verbose) body.verbose = true;
578
+
579
+ const response = await this.client.post<BlockBatchUpdateResponse>(
580
+ `/posts/${postId}/blocks/batch-update`,
581
+ body
582
+ );
583
+ return response.data;
584
+ }
585
+
586
+ /**
587
+ * Fetch a single block by stable ref or flat index. Returns the canonical
588
+ * `saved` snapshot — same shape that write endpoints echo, so verification
589
+ * reads use the identical contract as the writes that produced them.
590
+ *
591
+ * Lighter than getPageBlocks() when you only need one block. Use this when
592
+ * you want to confirm the current state of a known ref before chaining an
593
+ * edit, not to discover what's on the page.
594
+ *
595
+ * @param postId WordPress post/page ID.
596
+ * @param target Either `{ ref }` or `{ flatIndex }`. Exactly one required.
597
+ * @returns { success, saved } where `saved` mirrors update_block's saved.
598
+ */
599
+ async getBlock(
600
+ postId: number,
601
+ target: { ref?: string; flatIndex?: number }
602
+ ): Promise<GetBlockResponse> {
603
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
604
+ const hasRef = typeof target.ref === 'string' && target.ref !== '';
605
+ const hasIdx = typeof target.flatIndex === 'number';
606
+ if (hasRef === hasIdx) {
607
+ throw new Error('Provide exactly one of ref or flatIndex');
608
+ }
609
+
610
+ const params: Record<string, string | number> = {};
611
+ if (hasRef) params.ref = target.ref!;
612
+ else params.flat_index = target.flatIndex!;
613
+
614
+ const response = await this.client.get<GetBlockResponse>(`/posts/${postId}/block`, { params });
615
+ return response.data;
616
+ }
617
+
618
+ /**
619
+ * Insert one or more blocks at a specific position.
620
+ *
621
+ * @param postId - WordPress post/page ID
622
+ * @param data - Insertion position and blocks to insert
623
+ * @returns Inserted blocks with new indices, warnings, and revision ID
624
+ */
625
+ async insertBlocks(
626
+ postId: number,
627
+ data: {
628
+ after?: number | 'start';
629
+ before?: number;
630
+ after_ref?: string;
631
+ before_ref?: string;
632
+ blocks: Array<{
633
+ name: string;
634
+ attributes?: Record<string, unknown>;
635
+ innerHTML?: string;
636
+ }>;
637
+ }
638
+ ): Promise<BlockWriteResponse> {
639
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
640
+ if (!data.blocks || data.blocks.length === 0) {
641
+ throw new Error('At least one block is required');
642
+ }
643
+
644
+ const response = await this.client.post<BlockWriteResponse>(
645
+ `/posts/${postId}/blocks`,
646
+ data
647
+ );
648
+ return response.data;
649
+ }
650
+
651
+ /**
652
+ * Remove a block (or consecutive blocks) at a position.
653
+ *
654
+ * @param postId - WordPress post/page ID
655
+ * @param index - Zero-based block index to remove
656
+ * @param count - Number of consecutive blocks to remove (default 1)
657
+ * @returns Deletion confirmation with revision ID
658
+ */
659
+ async deleteBlock(
660
+ postId: number,
661
+ index: number,
662
+ count?: number
663
+ ): Promise<BlockDeleteResponse> {
664
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
665
+ if (index < 0) throw new Error('Block index must be non-negative');
666
+
667
+ const params: Record<string, string> = {};
668
+ if (count && count > 1) params.count = String(count);
669
+
670
+ const response = await this.client.delete<BlockDeleteResponse>(
671
+ `/posts/${postId}/blocks/${index}`,
672
+ { params }
673
+ );
674
+ return response.data;
675
+ }
676
+
677
+ /**
678
+ * Delete one or more blocks identified by the leading block's stable gk_ref.
679
+ *
680
+ * @param postId - WordPress post/page ID
681
+ * @param ref - Stable ref of the first block to remove
682
+ * @param count - Consecutive blocks to remove (default 1)
683
+ */
684
+ async deleteBlockByRef(
685
+ postId: number,
686
+ ref: string,
687
+ count?: number
688
+ ): Promise<BlockDeleteResponse> {
689
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
690
+ if (!ref || typeof ref !== 'string') throw new Error('Ref is required');
691
+
692
+ const params: Record<string, string> = {};
693
+ if (count && count > 1) params.count = String(count);
694
+
695
+ const response = await this.client.delete<BlockDeleteResponse>(
696
+ `/posts/${postId}/blocks/by-ref/${encodeURIComponent(ref)}`,
697
+ { params }
698
+ );
699
+ return response.data;
700
+ }
701
+
702
+ /**
703
+ * Atomically replace a range of top-level blocks with a new shape, in a
704
+ * single revision. Distinct from `replaceAllBlocks` (which rewrites the
705
+ * entire post).
706
+ *
707
+ * @param postId - WordPress post/page ID
708
+ * @param data - { start, count, blocks } range descriptor
709
+ * @returns Result with `removed`, `inserted[]`, warnings, revision IDs
710
+ */
711
+ async replaceBlocksRange(
712
+ postId: number,
713
+ data: {
714
+ start: number;
715
+ count: number;
716
+ blocks: Array<{
717
+ name: string;
718
+ attributes?: Record<string, unknown>;
719
+ innerHTML?: string;
720
+ }>;
721
+ }
722
+ ): Promise<BlockReplaceRangeResponse> {
723
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
724
+ if (typeof data.start !== 'number' || data.start < 0) {
725
+ throw new Error('start must be a non-negative integer');
726
+ }
727
+ if (typeof data.count !== 'number' || data.count < 0) {
728
+ throw new Error('count must be a non-negative integer');
729
+ }
730
+ if (!Array.isArray(data.blocks)) {
731
+ throw new Error('blocks must be an array (may be empty for a pure delete)');
732
+ }
733
+
734
+ const response = await this.client.post<BlockReplaceRangeResponse>(
735
+ `/posts/${postId}/blocks/replace`,
736
+ data
737
+ );
738
+ return response.data;
739
+ }
740
+
741
+ /**
742
+ * Replace all blocks on a page (full rewrite).
743
+ *
744
+ * Creates a revision before overwriting. Validates all block names and
745
+ * warns on legacy/avoid-tier blocks.
746
+ *
747
+ * @param postId - WordPress post/page ID
748
+ * @param blocks - Complete array of blocks for the page
749
+ * @returns Written blocks with revision ID
750
+ */
751
+ async replaceAllBlocks(
752
+ postId: number,
753
+ blocks: Array<{
754
+ name: string;
755
+ attributes?: Record<string, unknown>;
756
+ innerHTML?: string;
757
+ }>
758
+ ): Promise<BlockWriteResponse> {
759
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
760
+ if (!blocks || blocks.length === 0) {
761
+ throw new Error('At least one block is required for a full rewrite');
762
+ }
763
+
764
+ const response = await this.client.put<BlockWriteResponse>(
765
+ `/posts/${postId}/blocks`,
766
+ { blocks }
767
+ );
768
+ return response.data;
769
+ }
770
+
771
+ // ============================================
772
+ // Block Tree Mutation
773
+ // ============================================
774
+
775
+ /**
776
+ * Perform a structural mutation on a nested block tree.
777
+ *
778
+ * @param postId - WordPress post/page ID
779
+ * @param data - Mutation request with operation, path, and operation-specific fields
780
+ * @returns Mutation result with revision IDs and optional warnings
781
+ */
782
+ async mutateBlockTree(postId: number, data: MutationRequest): Promise<MutationResponse> {
783
+ if (postId === undefined || postId === null) {
784
+ throw new Error('Post ID is required');
785
+ }
786
+
787
+ const response = await this.client.post<MutationResponse>(
788
+ `/posts/${postId}/mutate`,
789
+ data
790
+ );
791
+ return response.data;
792
+ }
793
+
794
+ // ============================================
795
+ // Revert Operations
796
+ // ============================================
797
+
798
+ /**
799
+ * Revert a post to a specific revision.
800
+ *
801
+ * @param postId - WordPress post/page ID
802
+ * @param revisionId - Revision ID to restore
803
+ * @returns Revert result with revision IDs
804
+ */
805
+ async revertToRevision(postId: number, revisionId: number): Promise<unknown> {
806
+ if (postId === undefined || postId === null) {
807
+ throw new Error('Post ID is required');
808
+ }
809
+ const response = await this.client.post(`/posts/${postId}/revert`, { revision_id: revisionId });
810
+ return response.data;
811
+ }
812
+
813
+ // ============================================
814
+ // Pattern Operations
815
+ // ============================================
816
+
817
+ /**
818
+ * Insert a pattern at a position on a page.
819
+ *
820
+ * @param postId - WordPress post/page ID
821
+ * @param data - Pattern ID, insertion position, and sync mode
822
+ * @returns Inserted pattern details with revision ID
823
+ */
824
+ async insertPattern(
825
+ postId: number,
826
+ data: {
827
+ pattern_id: number | string;
828
+ after?: number;
829
+ before?: number;
830
+ synced?: boolean;
831
+ }
832
+ ): Promise<PatternInsertResponse> {
833
+ if (postId === undefined || postId === null) throw new Error('Post ID is required');
834
+ if (data.pattern_id === undefined || data.pattern_id === null) throw new Error('Pattern ID is required');
835
+
836
+ const response = await this.client.post<PatternInsertResponse>(
837
+ `/posts/${postId}/insert-pattern`,
838
+ data
839
+ );
840
+ return response.data;
841
+ }
842
+
843
+ // ──────────────────────────────────────────────────────────
844
+ // v1.2 — Docs lifecycle
845
+ // ──────────────────────────────────────────────────────────
846
+
847
+ /**
848
+ * Create a new post or page.
849
+ *
850
+ * @param data - Title (required), plus optional status, content/blocks,
851
+ * terms, parent, slug, etc.
852
+ * @returns The created post's ID, slug, permalink, edit link, revision.
853
+ */
854
+ async createPost(data: CreatePostRequest): Promise<PostMutationResponse> {
855
+ if (!data.title || data.title.trim() === '') {
856
+ throw new Error('create_post: a non-empty "title" is required');
857
+ }
858
+ if (data.content !== undefined && Array.isArray(data.blocks)) {
859
+ throw new Error('create_post: "content" and "blocks" are mutually exclusive');
860
+ }
861
+ const response = await this.client.post<PostMutationResponse>('/posts', data);
862
+ return response.data;
863
+ }
864
+
865
+ /**
866
+ * Update post metadata, status, or terms. Block content edits stay on the
867
+ * per-block tools (update_block / mutate_block_tree / replace_all_blocks).
868
+ *
869
+ * Use `status: trash` to trash; any non-trash status untrashes a trashed post.
870
+ */
871
+ async updatePost(postId: number, data: UpdatePostRequest): Promise<PostMutationResponse> {
872
+ if (postId === undefined || postId === null) {
873
+ throw new Error('update_post: post_id is required');
874
+ }
875
+ const response = await this.client.patch<PostMutationResponse>(`/posts/${postId}`, data);
876
+ return response.data;
877
+ }
878
+
879
+ /** List terms in a taxonomy (default: category). */
880
+ async listTerms(args: ListTermsRequest = {}): Promise<ListTermsResponse> {
881
+ const response = await this.client.get<ListTermsResponse>('/terms', { params: args });
882
+ return response.data;
883
+ }
884
+
885
+ /**
886
+ * Upload an item to the WordPress media library.
887
+ *
888
+ * Three input modes (exactly one of `path`, `url`, or `data_base64`):
889
+ * - `path`: local filesystem path on the MCP host. Read and POSTed as
890
+ * multipart/form-data. The MCP process must have read access.
891
+ * - `url`: WordPress fetches the URL server-side (sideload, 25 MB cap).
892
+ * - `data_base64`: base64-encoded contents. Requires `filename`.
893
+ */
894
+ async uploadMedia(args: UploadMediaRequest): Promise<UploadMediaResponse> {
895
+ const modes = (['path', 'url', 'data_base64'] as const).filter(
896
+ (k) => typeof args[k] === 'string' && (args[k] as string).length > 0,
897
+ );
898
+ if (modes.length === 0) {
899
+ throw new Error('upload_media: provide one of "path", "url", or "data_base64"');
900
+ }
901
+ if (modes.length > 1) {
902
+ throw new Error(`upload_media: only one of path/url/data_base64 (got ${modes.join(', ')})`);
903
+ }
904
+ if (args.data_base64 && !args.filename) {
905
+ throw new Error('upload_media: "filename" is required with "data_base64"');
906
+ }
907
+
908
+ if (args.path) {
909
+ const fs = await import('node:fs/promises');
910
+ const path = await import('node:path');
911
+ const data = await fs.readFile(args.path);
912
+ const filename = args.filename ?? path.basename(args.path);
913
+ const form = new FormData();
914
+ form.append('file', new Blob([new Uint8Array(data)]), filename);
915
+ if (args.title) form.append('title', args.title);
916
+ if (args.alt_text) form.append('alt_text', args.alt_text);
917
+ if (args.caption) form.append('caption', args.caption);
918
+ if (args.description) form.append('description', args.description);
919
+ if (typeof args.post_id === 'number') form.append('post_id', String(args.post_id));
920
+
921
+ // axios sets the multipart Content-Type and boundary automatically.
922
+ const response = await this.client.post<UploadMediaResponse>('/media', form);
923
+ return response.data;
924
+ }
925
+
926
+ // url or data_base64 ride as JSON.
927
+ const response = await this.client.post<UploadMediaResponse>('/media', args);
928
+ return response.data;
929
+ }
930
+
931
+ // ──────────────────────────────────────────────────────────
932
+ // v1.3 — Yoast SEO metadata (gk-block-api/v1/yoast/...)
933
+ //
934
+ // Backed by Yoast_Bridge inside gk-block-api itself. Routes register only
935
+ // when Yoast SEO is active; absent Yoast you'll get 404 rest_no_route.
936
+ // ──────────────────────────────────────────────────────────
937
+
938
+ /** Read all Yoast SEO metadata for a post. */
939
+ async getYoastSEO(postId: number): Promise<YoastSEOMeta> {
940
+ if (postId === undefined || postId === null) {
941
+ throw new Error('yoast_get_seo: post_id is required');
942
+ }
943
+ const response = await this.client.get<YoastSEOMeta>(`/yoast/${postId}`);
944
+ return response.data;
945
+ }
946
+
947
+ /** Partial update of Yoast SEO fields on a single post. */
948
+ async updateYoastSEO(postId: number, fields: YoastUpdateRequest): Promise<YoastSEOMeta> {
949
+ if (postId === undefined || postId === null) {
950
+ throw new Error('yoast_update_seo: post_id is required');
951
+ }
952
+ const response = await this.client.patch<YoastSEOMeta>(`/yoast/${postId}`, fields);
953
+ return response.data;
954
+ }
955
+
956
+ /** Batch-update Yoast SEO fields on multiple posts. Order preserved in response. */
957
+ async bulkUpdateYoastSEO(posts: YoastBulkUpdateItem[]): Promise<YoastBulkUpdateResponse> {
958
+ if (!Array.isArray(posts) || posts.length === 0) {
959
+ throw new Error('yoast_bulk_update_seo: non-empty `posts` array is required');
960
+ }
961
+ const response = await this.client.patch<YoastBulkUpdateResponse>('/yoast/bulk', { posts });
962
+ return response.data;
963
+ }
964
+ }