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