@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.
- package/.env.example +15 -0
- package/LICENSE +26 -0
- package/README.md +592 -0
- package/dist/index.cjs +52721 -0
- package/package.json +70 -0
- package/src/__tests__/fixtures/block-trees.ts +199 -0
- package/src/__tests__/fixtures/error-envelopes.ts +115 -0
- package/src/__tests__/fixtures/rest-responses.ts +280 -0
- package/src/__tests__/helpers/mock-client.ts +185 -0
- package/src/__tests__/helpers/request-matchers.ts +88 -0
- package/src/__tests__/helpers/schema-asserts.ts +132 -0
- package/src/__tests__/integration/concurrency.test.ts +129 -0
- package/src/__tests__/integration/dual-storage.test.ts +156 -0
- package/src/__tests__/integration/error-envelopes.test.ts +238 -0
- package/src/__tests__/integration/global-setup.ts +17 -0
- package/src/__tests__/integration/rate-limit.test.ts +88 -0
- package/src/__tests__/integration/read-edit-read.test.ts +141 -0
- package/src/__tests__/integration/ref-stability.test.ts +175 -0
- package/src/__tests__/integration/setup.ts +201 -0
- package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
- package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
- package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
- package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
- package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
- package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
- package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
- package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
- package/src/__tests__/tools/media/upload_media.test.ts +123 -0
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
- package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
- package/src/__tests__/tools/posts/create_post.test.ts +84 -0
- package/src/__tests__/tools/posts/update_post.test.ts +93 -0
- package/src/__tests__/tools/read/get_block.test.ts +96 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
- package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
- package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
- package/src/__tests__/tools/write/delete_block.test.ts +91 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
- package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
- package/src/__tests__/tools/write/update_block.test.ts +206 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
- package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
- package/src/__tests__/unit/instructions.test.ts +374 -0
- package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
- package/src/client.ts +964 -0
- package/src/connect.ts +877 -0
- package/src/enrichers.ts +348 -0
- package/src/error-translator.ts +156 -0
- package/src/index.ts +450 -0
- package/src/instructions.ts +270 -0
- package/src/preferences.ts +273 -0
- package/src/tools/discovery.ts +251 -0
- package/src/tools/media.ts +75 -0
- package/src/tools/mutate.ts +243 -0
- package/src/tools/patterns.ts +94 -0
- package/src/tools/posts.ts +200 -0
- package/src/tools/read.ts +201 -0
- package/src/tools/terms.ts +44 -0
- package/src/tools/write.ts +542 -0
- package/src/tools/yoast.ts +224 -0
- package/src/types.ts +862 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery / Lookup Tools
|
|
3
|
+
*
|
|
4
|
+
* Read-only tools for exploring the registry, browsing patterns, scanning
|
|
5
|
+
* inventory, and addressing posts (URL → ID, search, lookup). Always
|
|
6
|
+
* `readOnlyHint: true` except `scan_storage_modes` which writes a WP option.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { WordPressBlockClient } from '../client.js';
|
|
10
|
+
import { enrichBlockTypes, enrichPatternList } from '../preferences.js';
|
|
11
|
+
|
|
12
|
+
const READ_ANNOT = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } as const;
|
|
13
|
+
|
|
14
|
+
export const DISCOVERY_TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'list_block_types',
|
|
17
|
+
description:
|
|
18
|
+
'Registered block types with per-block `preference` (tier + replacement), `storage_mode` ("static"|"dynamic"|"dual"), `usage` (count + post_count), `attributes` (incl. `source` declarations), and a top-level `guidance` summary grouped by tier. Filters: namespace, category, tier, storage_mode, search (name/title substring), preferred_only, usage_only. Pagination: limit/offset → next_offset. Returns `{block_types[], count, total, offset, next_offset, guidance}`.',
|
|
19
|
+
annotations: { ...READ_ANNOT, title: 'List block types' },
|
|
20
|
+
outputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
block_types: { type: 'array' },
|
|
24
|
+
count: { type: 'number' },
|
|
25
|
+
total: { type: 'number' },
|
|
26
|
+
offset: { type: 'number' },
|
|
27
|
+
next_offset: { type: ['number', 'null'] },
|
|
28
|
+
guidance: { type: 'string' },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object' as const,
|
|
33
|
+
properties: {
|
|
34
|
+
namespace: { type: 'string', description: 'Filter by namespace (e.g. "core", "filter").' },
|
|
35
|
+
category: { type: 'string', description: 'Filter by category (e.g. "text", "media").' },
|
|
36
|
+
tier: { type: 'string', enum: ['preferred', 'acceptable', 'avoid', 'legacy'], description: 'Exact tier match. Use for migration audits.' },
|
|
37
|
+
storage_mode: { type: 'string', enum: ['static', 'dynamic', 'dual'], description: 'Filter by storage mode. "dual" surfaces blocks needing both attrs+innerHTML on update.' },
|
|
38
|
+
search: { type: 'string', description: 'Case-insensitive substring match against name + title.' },
|
|
39
|
+
preferred_only: { type: 'boolean', description: 'Shorthand for `tier in {preferred,acceptable}` (score ≥ 50).' },
|
|
40
|
+
usage_only: { type: 'boolean', description: 'Only blocks with usage.count > 0 on this site.' },
|
|
41
|
+
limit: { type: 'number', description: 'Max results. Default 50.' },
|
|
42
|
+
offset: { type: 'number', description: 'Skip this many. Default 0.' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'list_patterns',
|
|
48
|
+
description: 'Block patterns sorted by preference score. Check before building from scratch. Server respects `limit`; `offset` slices client-side. Reference counts are cached for 1 hour — pass `refresh:true` to rebuild.',
|
|
49
|
+
annotations: { ...READ_ANNOT, title: 'List patterns' },
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object' as const,
|
|
52
|
+
properties: {
|
|
53
|
+
search: { type: 'string', description: 'Search by name or keyword.' },
|
|
54
|
+
synced: { type: 'boolean', description: 'true = synced only, false = registered only, omit = all.' },
|
|
55
|
+
min_score: { type: 'number', description: 'Min preference score; 0 excludes legacy.' },
|
|
56
|
+
limit: { type: 'number', description: 'Max results. Default 20.' },
|
|
57
|
+
offset: { type: 'number', description: 'Skip this many results. Default 0.' },
|
|
58
|
+
refresh: { type: 'boolean', description: 'Bust the 1-hour reference-count cache before listing.' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'get_pattern',
|
|
64
|
+
description: "Single pattern's full block content + metadata. Use after list_patterns.",
|
|
65
|
+
annotations: { ...READ_ANNOT, title: 'Get pattern' },
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object' as const,
|
|
68
|
+
properties: {
|
|
69
|
+
pattern_id: { type: ['number', 'string'], description: 'Numeric post ID (synced) or registered pattern name.' },
|
|
70
|
+
},
|
|
71
|
+
required: ['pattern_id'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'get_site_usage',
|
|
76
|
+
description: 'Site-wide block + pattern inventory: usage counts, namespace totals, pattern reference counts, legacy patterns.',
|
|
77
|
+
annotations: { ...READ_ANNOT, title: 'Get site usage' },
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object' as const,
|
|
80
|
+
properties: {
|
|
81
|
+
refresh: { type: 'boolean', description: 'Bust the 1-hour cache and rebuild.' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'scan_storage_modes',
|
|
87
|
+
description:
|
|
88
|
+
'Walk every published post and persist a `block_name → "static"|"dynamic"|"dual"` map (option `gk_block_api_storage_modes`). Slow on large sites; rate-limited to 1/hr. After this runs, get_page_blocks `storage_mode` annotations and dual-storage write enforcement use the live classification. Returns `{scanned_posts, unique_blocks, classification, dual_count, dynamic_count, static_count}`.',
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, title: 'Scan storage modes' },
|
|
90
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'resolve_url',
|
|
94
|
+
description: 'URL or path → post ID. Accepts full URLs or site-relative paths. Run this before get_page_blocks / update_block / edit_block_tree when you only have a URL.',
|
|
95
|
+
annotations: { ...READ_ANNOT, title: 'Resolve URL to post' },
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object' as const,
|
|
98
|
+
properties: {
|
|
99
|
+
url: { type: 'string', description: 'Full URL or path (e.g. "/some/page/").' },
|
|
100
|
+
},
|
|
101
|
+
required: ['url'],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'list_posts',
|
|
106
|
+
description: 'Search posts by title/content with pagination. Returns `{posts: [{post_id, title, slug, post_type, post_status, post_url, modified}], total, page, per_page, total_pages}`. Use instead of wp post list / wp-json.',
|
|
107
|
+
annotations: { ...READ_ANNOT, title: 'List/search posts' },
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: 'object' as const,
|
|
110
|
+
properties: {
|
|
111
|
+
search: { type: 'string', description: 'Free-text across title + content. Omit to list.' },
|
|
112
|
+
post_type: { type: 'string', description: 'Single or comma-separated. Default: public types.' },
|
|
113
|
+
post_status: { type: 'string', description: 'publish | draft | private | any | csv. Default: publish. (`any` is exclusive.)' },
|
|
114
|
+
per_page: { type: 'number', description: 'Default 20, max 100.' },
|
|
115
|
+
page: { type: 'number', description: 'Default 1.' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'get_post_info',
|
|
121
|
+
description: 'Post metadata by post_id, url, or slug+post_type. Returns `{post_id, title, status, post_url, edit_url, modified, created, parent_id, author, mime_type, comment_count}`. Replaces wp eval / get_permalink() shell-outs.',
|
|
122
|
+
annotations: { ...READ_ANNOT, title: 'Get post info' },
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: 'object' as const,
|
|
125
|
+
properties: {
|
|
126
|
+
post_id: { type: 'number', description: 'One of post_id, url, or slug.' },
|
|
127
|
+
url: { type: 'string', description: 'Full URL or path. Resolved via url_to_postid.' },
|
|
128
|
+
slug: { type: 'string', description: 'post_name. Combine with post_type for uniqueness.' },
|
|
129
|
+
post_type: { type: 'string', description: 'Scope a slug lookup. Default: any.' },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
export async function handleDiscoveryTool(
|
|
136
|
+
toolName: string,
|
|
137
|
+
args: Record<string, unknown>,
|
|
138
|
+
client: WordPressBlockClient
|
|
139
|
+
): Promise<unknown> {
|
|
140
|
+
switch (toolName) {
|
|
141
|
+
case 'list_block_types': {
|
|
142
|
+
const response = await client.getBlockTypes({
|
|
143
|
+
namespace: args.namespace as string | undefined,
|
|
144
|
+
category: args.category as string | undefined,
|
|
145
|
+
preferred_only: args.preferred_only as boolean | undefined,
|
|
146
|
+
tier: args.tier as 'preferred' | 'acceptable' | 'avoid' | 'legacy' | undefined,
|
|
147
|
+
storage_mode: args.storage_mode as 'static' | 'dynamic' | 'dual' | undefined,
|
|
148
|
+
search: args.search as string | undefined,
|
|
149
|
+
usage_only: args.usage_only as boolean | undefined,
|
|
150
|
+
});
|
|
151
|
+
const enriched = enrichBlockTypes(response.block_types);
|
|
152
|
+
const total = enriched.block_types.length;
|
|
153
|
+
const limit = (args.limit as number | undefined) ?? 50;
|
|
154
|
+
const offset = (args.offset as number | undefined) ?? 0;
|
|
155
|
+
const page = enriched.block_types.slice(offset, offset + limit);
|
|
156
|
+
return {
|
|
157
|
+
block_types: page,
|
|
158
|
+
count: page.length,
|
|
159
|
+
total,
|
|
160
|
+
offset,
|
|
161
|
+
next_offset: offset + page.length < total ? offset + page.length : null,
|
|
162
|
+
guidance: enriched.guidance,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'list_patterns': {
|
|
167
|
+
const limit = (args.limit as number | undefined) ?? 20;
|
|
168
|
+
const offset = (args.offset as number | undefined) ?? 0;
|
|
169
|
+
const response = await client.getPatterns({
|
|
170
|
+
q: args.search as string | undefined,
|
|
171
|
+
synced: args.synced as boolean | undefined,
|
|
172
|
+
min_score: args.min_score as number | undefined,
|
|
173
|
+
// Fetch enough to honor offset+limit. Server caps respond too.
|
|
174
|
+
limit: offset + limit,
|
|
175
|
+
refresh: args.refresh as boolean | undefined,
|
|
176
|
+
});
|
|
177
|
+
const enriched = enrichPatternList(response.patterns);
|
|
178
|
+
const total = enriched.patterns.length;
|
|
179
|
+
const page = enriched.patterns.slice(offset, offset + limit);
|
|
180
|
+
return {
|
|
181
|
+
patterns: page,
|
|
182
|
+
count: page.length,
|
|
183
|
+
total,
|
|
184
|
+
offset,
|
|
185
|
+
next_offset: offset + page.length < total ? offset + page.length : null,
|
|
186
|
+
summary: enriched.summary,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'get_pattern': {
|
|
191
|
+
const patternId = args.pattern_id;
|
|
192
|
+
if (patternId === undefined || patternId === null) throw new Error('pattern_id is required');
|
|
193
|
+
return await client.getPattern(patternId as number | string);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'get_site_usage':
|
|
197
|
+
return await client.getSiteUsage(args.refresh as boolean | undefined);
|
|
198
|
+
|
|
199
|
+
case 'scan_storage_modes':
|
|
200
|
+
return await client.scanStorageModes();
|
|
201
|
+
|
|
202
|
+
case 'resolve_url': {
|
|
203
|
+
const url = args.url;
|
|
204
|
+
if (typeof url !== 'string' || url.length === 0) throw new Error('url is required');
|
|
205
|
+
return await client.resolveUrl(url);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'list_posts':
|
|
209
|
+
return await client.findPosts({
|
|
210
|
+
search: args.search as string | undefined,
|
|
211
|
+
post_type: args.post_type as string | undefined,
|
|
212
|
+
post_status: args.post_status as string | undefined,
|
|
213
|
+
per_page: args.per_page as number | undefined,
|
|
214
|
+
page: args.page as number | undefined,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
case 'get_post_info': {
|
|
218
|
+
const postId = args.post_id;
|
|
219
|
+
const url = args.url;
|
|
220
|
+
const slug = args.slug;
|
|
221
|
+
if (
|
|
222
|
+
(postId === undefined || postId === null) &&
|
|
223
|
+
(typeof url !== 'string' || url.length === 0) &&
|
|
224
|
+
(typeof slug !== 'string' || slug.length === 0)
|
|
225
|
+
) {
|
|
226
|
+
throw new Error('get_post_info requires one of: post_id, url, or slug');
|
|
227
|
+
}
|
|
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
|
+
}
|
|
240
|
+
return await client.getPostInfo({
|
|
241
|
+
post_id: normalizedPostId,
|
|
242
|
+
url: typeof url === 'string' ? url : undefined,
|
|
243
|
+
slug: typeof slug === 'string' ? slug : undefined,
|
|
244
|
+
post_type: args.post_type as string | undefined,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
default:
|
|
249
|
+
throw new Error(`Unknown discovery tool: ${toolName}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media tools — upload to the WordPress media library.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { WordPressBlockClient } from '../client.js';
|
|
6
|
+
import type { UploadMediaRequest } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export const MEDIA_TOOLS = [
|
|
9
|
+
{
|
|
10
|
+
name: 'upload_media',
|
|
11
|
+
description:
|
|
12
|
+
'Upload an item to the WordPress media library. Provide exactly one of: `path` (local filesystem on the MCP host, sent as multipart), `url` (server-side sideload, 25 MB cap), or `data_base64` (with `filename`). Returns the attachment ID and URL ready for core/image blocks.',
|
|
13
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Upload media' },
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: 'object' as const,
|
|
16
|
+
properties: {
|
|
17
|
+
path: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Absolute path on the MCP host. Will be read and POSTed as multipart.',
|
|
20
|
+
},
|
|
21
|
+
url: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Public URL the WordPress site can fetch.',
|
|
24
|
+
},
|
|
25
|
+
data_base64: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Base64-encoded file contents (requires filename).',
|
|
28
|
+
},
|
|
29
|
+
filename: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Override filename (required when using data_base64).',
|
|
32
|
+
},
|
|
33
|
+
title: { type: 'string' },
|
|
34
|
+
alt_text: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Saved as _wp_attachment_image_alt meta. Critical for accessibility.',
|
|
37
|
+
},
|
|
38
|
+
caption: { type: 'string' },
|
|
39
|
+
description: { type: 'string' },
|
|
40
|
+
post_id: {
|
|
41
|
+
type: 'number',
|
|
42
|
+
description: 'Attach to a parent post (sets post_parent).',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export async function handleMediaTool(
|
|
50
|
+
toolName: string,
|
|
51
|
+
args: Record<string, unknown>,
|
|
52
|
+
client: WordPressBlockClient,
|
|
53
|
+
): Promise<unknown> {
|
|
54
|
+
switch (toolName) {
|
|
55
|
+
case 'upload_media': {
|
|
56
|
+
const modes = (['path', 'url', 'data_base64'] as const).filter(
|
|
57
|
+
(k) => typeof args[k] === 'string' && (args[k] as string).length > 0,
|
|
58
|
+
);
|
|
59
|
+
if (modes.length === 0) {
|
|
60
|
+
throw new Error('upload_media: provide one of "path", "url", or "data_base64"');
|
|
61
|
+
}
|
|
62
|
+
if (modes.length > 1) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`upload_media: only one of path/url/data_base64 may be supplied (got ${modes.join(', ')})`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (args.data_base64 && !args.filename) {
|
|
68
|
+
throw new Error('upload_media: "filename" is required when using data_base64');
|
|
69
|
+
}
|
|
70
|
+
return client.uploadMedia(args as UploadMediaRequest);
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
throw new Error(`Unknown media tool: ${toolName}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit Block Tree — path-based structural mutation engine.
|
|
3
|
+
*
|
|
4
|
+
* Single tool exposing 9 ops (update-attrs, update-html, replace-block,
|
|
5
|
+
* remove-block, wrap-in-group, unwrap-group, insert-child, duplicate,
|
|
6
|
+
* move). Path is an integer array from get_page_blocks (e.g. [0,2,1] =
|
|
7
|
+
* top-level block 0 → innerBlock 2 → innerBlock 1). Creates a revision.
|
|
8
|
+
*
|
|
9
|
+
* Renamed from `mutate_block_tree` in 1.4.0 (verb-noun consistency).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { WordPressBlockClient } from '../client.js';
|
|
13
|
+
import type { MutationOp, MutationRequest, MutationResponse, StaticBlockWarning } from '../types.js';
|
|
14
|
+
import { formatPreferenceWarning } from '../preferences.js';
|
|
15
|
+
|
|
16
|
+
const OPS: readonly MutationOp[] = [
|
|
17
|
+
'update-attrs',
|
|
18
|
+
'update-html',
|
|
19
|
+
'replace-block',
|
|
20
|
+
'remove-block',
|
|
21
|
+
'wrap-in-group',
|
|
22
|
+
'unwrap-group',
|
|
23
|
+
'insert-child',
|
|
24
|
+
'duplicate',
|
|
25
|
+
'move',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export const MUTATE_TOOLS = [{
|
|
29
|
+
name: 'edit_block_tree',
|
|
30
|
+
description:
|
|
31
|
+
'Run one structural op on a nested block tree. Ops: update-attrs, update-html, replace-block, remove-block, wrap-in-group, unwrap-group, insert-child, duplicate, move. Target the block via `ref` (stable gk_ref — recommended, survives sibling shifts) OR `path` (integer array, e.g. [0,2,1]). For move, the destination can be a `destination_ref` instead of a path. Creates a revision.',
|
|
32
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Edit block tree' },
|
|
33
|
+
outputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
success: { type: 'boolean' },
|
|
37
|
+
op: { type: 'string' },
|
|
38
|
+
path: { type: 'array', items: { type: 'integer' } },
|
|
39
|
+
block: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
name: { type: 'string' },
|
|
43
|
+
attributes: { type: 'object' },
|
|
44
|
+
ref: { type: 'string', description: 'New ref when the op produced a new block.' },
|
|
45
|
+
new_path: { type: 'array', items: { type: 'integer' }, description: 'duplicate: path of the clone.' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
warnings: { type: 'array' },
|
|
49
|
+
formatted_warnings: { type: 'array', items: { type: 'string' } },
|
|
50
|
+
before_revision_id: { type: 'number' },
|
|
51
|
+
revision_id: { type: 'number' },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object' as const,
|
|
56
|
+
properties: {
|
|
57
|
+
post_id: { type: 'number', description: 'Post ID.' },
|
|
58
|
+
op: { type: 'string', enum: [...OPS], description: 'Operation to perform.' },
|
|
59
|
+
path: { type: 'array', items: { type: 'integer' }, description: 'Target block path (e.g. [0,2,1]). Provide this OR `ref`.' },
|
|
60
|
+
ref: { type: 'string', description: 'Stable gk_ref of the target block. Survives sibling shifts. Provide this OR `path`.' },
|
|
61
|
+
attributes: { type: 'object', description: 'update-attrs: attributes to merge.' },
|
|
62
|
+
innerHTML: { type: 'string', description: 'update-html: replacement innerHTML.' },
|
|
63
|
+
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
|
+
},
|
|
72
|
+
},
|
|
73
|
+
wrapper: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
description: 'wrap-in-group: optional wrapper block. Default core/group.',
|
|
76
|
+
properties: {
|
|
77
|
+
name: { type: 'string', description: 'Wrapper name. Default "core/group".' },
|
|
78
|
+
attributes: { type: 'object' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
position: { type: ['integer', 'string'], description: 'insert-child: index, "start", or "end" (default).' },
|
|
82
|
+
destination: { type: 'array', items: { type: 'integer' }, description: 'move: destination path (pre-move indexing).' },
|
|
83
|
+
destination_ref: { type: 'string', description: 'move: destination block ref (alternative to destination).' },
|
|
84
|
+
count: { type: 'integer', description: 'move: consecutive blocks to move. Default 1.' },
|
|
85
|
+
},
|
|
86
|
+
required: ['post_id', 'op'],
|
|
87
|
+
},
|
|
88
|
+
}];
|
|
89
|
+
|
|
90
|
+
function isIntegerArray(value: unknown, fieldName: string): value is number[] {
|
|
91
|
+
if (!Array.isArray(value)) {
|
|
92
|
+
throw new Error(`${fieldName} must be an array of integers`);
|
|
93
|
+
}
|
|
94
|
+
for (const item of value) {
|
|
95
|
+
if (typeof item !== 'number' || !Number.isInteger(item)) {
|
|
96
|
+
throw new Error(`${fieldName} must contain only integers, got: ${JSON.stringify(item)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isStaticBlockWarning(warning: unknown): warning is StaticBlockWarning {
|
|
103
|
+
return (
|
|
104
|
+
typeof warning === 'object' &&
|
|
105
|
+
warning !== null &&
|
|
106
|
+
(warning as Record<string, unknown>).type === 'static_markup_stale_risk'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatStaticBlockWarning(warning: StaticBlockWarning): string {
|
|
111
|
+
return `WARNING: Changing ${warning.changed_attrs.join(', ')} on static block ${warning.block_name} without updating innerHTML may leave markup stale.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function handleMutateTool(
|
|
115
|
+
toolName: string,
|
|
116
|
+
args: Record<string, unknown>,
|
|
117
|
+
client: WordPressBlockClient
|
|
118
|
+
): Promise<unknown> {
|
|
119
|
+
if (toolName !== 'edit_block_tree') {
|
|
120
|
+
throw new Error(`Unknown mutate tool: ${toolName}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const postId = args.post_id as number;
|
|
124
|
+
const op = args.op as string;
|
|
125
|
+
const path = args.path;
|
|
126
|
+
const ref = args.ref as string | undefined;
|
|
127
|
+
|
|
128
|
+
if (postId === undefined || postId === null) throw new Error('post_id is required');
|
|
129
|
+
// Op validation comes from the schema enum at request time; this guard
|
|
130
|
+
// exists for direct programmatic callers that bypass the MCP transport.
|
|
131
|
+
if (!op || !(OPS as readonly string[]).includes(op)) {
|
|
132
|
+
throw new Error(`op must be one of: ${OPS.join(', ')}. Got: ${JSON.stringify(op)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const pathProvided = path !== undefined && path !== null;
|
|
136
|
+
const hasRef = typeof ref === 'string' && ref.length > 0;
|
|
137
|
+
|
|
138
|
+
if (pathProvided && hasRef) {
|
|
139
|
+
throw new Error('Provide "path" OR "ref", not both');
|
|
140
|
+
}
|
|
141
|
+
if (!pathProvided && !hasRef) {
|
|
142
|
+
throw new Error('Provide either "path" or "ref" to identify the target block');
|
|
143
|
+
}
|
|
144
|
+
if (pathProvided) {
|
|
145
|
+
// Validate shape — must be an integer array.
|
|
146
|
+
isIntegerArray(path, 'path');
|
|
147
|
+
if ((path as number[]).length === 0) {
|
|
148
|
+
throw new Error('path must not be empty');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const requestBody: MutationRequest = {
|
|
153
|
+
op: op as MutationOp,
|
|
154
|
+
};
|
|
155
|
+
if (pathProvided) {
|
|
156
|
+
requestBody.path = path as number[];
|
|
157
|
+
} else {
|
|
158
|
+
requestBody.ref = ref as string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
switch (op) {
|
|
162
|
+
case 'update-attrs': {
|
|
163
|
+
const attributes = args.attributes as Record<string, unknown> | undefined;
|
|
164
|
+
if (!attributes || typeof attributes !== 'object') throw new Error('update-attrs requires an "attributes" object');
|
|
165
|
+
requestBody.attributes = attributes;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case 'update-html': {
|
|
169
|
+
const innerHTML = args.innerHTML as string | undefined;
|
|
170
|
+
if (innerHTML === undefined || innerHTML === null) throw new Error('update-html requires an "innerHTML" string');
|
|
171
|
+
requestBody.innerHTML = innerHTML;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'replace-block':
|
|
175
|
+
case 'insert-child': {
|
|
176
|
+
const block = args.block as { name?: string } | undefined;
|
|
177
|
+
if (!block || typeof block !== 'object' || !block.name) {
|
|
178
|
+
throw new Error(`${op} requires a "block" object with a "name" property`);
|
|
179
|
+
}
|
|
180
|
+
requestBody.block = block as MutationRequest['block'];
|
|
181
|
+
if (op === 'insert-child' && args.position !== undefined) {
|
|
182
|
+
const position = args.position;
|
|
183
|
+
if (typeof position === 'number' && Number.isInteger(position)) {
|
|
184
|
+
requestBody.position = position;
|
|
185
|
+
} else if (position === 'start' || position === 'end') {
|
|
186
|
+
requestBody.position = position;
|
|
187
|
+
} else {
|
|
188
|
+
throw new Error('position must be an integer, "start", or "end"');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'wrap-in-group': {
|
|
194
|
+
if (args.wrapper !== undefined) requestBody.wrapper = args.wrapper as MutationRequest['wrapper'];
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case 'remove-block':
|
|
198
|
+
case 'unwrap-group':
|
|
199
|
+
case 'duplicate':
|
|
200
|
+
break;
|
|
201
|
+
case 'move': {
|
|
202
|
+
const destination = args.destination;
|
|
203
|
+
const destRef = args.destination_ref as string | undefined;
|
|
204
|
+
|
|
205
|
+
const hasDestination = destination !== undefined && destination !== null;
|
|
206
|
+
const hasDestRef = typeof destRef === 'string' && destRef.length > 0;
|
|
207
|
+
|
|
208
|
+
// XOR: a path and a ref for the same anchor is ambiguous — silently
|
|
209
|
+
// preferring one would make a stale path silently invalidate a fresh ref.
|
|
210
|
+
if (hasDestination && hasDestRef) {
|
|
211
|
+
throw new Error('move: provide "destination" path OR "destination_ref", not both');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (hasDestination) {
|
|
215
|
+
isIntegerArray(destination, 'destination');
|
|
216
|
+
requestBody.destination = destination as number[];
|
|
217
|
+
} else if (hasDestRef) {
|
|
218
|
+
requestBody.destination_ref = destRef as string;
|
|
219
|
+
} else {
|
|
220
|
+
throw new Error('move requires "destination" path or "destination_ref"');
|
|
221
|
+
}
|
|
222
|
+
if (args.count !== undefined && args.count !== null) {
|
|
223
|
+
const count = args.count as number;
|
|
224
|
+
if (typeof count !== 'number' || !Number.isInteger(count) || count < 1) {
|
|
225
|
+
throw new Error('count must be a positive integer');
|
|
226
|
+
}
|
|
227
|
+
requestBody.count = count;
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const result: MutationResponse = await client.mutateBlockTree(postId, requestBody);
|
|
234
|
+
|
|
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;
|
|
243
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern Tools
|
|
3
|
+
*
|
|
4
|
+
* MCP tools for inserting WordPress block patterns into pages.
|
|
5
|
+
* Supports both synced patterns (core/block references that stay linked)
|
|
6
|
+
* and inline insertion (independent copy for per-page customization).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { WordPressBlockClient } from '../client.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tool definitions for the pattern category.
|
|
13
|
+
*/
|
|
14
|
+
export const PATTERN_TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'insert_pattern',
|
|
17
|
+
description:
|
|
18
|
+
'Insert a pattern. Default synced=true inserts a core/block reference (edits to source update all pages); synced=false inlines blocks for per-page edits. NOTE: registered (non-numeric) patterns cannot be synced — server forces synced=false. Response includes `synced` (actual mode used) so you can detect the override.',
|
|
19
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, title: 'Insert pattern' },
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object' as const,
|
|
22
|
+
properties: {
|
|
23
|
+
post_id: {
|
|
24
|
+
type: 'number',
|
|
25
|
+
description: 'Post ID.',
|
|
26
|
+
},
|
|
27
|
+
pattern_id: {
|
|
28
|
+
type: ['number', 'string'],
|
|
29
|
+
description: 'Numeric post ID (synced) or registered pattern name.',
|
|
30
|
+
},
|
|
31
|
+
after_top_level: {
|
|
32
|
+
type: 'number',
|
|
33
|
+
description: 'top_level_counter to insert AFTER. -1/omit = append. Matches insert_blocks naming.',
|
|
34
|
+
},
|
|
35
|
+
before_top_level: {
|
|
36
|
+
type: 'number',
|
|
37
|
+
description: 'top_level_counter to insert BEFORE. Matches insert_blocks naming.',
|
|
38
|
+
},
|
|
39
|
+
synced: {
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
description: 'true (default) = synced reference; false = inline copy.',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ['post_id', 'pattern_id'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Handle a pattern tool call.
|
|
51
|
+
*
|
|
52
|
+
* @param toolName - The name of the tool being called
|
|
53
|
+
* @param args - Tool arguments from the AI agent
|
|
54
|
+
* @param client - WordPress Block API client instance
|
|
55
|
+
* @returns Tool result ready for MCP response
|
|
56
|
+
*/
|
|
57
|
+
export async function handlePatternTool(
|
|
58
|
+
toolName: string,
|
|
59
|
+
args: Record<string, unknown>,
|
|
60
|
+
client: WordPressBlockClient
|
|
61
|
+
): Promise<unknown> {
|
|
62
|
+
switch (toolName) {
|
|
63
|
+
case 'insert_pattern': {
|
|
64
|
+
const postId = args.post_id as number;
|
|
65
|
+
const patternId = args.pattern_id as number | string;
|
|
66
|
+
const after = args.after_top_level as number | undefined;
|
|
67
|
+
const before = args.before_top_level as number | undefined;
|
|
68
|
+
const synced = args.synced as boolean | undefined;
|
|
69
|
+
|
|
70
|
+
if (postId === undefined || postId === null) throw new Error('post_id is required');
|
|
71
|
+
if (patternId === undefined || patternId === null) throw new Error('pattern_id is required');
|
|
72
|
+
|
|
73
|
+
const result = await client.insertPattern(postId, {
|
|
74
|
+
pattern_id: patternId,
|
|
75
|
+
after,
|
|
76
|
+
before,
|
|
77
|
+
synced: synced ?? true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Add a hint about sync behavior
|
|
81
|
+
const syncNote = result.synced
|
|
82
|
+
? 'Pattern inserted as synced reference. Changes to the source pattern will update this page.'
|
|
83
|
+
: 'Pattern blocks inserted inline. This copy is independent and can be edited per-page.';
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
...result,
|
|
87
|
+
note: syncNote,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
default:
|
|
92
|
+
throw new Error(`Unknown pattern tool: ${toolName}`);
|
|
93
|
+
}
|
|
94
|
+
}
|