@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
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Post lifecycle tools.
3
+ *
4
+ * - `create_post`: create a new post or page (draft, publish, etc.) optionally
5
+ * with structured blocks or raw content.
6
+ * - `update_post`: partial update of metadata, status, or terms. Status `trash`
7
+ * trashes the post; any non-trash status untrashes a trashed post.
8
+ *
9
+ * Block content edits stay on the per-block tools.
10
+ */
11
+
12
+ import type { WordPressBlockClient } from '../client.js';
13
+ import type { CreatePostRequest, UpdatePostRequest } from '../types.js';
14
+ import { BLOCK_INPUT_SCHEMA } from './write.js';
15
+
16
+ const POST_STATUS_CREATE = ['draft', 'pending', 'private', 'publish', 'future'] as const;
17
+ const POST_STATUS_UPDATE = ['draft', 'pending', 'private', 'publish', 'future', 'trash'] as const;
18
+
19
+ export const POST_TOOLS = [
20
+ {
21
+ name: 'create_post',
22
+ description:
23
+ 'Create a new post or page. Returns ID, slug, permalink, and edit link. Provide either `content` (raw HTML or block markup) OR `blocks` (structured), not both. Status defaults to draft. Use update_post for trash transitions.',
24
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Create post' },
25
+ inputSchema: {
26
+ type: 'object' as const,
27
+ properties: {
28
+ title: { type: 'string', description: 'Post title (required, non-empty).' },
29
+ post_type: { type: 'string', description: 'Post type slug (default: post).' },
30
+ status: {
31
+ type: 'string',
32
+ enum: [...POST_STATUS_CREATE],
33
+ description: 'Initial status. Use update_post for trash transitions.',
34
+ },
35
+ content: {
36
+ type: 'string',
37
+ description: 'Raw post_content (HTML or block markup). Mutually exclusive with blocks.',
38
+ },
39
+ blocks: {
40
+ type: 'array',
41
+ description:
42
+ 'Structured blocks. Validated against block registry and preference tier — legacy blocks are rejected.',
43
+ items: BLOCK_INPUT_SCHEMA,
44
+ },
45
+ slug: { type: 'string' },
46
+ parent: { type: 'number', description: 'Parent post ID (hierarchical post types only).' },
47
+ excerpt: { type: 'string' },
48
+ featured_media: {
49
+ type: 'number',
50
+ description: 'Attachment ID. Must be an image MIME. Send 0 to leave unset.',
51
+ },
52
+ categories: {
53
+ type: 'array',
54
+ items: { type: 'number' },
55
+ description: 'Term IDs in the `category` taxonomy.',
56
+ },
57
+ tags: {
58
+ type: 'array',
59
+ items: { type: 'number' },
60
+ description: 'Term IDs in the `post_tag` taxonomy.',
61
+ },
62
+ terms: {
63
+ type: 'object',
64
+ description: 'Map of taxonomy slug → term IDs. For non-built-in taxonomies on CPTs.',
65
+ },
66
+ date: { type: 'string', description: 'ISO 8601 publish date.' },
67
+ menu_order: { type: 'number' },
68
+ comment_status: { type: 'string', enum: ['open', 'closed'] },
69
+ ping_status: { type: 'string', enum: ['open', 'closed'] },
70
+ author: {
71
+ type: 'number',
72
+ description: 'User ID. Other-user authorship requires edit_others_posts cap.',
73
+ },
74
+ },
75
+ required: ['title'],
76
+ },
77
+ },
78
+ {
79
+ name: 'update_post',
80
+ description:
81
+ 'Partial update of post metadata, status, or terms. Block content edits stay on the per-block tools. Use status: "trash" to trash; any non-trash status untrashes a trashed post. At least one mutating field besides post_id is required.',
82
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Update post' },
83
+ inputSchema: {
84
+ type: 'object' as const,
85
+ properties: {
86
+ post_id: { type: 'number', description: 'WordPress post ID.' },
87
+ title: { type: 'string' },
88
+ status: { type: 'string', enum: [...POST_STATUS_UPDATE] },
89
+ slug: { type: 'string' },
90
+ parent: { type: 'number' },
91
+ excerpt: { type: 'string' },
92
+ featured_media: {
93
+ type: 'number',
94
+ description: 'Attachment ID. Send 0 to clear.',
95
+ },
96
+ categories: { type: 'array', items: { type: 'number' } },
97
+ tags: { type: 'array', items: { type: 'number' } },
98
+ terms: { type: 'object' },
99
+ date: { type: 'string' },
100
+ menu_order: { type: 'number' },
101
+ comment_status: { type: 'string', enum: ['open', 'closed'] },
102
+ ping_status: { type: 'string', enum: ['open', 'closed'] },
103
+ author: { type: 'number' },
104
+ },
105
+ required: ['post_id'],
106
+ },
107
+ },
108
+ ];
109
+
110
+ export async function handlePostTool(
111
+ toolName: string,
112
+ args: Record<string, unknown>,
113
+ client: WordPressBlockClient,
114
+ ): Promise<unknown> {
115
+ switch (toolName) {
116
+ case 'create_post': {
117
+ if (typeof args.title !== 'string' || args.title.trim() === '') {
118
+ throw new Error('create_post: a non-empty "title" is required');
119
+ }
120
+ if (args.content !== undefined && Array.isArray(args.blocks)) {
121
+ throw new Error('create_post: "content" and "blocks" are mutually exclusive');
122
+ }
123
+ // Narrow incoming args to the documented shape. The MCP SDK already
124
+ // validates against `inputSchema`, but `terms` (Record<string, number[]>)
125
+ // and nested block shapes aren't enforced — narrow defensively here.
126
+ const create = narrowCreatePost(args);
127
+ return client.createPost(create);
128
+ }
129
+
130
+ case 'update_post': {
131
+ if (typeof args.post_id !== 'number') {
132
+ throw new Error('update_post: "post_id" (number) is required');
133
+ }
134
+ const { post_id: postId, ...rest } = args;
135
+ if (Object.keys(rest).length === 0) {
136
+ throw new Error('update_post: provide at least one mutating field besides post_id');
137
+ }
138
+ const update = narrowUpdatePost(rest);
139
+ return client.updatePost(postId as number, update);
140
+ }
141
+
142
+ default:
143
+ throw new Error(`Unknown post tool: ${toolName}`);
144
+ }
145
+ }
146
+
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'];
154
+ if (typeof input.slug === 'string') out.slug = input.slug;
155
+ if (typeof input.parent === 'number') out.parent = input.parent;
156
+ if (typeof input.excerpt === 'string') out.excerpt = input.excerpt;
157
+ if (typeof input.featured_media === 'number') out.featured_media = input.featured_media;
158
+ if (Array.isArray(input.categories)) out.categories = (input.categories as unknown[]).filter((n) => typeof n === 'number') as number[];
159
+ if (Array.isArray(input.tags)) out.tags = (input.tags as unknown[]).filter((n) => typeof n === 'number') as number[];
160
+ if (input.terms && typeof input.terms === 'object' && !Array.isArray(input.terms)) {
161
+ out.terms = narrowTermsMap(input.terms as Record<string, unknown>);
162
+ }
163
+ if (typeof input.date === 'string') out.date = input.date;
164
+ if (typeof input.menu_order === 'number') out.menu_order = input.menu_order;
165
+ if (input.comment_status === 'open' || input.comment_status === 'closed') out.comment_status = input.comment_status;
166
+ if (input.ping_status === 'open' || input.ping_status === 'closed') out.ping_status = input.ping_status;
167
+ if (typeof input.author === 'number') out.author = input.author;
168
+ return out;
169
+ }
170
+
171
+ function narrowUpdatePost(input: Record<string, unknown>): UpdatePostRequest {
172
+ const out: UpdatePostRequest = {};
173
+ if (typeof input.title === 'string') out.title = input.title;
174
+ 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
+ return out;
190
+ }
191
+
192
+ function narrowTermsMap(input: Record<string, unknown>): Record<string, number[]> {
193
+ const out: Record<string, number[]> = {};
194
+ for (const [taxonomy, ids] of Object.entries(input)) {
195
+ if (Array.isArray(ids)) {
196
+ out[taxonomy] = (ids as unknown[]).filter((n) => typeof n === 'number') as number[];
197
+ }
198
+ }
199
+ return out;
200
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Read Tools
3
+ *
4
+ * MCP tools for reading block content from WordPress pages.
5
+ * Enriches raw block data with preference annotations so AI agents
6
+ * immediately see which blocks are legacy and what to use instead.
7
+ */
8
+
9
+ import type { WordPressBlockClient } from '../client.js';
10
+ import { enrichBlockList } from '../preferences.js';
11
+
12
+ /**
13
+ * Tool definitions for the read category.
14
+ */
15
+ export const READ_TOOLS = [
16
+ {
17
+ name: 'get_page_blocks',
18
+ description:
19
+ "Get a post's blocks. Pass post_id OR url (server resolves URL — don't shell out). Returns `{post_id, summary, blocks[], block_count, warnings}`. Each block: `{index (flat), top_level_counter? (top-level only), path, ref (stable gk_ref), name, attributes, innerHTML?, dynamic, storage_mode (\"static\"|\"dynamic\"|\"dual\"), preference? (when non-preferred)}`. Refs survive sibling shifts — pass them to update_block / delete_block / edit_block_tree to chain mutations without re-fetching. Use outline:true or summary_only:true for cheap inspection.",
20
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, title: 'Get post blocks' },
21
+ outputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ post_id: { type: 'number' },
25
+ summary: { type: 'object' },
26
+ blocks: { type: 'array' },
27
+ block_count: { type: 'number' },
28
+ warnings: { type: 'array' },
29
+ },
30
+ },
31
+ inputSchema: {
32
+ type: 'object' as const,
33
+ properties: {
34
+ post_id: {
35
+ type: 'number',
36
+ description: 'Post ID. Provide either this or url.',
37
+ },
38
+ url: {
39
+ type: 'string',
40
+ description: 'Full URL (https://site.com/path/) or site-relative path (/path/). Resolved via url_to_postid. Provide either this or post_id.',
41
+ },
42
+ fields: {
43
+ type: 'string',
44
+ description: 'Comma-separated fields (e.g. "path,name,text_preview"). Omit for all.',
45
+ },
46
+ render: {
47
+ type: 'boolean',
48
+ description: 'Expand shortcodes, resolve synced patterns, mark dynamic/static.',
49
+ },
50
+ search: {
51
+ type: 'string',
52
+ description: 'Filter by text in innerHTML. Returns flat matches.',
53
+ },
54
+ block_name: {
55
+ type: 'string',
56
+ description: 'Filter by block name (e.g. "core/button"). Returns flat matches.',
57
+ },
58
+ outline: {
59
+ type: 'boolean',
60
+ description: 'Return only headings and named sections as a flat outline. Fast page structure view.',
61
+ },
62
+ summary_only: {
63
+ type: 'boolean',
64
+ description: 'Return only the summary object (no blocks). Fastest page inspection.',
65
+ },
66
+ include_legacy_paths: {
67
+ type: 'boolean',
68
+ description: 'Add summary.legacy_blocks.paths (per-block path list). Off by default; turn on for migration audits.',
69
+ },
70
+ persist_refs: {
71
+ type: 'boolean',
72
+ 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
+ },
76
+ },
77
+ {
78
+ name: 'get_block',
79
+ description:
80
+ 'Fetch one block by stable ref OR flat_index — returns the canonical `saved` snapshot (inner_html + attributes) from the database. Same shape that update_block / update_blocks (with `verbose:true`) echo back, so verification reads use the identical contract as the writes that produced them. Lighter than get_page_blocks when you only need to confirm one known block. For dynamic blocks (`saved.is_dynamic`), `inner_html` is the stored template, not rendered output.',
81
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, title: 'Get one block' },
82
+ outputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ success: { type: 'boolean' },
86
+ saved: {
87
+ type: 'object',
88
+ properties: {
89
+ flat_index: { type: 'number' },
90
+ block_name: { type: 'string' },
91
+ attributes: { type: 'object' },
92
+ inner_html: { type: 'string' },
93
+ is_dynamic: { type: 'boolean' },
94
+ ref: { type: 'string' },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ inputSchema: {
100
+ type: 'object' as const,
101
+ properties: {
102
+ post_id: { type: 'number', description: 'Post ID.' },
103
+ ref: {
104
+ type: 'string',
105
+ description: 'Stable gk_ref. Provide this OR flat_index.',
106
+ },
107
+ flat_index: {
108
+ type: 'number',
109
+ description: 'Flat block index. Provide this OR ref.',
110
+ },
111
+ },
112
+ required: ['post_id'],
113
+ },
114
+ },
115
+ ];
116
+
117
+ /**
118
+ * Handle a read tool call.
119
+ *
120
+ * @param toolName - The name of the tool being called
121
+ * @param args - Tool arguments from the AI agent
122
+ * @param client - WordPress Block API client instance
123
+ * @returns Tool result ready for MCP response
124
+ */
125
+ export async function handleReadTool(
126
+ toolName: string,
127
+ args: Record<string, unknown>,
128
+ client: WordPressBlockClient
129
+ ): Promise<unknown> {
130
+ switch (toolName) {
131
+ case 'get_page_blocks': {
132
+ let postId = args.post_id as number | undefined;
133
+ const url = args.url as string | undefined;
134
+ const fields = args.fields as string | undefined;
135
+ const render = args.render as boolean | undefined;
136
+ const search = args.search as string | undefined;
137
+ const blockName = args.block_name as string | undefined;
138
+ const outline = args.outline as boolean | undefined;
139
+ const summaryOnly = args.summary_only as boolean | undefined;
140
+ const includeLegacyPaths = args.include_legacy_paths as boolean | undefined;
141
+ const persistRefs = args.persist_refs as boolean | undefined;
142
+
143
+ if ((postId === undefined || postId === null) && !url) {
144
+ throw new Error('Either post_id or url is required');
145
+ }
146
+
147
+ if (postId === undefined || postId === null) {
148
+ const resolved = await client.resolveUrl(url as string);
149
+ postId = resolved.post_id;
150
+ }
151
+
152
+ const response = await client.getPageBlocks(postId, {
153
+ fields, render, search, block_name: blockName, outline,
154
+ summary_only: summaryOnly,
155
+ include_legacy_paths: includeLegacyPaths,
156
+ ...(persistRefs !== undefined ? { persist_refs: persistRefs } : {}),
157
+ });
158
+
159
+ // summary_only mode: return server summary as-is.
160
+ if (summaryOnly) {
161
+ return {
162
+ post_id: postId,
163
+ summary: (response as { summary?: unknown }).summary,
164
+ };
165
+ }
166
+
167
+ const enriched = enrichBlockList(response.blocks || []);
168
+
169
+ return {
170
+ post_id: postId,
171
+ summary: (response as { summary?: unknown }).summary,
172
+ blocks: enriched.blocks,
173
+ block_count: enriched.blocks.length,
174
+ warnings: enriched.warnings,
175
+ };
176
+ }
177
+
178
+ case 'get_block': {
179
+ const postId = args.post_id as number;
180
+ const ref = typeof args.ref === 'string' && args.ref.length > 0 ? (args.ref as string) : undefined;
181
+ const flatIndex =
182
+ typeof args.flat_index === 'number' && Number.isFinite(args.flat_index)
183
+ ? (args.flat_index as number)
184
+ : undefined;
185
+
186
+ if (postId === undefined || postId === null) {
187
+ throw new Error('post_id is required');
188
+ }
189
+ const hasRef = ref !== undefined;
190
+ const hasIdx = flatIndex !== undefined;
191
+ if (hasRef === hasIdx) {
192
+ throw new Error('Provide exactly one of ref or flat_index');
193
+ }
194
+
195
+ return await client.getBlock(postId, hasRef ? { ref } : { flatIndex });
196
+ }
197
+
198
+ default:
199
+ throw new Error(`Unknown read tool: ${toolName}`);
200
+ }
201
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Term tools — taxonomy term discovery (read-only).
3
+ */
4
+
5
+ import type { WordPressBlockClient } from '../client.js';
6
+ import type { ListTermsRequest } from '../types.js';
7
+
8
+ export const TERM_TOOLS = [
9
+ {
10
+ name: 'list_terms',
11
+ description:
12
+ 'List terms in a taxonomy (default: category). Useful for discovering category and tag IDs to pass to create_post or update_post.',
13
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, title: 'List terms' },
14
+ inputSchema: {
15
+ type: 'object' as const,
16
+ properties: {
17
+ taxonomy: { type: 'string', description: 'Taxonomy slug. Default: category.' },
18
+ search: { type: 'string', description: 'LIKE match against term name.' },
19
+ parent: { type: 'number' },
20
+ hide_empty: { type: 'boolean', description: 'Default: false.' },
21
+ per_page: { type: 'number', description: 'Default 100, max 200.' },
22
+ page: { type: 'number', description: '1-indexed.' },
23
+ orderby: { type: 'string', enum: ['name', 'count', 'term_id', 'slug'] },
24
+ order: { type: 'string', enum: ['asc', 'desc'] },
25
+ include: { type: 'array', items: { type: 'number' } },
26
+ slug: { type: 'string' },
27
+ },
28
+ },
29
+ },
30
+ ];
31
+
32
+ export async function handleTermTool(
33
+ toolName: string,
34
+ args: Record<string, unknown>,
35
+ client: WordPressBlockClient,
36
+ ): Promise<unknown> {
37
+ switch (toolName) {
38
+ case 'list_terms': {
39
+ return client.listTerms(args as ListTermsRequest);
40
+ }
41
+ default:
42
+ throw new Error(`Unknown term tool: ${toolName}`);
43
+ }
44
+ }