@prenta/mcp-server 0.23.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/CHANGELOG.md +583 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +44 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1348 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1019 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +13 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +61 -0
- package/dist/instructions.js.map +1 -0
- package/dist/navigation.d.ts +76 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +228 -0
- package/dist/navigation.js.map +1 -0
- package/dist/resources.d.ts +28 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +88 -0
- package/dist/resources.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +90 -0
- package/dist/server.js.map +1 -0
- package/dist/tools.d.ts +25 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1920 -0
- package/dist/tools.js.map +1 -0
- package/package.json +43 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1920 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool definitions for Prenta CMS.
|
|
3
|
+
*
|
|
4
|
+
* Each tool is a thin wrapper around an PrentaClient call. We expose a small
|
|
5
|
+
* but powerful set focused on the workflows AI agents actually need:
|
|
6
|
+
* - discover collections and fields
|
|
7
|
+
* - list / get / create / update / delete documents
|
|
8
|
+
* - read / update globals (site settings)
|
|
9
|
+
* - generate pages and blocks via the AI page builder
|
|
10
|
+
* - create complete pages from a prompt in one shot
|
|
11
|
+
*
|
|
12
|
+
* We deliberately do not expose admin-only endpoints (user management,
|
|
13
|
+
* api-key CRUD, audit log) — the agent already has limited scope via its key.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { PrentaClientError } from './client.js';
|
|
17
|
+
import { NAVIGATION_COLLECTION, addNavItem, buildDocPath, coerceNavItems, genNavItemId, removeNavItem, reorderNavLevel, validateNavTree, } from './navigation.js';
|
|
18
|
+
// Convert a Zod object schema to a JSON Schema fragment understood by MCP.
|
|
19
|
+
// The MCP spec accepts standard JSON Schema, but we only need a small subset.
|
|
20
|
+
// Exported so unit tests can exercise nullable / optional / required logic
|
|
21
|
+
// directly without having to fabricate a fake `buildTools()` registration.
|
|
22
|
+
export function zodToJsonSchema(schema) {
|
|
23
|
+
const shape = schema.shape;
|
|
24
|
+
const properties = {};
|
|
25
|
+
const required = [];
|
|
26
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
27
|
+
properties[key] = zodTypeToJsonSchema(value);
|
|
28
|
+
if (!value.isOptional())
|
|
29
|
+
required.push(key);
|
|
30
|
+
}
|
|
31
|
+
const result = { type: 'object', properties };
|
|
32
|
+
if (required.length > 0)
|
|
33
|
+
result.required = required;
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function zodTypeToJsonSchema(schema) {
|
|
37
|
+
// Unwrap optionals / defaults so we look at the inner type. We capture
|
|
38
|
+
// nullable separately because it affects the emitted JSON Schema `type`
|
|
39
|
+
// (the field must accept `null` as well as the inner type) but does NOT
|
|
40
|
+
// affect requiredness — `.nullable()` alone keeps the field required.
|
|
41
|
+
let inner = schema;
|
|
42
|
+
let nullable = false;
|
|
43
|
+
while (inner?._def?.typeName === 'ZodOptional' ||
|
|
44
|
+
inner?._def?.typeName === 'ZodDefault' ||
|
|
45
|
+
inner?._def?.typeName === 'ZodNullable') {
|
|
46
|
+
if (inner._def.typeName === 'ZodNullable')
|
|
47
|
+
nullable = true;
|
|
48
|
+
inner = inner._def.innerType;
|
|
49
|
+
}
|
|
50
|
+
const typeName = inner?._def?.typeName;
|
|
51
|
+
const description = schema?._def?.description ?? inner?._def?.description;
|
|
52
|
+
const base = {};
|
|
53
|
+
if (description)
|
|
54
|
+
base.description = description;
|
|
55
|
+
// Helper: if a field is nullable, emit `type: [..., 'null']` so MCP-aware
|
|
56
|
+
// clients (and JSON-Schema validators) accept an explicit null. We mirror
|
|
57
|
+
// ZodNullable's parse-time semantics into the published schema; otherwise
|
|
58
|
+
// an agent passing `null` to e.g. `move_media_to_folder({ folderId: null })`
|
|
59
|
+
// would be rejected client-side before the tool handler ever sees it.
|
|
60
|
+
function withNull(t) {
|
|
61
|
+
return nullable ? [t, 'null'] : t;
|
|
62
|
+
}
|
|
63
|
+
switch (typeName) {
|
|
64
|
+
case 'ZodString':
|
|
65
|
+
return { ...base, type: withNull('string') };
|
|
66
|
+
case 'ZodNumber':
|
|
67
|
+
return { ...base, type: withNull('number') };
|
|
68
|
+
case 'ZodBoolean':
|
|
69
|
+
return { ...base, type: withNull('boolean') };
|
|
70
|
+
case 'ZodArray':
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
type: withNull('array'),
|
|
74
|
+
items: zodTypeToJsonSchema(inner._def.type),
|
|
75
|
+
};
|
|
76
|
+
case 'ZodObject': {
|
|
77
|
+
// Spread the inner object's properties/required, then re-apply
|
|
78
|
+
// `type` so a nullable ZodObject still publishes `["object", "null"]`
|
|
79
|
+
// (parity with every other branch via `withNull`).
|
|
80
|
+
const obj = zodToJsonSchema(inner);
|
|
81
|
+
return { ...base, ...obj, type: withNull('object') };
|
|
82
|
+
}
|
|
83
|
+
case 'ZodEnum':
|
|
84
|
+
return { ...base, type: withNull('string'), enum: inner._def.values };
|
|
85
|
+
case 'ZodLiteral': {
|
|
86
|
+
// JSON Schema convention: emit `enum: [value]` paired with the
|
|
87
|
+
// JS-level type. We avoid `const` because draft-07 was the first
|
|
88
|
+
// to support it and several MCP clients still target draft-06.
|
|
89
|
+
const value = inner._def.value;
|
|
90
|
+
const t = typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'string';
|
|
91
|
+
return { ...base, type: withNull(t), enum: [value] };
|
|
92
|
+
}
|
|
93
|
+
case 'ZodUnion': {
|
|
94
|
+
// ZodUnion is most commonly used as a "literal-of-N" enum in this
|
|
95
|
+
// codebase (e.g. `statusCode: z.union([z.literal(301), …])`). When
|
|
96
|
+
// every option is a literal of the same JS type we collapse to
|
|
97
|
+
// `{ type, enum: [...] }` which every JSON-Schema validator
|
|
98
|
+
// understands; otherwise we fall back to `oneOf` with the inner
|
|
99
|
+
// sub-schemas so non-literal unions still publish a usable shape.
|
|
100
|
+
const options = inner._def.options ?? [];
|
|
101
|
+
const literals = options.filter((o) => o?._def?.typeName === 'ZodLiteral');
|
|
102
|
+
if (literals.length === options.length && literals.length > 0) {
|
|
103
|
+
const values = literals.map((l) => l._def.value);
|
|
104
|
+
const types = new Set(values.map((v) => typeof v === 'boolean' ? 'boolean' : typeof v === 'number' ? 'number' : 'string'));
|
|
105
|
+
if (types.size === 1) {
|
|
106
|
+
const [t] = [...types];
|
|
107
|
+
return { ...base, type: withNull(t), enum: values };
|
|
108
|
+
}
|
|
109
|
+
// Mixed-type literal union — keep enum but drop type since JSON
|
|
110
|
+
// Schema `type` is a single string (or array of types). Most
|
|
111
|
+
// validators allow `enum` without `type`.
|
|
112
|
+
return { ...base, enum: values };
|
|
113
|
+
}
|
|
114
|
+
return { ...base, oneOf: options.map((o) => zodTypeToJsonSchema(o)) };
|
|
115
|
+
}
|
|
116
|
+
case 'ZodRecord':
|
|
117
|
+
return { ...base, type: withNull('object'), additionalProperties: true };
|
|
118
|
+
case 'ZodAny':
|
|
119
|
+
case 'ZodUnknown':
|
|
120
|
+
return { ...base };
|
|
121
|
+
default:
|
|
122
|
+
return { ...base, type: withNull('string') };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function buildTools(client) {
|
|
126
|
+
const tools = [];
|
|
127
|
+
function tool(name, description, schema, handler) {
|
|
128
|
+
tools.push({
|
|
129
|
+
name,
|
|
130
|
+
description,
|
|
131
|
+
inputSchema: zodToJsonSchema(schema),
|
|
132
|
+
handler: async (args) => {
|
|
133
|
+
const parsed = schema.parse(args ?? {});
|
|
134
|
+
return await handler(parsed);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
tool('list_documents', 'List documents in a collection. Returns paginated results.', z.object({
|
|
139
|
+
collection: z.string().describe('Collection slug (e.g. "pages", "posts")'),
|
|
140
|
+
page: z.number().int().min(1).optional().describe('1-indexed page number'),
|
|
141
|
+
pageSize: z.number().int().min(1).max(100).optional().describe('Items per page (max 100)'),
|
|
142
|
+
status: z.string().optional().describe('Filter by status (DRAFT, PUBLISHED, SCHEDULED)'),
|
|
143
|
+
search: z.string().optional().describe('Free-text search against title and body'),
|
|
144
|
+
}), (args) => client.listDocuments(args.collection, args));
|
|
145
|
+
tool('get_document', 'Fetch a single document by ID.', z.object({
|
|
146
|
+
collection: z.string(),
|
|
147
|
+
id: z.string().describe('Document ID'),
|
|
148
|
+
}), (args) => client.getDocument(args.collection, args.id));
|
|
149
|
+
tool('create_document', 'Create a new document in a collection. Defaults to DRAFT status.', z.object({
|
|
150
|
+
collection: z.string(),
|
|
151
|
+
title: z.string().optional(),
|
|
152
|
+
slug: z.string().optional(),
|
|
153
|
+
status: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional(),
|
|
154
|
+
data: z
|
|
155
|
+
.record(z.any())
|
|
156
|
+
.optional()
|
|
157
|
+
.describe('Arbitrary field data — depends on the collection schema'),
|
|
158
|
+
layout: z.any().optional().describe('Page-builder layout tree (for page collections)'),
|
|
159
|
+
pageSettings: z.record(z.any()).optional().describe('SEO and meta settings'),
|
|
160
|
+
}), (args) => {
|
|
161
|
+
const { collection, data, ...rest } = args;
|
|
162
|
+
return client.createDocument(collection, { ...(data ?? {}), ...rest });
|
|
163
|
+
});
|
|
164
|
+
tool('update_document', 'Update fields on an existing document. Only the provided top-level fields are changed (shallow merge). Nested objects of type `media` (e.g. `featuredImage`) are deep-merged so a partial `{ alt }` patch keeps `url`/`id`. Still prefer resending the full media object when you have it. A media object with neither `id` nor `url` is rejected as a likely partial-update accident.', z.object({
|
|
165
|
+
collection: z.string().describe('Collection slug (required).'),
|
|
166
|
+
id: z.string(),
|
|
167
|
+
title: z.string().optional(),
|
|
168
|
+
slug: z.string().optional(),
|
|
169
|
+
status: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional(),
|
|
170
|
+
data: z.record(z.any()).optional(),
|
|
171
|
+
layout: z.any().optional(),
|
|
172
|
+
pageSettings: z.record(z.any()).optional(),
|
|
173
|
+
}), (args) => {
|
|
174
|
+
const { collection, id, data, ...rest } = args;
|
|
175
|
+
return client.updateDocument(collection, id, { ...(data ?? {}), ...rest });
|
|
176
|
+
});
|
|
177
|
+
tool('delete_document', 'Soft-delete a document. Destructive — requires `confirm: true` (the wrapper refuses otherwise).', z.object({
|
|
178
|
+
collection: z.string(),
|
|
179
|
+
id: z.string(),
|
|
180
|
+
confirm: z
|
|
181
|
+
.literal(true)
|
|
182
|
+
.describe('Must be `true` to acknowledge this destructive action. Confirm with the user first.'),
|
|
183
|
+
}), (args) => {
|
|
184
|
+
if (args.confirm !== true) {
|
|
185
|
+
throw new Error('delete_document requires confirm: true to acknowledge a destructive action.');
|
|
186
|
+
}
|
|
187
|
+
return client.deleteDocument(args.collection, args.id);
|
|
188
|
+
});
|
|
189
|
+
tool('get_global', 'Fetch a global (e.g. site settings).', z.object({ slug: z.string().describe('Global slug (e.g. "site-settings")') }), (args) => client.getGlobal(args.slug));
|
|
190
|
+
tool('update_global', 'Update a global.', z.object({ slug: z.string(), data: z.record(z.any()) }), (args) => client.updateGlobal(args.slug, args.data));
|
|
191
|
+
tool('list_media', 'List media assets in the CMS library.', z.object({
|
|
192
|
+
page: z.number().int().min(1).optional(),
|
|
193
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
194
|
+
}), (args) => client.listMedia(args));
|
|
195
|
+
// ── Media write tools (Phase 7 MCP-parity, PR A) ──────────────────────
|
|
196
|
+
//
|
|
197
|
+
// Five tools that mirror the existing /media/* admin endpoints. Together
|
|
198
|
+
// with `list_media` they give an agent a complete media-library surface:
|
|
199
|
+
// upload bytes, edit alt/title/focal point, recompress, move into a
|
|
200
|
+
// folder, delete.
|
|
201
|
+
//
|
|
202
|
+
// upload_media accepts the file as base64-encoded bytes. We keep the
|
|
203
|
+
// base64 boundary inside the tool wrapper so the underlying client
|
|
204
|
+
// method stays binary (Uint8Array) — this is the standard MCP pattern
|
|
205
|
+
// for binary payloads since JSON-RPC has no native binary type.
|
|
206
|
+
tool('upload_media', 'Upload a file (image, document, video, etc.) to the CMS media library. **Required first step for every image** — do not use /public/ or static paths in content. Images are auto-optimized on upload: resize when >4096px, compress to high-quality WebP (q82 photos, q86 transparent PNGs), keep original bytes if WebP would be larger. Pass skipOptimize=true only for brand logos/favicons. After upload, call `generate_media_alt` then `update_media` to set alt text. The server enforces a 50 MB hard cap and validates magic bytes. Byte-identical re-uploads (same final stored bytes, e.g. seed re-runs) return the existing record with `deduplicated: true` and `duplicateOf` instead of creating another blob — pass `forceUpload: true` to bypass.', z.object({
|
|
207
|
+
filename: z
|
|
208
|
+
.string()
|
|
209
|
+
.min(1)
|
|
210
|
+
.describe('Filename including extension (e.g. "hero.jpg"). Drives the stored extension.'),
|
|
211
|
+
mimeType: z
|
|
212
|
+
.string()
|
|
213
|
+
.min(1)
|
|
214
|
+
.describe('MIME type of the file (e.g. "image/jpeg", "image/png", "image/svg+xml", "application/pdf"). Must match the actual bytes — magic-byte mismatch returns HTTP 415.'),
|
|
215
|
+
dataBase64: z
|
|
216
|
+
.string()
|
|
217
|
+
.min(1)
|
|
218
|
+
.describe('Base64-encoded file bytes. Standard base64 (with or without padding). Decoded payload must be ≤ 50 MB.'),
|
|
219
|
+
skipOptimize: z
|
|
220
|
+
.boolean()
|
|
221
|
+
.optional()
|
|
222
|
+
.describe('When true, store the original bytes verbatim. Default false.'),
|
|
223
|
+
forceUpload: z
|
|
224
|
+
.boolean()
|
|
225
|
+
.optional()
|
|
226
|
+
.describe('When true, always create a new media row even if identical bytes already exist. Default false (dedupe).'),
|
|
227
|
+
}), (args) => {
|
|
228
|
+
// Strip whitespace + optional data-URL prefix that some agents emit.
|
|
229
|
+
// Use a lazy `[^,]*?` match so the regex tolerates parameterised
|
|
230
|
+
// media types (e.g. `data:text/plain;charset=utf-8;base64,...`).
|
|
231
|
+
// A greedy `[^;]+;base64,` pattern would fail on the second `;` and
|
|
232
|
+
// silently leave the prefix in place, causing `Buffer.from(...,
|
|
233
|
+
// 'base64')` to skip non-base64 chars and decode garbage instead.
|
|
234
|
+
const cleaned = args.dataBase64.replace(/\s+/g, '').replace(/^data:[^,]*?;base64,/i, '');
|
|
235
|
+
const bytes = Buffer.from(cleaned, 'base64');
|
|
236
|
+
if (bytes.length === 0) {
|
|
237
|
+
throw new Error('dataBase64 decoded to zero bytes — check the encoding.');
|
|
238
|
+
}
|
|
239
|
+
return client.uploadMedia({
|
|
240
|
+
filename: args.filename,
|
|
241
|
+
mimeType: args.mimeType,
|
|
242
|
+
bytes,
|
|
243
|
+
...(args.skipOptimize !== undefined ? { skipOptimize: args.skipOptimize } : {}),
|
|
244
|
+
...(args.forceUpload !== undefined ? { forceUpload: args.forceUpload } : {}),
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
tool('update_media', 'Update metadata on an existing media item. Only the supplied fields are written. Use this to set alt text (accessibility), title, description, caption, focus keyword, focal points (smart-crop hints; both in 0..1 normalised coordinates), or `isDecorative` for ornamental images that correctly use empty alt. Media SEO scoring weights these fields as alt 30, title 20, description 20, caption 10, descriptive filename 10, focus keyword 10 — so alt + title + a descriptive filename alone caps an asset at 60 ("needs-work"), and adding a description is what lifts it to a passing 80 ("good"). Decorative assets (`isDecorative: true`) earn the alt (+ description) weight with empty alt and must not be "fixed" by inventing alt text.', z.object({
|
|
248
|
+
id: z.string().describe('Media item ID.'),
|
|
249
|
+
alt: z.string().optional().describe('Alt text for accessibility. Stored as `altText`.'),
|
|
250
|
+
title: z.string().optional(),
|
|
251
|
+
filename: z.string().optional().describe('Rename the file without re-uploading.'),
|
|
252
|
+
description: z
|
|
253
|
+
.string()
|
|
254
|
+
.optional()
|
|
255
|
+
.describe('Longer-form context for search and reuse. Worth 20 points of the media SEO score — the usual difference between a "needs-work" and a "good" grade.'),
|
|
256
|
+
caption: z
|
|
257
|
+
.string()
|
|
258
|
+
.optional()
|
|
259
|
+
.describe('Display caption. Also used as the transcript/caption signal for video assets.'),
|
|
260
|
+
focusKeyword: z.string().optional().describe('Primary keyword this asset targets.'),
|
|
261
|
+
isDecorative: z
|
|
262
|
+
.boolean()
|
|
263
|
+
.optional()
|
|
264
|
+
.describe('When true, marks the image as WCAG-decorative: forces empty alt and awards media SEO alt credit. When false, clears the flag. A non-empty `alt` also clears the flag.'),
|
|
265
|
+
focalX: z
|
|
266
|
+
.number()
|
|
267
|
+
.min(0)
|
|
268
|
+
.max(1)
|
|
269
|
+
.optional()
|
|
270
|
+
.describe('Horizontal focal point, 0 (left) to 1 (right).'),
|
|
271
|
+
focalY: z
|
|
272
|
+
.number()
|
|
273
|
+
.min(0)
|
|
274
|
+
.max(1)
|
|
275
|
+
.optional()
|
|
276
|
+
.describe('Vertical focal point, 0 (top) to 1 (bottom).'),
|
|
277
|
+
}), (args) => {
|
|
278
|
+
const { id, ...body } = args;
|
|
279
|
+
return client.updateMedia(id, body);
|
|
280
|
+
});
|
|
281
|
+
tool('generate_media_alt', 'Generate WCAG-aligned alt text for a media-library image using a vision model (when configured). Requires `ai.features.mediaAltText` enabled in Settings → AI. Returns `{ alt, title, alternatives, isDecorative, confidence, usedVision }` as a **suggestion** — review it, then persist with `update_media({ id, alt })` or, when `isDecorative` is true, `update_media({ id, alt: "", isDecorative: true })`. Prefer `mediaId` over raw URLs. Workflow: `upload_media` → `generate_media_alt` → `update_media`.', z.object({
|
|
282
|
+
mediaId: z
|
|
283
|
+
.string()
|
|
284
|
+
.optional()
|
|
285
|
+
.describe('Media item ID from upload_media or list_media (preferred).'),
|
|
286
|
+
imageUrl: z
|
|
287
|
+
.string()
|
|
288
|
+
.optional()
|
|
289
|
+
.describe('Fallback: a storage-allowlisted image URL when mediaId is unknown.'),
|
|
290
|
+
pageTitle: z
|
|
291
|
+
.string()
|
|
292
|
+
.optional()
|
|
293
|
+
.describe('Page or post title where the image will appear — improves context.'),
|
|
294
|
+
surroundingText: z
|
|
295
|
+
.string()
|
|
296
|
+
.optional()
|
|
297
|
+
.describe('Nearby heading or caption text for context (max ~300 chars used).'),
|
|
298
|
+
purpose: z
|
|
299
|
+
.enum(['decorative', 'informative', 'functional'])
|
|
300
|
+
.optional()
|
|
301
|
+
.describe('Image role hint. Use decorative for purely ornamental images (returns empty alt).'),
|
|
302
|
+
}), (args) => {
|
|
303
|
+
if (!args.mediaId && !args.imageUrl) {
|
|
304
|
+
throw new Error('generate_media_alt requires mediaId or imageUrl.');
|
|
305
|
+
}
|
|
306
|
+
return client.generateMediaAlt(args);
|
|
307
|
+
});
|
|
308
|
+
tool('delete_media', 'Delete a media item AND its underlying blob storage object (best-effort). Destructive — requires `confirm: true` (the wrapper refuses otherwise). Returns `{ data: { success: true } }`.', z.object({
|
|
309
|
+
id: z.string().describe('Media item ID.'),
|
|
310
|
+
confirm: z
|
|
311
|
+
.literal(true)
|
|
312
|
+
.describe('Must be `true` to acknowledge this destructive action. Confirm with the user first.'),
|
|
313
|
+
}), (args) => {
|
|
314
|
+
if (args.confirm !== true) {
|
|
315
|
+
throw new Error('delete_media requires confirm: true to acknowledge a destructive action.');
|
|
316
|
+
}
|
|
317
|
+
return client.deleteMedia(args.id);
|
|
318
|
+
});
|
|
319
|
+
const mediaMetadataFields = {
|
|
320
|
+
alt: z.string().optional().describe('Alt text for accessibility. Stored as `altText`.'),
|
|
321
|
+
title: z.string().optional(),
|
|
322
|
+
filename: z.string().optional().describe('Rename the file without re-uploading.'),
|
|
323
|
+
description: z.string().optional().describe('Longer-form context for search and reuse.'),
|
|
324
|
+
caption: z.string().optional().describe('Display caption / video transcript signal.'),
|
|
325
|
+
focusKeyword: z.string().optional().describe('Primary keyword this asset targets.'),
|
|
326
|
+
isDecorative: z
|
|
327
|
+
.boolean()
|
|
328
|
+
.optional()
|
|
329
|
+
.describe('When true, marks the image as WCAG-decorative (forces empty alt).'),
|
|
330
|
+
focalX: z.number().min(0).max(1).optional(),
|
|
331
|
+
focalY: z.number().min(0).max(1).optional(),
|
|
332
|
+
};
|
|
333
|
+
tool('bulk_update_media', 'Update metadata on many media items in one round trip (max 50). Each item carries its own patch — unlike form bulk tools, there is no shared patch. Returns per-id `{ ok, data|error }` so a library backfill can land the successes even when a few ids fail. Prefer this over looping `update_media` for alt/title/description passes.', z.object({
|
|
334
|
+
items: z
|
|
335
|
+
.array(z.object({
|
|
336
|
+
id: z.string().describe('Media item ID.'),
|
|
337
|
+
...mediaMetadataFields,
|
|
338
|
+
}))
|
|
339
|
+
.min(1)
|
|
340
|
+
.max(50)
|
|
341
|
+
.describe('Per-item metadata patches (1–50).'),
|
|
342
|
+
}), (args) => client.bulkUpdateMedia(args.items));
|
|
343
|
+
tool('bulk_delete_media', 'Delete many media items AND their blob storage objects (best-effort, max 50). Destructive — requires `confirm: true`. Returns per-id success/failure so missing ids do not abort the rest of the batch.', z.object({
|
|
344
|
+
ids: z.array(z.string()).min(1).max(50).describe('Media item IDs to delete (1–50).'),
|
|
345
|
+
confirm: z
|
|
346
|
+
.literal(true)
|
|
347
|
+
.describe('Must be `true` to acknowledge this destructive action. Confirm with the user first.'),
|
|
348
|
+
}), (args) => {
|
|
349
|
+
if (args.confirm !== true) {
|
|
350
|
+
throw new Error('bulk_delete_media requires confirm: true to acknowledge a destructive action.');
|
|
351
|
+
}
|
|
352
|
+
return client.bulkDeleteMedia(args.ids, true);
|
|
353
|
+
});
|
|
354
|
+
tool('optimize_media', 'Recompress an existing image to WebP. Returns the updated media row plus an `optimization` summary (`{ originalSize, optimizedSize, savings, ... }`). Already-WebP files and non-images short-circuit with `alreadyOptimized: true`. Useful for batch "shrink everything in this folder" workflows.', z.object({
|
|
355
|
+
id: z.string().describe('Media item ID.'),
|
|
356
|
+
}), (args) => client.optimizeMedia(args.id));
|
|
357
|
+
tool('move_media_to_folder', 'Move a media item into a folder, or out of every folder by passing folderId=null. The server validates that the target folder is in the media scope.', z.object({
|
|
358
|
+
id: z.string().describe('Media item ID.'),
|
|
359
|
+
folderId: z
|
|
360
|
+
.string()
|
|
361
|
+
.nullable()
|
|
362
|
+
.describe('Destination folder ID, or null to remove the item from any folder.'),
|
|
363
|
+
}), (args) => client.moveMediaToFolder(args.id, args.folderId));
|
|
364
|
+
// ── Folder tools (Phase 7 MCP-parity, PR A) ───────────────────────────
|
|
365
|
+
//
|
|
366
|
+
// Folders are scoped per-surface: "media" for the media library,
|
|
367
|
+
// "documents" / "collections" for document folders. A folder can only
|
|
368
|
+
// contain children that match its scope — the server enforces this.
|
|
369
|
+
tool('list_folders', 'List folders in a given scope as a nested tree. Each node is `{ id, name, scope, parentId, position, children: [...] }`. Scopes today: "media" for the media library, "documents" or "collections" for document folders. The scope arg is REQUIRED — there is no all-scopes endpoint.', z.object({
|
|
370
|
+
scope: z
|
|
371
|
+
.string()
|
|
372
|
+
.min(1)
|
|
373
|
+
.describe('Folder scope to list. Typical values: "media", "documents", "collections".'),
|
|
374
|
+
}), (args) => client.listFolders(args.scope));
|
|
375
|
+
tool('create_folder', 'Create a folder. If parentId is supplied it MUST already exist AND live in the same scope — the server refuses cross-scope reparenting.', z.object({
|
|
376
|
+
name: z.string().min(1).describe('Folder display name.'),
|
|
377
|
+
scope: z
|
|
378
|
+
.string()
|
|
379
|
+
.min(1)
|
|
380
|
+
.describe('Folder scope. Typical values: "media", "documents", "collections".'),
|
|
381
|
+
parentId: z
|
|
382
|
+
.string()
|
|
383
|
+
.nullable()
|
|
384
|
+
.optional()
|
|
385
|
+
.describe('Parent folder ID, or null/undefined for a root-level folder.'),
|
|
386
|
+
}), (args) => client.createFolder({
|
|
387
|
+
name: args.name,
|
|
388
|
+
scope: args.scope,
|
|
389
|
+
...(args.parentId !== undefined ? { parentId: args.parentId } : {}),
|
|
390
|
+
}));
|
|
391
|
+
tool('update_folder', 'Rename, reparent, or reorder a folder. Pass any subset of { name, parentId, position }. Use parentId=null to promote to root. The server prevents self-parenting and cross-scope reparenting.', z.object({
|
|
392
|
+
id: z.string().describe('Folder ID.'),
|
|
393
|
+
name: z.string().optional(),
|
|
394
|
+
parentId: z
|
|
395
|
+
.string()
|
|
396
|
+
.nullable()
|
|
397
|
+
.optional()
|
|
398
|
+
.describe('New parent folder ID, or null to promote to root. Omit to leave unchanged.'),
|
|
399
|
+
position: z
|
|
400
|
+
.number()
|
|
401
|
+
.int()
|
|
402
|
+
.min(0)
|
|
403
|
+
.optional()
|
|
404
|
+
.describe('Sort position within the parent. Omit to leave unchanged.'),
|
|
405
|
+
}), (args) => {
|
|
406
|
+
const { id, ...body } = args;
|
|
407
|
+
return client.updateFolder(id, body);
|
|
408
|
+
});
|
|
409
|
+
tool('delete_folder', 'Delete a folder. Refuses with HTTP 409 if the folder still contains documents, media, or sub-folders. Move or delete the contents first (use move_document_to_folder / move_media_to_folder, then delete_folder).', z.object({
|
|
410
|
+
id: z.string().describe('Folder ID.'),
|
|
411
|
+
}), (args) => client.deleteFolder(args.id));
|
|
412
|
+
tool('move_document_to_folder', 'Move a document into a folder, or out of every folder by passing folderId=null. The server validates that the target folder is in the documents / collections scope.', z.object({
|
|
413
|
+
id: z.string().describe('Document ID.'),
|
|
414
|
+
folderId: z
|
|
415
|
+
.string()
|
|
416
|
+
.nullable()
|
|
417
|
+
.describe('Destination folder ID, or null to remove the document from any folder.'),
|
|
418
|
+
}), (args) => client.moveDocumentToFolder(args.id, args.folderId));
|
|
419
|
+
// ── Version history tools (Phase 7 MCP-parity, PR B) ──────────────────
|
|
420
|
+
//
|
|
421
|
+
// Versions are immutable per-write snapshots living in `prenta_versions`.
|
|
422
|
+
// Listing is paged (default 20, max 50). Restore goes through the normal
|
|
423
|
+
// updateDocument path, which means it (a) runs hooks, (b) creates a NEW
|
|
424
|
+
// version row (history is never rewritten), (c) emits an audit log.
|
|
425
|
+
//
|
|
426
|
+
// Naming convention note: collection-scoped tools use `id` (matching
|
|
427
|
+
// `get_document`, `update_document`, `delete_document`, `audit_document`,
|
|
428
|
+
// `move_document_to_folder`). The collection slug disambiguates which
|
|
429
|
+
// `id` we mean, so `documentId` would be redundant and split the
|
|
430
|
+
// agent's mental model across two synonyms.
|
|
431
|
+
tool('list_versions', 'List the immutable change history of a document, newest first. Each version carries the full `data` payload at the time of write, plus the user who made the change. Paged: default 20 / max 50.', z.object({
|
|
432
|
+
collection: z.string().describe('Collection slug (e.g. "posts").'),
|
|
433
|
+
id: z.string().describe('Document ID.'),
|
|
434
|
+
page: z.number().int().min(1).optional(),
|
|
435
|
+
pageSize: z.number().int().min(1).max(50).optional(),
|
|
436
|
+
}), (args) => {
|
|
437
|
+
const { collection, id, ...query } = args;
|
|
438
|
+
return client.listVersions(collection, id, query);
|
|
439
|
+
});
|
|
440
|
+
tool('restore_version', 'Roll a document back to a prior version. The restore goes through the normal updateDocument path, which means hooks run, a NEW version row is created (history is never rewritten), and a `version_restored` event is appended to prenta_audit_logs. Pair with list_versions to discover restorable IDs.', z.object({
|
|
441
|
+
collection: z.string().describe('Collection slug.'),
|
|
442
|
+
id: z.string().describe('Document ID.'),
|
|
443
|
+
versionId: z.string().describe('Version row ID to roll back to.'),
|
|
444
|
+
}), (args) => client.restoreVersion(args.collection, args.id, args.versionId));
|
|
445
|
+
// ── Scheduling tools (Phase 7 MCP-parity, PR B) ───────────────────────
|
|
446
|
+
//
|
|
447
|
+
// Scheduling is intentionally a two-step model: this writes the future
|
|
448
|
+
// timestamps on the document, and the `/cron/publish` cron performs the
|
|
449
|
+
// actual status flip. That decoupling means a missed cron tick just
|
|
450
|
+
// delays the publish — it can never double-publish or skip a doc.
|
|
451
|
+
//
|
|
452
|
+
// Null semantics asymmetry (server-side, by design):
|
|
453
|
+
// - scheduledUnpublishAt: null → clears any existing unpublish field.
|
|
454
|
+
// - scheduledAt: null → silently no-op. The way to clear a
|
|
455
|
+
// pending publish is `unschedule_publish`.
|
|
456
|
+
// We only mark `scheduledUnpublishAt` as nullable in the schema so the
|
|
457
|
+
// published JSON Schema accurately reflects what the server accepts;
|
|
458
|
+
// promising `scheduledAt: null` would be misleading.
|
|
459
|
+
tool('schedule_publish', 'Schedule a document to publish and/or unpublish at a future timestamp. Pass at least one of `scheduledAt` (when to publish — forces status → SCHEDULED) or `scheduledUnpublishAt` (when to unpublish; pass null to clear an existing unpublish without cancelling the publish schedule). Both must be ISO 8601 strings AND in the future; `scheduledUnpublishAt` must be after `scheduledAt` when both are provided.', z.object({
|
|
460
|
+
collection: z.string().describe('Collection slug.'),
|
|
461
|
+
id: z.string().describe('Document ID.'),
|
|
462
|
+
scheduledAt: z
|
|
463
|
+
.string()
|
|
464
|
+
.optional()
|
|
465
|
+
.describe('ISO 8601 timestamp at which to publish. Must be in the future.'),
|
|
466
|
+
scheduledUnpublishAt: z
|
|
467
|
+
.string()
|
|
468
|
+
.nullable()
|
|
469
|
+
.optional()
|
|
470
|
+
.describe('ISO 8601 timestamp at which to unpublish. Must be in the future AND after scheduledAt when both are set. Pass null to clear an existing unpublish timestamp without cancelling the publish schedule.'),
|
|
471
|
+
}), (args) => {
|
|
472
|
+
// The server returns HTTP 400 when neither field is present. Fail
|
|
473
|
+
// fast at the wrapper so an empty call doesn't burn a round-trip.
|
|
474
|
+
// We treat "field present with null" as a deliberate clear, so it
|
|
475
|
+
// counts as "provided" here (matches server semantics for unpublishAt).
|
|
476
|
+
if (args.scheduledAt === undefined && args.scheduledUnpublishAt === undefined) {
|
|
477
|
+
throw new Error('At least one of scheduledAt or scheduledUnpublishAt must be provided.');
|
|
478
|
+
}
|
|
479
|
+
const { collection, id, ...body } = args;
|
|
480
|
+
return client.schedulePublish(collection, id, body);
|
|
481
|
+
});
|
|
482
|
+
tool('unschedule_publish', 'Cancel any pending publish + unpublish schedule for the document. If the document was in SCHEDULED (not yet published) it drops back to DRAFT. Already-published documents stay live.', z.object({
|
|
483
|
+
collection: z.string().describe('Collection slug.'),
|
|
484
|
+
id: z.string().describe('Document ID.'),
|
|
485
|
+
}), (args) => client.unschedulePublish(args.collection, args.id));
|
|
486
|
+
// ── Editorial workflow tools (Phase 7 MCP-parity, PR B) ───────────────
|
|
487
|
+
//
|
|
488
|
+
// The workflow state machine lives in `packages/cms-core/src/workflow`:
|
|
489
|
+
// DRAFT → IN_REVIEW → APPROVED → PUBLISHED → ARCHIVED
|
|
490
|
+
// Each transition is gated by both (a) the from→to edge being legal and
|
|
491
|
+
// (b) the caller's role allowing it. `get_workflow_state` returns the
|
|
492
|
+
// role-filtered list of legal next stages for the calling agent.
|
|
493
|
+
tool('get_workflow_state', 'Read the editorial workflow state of a document. Returns: { stage, transitions, reviewerId, reviewNote }. `transitions` is the list of next stages the *current API key holder* is allowed to move to — already filtered by role, so the agent can present them to a human as-is. Workflow stages: DRAFT → IN_REVIEW → APPROVED → PUBLISHED → ARCHIVED.', z.object({
|
|
494
|
+
collection: z.string().describe('Collection slug.'),
|
|
495
|
+
id: z.string().describe('Document ID.'),
|
|
496
|
+
}), (args) => client.getWorkflowState(args.collection, args.id));
|
|
497
|
+
tool('transition_workflow', "Move a document to a new workflow stage. The server validates that (a) the transition is allowed from the current stage and (b) the caller's role permits it; either failure returns HTTP 400 with the reason. Pair with get_workflow_state to discover which transitions are legal before calling.", z.object({
|
|
498
|
+
collection: z.string().describe('Collection slug.'),
|
|
499
|
+
id: z.string().describe('Document ID.'),
|
|
500
|
+
stage: z
|
|
501
|
+
.enum(['DRAFT', 'IN_REVIEW', 'APPROVED', 'PUBLISHED', 'ARCHIVED'])
|
|
502
|
+
.describe('Target workflow stage.'),
|
|
503
|
+
note: z.string().optional().describe('Optional reviewer note attached to the transition.'),
|
|
504
|
+
}), (args) => {
|
|
505
|
+
const { collection, id, ...body } = args;
|
|
506
|
+
return client.transitionWorkflow(collection, id, body);
|
|
507
|
+
});
|
|
508
|
+
// ── Comment tools (Phase 7 MCP-parity, PR D) ──────────────────────────
|
|
509
|
+
//
|
|
510
|
+
// Comments are how editors leave structured feedback on drafts. The
|
|
511
|
+
// server records each comment + reply with optional `anchor` (a text
|
|
512
|
+
// range that the comment pins to in the document), persists them in
|
|
513
|
+
// `prenta_document_comments`, fires an audit event, and dispatches a
|
|
514
|
+
// best-effort notification to mentioned users.
|
|
515
|
+
//
|
|
516
|
+
// Naming convention note: comment tools take `id` (the document ID),
|
|
517
|
+
// matching the PR B convention. No collection slug is needed because
|
|
518
|
+
// comments are attached to documents by ID alone — the underlying
|
|
519
|
+
// route is `/documents/:documentId/comments`, not collection-scoped.
|
|
520
|
+
tool('list_comments', 'List the comment thread attached to a document. By default returns only unresolved, non-deleted comments. Set `includeResolved: true` to widen the result to resolved threads. `includeDeleted` is admin-only and surfaces soft-deleted moderation history; non-admin API keys get HTTP 403.', z.object({
|
|
521
|
+
id: z.string().describe('Document ID.'),
|
|
522
|
+
includeResolved: z.boolean().optional().describe('Include resolved threads. Default false.'),
|
|
523
|
+
includeDeleted: z
|
|
524
|
+
.boolean()
|
|
525
|
+
.optional()
|
|
526
|
+
.describe('Include soft-deleted comments (admin-only — non-admin keys receive HTTP 403). Default false.'),
|
|
527
|
+
pageSize: z.number().int().min(1).max(200).optional(),
|
|
528
|
+
}), (args) => {
|
|
529
|
+
const { id, ...query } = args;
|
|
530
|
+
return client.listComments(id, query);
|
|
531
|
+
});
|
|
532
|
+
tool('create_comment', "Create a comment on a document. Pass `anchor` to pin the comment to a specific text range (used by the admin's rich-text selection UI). Pass `parentId` to reply to an existing comment. Posting a comment emits a `comment_created` audit event and triggers best-effort notification dispatch for any mentioned users.", z.object({
|
|
533
|
+
id: z.string().describe('Document ID.'),
|
|
534
|
+
body: z
|
|
535
|
+
.string()
|
|
536
|
+
.min(1)
|
|
537
|
+
.describe('Comment text. Required; empty strings are rejected by the server.'),
|
|
538
|
+
anchor: z
|
|
539
|
+
.object({
|
|
540
|
+
from: z
|
|
541
|
+
.string()
|
|
542
|
+
.max(4096)
|
|
543
|
+
.describe('Anchor start marker. Opaque to the server (≤ 4096 chars).'),
|
|
544
|
+
to: z
|
|
545
|
+
.string()
|
|
546
|
+
.max(4096)
|
|
547
|
+
.describe('Anchor end marker. Opaque to the server (≤ 4096 chars).'),
|
|
548
|
+
})
|
|
549
|
+
.nullable()
|
|
550
|
+
.optional()
|
|
551
|
+
.describe('Optional anchor pinning the comment to a text range. Pass null or omit for a thread-level comment.'),
|
|
552
|
+
parentId: z
|
|
553
|
+
.string()
|
|
554
|
+
.nullable()
|
|
555
|
+
.optional()
|
|
556
|
+
.describe('Parent comment ID for thread replies. Omit or pass null for top-level.'),
|
|
557
|
+
}), (args) => {
|
|
558
|
+
const { id, ...body } = args;
|
|
559
|
+
return client.createComment(id, body);
|
|
560
|
+
});
|
|
561
|
+
// ── Page-builder accessibility tools (Phase 7 MCP-parity, PR D) ───────
|
|
562
|
+
//
|
|
563
|
+
// The audit + fix pair operates on a page-builder tree (the JSON blob
|
|
564
|
+
// stored in a page document's `data.tree`). Both endpoints are
|
|
565
|
+
// deterministic — no LLM calls — so they're cheap to call on every
|
|
566
|
+
// save / debounced edit. `audit_page_a11y` is read-only; `fix_page_a11y`
|
|
567
|
+
// mutates the tree and emits a `settings_changed` audit event.
|
|
568
|
+
//
|
|
569
|
+
// Tree typing: we accept any JSON object (z.record(z.unknown())) —
|
|
570
|
+
// strings, numbers, arrays, and null are rejected at the Zod layer
|
|
571
|
+
// with a clear schema error. The page-builder schema itself is large
|
|
572
|
+
// and evolving, so we don't try to mirror it field-for-field; the
|
|
573
|
+
// server validates the top-level shape (`tree.type === 'page'`)
|
|
574
|
+
// before processing.
|
|
575
|
+
tool('audit_page_a11y', 'Run a deterministic accessibility audit over a page-builder tree. Detects missing alt text, heading-level skips, missing form labels, missing ARIA attributes, and undersized touch targets. Each returned issue carries `autoFixable: boolean` so the caller can decide whether to follow up with `fix_page_a11y` (auto-fix) or surface it for human review.', z.object({
|
|
576
|
+
tree: z
|
|
577
|
+
.record(z.unknown())
|
|
578
|
+
.describe('Page-builder tree (the JSON object from a page document\'s `data.tree`). Top-level node must have `type: "page"` or the server returns HTTP 400.'),
|
|
579
|
+
}), (args) => client.auditPageA11y(args.tree));
|
|
580
|
+
tool('fix_page_a11y', 'Auto-fix the accessibility issues marked `autoFixable: true` from `audit_page_a11y` and return the mutated tree plus a report (`fixedCount` / `remainingCount`). Non-auto-fixable issues are returned in `report.issues` so the caller can surface them for human follow-up. The server emits a `settings_changed` audit event with the fix counts.', z.object({
|
|
581
|
+
tree: z
|
|
582
|
+
.record(z.unknown())
|
|
583
|
+
.describe('Page-builder tree. Top-level node must have `type: "page"` or the server returns HTTP 400.'),
|
|
584
|
+
}), (args) => client.fixPageA11y(args.tree));
|
|
585
|
+
// ── Comment thread management (Phase 7 MCP-parity, PR E) ──────────────
|
|
586
|
+
//
|
|
587
|
+
// P1 follow-up to PR D's `list_comments` + `create_comment`. Lets agents
|
|
588
|
+
// run the full thread lifecycle: edit a typo, resolve a thread once
|
|
589
|
+
// addressed, re-open it if the fix was wrong, soft-delete a wayward
|
|
590
|
+
// comment.
|
|
591
|
+
//
|
|
592
|
+
// All four use `id` (the comment ID, not the document ID) — there is no
|
|
593
|
+
// ambiguity here because the URL path is `/comments/:id`, not nested
|
|
594
|
+
// under a document.
|
|
595
|
+
tool('edit_comment', 'Edit a comment body. Only the original author can edit. Resolved threads remain editable so a moderator can append clarifying context. The server emits an `updated_at` bump but does NOT create a version row (comments are not versioned the way documents are).', z.object({
|
|
596
|
+
id: z.string().describe('Comment ID.'),
|
|
597
|
+
body: z.string().min(1).describe('New comment text. Empty strings are rejected.'),
|
|
598
|
+
}), (args) => client.editComment(args.id, args.body));
|
|
599
|
+
tool('resolve_comment', "Mark a comment thread resolved. Requires write-role (admins, editors, authors). Emits a `comment_resolved` audit event and fires a best-effort notification dispatch for the thread's participants. Idempotent on the server side — resolving an already-resolved thread returns the existing state.", z.object({
|
|
600
|
+
id: z.string().describe('Comment ID.'),
|
|
601
|
+
}), (args) => client.resolveComment(args.id));
|
|
602
|
+
tool('reopen_comment', 'Re-open a previously resolved comment thread. Requires write-role. Emits a `comment_reopened` audit event. Pair with `resolve_comment` to bounce a thread back and forth as a fix iterates.', z.object({
|
|
603
|
+
id: z.string().describe('Comment ID.'),
|
|
604
|
+
}), (args) => client.reopenComment(args.id));
|
|
605
|
+
tool('delete_comment', 'Soft-delete a comment. Only the original author OR an admin can delete; the server returns HTTP 403 otherwise. Soft-deleted comments stay queryable via `list_comments({ includeDeleted: true })` for admins only — they hold moderation history that should not leak via a regular editor session.', z.object({
|
|
606
|
+
id: z.string().describe('Comment ID.'),
|
|
607
|
+
}), (args) => client.deleteComment(args.id));
|
|
608
|
+
// ── Redirects (Phase 7 MCP-parity, PR E) ──────────────────────────────
|
|
609
|
+
//
|
|
610
|
+
// Redirects are admin-only on the server side (create + delete return
|
|
611
|
+
// HTTP 403 for non-admin keys). The server enforces an open-redirect
|
|
612
|
+
// defence — absolute destinations must hit `NEXT_PUBLIC_SITE_URL` or a
|
|
613
|
+
// host explicitly listed in `redirects.allowedExternalHosts`. We let
|
|
614
|
+
// the server own that validation rather than re-implementing it here.
|
|
615
|
+
tool('list_redirects', 'List every redirect rule, newest first. Returns `{ data: Redirect[] }`. Each redirect carries `{ id, source, destination, statusCode, isRegex, notes, createdAt }`. Useful as the first call in a migration workflow ("what 301s are already in place?").', z.object({}), () => client.listRedirects());
|
|
616
|
+
tool('create_redirect', 'Create a redirect rule. **Admin-only** — non-admin API keys get HTTP 403. The server enforces an open-redirect defence: relative destinations (e.g. "/new-path") are always allowed; absolute URLs must point at `NEXT_PUBLIC_SITE_URL` or a host explicitly listed in `redirects.allowedExternalHosts` (CMS config). statusCode accepts 301 / 302 / 307 / 308 only and defaults to 301.', z.object({
|
|
617
|
+
source: z
|
|
618
|
+
.string()
|
|
619
|
+
.min(1)
|
|
620
|
+
.describe('The path or pattern that triggers the redirect (e.g. "/old-blog/:slug").'),
|
|
621
|
+
destination: z
|
|
622
|
+
.string()
|
|
623
|
+
.min(1)
|
|
624
|
+
.describe('Where to send the request. Relative path or absolute URL on an allowlisted host.'),
|
|
625
|
+
statusCode: z
|
|
626
|
+
.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)])
|
|
627
|
+
.optional()
|
|
628
|
+
.describe('HTTP redirect status. Default 301 (permanent).'),
|
|
629
|
+
isRegex: z
|
|
630
|
+
.boolean()
|
|
631
|
+
.optional()
|
|
632
|
+
.describe('When true, `source` is treated as a regex pattern. Default false.'),
|
|
633
|
+
notes: z
|
|
634
|
+
.string()
|
|
635
|
+
.nullable()
|
|
636
|
+
.optional()
|
|
637
|
+
.describe('Operator notes for why this redirect exists.'),
|
|
638
|
+
}), (args) => client.createRedirect(args));
|
|
639
|
+
tool('delete_redirect', 'Delete a redirect rule. **Admin-only**. Destructive — the source path will start returning the underlying page (or 404) again. Confirm with the human before invoking.', z.object({
|
|
640
|
+
id: z.string().describe('Redirect ID.'),
|
|
641
|
+
}), (args) => client.deleteRedirect(args.id));
|
|
642
|
+
// ── Page templates + saved sections (Phase 7 MCP-parity, PR E) ────────
|
|
643
|
+
//
|
|
644
|
+
// Discovery tools for the page-builder. Listed as P1 because most
|
|
645
|
+
// agents won't touch templates directly, but they're useful for
|
|
646
|
+
// generation flows that want to seed `generate_page` with a real
|
|
647
|
+
// starting structure. All three endpoints are admin-only on the
|
|
648
|
+
// server (templates are an authoring-power-tool, not a runtime concern).
|
|
649
|
+
tool('list_page_templates', 'List available page templates for the page-builder. **Admin-only**. Optional `category` filter narrows the result. Optional `collection` reorders the result so the suggested templates for that collection bubble to the top (e.g. "blog-post" for the `posts` collection); when supplied, the response also carries a top-level `suggestion` block. Useful as a discovery step before invoking `generate_page` or `create_page_from_prompt`.', z.object({
|
|
650
|
+
category: z
|
|
651
|
+
.string()
|
|
652
|
+
.optional()
|
|
653
|
+
.describe('Optional template category filter (e.g. "marketing", "blog").'),
|
|
654
|
+
collection: z
|
|
655
|
+
.string()
|
|
656
|
+
.optional()
|
|
657
|
+
.describe('Optional collection slug. Reorders results to surface templates suggested for this collection and annotates each row with `suggested` / `suggestionRank`.'),
|
|
658
|
+
}), (args) => client.listPageTemplates(args));
|
|
659
|
+
tool('get_page_template', 'Fetch a single page template by ID, including its full builder tree. **Admin-only**. Pair with `list_page_templates` to discover IDs, then use this to inspect the tree before applying it.', z.object({
|
|
660
|
+
id: z.string().describe('Page template ID (e.g. "builtin-blog-post" or a custom slug).'),
|
|
661
|
+
}), (args) => client.getPageTemplate(args.id));
|
|
662
|
+
tool('list_saved_sections', 'List saved page-builder sections (reusable blocks that authors have promoted to the section library). **Admin-only**. Optional `category` filter. Useful for "give me a starting point" workflows.', z.object({
|
|
663
|
+
category: z.string().optional().describe('Optional category filter.'),
|
|
664
|
+
}), (args) => client.listSavedSections(args));
|
|
665
|
+
// ── SEO aggregate tools (Phase 7 MCP-parity, PR E) ────────────────────
|
|
666
|
+
//
|
|
667
|
+
// `seo_summary` is cheap and dashboard-shaped. `seo_scan_run` is
|
|
668
|
+
// **expensive** (touches every published doc) so the audit recommended
|
|
669
|
+
// gating it behind a confirmation arg. We enforce that at the wrapper
|
|
670
|
+
// layer — calling without `confirm: true` throws before the HTTP call.
|
|
671
|
+
tool('seo_summary', 'Aggregate SEO health for the site dashboard. Returns counts of missing meta descriptions / missing alt text plus `topContent`: the recent published documents with light SEO scores. Cheap; safe to call on every dashboard refresh.', z.object({}), () => client.seoSummary());
|
|
672
|
+
tool('seo_scan_run', "Run a site-wide SEO scan over every published document. **Expensive** — the server walks every row in the published set. Returns `{ total, pagesWithIssues, totalProblems, issues[] }`. Requires `confirm: true` to invoke (the tool wrapper refuses to forward the request otherwise); this matches the audit's gate-behind-confirmation recommendation for expensive operations.", z.object({
|
|
673
|
+
confirm: z
|
|
674
|
+
.literal(true)
|
|
675
|
+
.describe('Must be `true` to acknowledge that this is an expensive site-wide scan. Calling without it is rejected at the wrapper.'),
|
|
676
|
+
}), (args) => {
|
|
677
|
+
// Defence-in-depth: Zod `.literal(true)` already rejects anything
|
|
678
|
+
// else, but we re-check so the failure mode is a clear runtime
|
|
679
|
+
// error if the schema ever drifts.
|
|
680
|
+
if (args.confirm !== true) {
|
|
681
|
+
throw new Error('seo_scan_run requires confirm: true to acknowledge expensive operation.');
|
|
682
|
+
}
|
|
683
|
+
return client.seoScanRun();
|
|
684
|
+
});
|
|
685
|
+
// ── SEO Operations Center (audit runs, issues, redirects) ─────────────
|
|
686
|
+
//
|
|
687
|
+
// These wrap the persistent audit/issue/redirect endpoints (distinct from
|
|
688
|
+
// the per-document `seo_*` analysis tools and the deterministic
|
|
689
|
+
// `seo_scan_run`). They let an agent inspect site SEO health, trigger an
|
|
690
|
+
// audit, triage issues, and review redirect hygiene.
|
|
691
|
+
tool('seo_overview', 'Aggregated SEO Overview: site score + grade + delta, headline metrics, open-issue summary, priority-ranked quick wins, and (gated) search-performance rows. Derived from the last completed audit run. Cheap read.', z.object({}), () => client.seoOverview());
|
|
692
|
+
tool('run_seo_audit', 'Start a persistent SEO audit run and return the created run (status, issue counts, before/after site score). `scope`: full (default), content, technical, or redirect. Heavier than seo_summary — persists issues queryable via list_seo_issues.', z.object({
|
|
693
|
+
scope: z
|
|
694
|
+
.enum(['full', 'content', 'technical', 'redirect'])
|
|
695
|
+
.optional()
|
|
696
|
+
.describe('Audit breadth. Defaults to full.'),
|
|
697
|
+
}), (args) => client.runSeoAudit(args.scope ?? 'full'));
|
|
698
|
+
tool('list_seo_audit_runs', 'List persisted SEO audit runs, newest first, with their status and issue counts.', z.object({}), () => client.listSeoAuditRuns());
|
|
699
|
+
tool('list_seo_issues', 'List SEO issues with optional filters. Use this to triage what needs fixing across the site.', z.object({
|
|
700
|
+
severity: z.enum(['critical', 'warning', 'info']).optional(),
|
|
701
|
+
category: z
|
|
702
|
+
.enum([
|
|
703
|
+
'content',
|
|
704
|
+
'technical',
|
|
705
|
+
'redirects',
|
|
706
|
+
'media',
|
|
707
|
+
'performance',
|
|
708
|
+
'schema',
|
|
709
|
+
'social',
|
|
710
|
+
'indexing',
|
|
711
|
+
])
|
|
712
|
+
.optional(),
|
|
713
|
+
status: z.enum(['open', 'ignored', 'resolved', 'recurring']).optional(),
|
|
714
|
+
auditRunId: z.string().optional().describe('Scope issues to a single audit run.'),
|
|
715
|
+
}), (args) => client.listSeoIssues(args));
|
|
716
|
+
tool('get_seo_issue', 'Fetch a single SEO issue by ID, including its impact and recommendation.', z.object({ id: z.string().describe('SEO issue ID') }), (args) => client.getSeoIssue(args.id));
|
|
717
|
+
tool('set_seo_issue_status', 'Resolve, ignore, or reopen an SEO issue. Status changes are audit-logged server-side.', z.object({
|
|
718
|
+
id: z.string().describe('SEO issue ID'),
|
|
719
|
+
action: z.enum(['resolve', 'ignore', 'reopen']),
|
|
720
|
+
}), (args) => client.setSeoIssueStatus(args.id, args.action));
|
|
721
|
+
tool('export_seo_fix_brief', 'Export a markdown SEO fix brief for dev-team handoff. Groups manual/content issues by page, includes issue IDs, admin deep links, and embedded cached AI content briefs + topic research when available. Cheap read — use after batch-generating briefs or to pull the full queue into your workspace.', z.object({
|
|
722
|
+
severity: z.enum(['critical', 'warning', 'info']).optional(),
|
|
723
|
+
status: z.enum(['open', 'ignored', 'resolved', 'recurring']).optional(),
|
|
724
|
+
auditRunId: z.string().optional(),
|
|
725
|
+
manualOnly: z
|
|
726
|
+
.boolean()
|
|
727
|
+
.optional()
|
|
728
|
+
.describe('When true (default), omit inline metadata fixes that have Approve fix in admin.'),
|
|
729
|
+
includeContentBriefs: z
|
|
730
|
+
.boolean()
|
|
731
|
+
.optional()
|
|
732
|
+
.describe('Embed cached AI briefs (default true).'),
|
|
733
|
+
includeContentResearch: z
|
|
734
|
+
.boolean()
|
|
735
|
+
.optional()
|
|
736
|
+
.describe('Embed cached topic research (default true).'),
|
|
737
|
+
}), (args) => client.exportSeoFixBrief(args));
|
|
738
|
+
tool('suggest_seo_issue_fix', 'Preview an inline SEO fix for metadata/schema issues (meta title/desc, canonical, noindex, duplicates, structured data). Returns `{ suggestion }` with field changes, fingerprint, and `autoFixable`. Call `apply_seo_issue_fix` with the same fingerprint to apply. Does not modify page body content.', z.object({ id: z.string().describe('SEO issue ID from list_seo_issues') }), (args) => client.suggestSeoIssueFix(args.id));
|
|
739
|
+
tool('apply_seo_issue_fix', 'Apply a previously previewed inline SEO fix and mark the issue resolved. Writes SEO metadata fields only — never page body. Requires `confirm: true` and the exact `fingerprint` from `suggest_seo_issue_fix`.', z.object({
|
|
740
|
+
id: z.string().describe('SEO issue ID'),
|
|
741
|
+
fingerprint: z
|
|
742
|
+
.string()
|
|
743
|
+
.describe('Fingerprint from suggest_seo_issue_fix — prevents stale apply'),
|
|
744
|
+
confirm: z
|
|
745
|
+
.literal(true)
|
|
746
|
+
.describe('Must be true — confirms user approval to mutate SEO fields'),
|
|
747
|
+
}), (args) => {
|
|
748
|
+
if (args.confirm !== true) {
|
|
749
|
+
throw new Error('apply_seo_issue_fix requires confirm: true.');
|
|
750
|
+
}
|
|
751
|
+
return client.applySeoIssueFix(args.id, args.fingerprint);
|
|
752
|
+
});
|
|
753
|
+
tool('get_seo_issue_content_brief', 'Load a cached AI content brief for thin-content, heading, or alt-text issues. Returns `{ supported, brief, stale }`. Use `generate_seo_issue_content_brief` or `research_and_brief_seo_issue` to create one.', z.object({ id: z.string().describe('SEO issue ID') }), (args) => client.getSeoIssueContentBrief(args.id));
|
|
754
|
+
tool('generate_seo_issue_content_brief', 'Generate an AI content brief (topics, suggested H1, GEO takeaway) for manual content fixes. Suggestions only — does not write page body. Rate-limited server-side.', z.object({ id: z.string().describe('SEO issue ID') }), (args) => client.generateSeoIssueContentBrief(args.id));
|
|
755
|
+
tool('research_and_brief_seo_issue', 'For thin-content issues: run topic research (optional competitor URLs, SSRF-safe, max 3) then generate a content brief informed by that research. For other brief-eligible issues, generates a brief only. Rate-limited; caches results on the issue.', z.object({
|
|
756
|
+
id: z.string().describe('SEO issue ID'),
|
|
757
|
+
competitorUrls: z
|
|
758
|
+
.array(z.string().url())
|
|
759
|
+
.max(3)
|
|
760
|
+
.optional()
|
|
761
|
+
.describe('Optional public competitor pages to inform topic research'),
|
|
762
|
+
}), (args) => client.researchAndBriefSeoIssue(args.id, args.competitorUrls));
|
|
763
|
+
tool('batch_generate_seo_content_briefs', 'Pre-generate AI content briefs for up to 15 open issues (skips fresh cached briefs). **Expensive** — multiple AI calls. Requires `confirm: true`. Pair with `export_seo_fix_brief` to pull the full handoff doc.', z.object({
|
|
764
|
+
confirm: z.literal(true).describe('Must be true to acknowledge batch AI cost'),
|
|
765
|
+
severity: z.enum(['critical', 'warning', 'info']).optional(),
|
|
766
|
+
status: z.enum(['open', 'ignored', 'resolved', 'recurring']).optional(),
|
|
767
|
+
issueIds: z.array(z.string()).optional().describe('Limit to specific issue IDs'),
|
|
768
|
+
skipExisting: z
|
|
769
|
+
.boolean()
|
|
770
|
+
.optional()
|
|
771
|
+
.describe('Skip issues with fresh cached briefs (default true)'),
|
|
772
|
+
includeResearch: z
|
|
773
|
+
.boolean()
|
|
774
|
+
.optional()
|
|
775
|
+
.describe('Run topic research before briefs for thin-content issues'),
|
|
776
|
+
competitorUrls: z
|
|
777
|
+
.array(z.string().url())
|
|
778
|
+
.max(3)
|
|
779
|
+
.optional()
|
|
780
|
+
.describe('Competitor URLs for thin-content research'),
|
|
781
|
+
}), (args) => {
|
|
782
|
+
if (args.confirm !== true) {
|
|
783
|
+
throw new Error('batch_generate_seo_content_briefs requires confirm: true.');
|
|
784
|
+
}
|
|
785
|
+
const { confirm: _c, ...body } = args;
|
|
786
|
+
return client.batchGenerateSeoContentBriefs(body);
|
|
787
|
+
});
|
|
788
|
+
tool('list_redirect_rules', 'List redirect rules with hit counts, neverHit (no recorded hits), and stale flags (last hit 60+ days ago). Use to audit redirect hygiene.', z.object({}), () => client.listRedirectRules());
|
|
789
|
+
tool('seo_redirect_overview', 'Redirect overview counts: active redirects, stale redirects, tracked 404s, and chains/loops.', z.object({}), () => client.redirectOverview());
|
|
790
|
+
tool('seo_redirect_chains', 'List detected redirect chains and loops with safe-flatten suggestions. Chains hurt crawl budget and link equity.', z.object({}), () => client.redirectChains());
|
|
791
|
+
tool('flatten_redirect_chain', "Flatten a redirect chain to a single hop by updating the first rule's destination. Requires the `chainId` from `seo_redirect_chains`.", z.object({
|
|
792
|
+
chainId: z.string().describe('Chain ID from seo_redirect_chains.'),
|
|
793
|
+
}), (args) => client.flattenRedirectChain(args.chainId));
|
|
794
|
+
tool('list_redirect_suggestions', 'List AI-generated 404 recovery redirect suggestions awaiting review.', z.object({}), () => client.listRedirectSuggestions());
|
|
795
|
+
tool('get_redirect_recovery_settings', 'Fetch 404 recovery settings: AI rerank, auto-apply, and minimum confidence threshold.', z.object({}), () => client.getRedirectRecoverySettings());
|
|
796
|
+
tool('update_redirect_recovery_settings', 'Update 404 recovery settings (partial patch). Admin-only.', z.object({
|
|
797
|
+
aiRerank: z.boolean().optional(),
|
|
798
|
+
autoApply: z.boolean().optional(),
|
|
799
|
+
autoApplyMinConfidence: z.number().min(0).max(1).optional(),
|
|
800
|
+
}), (args) => client.updateRedirectRecoverySettings(args));
|
|
801
|
+
tool('generate_redirect_recovery_suggestions', 'Scan tracked 404s and generate recovery redirect suggestions. **Expensive** — may invoke AI reranking. Requires `confirm: true`.', z.object({
|
|
802
|
+
confirm: z
|
|
803
|
+
.literal(true)
|
|
804
|
+
.optional()
|
|
805
|
+
.describe('Must be true — acknowledges an expensive recovery scan.'),
|
|
806
|
+
}), (args) => {
|
|
807
|
+
if (args.confirm !== true) {
|
|
808
|
+
throw new Error('generate_redirect_recovery_suggestions requires confirm: true.');
|
|
809
|
+
}
|
|
810
|
+
return client.generateRedirectRecoverySuggestions();
|
|
811
|
+
});
|
|
812
|
+
tool('accept_redirect_suggestion', 'Accept a 404 recovery suggestion — creates the redirect rule. Requires suggestion `id` from `list_redirect_suggestions`.', z.object({ id: z.string().describe('Suggestion ID.') }), (args) => client.acceptRedirectSuggestion(args.id));
|
|
813
|
+
tool('dismiss_redirect_suggestion', 'Dismiss a 404 recovery suggestion without creating a redirect.', z.object({ id: z.string().describe('Suggestion ID.') }), (args) => client.dismissRedirectSuggestion(args.id));
|
|
814
|
+
tool('export_redirects_csv', 'Export all redirect rules as CSV for backup or migration.', z.object({}), () => client.exportRedirectsCsv());
|
|
815
|
+
tool('import_redirects_csv', 'Import redirect rules from CSV. **Bulk mutation** — requires `confirm: true`. Returns `{ imported, errors[] }`.', z.object({
|
|
816
|
+
csv: z.string().min(1).describe('CSV content (from export_redirects_csv format).'),
|
|
817
|
+
confirm: z.literal(true).optional().describe('Must be true — acknowledges bulk import.'),
|
|
818
|
+
}), (args) => {
|
|
819
|
+
if (args.confirm !== true) {
|
|
820
|
+
throw new Error('import_redirects_csv requires confirm: true.');
|
|
821
|
+
}
|
|
822
|
+
return client.importRedirectsCsv(args.csv);
|
|
823
|
+
});
|
|
824
|
+
tool('update_redirect_rule', 'Update an existing redirect rule by ID. Partial patch — only supplied fields change. Admin-only.', z.object({
|
|
825
|
+
id: z.string().describe('Redirect rule ID.'),
|
|
826
|
+
fromPath: z.string().optional(),
|
|
827
|
+
toPath: z.string().optional(),
|
|
828
|
+
type: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).optional(),
|
|
829
|
+
enabled: z.boolean().optional(),
|
|
830
|
+
preserveQuery: z.boolean().optional(),
|
|
831
|
+
notes: z.string().optional(),
|
|
832
|
+
}), (args) => {
|
|
833
|
+
const { id, ...body } = args;
|
|
834
|
+
return client.updateRedirectRule(id, body);
|
|
835
|
+
});
|
|
836
|
+
// ── SEO Links dashboard (Phase 1 MCP parity) ──────────────────────────
|
|
837
|
+
tool('seo_links_snapshot', 'Site-wide link graph snapshot: internal/external counts, orphan pages, under-linked content, broken links, and AI index readiness. Cheap read — mirrors the SEO Links tab.', z.object({}), () => client.seoLinkSnapshot());
|
|
838
|
+
tool('seo_link_health', 'Scan published content for broken or suspicious outbound links. Returns `{ issues[], checked, truncated }` — bounded to protect large sites.', z.object({}), () => client.seoLinkHealth());
|
|
839
|
+
tool('seo_link_suggest', 'Suggest contextual internal links TO a target page/post from other published content. Requires a built AI link index (`run_seo_link_ai_index`).', z.object({
|
|
840
|
+
targetEntityId: z.string().describe('Target document ID to receive inbound links.'),
|
|
841
|
+
targetEntityType: z.enum(['page', 'post']),
|
|
842
|
+
limit: z.number().int().min(1).max(20).optional(),
|
|
843
|
+
}), (args) => client.seoLinkSuggest(args));
|
|
844
|
+
tool('run_seo_link_ai_index', 'Build or refresh the AI link index for contextual link suggestions. **Expensive** — scans content and may call the AI provider. Requires `confirm: true`.', z.object({
|
|
845
|
+
confirm: z
|
|
846
|
+
.literal(true)
|
|
847
|
+
.optional()
|
|
848
|
+
.describe('Must be true — acknowledges expensive indexing.'),
|
|
849
|
+
}), (args) => {
|
|
850
|
+
if (args.confirm !== true) {
|
|
851
|
+
throw new Error('run_seo_link_ai_index requires confirm: true.');
|
|
852
|
+
}
|
|
853
|
+
return client.seoLinkAiIndex();
|
|
854
|
+
});
|
|
855
|
+
// ── SEO technical settings (Phase 1 MCP parity) ───────────────────────
|
|
856
|
+
tool('get_seo_crawl_settings', 'Fetch crawl/sitemap settings: indexEntireSite, sitemapEnabled, autoRegenerateSitemapOnPublish, pingSearchEnginesOnPublish, etc.', z.object({}), () => client.getCrawlSettings());
|
|
857
|
+
tool('update_seo_crawl_settings', 'Update crawl/sitemap settings (partial patch). Admin-only.', z.object({
|
|
858
|
+
indexEntireSite: z.boolean().optional(),
|
|
859
|
+
includeImagesInSitemap: z.boolean().optional(),
|
|
860
|
+
includePostsInSitemap: z.boolean().optional(),
|
|
861
|
+
sitemapEnabled: z.boolean().optional(),
|
|
862
|
+
autoRegenerateSitemapOnPublish: z.boolean().optional(),
|
|
863
|
+
pingSearchEnginesOnPublish: z.boolean().optional(),
|
|
864
|
+
}), (args) => client.updateCrawlSettings(args));
|
|
865
|
+
tool('get_seo_robots', 'Fetch robots.txt configuration: `{ body, generated }`.', z.object({}), () => client.getRobotsTxt());
|
|
866
|
+
tool('update_seo_robots', 'Replace robots.txt body. Admin-only — validate carefully; a bad robots.txt can deindex the site.', z.object({
|
|
867
|
+
body: z.string().describe('Full robots.txt content.'),
|
|
868
|
+
}), (args) => client.updateRobotsTxt(args.body));
|
|
869
|
+
tool('get_seo_sitemap_status', 'Fetch sitemap generation status: last run, URL count, errors.', z.object({}), () => client.getSitemapStatus());
|
|
870
|
+
tool('regenerate_seo_sitemap', 'Force sitemap regeneration. Requires `confirm: true`.', z.object({
|
|
871
|
+
confirm: z.literal(true).optional().describe('Must be true — acknowledges sitemap rebuild.'),
|
|
872
|
+
}), (args) => {
|
|
873
|
+
if (args.confirm !== true) {
|
|
874
|
+
throw new Error('regenerate_seo_sitemap requires confirm: true.');
|
|
875
|
+
}
|
|
876
|
+
return client.regenerateSitemap();
|
|
877
|
+
});
|
|
878
|
+
// ── SEO content records (Phase 1 MCP parity) ──────────────────────────
|
|
879
|
+
tool('list_seo_content', 'List all pages/posts with SEO scores and metadata summary. Use to find content needing optimization.', z.object({}), () => client.listSeoContentRecords());
|
|
880
|
+
tool('get_seo_content', 'Fetch full SEO fields for one page or post (`entityType`: page|post, `entityId`: document ID).', z.object({
|
|
881
|
+
entityType: z.enum(['page', 'post']),
|
|
882
|
+
entityId: z.string(),
|
|
883
|
+
}), (args) => client.getSeoContentRecord(args.entityType, args.entityId));
|
|
884
|
+
tool('update_seo_content', 'Update SEO metadata fields for a page or post. Writes meta title/desc, canonical, OG, robots flags, schema type, llms.txt curation — never page body content.', z.object({
|
|
885
|
+
entityType: z.enum(['page', 'post']),
|
|
886
|
+
entityId: z.string(),
|
|
887
|
+
metaTitle: z.string().optional().describe('Page-specific title (%page% slot).'),
|
|
888
|
+
metaDescription: z.string().optional(),
|
|
889
|
+
canonicalUrl: z.string().optional(),
|
|
890
|
+
focusKeyword: z.string().optional(),
|
|
891
|
+
keyTakeaway: z.string().optional(),
|
|
892
|
+
ogTitle: z.string().optional(),
|
|
893
|
+
ogDescription: z.string().optional(),
|
|
894
|
+
noindex: z.boolean().optional(),
|
|
895
|
+
nofollow: z.boolean().optional(),
|
|
896
|
+
structuredDataType: z.string().optional(),
|
|
897
|
+
llmsInclude: z.enum(['AUTO', 'ALWAYS', 'NEVER']).optional(),
|
|
898
|
+
llmsDescription: z.string().optional(),
|
|
899
|
+
llmsSection: z.string().optional(),
|
|
900
|
+
llmsOptional: z.boolean().optional(),
|
|
901
|
+
}), (args) => {
|
|
902
|
+
const { entityType, entityId, ...body } = args;
|
|
903
|
+
return client.updateSeoContentFields(entityType, entityId, body);
|
|
904
|
+
});
|
|
905
|
+
// ── AI SEO assistant (Phase 1 MCP parity) ─────────────────────────────
|
|
906
|
+
tool('ai_seo_generate_field', 'Generate an SEO field (metaTitle, metaDescription, ogTitle, ogDescription, focusKeyword, keyTakeaway) via AI. Returns suggestions or `{ unavailable: true }` when plugin-ai is not configured. Rate-limited.', z.object({
|
|
907
|
+
field: z.enum([
|
|
908
|
+
'metaTitle',
|
|
909
|
+
'metaDescription',
|
|
910
|
+
'ogTitle',
|
|
911
|
+
'ogDescription',
|
|
912
|
+
'focusKeyword',
|
|
913
|
+
'keyTakeaway',
|
|
914
|
+
]),
|
|
915
|
+
entityType: z.enum(['page', 'post']),
|
|
916
|
+
entityId: z.string(),
|
|
917
|
+
}), (args) => client.aiSeoGenerateField(args));
|
|
918
|
+
tool('ai_seo_explain_issue', 'AI explanation of an SEO audit issue — impact, root cause, and fix guidance. Returns `{ unavailable: true }` when AI is not configured.', z.object({ issueId: z.string().describe('SEO issue ID from list_seo_issues.') }), (args) => client.aiSeoExplainIssue(args.issueId));
|
|
919
|
+
tool('ai_seo_generate_schema', 'Generate JSON-LD structured data for a page/post via AI. Review before saving with update_seo_content or apply_seo_issue_fix.', z.object({
|
|
920
|
+
entityType: z.enum(['page', 'post']),
|
|
921
|
+
entityId: z.string(),
|
|
922
|
+
schemaType: z.string().describe('Schema.org type (e.g. Article, WebPage, FAQPage).'),
|
|
923
|
+
}), (args) => client.aiSeoGenerateSchema(args));
|
|
924
|
+
tool('ai_seo_summarize_audit', 'AI summary of the latest SEO audit — headline findings and recommended priorities. Returns `{ unavailable: true }` when AI is not configured.', z.object({}), () => client.aiSeoSummarize());
|
|
925
|
+
// ── Forms + health (Phase 7 MCP-parity, PR E) ─────────────────────────
|
|
926
|
+
tool('list_form_submissions', 'List submissions for a specific form, paged (default 20). **PII-sensitive** — submissions typically contain names, emails, phone numbers, and free-form messages. The tool refuses to enumerate forms automatically; the caller MUST pass an explicit `formId`. Requires write-role on the API key. Pair with the existing `list_documents({ collection: "forms" })` to discover form IDs first.', z.object({
|
|
927
|
+
formId: z.string().describe('Form document ID. No enumeration — the caller must know it.'),
|
|
928
|
+
page: z.number().int().min(1).optional(),
|
|
929
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
930
|
+
}), (args) => {
|
|
931
|
+
const { formId, ...query } = args;
|
|
932
|
+
return client.listFormSubmissions(formId, query);
|
|
933
|
+
});
|
|
934
|
+
tool('list_form_entries', 'List entries from the submissions inbox for a specific form. Returns `{ data: { entries, total, page, pageSize } }` with decrypted field values, sender info, attribution, and read/star/archive state. **PII-sensitive** — entries contain names, emails, phone numbers, and free-form messages. The caller MUST pass an explicit `formId` (no enumeration). Use `list_documents({ collection: "forms" })` to discover form IDs first. Filter with `unread` / `starred` / `archived` / `spamStatus`.', z.object({
|
|
935
|
+
formId: z.string().describe('Form document ID. Required — no enumeration.'),
|
|
936
|
+
page: z.number().int().min(1).optional(),
|
|
937
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
938
|
+
unread: z.boolean().optional(),
|
|
939
|
+
starred: z.boolean().optional(),
|
|
940
|
+
archived: z.boolean().optional(),
|
|
941
|
+
spamStatus: z.enum(['clean', 'suspected', 'spam']).optional(),
|
|
942
|
+
}), (args) => client.listFormEntries(args));
|
|
943
|
+
tool('get_form_entry', 'Read a single submission inbox entry by id, including the schema fields it was captured against: `{ data: { entry, fields } }`. **PII-sensitive** — exposes decrypted field values; the server records a `form.entry.viewed` audit event for every read. Obtain the `entryId` from `list_form_entries`.', z.object({
|
|
944
|
+
entryId: z.string().describe('Submission entry id (from list_form_entries).'),
|
|
945
|
+
}), (args) => client.getFormEntry(args.entryId));
|
|
946
|
+
// ── Form authoring (WS-C C1) ──────────────────────────────────────────
|
|
947
|
+
//
|
|
948
|
+
// The tools above READ a form's submissions. These let an agent BUILD and
|
|
949
|
+
// MAINTAIN the form itself: discover/create/edit the definition, version
|
|
950
|
+
// its field schema, manage the submissions inbox, and export. All require
|
|
951
|
+
// a write-role API key server-side. With these an agent can stand up a
|
|
952
|
+
// working contact/lead form and wire it into a page end-to-end over MCP.
|
|
953
|
+
tool('list_forms', 'List form definitions (NOT submissions). Returns `{ data: Form[], total, page, pageSize }`; each form includes its field schema, `fieldCount`, and submission count. This is the canonical way to discover form IDs before reading entries (`list_form_entries`) or editing a schema (`save_form_schema`). Filter with `status` (active | draft | archived) and `search`.', z.object({
|
|
954
|
+
page: z.number().int().min(1).optional(),
|
|
955
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
956
|
+
status: z.enum(['active', 'draft', 'archived']).optional(),
|
|
957
|
+
search: z.string().optional().describe('Free-text search against form name.'),
|
|
958
|
+
}), (args) => client.listForms(args));
|
|
959
|
+
tool('get_form', 'Fetch a single form definition by ID — metadata plus the current field schema. Pair with `list_forms` to discover IDs.', z.object({ id: z.string().describe('Form ID.') }), (args) => client.getForm(args.id));
|
|
960
|
+
tool('create_form', 'Create a form. Only `name` is required; the slug is derived from the name when omitted. Pass `fields` to seed the field schema in one shot, or omit it and add the schema later with `save_form_schema`. New forms default to `draft` status — set `status: "active"` to make the form accept submissions. Returns the created form.', z.object({
|
|
961
|
+
name: z.string().min(1).describe('Human-facing form name (also seeds the slug).'),
|
|
962
|
+
slug: z.string().optional().describe('URL slug override. Auto-derived from name if omitted.'),
|
|
963
|
+
description: z.string().optional(),
|
|
964
|
+
status: z
|
|
965
|
+
.enum(['active', 'draft', 'archived'])
|
|
966
|
+
.optional()
|
|
967
|
+
.describe('Defaults to "draft". Only "active" forms accept public submissions.'),
|
|
968
|
+
fields: z
|
|
969
|
+
.array(z.record(z.any()))
|
|
970
|
+
.optional()
|
|
971
|
+
.describe('Initial field schema. Each field: { label, key, type, required, ... }. `type` is one of text|email|phone|textarea|select|multiselect|radio|checkbox|date|number|url|hidden|file|consent|richtext|divider|honeypot. Invalid schemas are rejected with field-level errors.'),
|
|
972
|
+
successMessage: z.string().optional().describe('Message shown after a successful submit.'),
|
|
973
|
+
redirectUrl: z.string().optional().describe('Optional post-submit redirect URL.'),
|
|
974
|
+
}), (args) => client.createForm(args));
|
|
975
|
+
tool('update_form', 'Update a form\'s metadata (name, description, status, success message, redirect). Only the supplied fields change. NOTE: the field SCHEMA is NOT editable here — use `save_form_schema` so every schema change is versioned. Set `status: "archived"` to stop accepting submissions without deleting the form.', z.object({
|
|
976
|
+
id: z.string().describe('Form ID.'),
|
|
977
|
+
name: z.string().optional(),
|
|
978
|
+
description: z.string().optional(),
|
|
979
|
+
status: z.enum(['active', 'draft', 'archived']).optional(),
|
|
980
|
+
successMessage: z.string().optional(),
|
|
981
|
+
redirectUrl: z.string().optional(),
|
|
982
|
+
}), (args) => {
|
|
983
|
+
const { id, ...body } = args;
|
|
984
|
+
return client.updateForm(id, body);
|
|
985
|
+
});
|
|
986
|
+
tool('delete_form', 'Delete a form. Destructive — confirm with the user first. By default the server refuses (HTTP 409) when the form has submissions; pass `force: true` to delete the form AND all its captured submissions (PII). Prefer `update_form({ status: "archived" })` if you only want to stop new submissions.', z.object({
|
|
987
|
+
id: z.string().describe('Form ID.'),
|
|
988
|
+
force: z
|
|
989
|
+
.boolean()
|
|
990
|
+
.optional()
|
|
991
|
+
.describe('When true, delete even if the form has submissions (also deletes them).'),
|
|
992
|
+
confirm: z
|
|
993
|
+
.literal(true)
|
|
994
|
+
.describe('Must be `true` to acknowledge this destructive action. Confirm with the user first.'),
|
|
995
|
+
}), (args) => {
|
|
996
|
+
if (args.confirm !== true) {
|
|
997
|
+
throw new Error('delete_form requires confirm: true to acknowledge a destructive action.');
|
|
998
|
+
}
|
|
999
|
+
return client.deleteForm(args.id, args.force);
|
|
1000
|
+
});
|
|
1001
|
+
tool('duplicate_form', 'Duplicate a form into a new draft copy with a fresh slug. The copy starts in "draft" and has no submissions. Useful as a starting point for a variant.', z.object({ id: z.string().describe('Form ID to duplicate.') }), (args) => client.duplicateForm(args.id));
|
|
1002
|
+
tool('get_form_schema', "Read a form's field schema plus its version history: `{ fields, activeVersionId, versions }`. Call this BEFORE `save_form_schema` so you can mutate the existing `fields` array and write back the whole thing rather than clobbering the live schema.", z.object({ id: z.string().describe('Form ID.') }), (args) => client.getFormSchema(args.id));
|
|
1003
|
+
tool('save_form_schema', "Replace a form's field schema. `fields` is the FULL ordered field list (partial patches are not supported — read with `get_form_schema`, mutate, write the whole array back). Creates a NEW schema version (history is never rewritten) and points the form at it. Invalid schemas are rejected with field-level errors. Each field: { label, key, type, required, options?, validationRules?, ... }.", z.object({
|
|
1004
|
+
id: z.string().describe('Form ID.'),
|
|
1005
|
+
fields: z
|
|
1006
|
+
.array(z.record(z.any()))
|
|
1007
|
+
.describe('Full ordered field list. `type` ∈ text|email|phone|textarea|select|multiselect|radio|checkbox|date|number|url|hidden|file|consent|richtext|divider|honeypot.'),
|
|
1008
|
+
notes: z.string().optional().describe('Optional changelog note attached to this version.'),
|
|
1009
|
+
}), (args) => client.saveFormSchema(args.id, args.fields, args.notes));
|
|
1010
|
+
tool('set_form_entry_status', 'Update inbox state on a single submission entry: mark read/unread, star, or archive. Pass any subset of { unread, starred, archived }. Does NOT alter the captured field values. Obtain `entryId` from `list_form_entries`.', z.object({
|
|
1011
|
+
entryId: z.string().describe('Submission entry ID (from list_form_entries).'),
|
|
1012
|
+
unread: z.boolean().optional().describe('true = mark unread, false = mark read.'),
|
|
1013
|
+
starred: z.boolean().optional(),
|
|
1014
|
+
archived: z.boolean().optional(),
|
|
1015
|
+
}), (args) => {
|
|
1016
|
+
const { entryId, ...patch } = args;
|
|
1017
|
+
if (patch.unread === undefined &&
|
|
1018
|
+
patch.starred === undefined &&
|
|
1019
|
+
patch.archived === undefined) {
|
|
1020
|
+
throw new Error('Provide at least one of unread, starred, or archived.');
|
|
1021
|
+
}
|
|
1022
|
+
return client.updateFormEntry(entryId, patch);
|
|
1023
|
+
});
|
|
1024
|
+
tool('bulk_set_form_entry_status', 'Apply the same inbox-state patch (read/star/archive) to many entries at once. Pass a non-empty `ids` array and a `patch` with at least one of { unread, starred, archived }. Returns `{ updated: <count> }`. Use for "archive all spam" / "mark all read" workflows.', z.object({
|
|
1025
|
+
ids: z.array(z.string()).min(1).describe('Entry IDs to update (non-empty).'),
|
|
1026
|
+
unread: z.boolean().optional(),
|
|
1027
|
+
starred: z.boolean().optional(),
|
|
1028
|
+
archived: z.boolean().optional(),
|
|
1029
|
+
}), (args) => {
|
|
1030
|
+
const { ids, ...patch } = args;
|
|
1031
|
+
if (patch.unread === undefined &&
|
|
1032
|
+
patch.starred === undefined &&
|
|
1033
|
+
patch.archived === undefined) {
|
|
1034
|
+
throw new Error('Provide at least one of unread, starred, or archived in the patch.');
|
|
1035
|
+
}
|
|
1036
|
+
return client.bulkUpdateFormEntries(ids, patch);
|
|
1037
|
+
});
|
|
1038
|
+
tool('delete_form_entry', 'Permanently delete a single submission entry and its decrypted PII. Destructive and irreversible — requires `confirm: true` (the wrapper refuses otherwise). Prefer `set_form_entry_status({ archived: true })` to hide an entry without destroying it.', z.object({
|
|
1039
|
+
entryId: z.string().describe('Submission entry ID to delete.'),
|
|
1040
|
+
confirm: z
|
|
1041
|
+
.literal(true)
|
|
1042
|
+
.describe('Must be `true` to acknowledge this irreversible action. Confirm with the user first.'),
|
|
1043
|
+
}), (args) => {
|
|
1044
|
+
if (args.confirm !== true) {
|
|
1045
|
+
throw new Error('delete_form_entry requires confirm: true to acknowledge an irreversible action.');
|
|
1046
|
+
}
|
|
1047
|
+
return client.deleteFormEntry(args.entryId);
|
|
1048
|
+
});
|
|
1049
|
+
tool('export_form_entries', 'Export EVERY submission for a form as CSV. **Bulk PII egress** — the CSV contains decrypted names, emails, phone numbers, and free-form messages, and the server records a `form.entries.exported` audit event. Requires `confirm: true` to acknowledge the bulk PII access (the wrapper refuses otherwise). Returns `{ filename, csv }`. Discover `formId` via `list_forms`.', z.object({
|
|
1050
|
+
formId: z.string().describe('Form ID whose submissions to export. No enumeration.'),
|
|
1051
|
+
confirm: z
|
|
1052
|
+
.literal(true)
|
|
1053
|
+
.describe('Must be `true` to acknowledge this exports decrypted PII in bulk.'),
|
|
1054
|
+
}), (args) => {
|
|
1055
|
+
// Defence-in-depth: Zod `.literal(true)` already rejects anything
|
|
1056
|
+
// else, but re-check so the failure mode stays a clear runtime error
|
|
1057
|
+
// if the schema ever drifts.
|
|
1058
|
+
if (args.confirm !== true) {
|
|
1059
|
+
throw new Error('export_form_entries requires confirm: true to acknowledge bulk PII export.');
|
|
1060
|
+
}
|
|
1061
|
+
return client.exportFormEntries(args.formId);
|
|
1062
|
+
});
|
|
1063
|
+
tool('health_check', 'Read deployment health: `{ status: "healthy" | "degraded" | "unhealthy", version, env (per-secret validation), models (per-table availability), databaseConnected, secretConfigured }`. The /health endpoint itself is unauthenticated on the server so this works even when the API key has zero scopes. Useful as an agent\'s first call — a `degraded` or `unhealthy` status surfaces operator-level config problems early instead of as cryptic errors deep in a later workflow.', z.object({}), () => client.healthCheck());
|
|
1064
|
+
tool('search_users', "Resolve up to 10 active + approved users by name or email (case-insensitive `contains`). Returns `{ data: Array<{ id, name, email }> }`. Use this when drafting a comment that should `@`-mention a teammate: take the user-supplied name fragment, call `search_users`, and inject the resulting `id` as `@<userId>` into the comment body — the CMS's `extractMentionedUserIds` parser then routes a `comment_mention` notification to that user. The tool-level schema requires a non-empty `query` (the server-side endpoint additionally returns `[]` for empty or whitespace input, but the wrapper fails closed at the MCP boundary so an agent can't accidentally enumerate the directory).", z.object({
|
|
1065
|
+
query: z
|
|
1066
|
+
.string()
|
|
1067
|
+
.min(1)
|
|
1068
|
+
.describe('Name or email fragment to match. Required and non-empty; trimmed and capped at 64 chars server-side.'),
|
|
1069
|
+
}), (args) => client.searchUsers(args.query));
|
|
1070
|
+
tool('generate_page', 'Run the AI page builder to produce a page-builder tree from a prompt. Does NOT persist the result — use create_page_from_prompt for one-shot creation.', z.object({
|
|
1071
|
+
prompt: z.string().describe('Plain-language description of the page to generate'),
|
|
1072
|
+
template: z.string().optional().describe('Optional template slug to seed the structure'),
|
|
1073
|
+
context: z.string().optional().describe('Additional context (brand voice, audience, etc.)'),
|
|
1074
|
+
steps: z
|
|
1075
|
+
.array(z.enum(['structure', 'content', 'seo', 'accessibility']))
|
|
1076
|
+
.optional()
|
|
1077
|
+
.describe('Which generation steps to run; defaults to all four'),
|
|
1078
|
+
tone: z.string().optional(),
|
|
1079
|
+
}), (args) => client.generatePage(args));
|
|
1080
|
+
tool('create_page_from_prompt', 'One-shot page creation: generate a complete page via AI and persist it as a draft (or published) document. Returns the new document.', z.object({
|
|
1081
|
+
prompt: z.string(),
|
|
1082
|
+
collection: z
|
|
1083
|
+
.string()
|
|
1084
|
+
.optional()
|
|
1085
|
+
.describe('Destination collection slug. Defaults to "pages".'),
|
|
1086
|
+
template: z.string().optional(),
|
|
1087
|
+
context: z.string().optional(),
|
|
1088
|
+
steps: z.array(z.enum(['structure', 'content', 'seo', 'accessibility'])).optional(),
|
|
1089
|
+
tone: z.string().optional(),
|
|
1090
|
+
title: z.string().optional().describe('Override the AI-suggested title'),
|
|
1091
|
+
slug: z.string().optional().describe('Override the auto-generated slug'),
|
|
1092
|
+
publish: z
|
|
1093
|
+
.boolean()
|
|
1094
|
+
.optional()
|
|
1095
|
+
.describe('When true, publish immediately instead of creating as DRAFT'),
|
|
1096
|
+
}), (args) => client.createPageFromPrompt(args));
|
|
1097
|
+
tool('generate_block', 'Generate a single page-builder block via AI.', z.object({
|
|
1098
|
+
blockType: z.string().describe('Block type (e.g. "hero", "cta", "faq")'),
|
|
1099
|
+
variant: z.string().optional(),
|
|
1100
|
+
pageContext: z.any().optional(),
|
|
1101
|
+
tone: z.string().optional(),
|
|
1102
|
+
}), (args) => client.generateBlock(args));
|
|
1103
|
+
// ── AI content authoring for non-page collections ────────────────────────
|
|
1104
|
+
//
|
|
1105
|
+
// Phase 7 PR F (audit stylistic note #1): collapsed the previous 6-tool
|
|
1106
|
+
// bloat ("create_in_collection" + 5 per-type wrappers) into a single
|
|
1107
|
+
// generic tool plus two type-specific tools that stay because they
|
|
1108
|
+
// unlock genuinely different field-sets:
|
|
1109
|
+
//
|
|
1110
|
+
// - `create_in_collection` — accepts an optional `template` arg that
|
|
1111
|
+
// selects a built-in prompt-enrichment recipe (service / location /
|
|
1112
|
+
// team / generic) AND a default collection slug. The recipe folds
|
|
1113
|
+
// structured fields (category, cityState, name+role) into the
|
|
1114
|
+
// prompt so the AI lands them in the right document fields.
|
|
1115
|
+
// - `create_blog_post` — most-common authoring path; kept because
|
|
1116
|
+
// "blog post" is a canonical agent intent.
|
|
1117
|
+
// - `create_case_study` — different enough field-set (client /
|
|
1118
|
+
// industry framing) that collapsing it would hurt the resulting
|
|
1119
|
+
// content quality.
|
|
1120
|
+
//
|
|
1121
|
+
// Removed: `create_service`, `create_location`, `create_team_member`.
|
|
1122
|
+
// Their behaviour is identical to
|
|
1123
|
+
// `create_in_collection({ template: 'service' | 'location' |
|
|
1124
|
+
// 'team_member', ... })` so the migration is mechanical.
|
|
1125
|
+
const aiContentInputBase = {
|
|
1126
|
+
prompt: z.string().describe('Plain-language brief describing what to write.'),
|
|
1127
|
+
context: z
|
|
1128
|
+
.string()
|
|
1129
|
+
.optional()
|
|
1130
|
+
.describe('Optional reference material — existing related content, brand facts, etc.'),
|
|
1131
|
+
tone: z
|
|
1132
|
+
.string()
|
|
1133
|
+
.optional()
|
|
1134
|
+
.describe('Tone modifier (e.g. "conversational", "expert", "playful").'),
|
|
1135
|
+
targetAudience: z.string().optional(),
|
|
1136
|
+
title: z.string().optional().describe('Override the AI-suggested title.'),
|
|
1137
|
+
slug: z.string().optional().describe('Override the auto-generated slug.'),
|
|
1138
|
+
publish: z
|
|
1139
|
+
.boolean()
|
|
1140
|
+
.optional()
|
|
1141
|
+
.describe('When true, publish immediately instead of creating as DRAFT.'),
|
|
1142
|
+
};
|
|
1143
|
+
const templateRecipes = {
|
|
1144
|
+
service: {
|
|
1145
|
+
defaultCollection: 'services',
|
|
1146
|
+
enrich: (prompt, args) => typeof args.category === 'string' && args.category
|
|
1147
|
+
? `${prompt} Category: ${args.category}.`
|
|
1148
|
+
: prompt,
|
|
1149
|
+
},
|
|
1150
|
+
location: {
|
|
1151
|
+
defaultCollection: 'locations',
|
|
1152
|
+
enrich: (prompt, args) => typeof args.cityState === 'string' && args.cityState
|
|
1153
|
+
? `${prompt} Service area: ${args.cityState}.`
|
|
1154
|
+
: prompt,
|
|
1155
|
+
},
|
|
1156
|
+
team_member: {
|
|
1157
|
+
defaultCollection: 'team',
|
|
1158
|
+
enrich: (prompt, args) => [
|
|
1159
|
+
prompt,
|
|
1160
|
+
typeof args.name === 'string' && args.name ? `Name: ${args.name}.` : '',
|
|
1161
|
+
typeof args.role === 'string' && args.role ? `Role: ${args.role}.` : '',
|
|
1162
|
+
]
|
|
1163
|
+
.filter(Boolean)
|
|
1164
|
+
.join(' '),
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
tool('create_in_collection', "Generate a complete document for ANY non-page collection via AI and persist it. The AI maps the prompt into the collection's field schema and returns the new document (default DRAFT status). Pass `template` to apply a built-in prompt-enrichment recipe — this is the supported migration path for the now-removed `create_service` / `create_location` / `create_team_member` tools. Omit `template` for plain generic generation; `collection` is then required.", z.object({
|
|
1168
|
+
collection: z
|
|
1169
|
+
.string()
|
|
1170
|
+
.optional()
|
|
1171
|
+
.describe('Destination collection slug. Required when `template` is omitted. When a template is supplied, `collection` overrides the template default.'),
|
|
1172
|
+
template: z
|
|
1173
|
+
.enum(['service', 'location', 'team_member'])
|
|
1174
|
+
.optional()
|
|
1175
|
+
.describe('Pre-built recipe. "service" folds `category` into the prompt and defaults to "services". "location" folds `cityState` and defaults to "locations". "team_member" folds `name` + `role` and defaults to "team". Omit for plain generic generation (then `collection` is required).'),
|
|
1176
|
+
// Template-specific structured fields. All optional at the schema
|
|
1177
|
+
// layer; the recipe is responsible for folding only the ones it
|
|
1178
|
+
// understands. Unknown extras are ignored — agents that pass
|
|
1179
|
+
// `category` with template "location" simply don't get it enriched.
|
|
1180
|
+
category: z.string().optional().describe('Template "service": product/service category.'),
|
|
1181
|
+
cityState: z
|
|
1182
|
+
.string()
|
|
1183
|
+
.optional()
|
|
1184
|
+
.describe('Template "location": city and state/region, e.g. "Austin, TX".'),
|
|
1185
|
+
name: z.string().optional().describe('Template "team_member": full name.'),
|
|
1186
|
+
role: z.string().optional().describe('Template "team_member": job title / role.'),
|
|
1187
|
+
...aiContentInputBase,
|
|
1188
|
+
}), async (args) => {
|
|
1189
|
+
const { collection, template, category, cityState, name, role, prompt, ...rest } = args;
|
|
1190
|
+
const recipe = template ? templateRecipes[template] : undefined;
|
|
1191
|
+
const targetCollection = collection ?? recipe?.defaultCollection;
|
|
1192
|
+
if (!targetCollection) {
|
|
1193
|
+
// Defence-in-depth: Zod can't express "at least one of A or B"
|
|
1194
|
+
// cleanly. Reject at the handler so the agent gets a clear
|
|
1195
|
+
// message instead of a server-side 404 on `/collections//ai-create`.
|
|
1196
|
+
throw new Error('create_in_collection requires either `collection` or a `template` that supplies a default.');
|
|
1197
|
+
}
|
|
1198
|
+
const enrichedPrompt = recipe
|
|
1199
|
+
? recipe.enrich(prompt, { category, cityState, name, role })
|
|
1200
|
+
: prompt;
|
|
1201
|
+
return await client.createDocumentFromPrompt(targetCollection, {
|
|
1202
|
+
...rest,
|
|
1203
|
+
prompt: enrichedPrompt,
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
tool('create_blog_post', 'Generate a complete blog post via AI and save it as a DRAFT. Targets the conventional "posts" collection — use `create_in_collection` if your site uses a different slug.', z.object({
|
|
1207
|
+
...aiContentInputBase,
|
|
1208
|
+
collection: z
|
|
1209
|
+
.string()
|
|
1210
|
+
.optional()
|
|
1211
|
+
.describe('Override the destination collection (default "posts").'),
|
|
1212
|
+
}), async (args) => {
|
|
1213
|
+
const { collection, ...rest } = args;
|
|
1214
|
+
return await client.createDocumentFromPrompt(collection ?? 'posts', rest);
|
|
1215
|
+
});
|
|
1216
|
+
tool('create_case_study', 'Generate a complete case study via AI. Targets "case-studies" by default. Kept as a dedicated tool (rather than a `create_in_collection` template) because the client/industry framing is distinctive enough to be a top-level agent intent.', z.object({
|
|
1217
|
+
...aiContentInputBase,
|
|
1218
|
+
clientName: z.string().optional().describe('Client / customer name to feature.'),
|
|
1219
|
+
industry: z.string().optional(),
|
|
1220
|
+
collection: z.string().optional(),
|
|
1221
|
+
}), async (args) => {
|
|
1222
|
+
const { collection, clientName, industry, prompt, ...rest } = args;
|
|
1223
|
+
const enriched = [
|
|
1224
|
+
prompt,
|
|
1225
|
+
clientName ? `Client: ${clientName}.` : '',
|
|
1226
|
+
industry ? `Industry: ${industry}.` : '',
|
|
1227
|
+
]
|
|
1228
|
+
.filter(Boolean)
|
|
1229
|
+
.join(' ');
|
|
1230
|
+
return await client.createDocumentFromPrompt(collection ?? 'case-studies', {
|
|
1231
|
+
...rest,
|
|
1232
|
+
prompt: enriched,
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
tool('list_collections', 'Discover available collections, their labels, type (`page`|`post`), and field summary. Primary source is the dedicated `/collections` discovery endpoint (sourced directly from the runtime CMS config so it stays accurate when the OpenAPI spec is incomplete). Falls back to the OpenAPI spec on older CMS servers that don\'t yet expose `/collections`. Returns `{ data: CollectionSummary[], source: "collections" | "openapi" }` — `data` is ALWAYS an array (the OpenAPI fallback is normalised to the same shape) so callers get a consistent contract regardless of which path responded.', z.object({}), async () => {
|
|
1236
|
+
// Phase 7 PR F (audit stylistic note #2): the previous
|
|
1237
|
+
// implementation relied solely on /openapi.json and silently
|
|
1238
|
+
// returned an empty map whenever the OpenAPI generator drifted.
|
|
1239
|
+
// We now prefer the dedicated /collections endpoint and only
|
|
1240
|
+
// fall back to OpenAPI on a real HTTP 404.
|
|
1241
|
+
//
|
|
1242
|
+
// Review fix (PR #155, Bugbot + Copilot): use the typed
|
|
1243
|
+
// `PrentaClientError.status` instead of grepping the message
|
|
1244
|
+
// string — a message-text regex could misclassify a server's
|
|
1245
|
+
// unrelated "page not found"-style 500/400 as a missing
|
|
1246
|
+
// endpoint, and could also fail to recognise a real 404 whose
|
|
1247
|
+
// message doesn't contain the literal phrase.
|
|
1248
|
+
//
|
|
1249
|
+
// Also normalised: the OpenAPI fallback now returns the same
|
|
1250
|
+
// CollectionSummary[] shape as the primary path so `data` is
|
|
1251
|
+
// always an array. Callers no longer need to branch on `source`
|
|
1252
|
+
// to know whether they got an object map or an array.
|
|
1253
|
+
try {
|
|
1254
|
+
const res = await client.listCollections();
|
|
1255
|
+
return { data: res.data, source: 'collections' };
|
|
1256
|
+
}
|
|
1257
|
+
catch (err) {
|
|
1258
|
+
const status = err instanceof PrentaClientError ? err.status : 0;
|
|
1259
|
+
if (status !== 404) {
|
|
1260
|
+
throw err;
|
|
1261
|
+
}
|
|
1262
|
+
const spec = await client.getOpenApi();
|
|
1263
|
+
const data = [];
|
|
1264
|
+
if (spec?.paths) {
|
|
1265
|
+
for (const path of Object.keys(spec.paths)) {
|
|
1266
|
+
const m = /^\/collections\/([^/]+)$/.exec(path);
|
|
1267
|
+
if (!m)
|
|
1268
|
+
continue;
|
|
1269
|
+
const slug = m[1];
|
|
1270
|
+
const schemaRef = spec.paths[path]?.post?.requestBody?.content?.['application/json']?.schema?.$ref;
|
|
1271
|
+
const schemaName = schemaRef?.split('/').pop();
|
|
1272
|
+
const properties = spec.components?.schemas?.[schemaName]?.properties ?? {};
|
|
1273
|
+
const required = new Set(spec.components?.schemas?.[schemaName]?.required ?? []);
|
|
1274
|
+
const fields = Object.entries(properties).map(([name, def]) => ({
|
|
1275
|
+
name,
|
|
1276
|
+
type: typeof def?.type === 'string' ? def.type : 'unknown',
|
|
1277
|
+
required: required.has(name),
|
|
1278
|
+
}));
|
|
1279
|
+
data.push({
|
|
1280
|
+
slug,
|
|
1281
|
+
// OpenAPI doesn't carry the singular/plural label split,
|
|
1282
|
+
// so we fall back to the slug for both. Callers that care
|
|
1283
|
+
// about labels should target a CMS server with the new
|
|
1284
|
+
// /collections endpoint.
|
|
1285
|
+
labels: { singular: slug, plural: slug },
|
|
1286
|
+
type: 'page',
|
|
1287
|
+
urlPrefix: slug,
|
|
1288
|
+
hidden: false,
|
|
1289
|
+
fieldCount: fields.length,
|
|
1290
|
+
fields,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return { data, source: 'openapi' };
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
tool('resolve_url', 'Resolve a public URL path (e.g. "/about") to its CMS document.', z.object({ path: z.string() }), (args) => client.resolveUrl(args.path));
|
|
1298
|
+
// ── Relationship resolver (WS-C C2) ───────────────────────────────────
|
|
1299
|
+
//
|
|
1300
|
+
// Relationship fields store a TARGET DOCUMENT ID, but humans (and AI
|
|
1301
|
+
// prompts) think in slugs ("link the author to jane-doe"). Without these
|
|
1302
|
+
// tools an agent has to guess IDs. The flow is:
|
|
1303
|
+
// 1. get_relationship_fields(collection) → which fields are relationships
|
|
1304
|
+
// and which collection(s) each points at (`relationTo`).
|
|
1305
|
+
// 2. resolve_relationship(relationTo, slug) → the concrete document ID.
|
|
1306
|
+
// 3. update_document(..., data: { <field>: <id> }) to wire it up.
|
|
1307
|
+
// `list_relationship_targets` is the browse fallback when the exact slug
|
|
1308
|
+
// isn't known.
|
|
1309
|
+
tool('get_relationship_fields', "Inspect a collection's schema and return only the fields that reference other content — relationship fields (with their `relationTo` target collection(s) and `hasMany`), media fields, and select fields (with their `options`). Use this FIRST to discover which collection a relationship field points at before resolving a slug to an ID. Requires a CMS new enough to expose field metadata on `GET /collections`.", z.object({
|
|
1310
|
+
collection: z.string().describe('Collection slug to inspect (e.g. "posts").'),
|
|
1311
|
+
}), async (args) => {
|
|
1312
|
+
let res;
|
|
1313
|
+
try {
|
|
1314
|
+
res = await client.listCollections();
|
|
1315
|
+
}
|
|
1316
|
+
catch (err) {
|
|
1317
|
+
const status = err instanceof PrentaClientError ? err.status : 0;
|
|
1318
|
+
if (status === 404) {
|
|
1319
|
+
throw new Error('This CMS server does not expose the /collections discovery endpoint, so relationship field metadata is unavailable. Upgrade @prenta/core.');
|
|
1320
|
+
}
|
|
1321
|
+
throw err;
|
|
1322
|
+
}
|
|
1323
|
+
const col = (res.data ?? []).find((c) => c.slug === args.collection);
|
|
1324
|
+
if (!col) {
|
|
1325
|
+
const slugs = (res.data ?? []).map((c) => c.slug).join(', ');
|
|
1326
|
+
throw new Error(`Unknown collection "${args.collection}". Known collections: ${slugs || '(none)'}.`);
|
|
1327
|
+
}
|
|
1328
|
+
const relational = (col.fields ?? []).filter((f) => ['relationship', 'media', 'select'].includes(f.type));
|
|
1329
|
+
return { data: { collection: col.slug, fields: relational } };
|
|
1330
|
+
});
|
|
1331
|
+
tool('resolve_relationship', 'Resolve a human-friendly slug to a document ID within a collection — the value you store in a relationship field. `slug` is matched exactly against the document `slug` column (unique per collection), so this returns at most one document: `{ id, slug, title, status }`. Throws if no document matches (use `list_relationship_targets` to browse). Discover the target collection with `get_relationship_fields`.', z.object({
|
|
1332
|
+
collection: z
|
|
1333
|
+
.string()
|
|
1334
|
+
.describe("Collection to search — typically a relationship field's `relationTo`."),
|
|
1335
|
+
slug: z.string().min(1).describe('Exact document slug to resolve.'),
|
|
1336
|
+
}), async (args) => {
|
|
1337
|
+
const res = await client.listDocuments(args.collection, { slug: args.slug, pageSize: 2 });
|
|
1338
|
+
const docs = res.data?.docs ?? [];
|
|
1339
|
+
const match = docs.find((d) => d?.slug === args.slug) ?? docs[0];
|
|
1340
|
+
if (!match) {
|
|
1341
|
+
throw new Error(`No document in "${args.collection}" with slug "${args.slug}". Use list_relationship_targets({ collection: "${args.collection}" }) to browse candidates.`);
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
data: { id: match.id, slug: match.slug, title: match.title, status: match.status },
|
|
1345
|
+
};
|
|
1346
|
+
});
|
|
1347
|
+
tool('list_relationship_targets', 'List candidate documents to link to in a collection, projected to the fields you need to pick one: `{ id, slug, title, status }`. Use when the exact slug isn\'t known (the browse fallback for `resolve_relationship`). Filter by `status` (e.g. "PUBLISHED") and page through with `page` / `pageSize`. Returns `{ data: Target[], total, page, pageSize }`.', z.object({
|
|
1348
|
+
collection: z.string().describe('Collection to list candidates from.'),
|
|
1349
|
+
status: z
|
|
1350
|
+
.string()
|
|
1351
|
+
.optional()
|
|
1352
|
+
.describe('Filter by status (e.g. "PUBLISHED") — usually you only link to published content.'),
|
|
1353
|
+
page: z.number().int().min(1).optional(),
|
|
1354
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
1355
|
+
}), async (args) => {
|
|
1356
|
+
const { collection, ...query } = args;
|
|
1357
|
+
const res = await client.listDocuments(collection, query);
|
|
1358
|
+
const env = res.data ?? { docs: [], total: 0, page: 1, pageSize: 0 };
|
|
1359
|
+
const docs = (env.docs ?? []).map((d) => ({
|
|
1360
|
+
id: d?.id,
|
|
1361
|
+
slug: d?.slug,
|
|
1362
|
+
title: d?.title,
|
|
1363
|
+
status: d?.status,
|
|
1364
|
+
}));
|
|
1365
|
+
return { data: docs, total: env.total, page: env.page, pageSize: env.pageSize };
|
|
1366
|
+
});
|
|
1367
|
+
/** Resolve a menu by slug (exact, cheap) or, failing that, by document id. */
|
|
1368
|
+
async function loadNav(ref) {
|
|
1369
|
+
const bySlug = await client.listDocuments(NAVIGATION_COLLECTION, { slug: ref, pageSize: 1 });
|
|
1370
|
+
let doc = bySlug.data?.docs?.find((d) => d?.slug === ref) ?? bySlug.data?.docs?.[0];
|
|
1371
|
+
if (!doc) {
|
|
1372
|
+
try {
|
|
1373
|
+
const res = await client.getDocument(NAVIGATION_COLLECTION, ref);
|
|
1374
|
+
doc = res.data;
|
|
1375
|
+
}
|
|
1376
|
+
catch (err) {
|
|
1377
|
+
const status = err instanceof PrentaClientError ? err.status : 0;
|
|
1378
|
+
if (status !== 404)
|
|
1379
|
+
throw err;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (!doc) {
|
|
1383
|
+
throw new Error(`Navigation menu "${ref}" not found (looked up by slug, then by id).`);
|
|
1384
|
+
}
|
|
1385
|
+
const data = (doc.data ?? {});
|
|
1386
|
+
return {
|
|
1387
|
+
id: doc.id,
|
|
1388
|
+
name: (typeof data.name === 'string' && data.name) ||
|
|
1389
|
+
(typeof doc.title === 'string' && doc.title) ||
|
|
1390
|
+
doc.slug ||
|
|
1391
|
+
doc.id,
|
|
1392
|
+
slug: doc.slug ?? (typeof data.slug === 'string' ? data.slug : ''),
|
|
1393
|
+
location: typeof data.location === 'string' ? data.location : undefined,
|
|
1394
|
+
items: coerceNavItems(data.items),
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
/** Persist a new items tree onto a menu (merge — other fields are preserved). */
|
|
1398
|
+
function saveNavItems(navId, items) {
|
|
1399
|
+
return client.updateDocument(NAVIGATION_COLLECTION, navId, { items });
|
|
1400
|
+
}
|
|
1401
|
+
/** Resolve a published-page reference to a `{ label, url }` for a menu link. */
|
|
1402
|
+
async function resolvePageLink(page) {
|
|
1403
|
+
const collection = page.collection ?? 'pages';
|
|
1404
|
+
let doc;
|
|
1405
|
+
if (page.slug) {
|
|
1406
|
+
const res = await client.listDocuments(collection, { slug: page.slug, pageSize: 1 });
|
|
1407
|
+
doc = res.data?.docs?.find((d) => d?.slug === page.slug) ?? res.data?.docs?.[0];
|
|
1408
|
+
}
|
|
1409
|
+
else if (page.id) {
|
|
1410
|
+
const res = await client.getDocument(collection, page.id);
|
|
1411
|
+
doc = res.data;
|
|
1412
|
+
}
|
|
1413
|
+
if (!doc) {
|
|
1414
|
+
throw new Error(`Could not resolve a ${collection} document from ${JSON.stringify(page)}. Use list_relationship_targets to find a valid slug/id.`);
|
|
1415
|
+
}
|
|
1416
|
+
const data = (doc.data ?? {});
|
|
1417
|
+
const slug = doc.slug ?? (typeof data.slug === 'string' ? data.slug : '');
|
|
1418
|
+
if (!slug)
|
|
1419
|
+
throw new Error('Target document has no slug, so its URL cannot be derived.');
|
|
1420
|
+
// Look up the collection's urlPrefix + type from discovery so the URL
|
|
1421
|
+
// matches the site's real routing.
|
|
1422
|
+
let urlPrefix;
|
|
1423
|
+
let collectionType;
|
|
1424
|
+
try {
|
|
1425
|
+
const cols = await client.listCollections();
|
|
1426
|
+
const col = cols.data?.find((c) => c.slug === collection);
|
|
1427
|
+
urlPrefix = col?.urlPrefix;
|
|
1428
|
+
collectionType = col?.type;
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
// Discovery unavailable (older server) — fall back to no prefix.
|
|
1432
|
+
}
|
|
1433
|
+
const isHome = (collectionType === undefined || collectionType === 'page') && slug === 'home';
|
|
1434
|
+
const url = buildDocPath(urlPrefix, slug, isHome, data);
|
|
1435
|
+
const label = (typeof doc.title === 'string' && doc.title) ||
|
|
1436
|
+
(typeof data.title === 'string' && data.title) ||
|
|
1437
|
+
(typeof data.name === 'string' && data.name) ||
|
|
1438
|
+
slug;
|
|
1439
|
+
return { label, url };
|
|
1440
|
+
}
|
|
1441
|
+
tool('list_navigations', 'List navigation menus, projected to `{ id, name, slug, location, itemCount }`. Menus are documents in the `navigations` collection (requires the navigation plugin).', z.object({
|
|
1442
|
+
location: z
|
|
1443
|
+
.string()
|
|
1444
|
+
.optional()
|
|
1445
|
+
.describe('Filter by placement (e.g. "header", "footer", "sidebar", "mobile").'),
|
|
1446
|
+
page: z.number().int().min(1).optional(),
|
|
1447
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
1448
|
+
}), async (args) => {
|
|
1449
|
+
const res = await client.listDocuments(NAVIGATION_COLLECTION, {
|
|
1450
|
+
page: args.page,
|
|
1451
|
+
pageSize: args.pageSize,
|
|
1452
|
+
});
|
|
1453
|
+
const env = res.data ?? { docs: [], total: 0, page: 1, pageSize: 0 };
|
|
1454
|
+
let docs = (env.docs ?? []).map((d) => {
|
|
1455
|
+
const data = (d?.data ?? {});
|
|
1456
|
+
return {
|
|
1457
|
+
id: d?.id,
|
|
1458
|
+
name: (typeof data.name === 'string' && data.name) ||
|
|
1459
|
+
(typeof d?.title === 'string' && d.title) ||
|
|
1460
|
+
d?.slug ||
|
|
1461
|
+
d?.id,
|
|
1462
|
+
slug: d?.slug ?? null,
|
|
1463
|
+
location: typeof data.location === 'string' ? data.location : null,
|
|
1464
|
+
itemCount: coerceNavItems(data.items).length,
|
|
1465
|
+
};
|
|
1466
|
+
});
|
|
1467
|
+
if (args.location)
|
|
1468
|
+
docs = docs.filter((d) => d.location === args.location);
|
|
1469
|
+
return { data: docs, total: env.total, page: env.page, pageSize: env.pageSize };
|
|
1470
|
+
});
|
|
1471
|
+
tool('get_navigation', 'Fetch one navigation menu (by slug or document id) including its full link tree: `{ id, name, slug, location, items }`.', z.object({
|
|
1472
|
+
navigation: z.string().describe('Menu slug (e.g. "main-navigation") or document id.'),
|
|
1473
|
+
}), async (args) => ({ data: await loadNav(args.navigation) }));
|
|
1474
|
+
tool('create_navigation', 'Create a navigation menu. Provide a `name`; `slug` is derived from it if omitted. Optionally seed `location` and an `items` tree (validated before save — items missing ids get them generated). Created PUBLISHED so it is live immediately.', z.object({
|
|
1475
|
+
name: z.string().min(1).describe('Menu name, e.g. "Main Navigation".'),
|
|
1476
|
+
slug: z.string().optional().describe('URL-safe slug; derived from name if omitted.'),
|
|
1477
|
+
location: z
|
|
1478
|
+
.enum(['header', 'footer', 'sidebar', 'mobile'])
|
|
1479
|
+
.optional()
|
|
1480
|
+
.describe('Where the menu renders.'),
|
|
1481
|
+
items: z
|
|
1482
|
+
.array(z.record(z.any()))
|
|
1483
|
+
.optional()
|
|
1484
|
+
.describe('Initial link tree (NavItem[]). Each item: { label, url, target?, children? }.'),
|
|
1485
|
+
status: z.enum(['DRAFT', 'PUBLISHED']).optional(),
|
|
1486
|
+
}), async (args) => {
|
|
1487
|
+
const items = coerceNavItems(args.items ?? []);
|
|
1488
|
+
const validation = validateNavTree(items);
|
|
1489
|
+
if (!validation.valid) {
|
|
1490
|
+
throw new Error(`Invalid navigation tree: ${validation.errors.join('; ')}`);
|
|
1491
|
+
}
|
|
1492
|
+
const slug = args.slug ??
|
|
1493
|
+
args.name
|
|
1494
|
+
.toLowerCase()
|
|
1495
|
+
.trim()
|
|
1496
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1497
|
+
.replace(/^-+|-+$/g, '');
|
|
1498
|
+
return client.createDocument(NAVIGATION_COLLECTION, {
|
|
1499
|
+
name: args.name,
|
|
1500
|
+
title: args.name,
|
|
1501
|
+
slug,
|
|
1502
|
+
location: args.location,
|
|
1503
|
+
items,
|
|
1504
|
+
status: args.status ?? 'PUBLISHED',
|
|
1505
|
+
});
|
|
1506
|
+
});
|
|
1507
|
+
tool('update_navigation', "Update a menu's metadata (`name`, `location`) and/or replace its entire `items` tree. To add/remove/reorder single links without replacing the whole tree, prefer add_navigation_item / remove_navigation_item / reorder_navigation_items.", z.object({
|
|
1508
|
+
navigation: z.string().describe('Menu slug or document id.'),
|
|
1509
|
+
name: z.string().min(1).optional(),
|
|
1510
|
+
location: z.enum(['header', 'footer', 'sidebar', 'mobile']).optional(),
|
|
1511
|
+
items: z
|
|
1512
|
+
.array(z.record(z.any()))
|
|
1513
|
+
.optional()
|
|
1514
|
+
.describe('Replacement link tree (validated before save).'),
|
|
1515
|
+
}), async (args) => {
|
|
1516
|
+
const nav = await loadNav(args.navigation);
|
|
1517
|
+
const patch = {};
|
|
1518
|
+
if (args.name !== undefined) {
|
|
1519
|
+
patch.name = args.name;
|
|
1520
|
+
patch.title = args.name;
|
|
1521
|
+
}
|
|
1522
|
+
if (args.location !== undefined)
|
|
1523
|
+
patch.location = args.location;
|
|
1524
|
+
if (args.items !== undefined) {
|
|
1525
|
+
const items = coerceNavItems(args.items);
|
|
1526
|
+
const validation = validateNavTree(items);
|
|
1527
|
+
if (!validation.valid) {
|
|
1528
|
+
throw new Error(`Invalid navigation tree: ${validation.errors.join('; ')}`);
|
|
1529
|
+
}
|
|
1530
|
+
patch.items = items;
|
|
1531
|
+
}
|
|
1532
|
+
if (Object.keys(patch).length === 0) {
|
|
1533
|
+
throw new Error('Nothing to update — provide at least one of name, location, or items.');
|
|
1534
|
+
}
|
|
1535
|
+
return client.updateDocument(NAVIGATION_COLLECTION, nav.id, patch);
|
|
1536
|
+
});
|
|
1537
|
+
tool('delete_navigation', 'Soft-delete a navigation menu. Destructive — confirm with the user first.', z.object({ navigation: z.string().describe('Menu slug or document id.') }), async (args) => {
|
|
1538
|
+
const nav = await loadNav(args.navigation);
|
|
1539
|
+
return client.deleteDocument(NAVIGATION_COLLECTION, nav.id);
|
|
1540
|
+
});
|
|
1541
|
+
tool('add_navigation_item', 'Add a link to a menu. Provide an explicit `{ label, url }`, OR a `page` reference ({ collection?, slug? | id? }) to link a published page — its URL and label are resolved for you ("link page X into menu Y"). Optionally nest under `parentId`. Returns the updated menu.', z.object({
|
|
1542
|
+
navigation: z.string().describe('Menu slug or document id.'),
|
|
1543
|
+
label: z.string().optional().describe('Link text (required unless `page` is given).'),
|
|
1544
|
+
url: z.string().optional().describe('Link href (required unless `page` is given).'),
|
|
1545
|
+
target: z.enum(['_blank', '_self']).optional(),
|
|
1546
|
+
parentId: z
|
|
1547
|
+
.string()
|
|
1548
|
+
.optional()
|
|
1549
|
+
.describe('Nest under this existing item id; omit for a top-level link.'),
|
|
1550
|
+
page: z
|
|
1551
|
+
.object({
|
|
1552
|
+
collection: z.string().optional().describe('Defaults to "pages".'),
|
|
1553
|
+
slug: z.string().optional(),
|
|
1554
|
+
id: z.string().optional(),
|
|
1555
|
+
})
|
|
1556
|
+
.optional()
|
|
1557
|
+
.describe('Link a published page by slug or id instead of a raw url.'),
|
|
1558
|
+
}), async (args) => {
|
|
1559
|
+
let label = args.label;
|
|
1560
|
+
let url = args.url;
|
|
1561
|
+
if (args.page) {
|
|
1562
|
+
if (!args.page.slug && !args.page.id) {
|
|
1563
|
+
throw new Error('`page` requires a `slug` or `id`.');
|
|
1564
|
+
}
|
|
1565
|
+
const resolved = await resolvePageLink(args.page);
|
|
1566
|
+
url = url ?? resolved.url;
|
|
1567
|
+
label = label ?? resolved.label;
|
|
1568
|
+
}
|
|
1569
|
+
if (typeof label !== 'string' || label.trim().length === 0) {
|
|
1570
|
+
throw new Error('A non-empty `label` is required (or pass a `page` to derive it).');
|
|
1571
|
+
}
|
|
1572
|
+
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
1573
|
+
throw new Error('A non-empty `url` is required (or pass a `page` to derive it).');
|
|
1574
|
+
}
|
|
1575
|
+
const item = { id: genNavItemId(), label, url };
|
|
1576
|
+
if (args.target)
|
|
1577
|
+
item.target = args.target;
|
|
1578
|
+
const nav = await loadNav(args.navigation);
|
|
1579
|
+
const nextItems = addNavItem(nav.items, item, args.parentId ?? null);
|
|
1580
|
+
const validation = validateNavTree(nextItems);
|
|
1581
|
+
if (!validation.valid) {
|
|
1582
|
+
throw new Error(`Resulting navigation tree is invalid: ${validation.errors.join('; ')}`);
|
|
1583
|
+
}
|
|
1584
|
+
await saveNavItems(nav.id, nextItems);
|
|
1585
|
+
return { data: { navigation: nav.id, addedItemId: item.id, items: nextItems } };
|
|
1586
|
+
});
|
|
1587
|
+
tool('remove_navigation_item', 'Remove a single link (and its sub-tree) from a menu by item id. Returns the updated menu.', z.object({
|
|
1588
|
+
navigation: z.string().describe('Menu slug or document id.'),
|
|
1589
|
+
itemId: z.string().describe('The id of the item to remove.'),
|
|
1590
|
+
}), async (args) => {
|
|
1591
|
+
const nav = await loadNav(args.navigation);
|
|
1592
|
+
const { items, removed } = removeNavItem(nav.items, args.itemId);
|
|
1593
|
+
if (!removed) {
|
|
1594
|
+
throw new Error(`Item "${args.itemId}" not found in menu "${nav.slug || nav.id}".`);
|
|
1595
|
+
}
|
|
1596
|
+
await saveNavItems(nav.id, items);
|
|
1597
|
+
return { data: { navigation: nav.id, removedItemId: args.itemId, items } };
|
|
1598
|
+
});
|
|
1599
|
+
tool('reorder_navigation_items', "Reorder the links at one level of a menu. `orderedIds` must be a permutation of exactly the item ids at that level. Omit `parentId` to reorder the top level, or pass it to reorder a parent's children.", z.object({
|
|
1600
|
+
navigation: z.string().describe('Menu slug or document id.'),
|
|
1601
|
+
orderedIds: z.array(z.string()).min(1).describe('Item ids in the desired order.'),
|
|
1602
|
+
parentId: z
|
|
1603
|
+
.string()
|
|
1604
|
+
.optional()
|
|
1605
|
+
.describe("Reorder this parent's children; omit for the top level."),
|
|
1606
|
+
}), async (args) => {
|
|
1607
|
+
const nav = await loadNav(args.navigation);
|
|
1608
|
+
const items = reorderNavLevel(nav.items, args.orderedIds, args.parentId ?? null);
|
|
1609
|
+
await saveNavItems(nav.id, items);
|
|
1610
|
+
return { data: { navigation: nav.id, items } };
|
|
1611
|
+
});
|
|
1612
|
+
tool('validate_navigation_tree', 'Validate a navigation tree WITHOUT saving. Pass an existing menu (`navigation`) or a candidate `items` array. Returns `{ valid, errors, warnings, stats }` — errors are hard problems (missing label/url, duplicate ids, exceeding maxDepth); warnings include placeholder "#"/empty urls and (when target checking runs) internal URLs with no published document. Target checking runs by default for stored menus; for candidate `items` pass `checkPublishedTargets: true` (uses the network). Soft warnings never set `valid` to false.', z.object({
|
|
1613
|
+
navigation: z.string().optional().describe('Existing menu slug or id to validate.'),
|
|
1614
|
+
items: z
|
|
1615
|
+
.array(z.record(z.any()))
|
|
1616
|
+
.optional()
|
|
1617
|
+
.describe('Candidate items tree to validate instead of a stored menu.'),
|
|
1618
|
+
maxDepth: z
|
|
1619
|
+
.number()
|
|
1620
|
+
.int()
|
|
1621
|
+
.min(1)
|
|
1622
|
+
.max(10)
|
|
1623
|
+
.optional()
|
|
1624
|
+
.describe('Max nesting depth (default 5).'),
|
|
1625
|
+
checkPublishedTargets: z
|
|
1626
|
+
.boolean()
|
|
1627
|
+
.optional()
|
|
1628
|
+
.describe('When true, also warn on internal URLs with no published page (network). Default: true for stored `navigation`, false for candidate `items`.'),
|
|
1629
|
+
}), async (args) => {
|
|
1630
|
+
let items = args.items;
|
|
1631
|
+
const loadedFromStore = items === undefined;
|
|
1632
|
+
if (items === undefined) {
|
|
1633
|
+
if (!args.navigation) {
|
|
1634
|
+
throw new Error('Provide either `navigation` (an existing menu) or `items` to validate.');
|
|
1635
|
+
}
|
|
1636
|
+
const nav = await loadNav(args.navigation);
|
|
1637
|
+
items = nav.items;
|
|
1638
|
+
}
|
|
1639
|
+
const structural = validateNavTree(items, args.maxDepth ?? 5);
|
|
1640
|
+
const checkTargets = args.checkPublishedTargets === true ||
|
|
1641
|
+
(args.checkPublishedTargets !== false && loadedFromStore);
|
|
1642
|
+
if (!checkTargets || !Array.isArray(items)) {
|
|
1643
|
+
return { data: structural };
|
|
1644
|
+
}
|
|
1645
|
+
try {
|
|
1646
|
+
const res = await client.validateNavigationTargets(items);
|
|
1647
|
+
const targetWarnings = res.data?.warnings ?? [];
|
|
1648
|
+
return {
|
|
1649
|
+
data: {
|
|
1650
|
+
...structural,
|
|
1651
|
+
warnings: [...structural.warnings, ...targetWarnings],
|
|
1652
|
+
unresolved: res.data?.unresolved ?? [],
|
|
1653
|
+
},
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
catch (err) {
|
|
1657
|
+
// Older CMS builds lack the endpoint — keep structural result.
|
|
1658
|
+
if (err instanceof PrentaClientError && (err.status === 404 || err.status === 501)) {
|
|
1659
|
+
return { data: structural };
|
|
1660
|
+
}
|
|
1661
|
+
throw err;
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
// ── Page sections (flat Page Editor model) ────────────────────────────
|
|
1665
|
+
//
|
|
1666
|
+
// These let an AI agent (e.g. running in Cursor / VS Code) BUILD a page
|
|
1667
|
+
// section-by-section so the result becomes a REAL Page Editor page:
|
|
1668
|
+
// previewable, manually editable, reorderable, savable, and publishable
|
|
1669
|
+
// through the same model the visual editor uses — not disposable HTML.
|
|
1670
|
+
//
|
|
1671
|
+
// Workflow: discover types (`list_section_types`) → optionally inspect a
|
|
1672
|
+
// schema (`get_section_schema`) → add/update sections on a page document
|
|
1673
|
+
// (`add_page_section` / `update_page_section`) → reorder / remove →
|
|
1674
|
+
// publish via the normal `update_document({ status: 'PUBLISHED' })`.
|
|
1675
|
+
//
|
|
1676
|
+
// Sections live in a page document's `data.sections` array; the page is a
|
|
1677
|
+
// document in a collection that declares a `sections` JSON field (default
|
|
1678
|
+
// slug "pages"). All mutations are server-side validated + audited.
|
|
1679
|
+
tool('list_section_types', 'Discover the registered section types and their schemas. Returns each type\'s `id` (e.g. "hero", "text", "feature", "stats", "cta", "article-body", "quote", "author-bio", "related-posts", "gallery", "embed", "widget-embed"), `name`, `description`, `category`, `scope` (which surfaces it works on: "page" and/or "post"), the inspector `fields` schema (which content keys exist + which are required), and `defaultContent` / `defaultSettings`. Call this FIRST before building a page or a post template so you only use types that have a working renderer (no dead sections). Pass `scope` to narrow to one surface.', z.object({
|
|
1680
|
+
scope: z
|
|
1681
|
+
.enum(['page', 'post'])
|
|
1682
|
+
.optional()
|
|
1683
|
+
.describe('Limit to types usable on this surface: "page" for the Page Editor, "post" for posts + post templates. Omit for all types.'),
|
|
1684
|
+
}), (args) => client.listSectionTypes(args.scope));
|
|
1685
|
+
tool('get_section_schema', 'Fetch the schema for a SINGLE section type by id (e.g. "hero"). Returns the same shape as one entry of `list_section_types`. Use this to see exactly which `content` fields a type accepts and which are required before calling `add_page_section`. Throws if the id is not a registered type.', z.object({
|
|
1686
|
+
sectionType: z
|
|
1687
|
+
.string()
|
|
1688
|
+
.describe('Section type id, e.g. "hero" | "text" | "feature" | "stats" | "cta".'),
|
|
1689
|
+
}), async (args) => {
|
|
1690
|
+
const res = await client.listSectionTypes();
|
|
1691
|
+
const match = (res.data ?? []).find((t) => t?.id === args.sectionType);
|
|
1692
|
+
if (!match) {
|
|
1693
|
+
const ids = (res.data ?? [])
|
|
1694
|
+
.map((t) => t?.id)
|
|
1695
|
+
.filter(Boolean)
|
|
1696
|
+
.join(', ');
|
|
1697
|
+
throw new Error(`Unknown section type "${args.sectionType}". Registered types: ${ids || '(none)'}.`);
|
|
1698
|
+
}
|
|
1699
|
+
return { data: match };
|
|
1700
|
+
});
|
|
1701
|
+
tool('validate_section_content', 'Validate section content against its type schema WITHOUT persisting. Deterministic (no LLM). Returns `{ valid, errors, warnings }` where each issue is `{ sectionType, field?, message }`. Use to check AI-authored content before adding it, or to explain why a publish is blocked. An unknown `sectionType` is a hard error.', z.object({
|
|
1702
|
+
sectionType: z.string().describe('Section type id to validate against.'),
|
|
1703
|
+
content: z
|
|
1704
|
+
.record(z.any())
|
|
1705
|
+
.describe("The section content object (keys match the type's field schema)."),
|
|
1706
|
+
settings: z.record(z.any()).optional().describe('Optional section settings object.'),
|
|
1707
|
+
}), (args) => client.validateSection({
|
|
1708
|
+
sectionType: args.sectionType,
|
|
1709
|
+
content: args.content,
|
|
1710
|
+
...(args.settings !== undefined ? { settings: args.settings } : {}),
|
|
1711
|
+
}));
|
|
1712
|
+
tool('list_page_sections', 'List the ordered sections on a page document (`data.sections`). Each section is `{ id, sectionType, name, visible, content, settings }`. Use to inspect the current page before editing, or to discover section ids for `update_page_section` / `remove_page_section` / `reorder_page_sections`.', z.object({
|
|
1713
|
+
id: z.string().describe('Page document ID.'),
|
|
1714
|
+
collection: z
|
|
1715
|
+
.string()
|
|
1716
|
+
.optional()
|
|
1717
|
+
.describe('Collection slug of the page. Defaults to "pages".'),
|
|
1718
|
+
}), (args) => client.listPageSections(args.collection ?? 'pages', args.id));
|
|
1719
|
+
tool('add_page_section', "Add a section to a page. The server seeds the type's registry defaults, shallow-merges your `content` / `settings`, generates an id, inserts at `position` (0-based; omit to append), and persists through the audited update path. Returns `{ section, sections, validation }` — inspect `validation` for any content issues (the section is still added so you can fix it with `update_page_section`). Only registered `sectionType`s are accepted (see `list_section_types`).", z.object({
|
|
1720
|
+
id: z.string().describe('Page document ID.'),
|
|
1721
|
+
sectionType: z
|
|
1722
|
+
.string()
|
|
1723
|
+
.describe('Registered section type id (see list_section_types), e.g. "hero".'),
|
|
1724
|
+
collection: z.string().optional().describe('Page collection slug. Defaults to "pages".'),
|
|
1725
|
+
name: z
|
|
1726
|
+
.string()
|
|
1727
|
+
.optional()
|
|
1728
|
+
.describe('Editor-facing display name. Defaults to the type name.'),
|
|
1729
|
+
internalLabel: z.string().optional().describe('Optional internal-only label for the team.'),
|
|
1730
|
+
visible: z.boolean().optional().describe('Whether the section renders. Defaults to true.'),
|
|
1731
|
+
content: z
|
|
1732
|
+
.record(z.any())
|
|
1733
|
+
.optional()
|
|
1734
|
+
.describe('Content overrides (merged over the type defaults). Keys match the field schema.'),
|
|
1735
|
+
settings: z
|
|
1736
|
+
.record(z.any())
|
|
1737
|
+
.optional()
|
|
1738
|
+
.describe('Settings overrides (backgroundType, backgroundColor, paddingY, anchorId, cssClass).'),
|
|
1739
|
+
position: z
|
|
1740
|
+
.number()
|
|
1741
|
+
.int()
|
|
1742
|
+
.min(0)
|
|
1743
|
+
.optional()
|
|
1744
|
+
.describe('0-based insert index. Omit to append at the end.'),
|
|
1745
|
+
}), (args) => {
|
|
1746
|
+
const { id, collection, ...body } = args;
|
|
1747
|
+
return client.addPageSection(collection ?? 'pages', id, body);
|
|
1748
|
+
});
|
|
1749
|
+
tool('update_page_section', 'Update a section in place. Only supplied fields change; `content` and `settings` are shallow-merged (so you can patch a single key). Use `visible` to show/hide, `name` / `internalLabel` to relabel. Returns `{ section, sections, validation }`. Pair with `list_page_sections` to discover the `sectionId`.', z.object({
|
|
1750
|
+
id: z.string().describe('Page document ID.'),
|
|
1751
|
+
sectionId: z.string().describe('Section id within the page (from list_page_sections).'),
|
|
1752
|
+
collection: z.string().optional().describe('Page collection slug. Defaults to "pages".'),
|
|
1753
|
+
content: z.record(z.any()).optional().describe('Content fields to merge in.'),
|
|
1754
|
+
settings: z.record(z.any()).optional().describe('Settings fields to merge in.'),
|
|
1755
|
+
name: z.string().optional(),
|
|
1756
|
+
internalLabel: z.string().optional(),
|
|
1757
|
+
visible: z.boolean().optional().describe('Show (true) or hide (false) the section.'),
|
|
1758
|
+
}), (args) => {
|
|
1759
|
+
const { id, sectionId, collection, ...body } = args;
|
|
1760
|
+
return client.updatePageSection(collection ?? 'pages', id, sectionId, body);
|
|
1761
|
+
});
|
|
1762
|
+
tool('reorder_page_sections', "Reorder a page's sections to an explicit id order. Pass the full list of section ids in the desired top-to-bottom order; any ids you omit are appended at the end (defensive — sections are never dropped). Returns the new ordered section array.", z.object({
|
|
1763
|
+
id: z.string().describe('Page document ID.'),
|
|
1764
|
+
orderedIds: z.array(z.string()).describe('Section ids in the desired order (top to bottom).'),
|
|
1765
|
+
collection: z.string().optional().describe('Page collection slug. Defaults to "pages".'),
|
|
1766
|
+
}), (args) => client.reorderPageSections(args.collection ?? 'pages', args.id, args.orderedIds));
|
|
1767
|
+
tool('remove_page_section', 'Remove a section from a page. Destructive (the section content is dropped from the draft) — confirm with the user for published pages. Returns the remaining sections.', z.object({
|
|
1768
|
+
id: z.string().describe('Page document ID.'),
|
|
1769
|
+
sectionId: z.string().describe('Section id to remove (from list_page_sections).'),
|
|
1770
|
+
collection: z.string().optional().describe('Page collection slug. Defaults to "pages".'),
|
|
1771
|
+
}), (args) => client.removePageSection(args.collection ?? 'pages', args.id, args.sectionId));
|
|
1772
|
+
tool('toggle_page_section_visibility', 'Show or hide a section without deleting it. Hidden sections stay in the draft but are excluded from the published page and from publish-time validation. Pass `visible` explicitly. Returns `{ section, sections, validation }`.', z.object({
|
|
1773
|
+
id: z.string().describe('Page document ID.'),
|
|
1774
|
+
sectionId: z.string().describe('Section id to toggle.'),
|
|
1775
|
+
visible: z.boolean().describe('true to show, false to hide.'),
|
|
1776
|
+
collection: z.string().optional().describe('Page collection slug. Defaults to "pages".'),
|
|
1777
|
+
}), (args) => client.updatePageSection(args.collection ?? 'pages', args.id, args.sectionId, {
|
|
1778
|
+
visible: args.visible,
|
|
1779
|
+
}));
|
|
1780
|
+
// ── Post templates ────────────────────────────────────────────────────
|
|
1781
|
+
//
|
|
1782
|
+
// A post type's template (one per type) defines the design-driven header
|
|
1783
|
+
// layout + the default section list that NEW posts of that type inherit.
|
|
1784
|
+
// Workflow for an external agent driving a frontend design into the CMS:
|
|
1785
|
+
// 1. list_section_types({ scope: 'post' }) → see post-usable sections
|
|
1786
|
+
// 2. get_post_template({ postType }) → read the current template
|
|
1787
|
+
// 3. save_post_template({ postType, header, sections }) → write the design
|
|
1788
|
+
// 4. create posts of that type (they start from this template)
|
|
1789
|
+
tool('get_post_template', "Read a post type's template: `{ postType, docId, header, sections }`. `header` is the design-driven header layout (`{ layout, showFeaturedImage, showExcerpt, showAuthor, showDate, showCategory, showReadingTime }`); `sections` is the default `PageSection[]` new posts inherit. Returns an unsaved default (centered header, empty sections, `docId: null`) when the type has no template yet. Call before `save_post_template` to avoid clobbering an existing design.", z.object({
|
|
1790
|
+
postType: z
|
|
1791
|
+
.string()
|
|
1792
|
+
.describe('Post-type collection slug, e.g. "blog" | "case-studies" | "news".'),
|
|
1793
|
+
}), (args) => client.getPostTemplate(args.postType));
|
|
1794
|
+
tool('save_post_template', "Create or replace a post type's template (header layout + default sections). Use ONLY section types whose `scope` includes \"post\" (see `list_section_types({ scope: 'post' })`) — unknown/page-only types are dropped server-side. `header` keys are coerced to a full config (missing keys filled with defaults). Each section is `{ sectionType, name?, visible?, content?, settings? }` (ids are generated if omitted). This sets the starting layout for FUTURE posts of this type; it does not retroactively change existing posts. Returns the saved `{ postType, docId, header, sections }`.", z.object({
|
|
1795
|
+
postType: z.string().describe('Post-type collection slug to write the template for.'),
|
|
1796
|
+
header: z
|
|
1797
|
+
.record(z.any())
|
|
1798
|
+
.optional()
|
|
1799
|
+
.describe('Header layout config: { layout: "centered"|"left"|"overlay", showFeaturedImage, showExcerpt, showAuthor, showDate, showCategory, showReadingTime }.'),
|
|
1800
|
+
sections: z
|
|
1801
|
+
.array(z.any())
|
|
1802
|
+
.optional()
|
|
1803
|
+
.describe('Ordered default sections (post-scoped types only): [{ sectionType, name?, visible?, content?, settings? }].'),
|
|
1804
|
+
}), (args) => {
|
|
1805
|
+
const body = {};
|
|
1806
|
+
if (args.header !== undefined)
|
|
1807
|
+
body.header = args.header;
|
|
1808
|
+
if (args.sections !== undefined)
|
|
1809
|
+
body.sections = args.sections;
|
|
1810
|
+
return client.savePostTemplate(args.postType, body);
|
|
1811
|
+
});
|
|
1812
|
+
// ── AI quality pass (Slice F) ─────────────────────────────────────────
|
|
1813
|
+
//
|
|
1814
|
+
// Both tools are deterministic — they do not call any LLM. They run the
|
|
1815
|
+
// built-in scoring functions plus structural / link-graph analysis, so
|
|
1816
|
+
// they're cheap enough that agents can call them on every edit cycle.
|
|
1817
|
+
// Phase 7 PR F (audit stylistic note #3): split `audit_document` into
|
|
1818
|
+
// two explicit tools so the agent picks the right mode at the tool
|
|
1819
|
+
// boundary instead of constructing a half-filled `{collection?, id?,
|
|
1820
|
+
// body?, title?, ...}` blob and discovering the server's "must
|
|
1821
|
+
// provide either (collection,id) or body" rule the hard way.
|
|
1822
|
+
//
|
|
1823
|
+
// Both tools forward to the same `/ai/audit-document` endpoint; the
|
|
1824
|
+
// split is purely a UX improvement for agents.
|
|
1825
|
+
tool('audit_document_by_id', 'Run the composite content quality audit (SEO + readability + accessibility + freshness) against a stored document. Returns a letter grade, 0–100 per-dimension scores, and a prioritized list of actionable issues. Optional override fields let the agent audit a hypothetical mutation of the stored document (e.g. "score this doc IF I changed the metaTitle to X") without persisting. Use `audit_inline_content` for ad-hoc text that is not yet stored.', z.object({
|
|
1826
|
+
collection: z.string().describe('Collection slug of the stored document.'),
|
|
1827
|
+
id: z.string().describe('Document ID.'),
|
|
1828
|
+
// Optional overrides forwarded to /ai/audit-document. The server
|
|
1829
|
+
// accepts these on the stored-doc path too — they let the agent
|
|
1830
|
+
// run "what-if" audits without committing changes. Removing them
|
|
1831
|
+
// (as we initially did in PR F) would silently regress that
|
|
1832
|
+
// capability for any tool consumer that relied on it.
|
|
1833
|
+
title: z.string().optional().describe('Override the stored title for this audit only.'),
|
|
1834
|
+
metaTitle: z.string().optional(),
|
|
1835
|
+
metaDescription: z.string().optional(),
|
|
1836
|
+
targetKeyword: z.string().optional(),
|
|
1837
|
+
url: z.string().optional(),
|
|
1838
|
+
headings: z.array(z.string()).optional(),
|
|
1839
|
+
contentType: z
|
|
1840
|
+
.string()
|
|
1841
|
+
.optional()
|
|
1842
|
+
.describe('Content type for freshness scoring: news, blog, tutorial, documentation, evergreen.'),
|
|
1843
|
+
}), (args) => client.auditDocument(args));
|
|
1844
|
+
tool('audit_inline_content', 'Run the composite content quality audit against ad-hoc text that is NOT (yet) a stored document — useful for previewing a draft before saving, or auditing user-supplied content. Pass `title` + `body` at minimum. Use `audit_document_by_id` to audit content that already lives in the CMS.', z.object({
|
|
1845
|
+
title: z.string().describe('Document title (required for SEO scoring).'),
|
|
1846
|
+
body: z.string().describe('Body / main content text.'),
|
|
1847
|
+
metaTitle: z.string().optional(),
|
|
1848
|
+
metaDescription: z.string().optional(),
|
|
1849
|
+
targetKeyword: z.string().optional(),
|
|
1850
|
+
url: z.string().optional(),
|
|
1851
|
+
headings: z.array(z.string()).optional(),
|
|
1852
|
+
contentType: z
|
|
1853
|
+
.string()
|
|
1854
|
+
.optional()
|
|
1855
|
+
.describe('Content type for freshness scoring: news, blog, tutorial, documentation, evergreen.'),
|
|
1856
|
+
}), (args) => client.auditDocument(args));
|
|
1857
|
+
tool('coauthor_text', 'Inline AI co-author. Transform a chunk of text via one of four actions: rewrite (with optional style/tone), expand (lengthen with optional instructions), compress (shorten, optionally to a target length), or proofread (returns corrected text + list of changes).', z.object({
|
|
1858
|
+
action: z
|
|
1859
|
+
.enum(['rewrite', 'expand', 'compress', 'proofread'])
|
|
1860
|
+
.describe('Which transformation to apply.'),
|
|
1861
|
+
text: z.string().describe('Text to transform.'),
|
|
1862
|
+
style: z.string().optional().describe('Style hint for rewrite (e.g. "formal", "casual").'),
|
|
1863
|
+
tone: z
|
|
1864
|
+
.string()
|
|
1865
|
+
.optional()
|
|
1866
|
+
.describe('Tone hint for rewrite (e.g. "friendly", "authoritative").'),
|
|
1867
|
+
instructions: z.string().optional().describe('Free-form expansion instructions.'),
|
|
1868
|
+
targetLength: z
|
|
1869
|
+
.number()
|
|
1870
|
+
.int()
|
|
1871
|
+
.min(50)
|
|
1872
|
+
.max(20000)
|
|
1873
|
+
.optional()
|
|
1874
|
+
.describe('Approximate character target for compress.'),
|
|
1875
|
+
collectionSlug: z.string().optional(),
|
|
1876
|
+
targetAudience: z.string().optional(),
|
|
1877
|
+
}), (args) => client.coauthor(args));
|
|
1878
|
+
tool('suggest_internal_links', 'Scan published CMS documents for phrases the supplied content could link to, returning ranked anchor-text + target-URL suggestions. Use to enrich a draft with relevant internal links before publish.', z.object({
|
|
1879
|
+
content: z.string().describe('Body text to analyze for link opportunities.'),
|
|
1880
|
+
collection: z
|
|
1881
|
+
.string()
|
|
1882
|
+
.optional()
|
|
1883
|
+
.describe('Restrict candidates to a single collection (e.g. "services").'),
|
|
1884
|
+
limit: z
|
|
1885
|
+
.number()
|
|
1886
|
+
.int()
|
|
1887
|
+
.min(1)
|
|
1888
|
+
.max(50)
|
|
1889
|
+
.optional()
|
|
1890
|
+
.describe('Max suggestions to return. Defaults to 10.'),
|
|
1891
|
+
excludeId: z
|
|
1892
|
+
.string()
|
|
1893
|
+
.optional()
|
|
1894
|
+
.describe('Document ID to exclude (typically the doc being edited).'),
|
|
1895
|
+
}), (args) => client.suggestInternalLinks(args));
|
|
1896
|
+
// ── Per-document SEO analysis (Phase 7 MCP-parity, PR C) ───────────────
|
|
1897
|
+
//
|
|
1898
|
+
// Five read-only tools that wrap the existing GET /seo/<scope>/:documentId
|
|
1899
|
+
// endpoints. All are deterministic — no LLM calls. Cheap enough for an
|
|
1900
|
+
// agent to invoke as part of a "publish-readiness check" pipeline.
|
|
1901
|
+
tool('seo_analyze_document', 'Run a deterministic SEO content analysis against a stored document. Returns checks for title length, meta description, headings, focus keyphrase, content length, internal/external link counts, and an overall score. Pair with audit_document for the broader SEO + readability + a11y + freshness audit.', z.object({
|
|
1902
|
+
documentId: z.string().describe('Document ID to analyze.'),
|
|
1903
|
+
}), (args) => client.seoAnalyzeDocument(args.documentId));
|
|
1904
|
+
tool('seo_readability_score', 'Compute a Flesch-Kincaid reading-ease score and grade level for a document body. Returns wordCount, sentenceCount, avgSentenceLength, syllablesPerWord, and qualitative bands (easy / medium / hard). Use to spot drafts that are too dense or too sparse for the target audience.', z.object({
|
|
1905
|
+
documentId: z.string().describe('Document ID whose body should be scored.'),
|
|
1906
|
+
}), (args) => client.seoReadability(args.documentId));
|
|
1907
|
+
tool('seo_internal_link_targets', 'Suggest up to 10 published documents that this document could link TO, based on shared title keywords. Use when you want link suggestions rooted in a SPECIFIC stored document. For content-rooted suggestions on raw text (e.g. a freshly drafted body), use suggest_internal_links instead.', z.object({
|
|
1908
|
+
documentId: z
|
|
1909
|
+
.string()
|
|
1910
|
+
.describe('Document ID — the source page that should gain outbound links.'),
|
|
1911
|
+
}), (args) => client.seoInternalLinks(args.documentId));
|
|
1912
|
+
tool('seo_meta_preview', 'Build the resolved meta-tag preview for a document: title, description, canonical, OG (title/description/image/type/site_name/published_time/modified_time), Twitter card tags, and robots directives (resolved against per-page overrides and site-level defaults). Use to verify what search engines and social platforms will see before publish.', z.object({
|
|
1913
|
+
documentId: z.string().describe('Document ID whose meta tags should be previewed.'),
|
|
1914
|
+
}), (args) => client.seoMetaPreview(args.documentId));
|
|
1915
|
+
tool('seo_schema_graph', 'Build the Schema.org JSON-LD graph for a document. Returns the @context + @graph array with WebSite, WebPage, and (for posts) Article nodes wired together via @id references. Drop the result into a <script type="application/ld+json"> tag, or use it to verify what structured data the site emits today.', z.object({
|
|
1916
|
+
documentId: z.string().describe('Document ID whose JSON-LD graph should be built.'),
|
|
1917
|
+
}), (args) => client.seoSchemaGraph(args.documentId));
|
|
1918
|
+
return tools;
|
|
1919
|
+
}
|
|
1920
|
+
//# sourceMappingURL=tools.js.map
|