agent-cms 0.1.0 → 0.3.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.
@@ -1,8 +1,7 @@
1
- import { $ as listRecords, A as scheduleUnpublish, C as SearchInput, D as clearSchedule, F as removeBlockType, H as listAssets, I as removeLocale, J as publishRecord, K as bulkPublishRecords, L as AssetImportContext, P as removeBlockFromWhitelist, Q as getRecord, R as createAsset, U as replaceAsset, V as importAssetFromUrl, X as bulkCreateRecords, Y as unpublishRecord, Z as createRecord, _t as updateModel, a as exportSchema, at as getVersion, b as ReorderInput, bt as VectorizeContext, c as CreateAssetInput, ct as HooksContext, et as patchBlocksForField, f as CreateModelInput, ft as updateField, g as ImportSchemaInput, h as ImportAssetFromUrlInput, it as updateSingletonRecord, k as schedulePublish, lt as createField, mt as deleteModel, n as listEditorTokens, nt as removeRecord, o as importSchema, ot as listVersions, p as CreateRecordInput, pt as createModel, q as bulkUnpublishRecords, r as revokeEditorToken, rt as reorderRecords, st as restoreVersion, t as createEditorToken, tt as patchRecord, u as CreateFieldInput, ut as deleteField, vt as reindexAll, y as ReindexSearchInput, yt as search } from "./token-service-BDjccMmz.mjs";
2
- import { B as encodeJson, J as ValidationError, K as SchemaEngineError, L as decodeJsonRecordStringOr, m as markdownToDast } from "./structured-text-service-B4xSlUg_.mjs";
1
+ import { $ as bulkCreateRecords, A as clearSchedule, B as AssetImportContext, C as ReorderInput, Ct as search, E as SearchInput, G as listAssets, K as replaceAsset, L as removeBlockFromWhitelist, M as schedulePublish, N as scheduleUnpublish, Q as unpublishRecord, R as removeBlockType, S as ReindexSearchInput, St as reindexAll, V as createAsset, W as importAssetFromUrl, X as bulkUnpublishRecords, Y as bulkPublishRecords, Z as publishRecord, _t as deleteModel, a as listEditorTokens, at as removeRecord, b as PatchBlocksInput, c as exportSchema, ct as getVersion, d as CreateAssetInput, dt as HooksContext, et as createRecord, ft as createField, g as CreateRecordInput, gt as createModel, h as CreateModelInput, ht as updateField, i as createEditorToken, it as patchRecord, l as importSchema, lt as listVersions, n as resolvePreviewPath, nt as listRecords, o as revokeEditorToken, ot as reorderRecords, p as CreateFieldInput, pt as deleteField, rt as patchBlocksForField, st as updateSingletonRecord, t as createPreviewToken, tt as getRecord, ut as restoreVersion, v as ImportAssetFromUrlInput, wt as VectorizeContext, xt as updateModel, y as ImportSchemaInput, yt as getModelByApiKey, z as removeLocale } from "./preview-service-C9Tmhdye.mjs";
2
+ import { B as encodeJson, J as ValidationError, K as SchemaEngineError, L as decodeJsonRecordStringOr } from "./structured-text-service-BJkqWRkq.mjs";
3
3
  import { Context, Effect, Layer, Option, Schema } from "effect";
4
4
  import { SqlClient } from "@effect/sql";
5
- import * as HttpLayerRouter from "@effect/platform/HttpLayerRouter";
6
5
  import * as McpServer from "@effect/ai/McpServer";
7
6
  import * as McpSchema from "@effect/ai/McpSchema";
8
7
  import * as AiTool from "@effect/ai/Tool";
@@ -72,37 +71,6 @@ const CommonDependencies = [
72
71
  HooksContext,
73
72
  AssetImportContext
74
73
  ];
75
- const BlockEntry = Schema.Struct({
76
- id: Schema.String,
77
- type: Schema.String,
78
- data: JsonRecord
79
- });
80
- const BuildStructuredTextInput = Schema.Struct({
81
- markdown: Schema.optional(Schema.String),
82
- blocks: Schema.optional(Schema.Array(BlockEntry)),
83
- nodes: Schema.optional(Schema.Array(Schema.Union(Schema.Struct({
84
- type: Schema.Literal("paragraph"),
85
- text: Schema.String
86
- }), Schema.Struct({
87
- type: Schema.Literal("heading"),
88
- level: Schema.Number,
89
- text: Schema.String
90
- }), Schema.Struct({
91
- type: Schema.Literal("code"),
92
- code: Schema.String,
93
- language: Schema.optional(Schema.String)
94
- }), Schema.Struct({
95
- type: Schema.Literal("blockquote"),
96
- text: Schema.String
97
- }), Schema.Struct({
98
- type: Schema.Literal("list"),
99
- style: Schema.optional(Schema.Literal("bulleted", "numbered")),
100
- items: Schema.Array(Schema.String)
101
- }), Schema.Struct({ type: Schema.Literal("thematicBreak") }), Schema.Struct({
102
- type: Schema.Literal("block"),
103
- ref: Schema.String
104
- }))))
105
- });
106
74
  const UpdateModelInput = Schema.Struct({
107
75
  modelId: Schema.String,
108
76
  name: Schema.optional(Schema.String),
@@ -110,7 +78,8 @@ const UpdateModelInput = Schema.Struct({
110
78
  singleton: Schema.optional(Schema.Boolean),
111
79
  sortable: Schema.optional(Schema.Boolean),
112
80
  hasDraft: Schema.optional(Schema.Boolean),
113
- allLocalesRequired: Schema.optional(Schema.Boolean)
81
+ allLocalesRequired: Schema.optional(Schema.Boolean),
82
+ canonicalPathTemplate: Schema.optional(Schema.NullOr(Schema.String))
114
83
  });
115
84
  const UpdateFieldInput = Schema.Struct({
116
85
  fieldId: Schema.String,
@@ -132,16 +101,6 @@ const UpdateRecordInput = Schema.Struct({
132
101
  modelApiKey: Schema.String,
133
102
  data: Schema.optionalWith(JsonRecord, { default: () => ({}) })
134
103
  });
135
- const PatchBlocksInput = Schema.Struct({
136
- recordId: Schema.String,
137
- modelApiKey: Schema.String,
138
- fieldApiKey: Schema.String,
139
- value: Schema.optional(Schema.Unknown),
140
- blocks: Schema.Record({
141
- key: Schema.String,
142
- value: Schema.NullOr(Schema.Unknown)
143
- })
144
- });
145
104
  const DeleteRecordInput = Schema.Struct({
146
105
  recordId: Schema.String,
147
106
  modelApiKey: Schema.String
@@ -155,6 +114,10 @@ const PublishRecordsInput = Schema.Struct({
155
114
  modelApiKey: Schema.String,
156
115
  recordIds: Schema.Array(Schema.String).pipe(Schema.filter((value) => value.length >= 1, { message: () => "recordIds must contain at least 1 entry" }), Schema.filter((value) => value.length <= 1e3, { message: () => "recordIds must contain at most 1000 entries" }))
157
116
  });
117
+ const SetPublishStatusInput = Schema.Struct({
118
+ action: Schema.Literal("publish", "unpublish"),
119
+ ...PublishRecordsInput.fields
120
+ });
158
121
  const ScheduleInput = Schema.Struct({
159
122
  recordId: Schema.String,
160
123
  modelApiKey: Schema.String,
@@ -201,6 +164,10 @@ const GetRecordInput = Schema.Struct({
201
164
  recordId: Schema.String,
202
165
  modelApiKey: Schema.String
203
166
  });
167
+ const GetPreviewUrlInput = Schema.Struct({
168
+ recordId: Schema.String,
169
+ modelApiKey: Schema.String
170
+ });
204
171
  const SetupContentModelPromptInput = Schema.Struct({ description: Schema.String });
205
172
  const GenerateGraphqlQueriesPromptInput = Schema.Struct({ modelApiKey: Schema.String });
206
173
  function cmsTool(name, description, parameters) {
@@ -211,48 +178,60 @@ function cmsTool(name, description, parameters) {
211
178
  failure: Schema.Unknown,
212
179
  dependencies: CommonDependencies
213
180
  });
214
- const isReadonly = name.startsWith("query_") || name.startsWith("get_") || name === "schema_info" || name === "build_structured_text" || name === "search_content";
181
+ const isReadonly = name.startsWith("query_") || name.startsWith("get_") || name === "schema_info" || name === "search_content";
215
182
  tool = tool.annotate(AiTool.Readonly, isReadonly);
216
183
  tool = tool.annotate(AiTool.Idempotent, isReadonly || name.startsWith("update_") || name.startsWith("replace_"));
217
184
  tool = tool.annotate(AiTool.Destructive, name.startsWith("delete_") || name === "remove_block");
218
185
  tool = tool.annotate(AiTool.OpenWorld, name === "search_content");
219
186
  return tool;
220
187
  }
221
- /**
222
- * Parse a text string with inline markdown into DAST inline (span) nodes.
223
- * Returns the children of the first paragraph, or a single span fallback.
224
- */
225
- function parseInlineSpans(text) {
226
- const first = markdownToDast(text).document.children.at(0);
227
- if (first != null && "children" in first) return first.children;
228
- return [{
229
- type: "span",
230
- value: text
231
- }];
232
- }
233
- function collectBlockRefs(node, refs = /* @__PURE__ */ new Set()) {
234
- if (node == null || typeof node !== "object") return refs;
235
- if (Array.isArray(node)) {
236
- for (const entry of node) collectBlockRefs(entry, refs);
237
- return refs;
238
- }
239
- if ("type" in node && node.type === "block" && "item" in node && typeof node.item === "string") refs.add(node.item);
240
- for (const value of Object.values(node)) collectBlockRefs(value, refs);
241
- return refs;
242
- }
243
- function assertKnownBlockRefs(blockMap, refs) {
244
- for (const ref of refs) if (!(ref in blockMap)) throw new Error(`Unknown block ref '${ref}'. Define it in blocks before referencing it.`);
245
- }
246
- function assertNoUnusedBlocks(blockMap, refs) {
247
- const unused = Object.keys(blockMap).filter((id) => !refs.has(id));
248
- if (unused.length > 0) throw new Error(`Unused blocks: ${unused.join(", ")}. Every block must be referenced in the document.`);
249
- }
250
188
  function toStructuredContent(value) {
251
189
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
252
190
  }
253
191
  function isToolPayload(value) {
254
192
  return value !== null && typeof value === "object";
255
193
  }
194
+ function compactPatchBlocksResponse(fullRecord, fieldApiKey, deletedBlockIds) {
195
+ const fieldValue = fullRecord[fieldApiKey];
196
+ const envelope = (() => {
197
+ if (fieldValue === null || fieldValue === void 0) return null;
198
+ if (typeof fieldValue === "string") try {
199
+ return JSON.parse(fieldValue);
200
+ } catch {
201
+ return null;
202
+ }
203
+ if (typeof fieldValue === "object" && !Array.isArray(fieldValue)) return fieldValue;
204
+ return null;
205
+ })();
206
+ if (!envelope) return {
207
+ recordId: fullRecord.id,
208
+ status: fullRecord._status ?? null,
209
+ fieldApiKey,
210
+ field: null,
211
+ blocks: {},
212
+ deleted: deletedBlockIds,
213
+ blockOrder: []
214
+ };
215
+ const blocks = typeof envelope.blocks === "object" && envelope.blocks !== null && !Array.isArray(envelope.blocks) ? envelope.blocks : {};
216
+ const blockOrder = [];
217
+ function walkDast(node) {
218
+ if (typeof node !== "object" || node === null) return;
219
+ const n = node;
220
+ if (n.type === "block" && typeof n.item === "string") blockOrder.push(n.item);
221
+ if (Array.isArray(n.children)) n.children.forEach(walkDast);
222
+ }
223
+ const value = envelope.value;
224
+ if (typeof value === "object" && value !== null) walkDast(value.document);
225
+ return {
226
+ recordId: fullRecord.id,
227
+ status: fullRecord._status ?? null,
228
+ fieldApiKey,
229
+ field: envelope,
230
+ blocks,
231
+ deleted: deletedBlockIds,
232
+ blockOrder
233
+ };
234
+ }
256
235
  function parseValidators(value) {
257
236
  if (value == null || value === "") return {};
258
237
  if (typeof value === "string") return decodeJsonRecordStringOr(value, {});
@@ -297,10 +276,10 @@ Key validators by field type:
297
276
  const UpdateFieldTool = cmsTool("update_field", "Update field properties (label, apiKey, validators, hint)", UpdateFieldInput.fields);
298
277
  const DeleteModelTool = cmsTool("delete_model", "Delete a model (fails if referenced)", ModelIdInput.fields);
299
278
  const DeleteFieldTool = cmsTool("delete_field", "Delete a field and drop column", FieldIdInput.fields);
300
- const CreateRecordTool = cmsTool("create_record", `Create a content record. Records on draft-enabled models start as draft — call publish_records to make them visible in GraphQL.
279
+ const CreateRecordTool = cmsTool("create_record", `Create a content record. Records on draft-enabled models start as draft — call set_publish_status to make them visible in GraphQL.
301
280
 
302
281
  Validation note:
303
- - For models with drafts (has_draft=true), required-field validation is deferred until publish_records.
282
+ - For models with drafts (has_draft=true), required-field validation is deferred until set_publish_status.
304
283
  - For models without drafts (has_draft=false), required fields are enforced during create_record.
305
284
 
306
285
  Field value formats:
@@ -309,10 +288,20 @@ Field value formats:
309
288
  - link: record ID string
310
289
  - links: array of record ID strings
311
290
  - seo: {"title":"...","description":"...","image":"<asset_id>","twitterCard":"summary_large_image"}
312
- - structured_text: {"value":{"schema":"dast","document":{...}},"blocks":{"<id>":{"_type":"block_api_key",...}}}
291
+ - structured_text: markdown string, typed nodes array, {markdown:"...",blocks:[...]}, {nodes:[...],blocks:[...]}, or full DAST envelope {"value":{"schema":"dast","document":{...}},"blocks":{...}}
292
+ Markdown mode is the easiest way to write prose. Standard inline formatting (**bold**, *italic*, \`code\`, ~~strikethrough~~, [links](url)) all work.
293
+ Special sentinels for CMS references in markdown:
294
+ - Block refs: <!-- cms:block:BLOCK_ID --> (on its own line)
295
+ - Inline items: <!-- cms:inlineItem:RECORD_ID -->
296
+ - Inline blocks: <!-- cms:inlineBlock:BLOCK_ID -->
297
+ - Record links: [link text](itemLink:RECORD_ID)
298
+ When using {markdown, blocks}, the blocks array/map provides block data and sentinels place them in the document.
299
+ Those block field values are persisted by the same create_record/update_record call — you do not need a follow-up patch_blocks call just to save the initial block payload.
313
300
  - color: {"red":255,"green":0,"blue":0,"alpha":255}
314
301
  - lat_lon: {"latitude":64.13,"longitude":-21.89}`, CreateRecordInput.fields);
315
- const UpdateRecordTool = cmsTool("update_record", "Update record fields. For singletons, recordId can be omitted — the single record is found automatically.", UpdateRecordInput.fields);
302
+ const UpdateRecordTool = cmsTool("update_record", `Update record fields. For singletons, recordId can be omitted — the single record is found automatically.
303
+
304
+ Accepts all the same field value formats as create_record, including markdown mode for structured_text. For editorial content edits, prefer markdown mode over hand-assembled DAST — it's simpler and less error-prone.`, UpdateRecordInput.fields);
316
305
  const PatchBlocksTool = cmsTool("patch_blocks", `Partially update blocks in a structured text field without resending the entire content tree.
317
306
 
318
307
  You can target block IDs from either:
@@ -320,26 +309,34 @@ You can target block IDs from either:
320
309
  - nested structured_text sub-fields stored inside those blocks
321
310
 
322
311
  Patch map semantics for each block ID:
323
- - string value (the block ID) → keep block unchanged
324
312
  - object with field overrides → merge into existing block (only specified fields updated)
325
313
  - null → delete block and auto-prune from the relevant DAST tree
326
314
 
327
- Block IDs not in the patch map are kept unchanged.
315
+ Block IDs not in the patch map are kept unchanged. Omit a block ID to leave it as-is.
328
316
 
329
317
  If a nested block ID appears in multiple nested structured_text locations, the tool will fail and ask you to patch the parent block explicitly.
330
318
 
331
319
  Optionally provide a new top-level DAST \`value\`. If omitted, the existing DAST is preserved (with deleted top-level blocks auto-pruned).
332
320
 
321
+ Use \`append\` to insert new blocks without reconstructing the DAST. Each entry is a record with \`_type\` and field values. New block IDs are auto-generated, DAST block nodes are appended to the end, and the response includes \`_appendedIds\`. Cannot be combined with \`value\`.
322
+
323
+ For blocks_only structured_text fields, you can pass an \`order\` array of block IDs to reorder blocks without constructing a full DAST document. The order array replaces the DAST children list. Cannot be combined with \`value\`. All block IDs in \`order\` must exist in the merged blocks map, and all merged blocks must appear in \`order\`.
324
+
333
325
  Example — update one block's description, delete another, keep the rest:
334
- { blocks: { "block-1": "block-1", "block-2": { "description": "New text" }, "block-3": null } }`, PatchBlocksInput.fields);
326
+ { blocks: { "block-2": { "description": "New text" }, "block-3": null } }
327
+
328
+ Example — append a new block while patching an existing one:
329
+ { blocks: { "block-2": { "description": "Updated" } }, append: [{ "_type": "venue", "name": "New Place" }] }
330
+
331
+ Example — reorder blocks on a blocks_only field:
332
+ { order: ["block-3", "block-1", "block-2"], blocks: {} }`, PatchBlocksInput.fields);
335
333
  const DeleteRecordTool = cmsTool("delete_record", "Delete a record", DeleteRecordInput.fields);
336
- const GetRecordTool = cmsTool("get_record", "Get a single record by modelApiKey + recordId. Useful after search_content when you need the full materialized record, including structured_text fields, before patch_blocks or update_record.", GetRecordInput.fields);
337
- const QueryRecordsTool = cmsTool("query_records", "List records for a model. Structured_text fields are materialized for inspection, including nested blocks inside parent block fields. Useful for finding record IDs before update_record, patch_blocks, publish_records, or record_versions.", QueryRecordsInput.fields);
334
+ const GetRecordTool = cmsTool("get_record", "Get a single record by modelApiKey + recordId. Useful after search_content when you need the full materialized record, including structured_text fields, before patch_blocks or update_record. This is a workspace/content-management read tool, not a substitute for final live verification via GraphQL or the site URL.", GetRecordInput.fields);
335
+ const QueryRecordsTool = cmsTool("query_records", "List records for a model. Structured_text fields are materialized for inspection, including nested blocks inside parent block fields. Useful for finding record IDs before update_record, patch_blocks, set_publish_status, or record_versions. Use GraphQL or the site URL for final public/live verification after publishing.", QueryRecordsInput.fields);
338
336
  const BulkCreateRecordsTool = cmsTool("bulk_create_records", `Create multiple records in one operation (up to 1000). Much faster than calling create_record in a loop.
339
337
 
340
338
  All records must belong to the same model. Slugs are auto-generated. Returns {created, records}, where records is an array of objects like {id}.`, BulkCreateRecordsInput.fields);
341
- const PublishRecordsTool = cmsTool("publish_records", "Publish one or more records. Required/unique validation is enforced at publish time for draft-enabled models. Pass recordIds as an array, even for a single record.", PublishRecordsInput.fields);
342
- const UnpublishRecordsTool = cmsTool("unpublish_records", "Unpublish one or more records.", PublishRecordsInput.fields);
339
+ const SetPublishStatusTool = cmsTool("set_publish_status", "Publish or unpublish one or more records. Pass recordIds as an array, even for a single record. Required-field validation is enforced at publish time for draft-enabled models.", SetPublishStatusInput.fields);
343
340
  const ScheduleTool = cmsTool("schedule", `Schedule a record to publish or unpublish at a future ISO datetime, or clear both schedules.
344
341
 
345
342
  action: "publish" | "unpublish" | "clear"
@@ -354,37 +351,6 @@ action: "list" | "get" | "restore"
354
351
  const ReorderRecordsTool = cmsTool("reorder_records", "Reorder records in a sortable/tree model by providing ordered record IDs", ReorderInput.fields);
355
352
  const RemoveBlockTool = cmsTool("remove_block", "Remove a block type entirely (cleans DAST trees, deletes blocks, drops table), or remove it from a specific field's whitelist (provide fieldId).", RemoveBlockInput.fields);
356
353
  const RemoveLocaleTool = cmsTool("remove_locale", "Remove a locale and strip it from all localized field values", LocaleIdInput.fields);
357
- const BuildStructuredTextTool = cmsTool("build_structured_text", `Build a StructuredText value from typed nodes or markdown, plus optional block definitions.
358
-
359
- Two modes:
360
- 1. Typed nodes (provide "nodes"): precise control over document structure
361
- 2. Markdown (provide "markdown"): natural formatting for prose-heavy content
362
-
363
- Workflow: prepare blocks first, then reference them in nodes or markdown.
364
-
365
- blocks: [{id: "v1", type: "venue", data: {name: "Chickpea", image: "asset_id"}}]
366
-
367
- Typed nodes (text structure referencing blocks by ID):
368
- - paragraph: {type:"paragraph", text:"Inline **markdown** and [links](url) supported"}
369
- - heading: {type:"heading", level:2, text:"Section Title"}
370
- - code: {type:"code", code:"const x = 1", language:"typescript"}
371
- - blockquote: {type:"blockquote", text:"Quote text"}
372
- - list: {type:"list", style:"bulleted"|"numbered", items:["First","Second"]}
373
- - thematicBreak: {type:"thematicBreak"}
374
- - block: {type:"block", ref:"v1"} — places a block defined in the blocks array
375
-
376
- Markdown mode: write standard markdown. Place blocks with sentinels: <!-- cms:block:BLOCK_ID -->
377
-
378
- Inline markdown in text fields: **bold**, *italic*, \`code\`, [links](url), ~~strikethrough~~.
379
-
380
- Encoding note: MCP tool arguments are XML-encoded by some clients. If any literal string in your structured text includes angle brackets (for example TypeScript generics like <T>, JSX, or HTML snippets), escape them as Unicode in the JSON string: \u003C and \u003E.
381
-
382
- For nested blocks (e.g. sections containing venues), compose bottom-up:
383
- 1. Build inner structured text (venues) → get {value, blocks} result
384
- 2. Use that result as a field value in a parent block's data
385
- 3. Build outer structured text (sections) referencing the parent blocks
386
-
387
- IMPORTANT: Call schema_info first to verify which block types are allowed on the target structured_text field (check the structured_text_blocks validator).`, BuildStructuredTextInput.fields);
388
354
  const UploadAssetTool = cmsTool("upload_asset", `Register an asset after uploading the original file to R2 out of band.
389
355
 
390
356
  Upload flow:
@@ -427,6 +393,15 @@ const UpdateSiteSettingsTool = cmsTool("update_site_settings", `Update global si
427
393
  Use this tool for fields like siteName, titleSuffix, fallbackSeoTitle, and fallbackSeoDescription.
428
394
  If your schema also has a singleton content model named site_settings with fields like tagline or logo, update that record with query_records + update_record instead of this tool.
429
395
  When the task is specifically about the singleton record, avoid mixing both surfaces unless the user explicitly asks for both.`, UpdateSiteSettingsInput.fields);
396
+ const EditorTokensTool = cmsTool("editor_tokens", `Manage editor tokens: create, list, or revoke.
397
+
398
+ action: "create" | "list" | "revoke"
399
+ - create: provide name, optional expiresIn (seconds). Returns token for restricted write access (no schema mutations).
400
+ - list: returns all non-expired editor tokens.
401
+ - revoke: provide tokenId to revoke.`, EditorTokensInput.fields);
402
+ const GetPreviewUrlTool = cmsTool("get_preview_url", `Generate a preview URL for a draft record. The model must have a canonicalPathTemplate set (e.g. /posts/{slug}).
403
+
404
+ Returns a fully assembled URL with a short-lived preview token when siteUrl is configured, or the previewPath and token separately otherwise.`, GetPreviewUrlInput.fields);
430
405
  const AdminTools = [
431
406
  SchemaInfoTool,
432
407
  CreateModelTool,
@@ -442,14 +417,12 @@ const AdminTools = [
442
417
  GetRecordTool,
443
418
  QueryRecordsTool,
444
419
  BulkCreateRecordsTool,
445
- PublishRecordsTool,
446
- UnpublishRecordsTool,
420
+ SetPublishStatusTool,
447
421
  ScheduleTool,
448
422
  RecordVersionsTool,
449
423
  ReorderRecordsTool,
450
424
  RemoveBlockTool,
451
425
  RemoveLocaleTool,
452
- BuildStructuredTextTool,
453
426
  UploadAssetTool,
454
427
  ImportAssetFromUrlTool,
455
428
  ListAssetsTool,
@@ -459,12 +432,8 @@ const AdminTools = [
459
432
  ReindexSearchTool,
460
433
  GetSiteSettingsTool,
461
434
  UpdateSiteSettingsTool,
462
- cmsTool("editor_tokens", `Manage editor tokens: create, list, or revoke.
463
-
464
- action: "create" | "list" | "revoke"
465
- - create: provide name, optional expiresIn (seconds). Returns token for restricted write access (no schema mutations).
466
- - list: returns all non-expired editor tokens.
467
- - revoke: provide tokenId to revoke.`, EditorTokensInput.fields)
435
+ EditorTokensTool,
436
+ GetPreviewUrlTool
468
437
  ];
469
438
  const EditorTools = [
470
439
  SchemaInfoTool,
@@ -475,23 +444,30 @@ const EditorTools = [
475
444
  GetRecordTool,
476
445
  QueryRecordsTool,
477
446
  BulkCreateRecordsTool,
478
- PublishRecordsTool,
479
- UnpublishRecordsTool,
447
+ SetPublishStatusTool,
480
448
  ScheduleTool,
481
449
  RecordVersionsTool,
482
450
  ReorderRecordsTool,
483
- BuildStructuredTextTool,
484
451
  UploadAssetTool,
485
452
  ImportAssetFromUrlTool,
486
453
  ListAssetsTool,
487
454
  ReplaceAssetTool,
488
- SchemaIOTool,
489
455
  SearchContentTool,
490
456
  GetSiteSettingsTool,
491
- UpdateSiteSettingsTool
457
+ UpdateSiteSettingsTool,
458
+ GetPreviewUrlTool
492
459
  ];
493
460
  const CmsToolkit = Toolkit.make(...AdminTools);
494
461
  const EditorToolkit = Toolkit.make(...EditorTools);
462
+ /** Tool metadata for Code Mode — extracted without MCP protocol overhead */
463
+ function getToolMeta(mode = "admin") {
464
+ const toolkit = mode === "editor" ? EditorToolkit : CmsToolkit;
465
+ return Object.values(toolkit.tools).map((tool) => ({
466
+ name: tool.name,
467
+ description: tool.description ?? "",
468
+ inputSchema: toMcpInputSchema(tool)
469
+ }));
470
+ }
495
471
  function createGuideResource() {
496
472
  return McpServer.resource({
497
473
  uri: "agent-cms://guide",
@@ -505,7 +481,7 @@ Server boundary:
505
481
  - Editor MCP: /mcp/editor — content/publishing/assets/search only. If a schema-mutation tool is missing, you are probably on the editor MCP and should switch surfaces instead of retrying.
506
482
 
507
483
  Workflow order:
508
- schema_info -> create_model -> create_field -> create_record -> publish_records
484
+ schema_info -> create_model -> create_field -> create_record -> set_publish_status
509
485
 
510
486
  Naming conventions:
511
487
  - api_key: snake_case (e.g. blog_post, cover_image)
@@ -521,22 +497,30 @@ Field value formats (composite types):
521
497
  - link: record ID string
522
498
  - links: array of record ID strings
523
499
  - seo: {"title":"...","description":"...","image":"<asset_id>","twitterCard":"summary_large_image"}
524
- - structured_text: {"value":{"schema":"dast","document":{...}},"blocks":{"<id>":{"_type":"block_api_key",...}}}
500
+ - structured_text: markdown string, typed nodes array, {markdown:"...",blocks:[...]}, {nodes:[...],blocks:[...]}, or full DAST envelope {"value":{"schema":"dast","document":{...}},"blocks":{...}}
501
+ Prefer markdown mode for prose-heavy content. Inline formatting, links, and block placement all work. Initial block payloads are persisted by the same create_record/update_record call; patch_blocks is for later targeted edits, not for finishing initial block creation:
502
+ - Standard markdown: **bold**, *italic*, \`code\`, ~~strike~~, [links](url)
503
+ - Block refs: <!-- cms:block:BLOCK_ID --> (own line)
504
+ - Record links: [link text](itemLink:RECORD_ID)
505
+ - Inline items: <!-- cms:inlineItem:RECORD_ID -->
506
+ - Inline blocks: <!-- cms:inlineBlock:BLOCK_ID -->
525
507
  - color: {"red":255,"green":0,"blue":0,"alpha":255}
526
508
  - lat_lon: {"latitude":64.13,"longitude":-21.89}
527
509
 
528
510
  Structured text editing notes:
529
511
  - patch_blocks can target both top-level blocks and nested blocks inside structured_text sub-fields.
530
512
  - If the same nested block ID exists in multiple locations, patch_blocks will ask you to patch the parent block explicitly.
513
+ - patch_blocks supports an \`append\` array to insert new blocks without rebuilding the DAST. Each entry needs \`_type\` plus field values. New block nodes are appended to the end of the document. The response includes \`_appendedIds\` with the generated IDs.
531
514
  - get_record is the fastest way to inspect one known record's full materialized structured_text after search_content returns its id.
532
515
  - query_records materializes structured_text fields for inspection; on published records, _published_snapshot remains useful as a raw snapshot of what is live.
533
516
 
534
517
  Draft/publish lifecycle:
535
- Records on draft-enabled models start as drafts. create_record returns the created draft record object, including its top-level id. Call publish_records with that recordId to make it visible in GraphQL.
536
- Use publish_records and unpublish_records for both single and bulk operations — just pass an array of recordIds.
518
+ Records on draft-enabled models start as drafts. create_record returns the created draft record object, including its top-level id. Call set_publish_status with action "publish" and that recordId to make it visible in GraphQL.
519
+ Use set_publish_status for both publish and unpublish, single and bulk operations — just pass an array of recordIds and the desired action.
537
520
  Required-field validation for draft-enabled models happens at publish time, not create_record time.
538
521
  Edits after publishing create a new draft version — publish again to update.
539
522
  GraphQL serves published content by default; use X-Include-Drafts header for previews.
523
+ If a task says to verify what is live or publicly visible, do that via GraphQL or the site URL after publishing — not via query_records/get_record, which show workspace state rather than the public delivery surface.
540
524
 
541
525
  Singletons and site settings:
542
526
  - If a singleton exists as a normal content model in your schema (for example a site_settings record with fields like tagline), treat it like content. Use update_record without recordId for direct singleton edits, or query_records + update_record if you need to inspect first.
@@ -556,7 +540,7 @@ Asset upload flow:
556
540
  Tool argument encoding:
557
541
  - Some MCP clients XML-encode tool arguments before they reach the server.
558
542
  - If a literal string value contains angle brackets (for example TypeScript generics like <T>, JSX, or inline HTML), escape them inside the JSON string as \u003C and \u003E.
559
- - This matters most for build_structured_text code nodes and any create_record/update_record payload carrying code snippets.
543
+ - This matters most for create_record/update_record payloads carrying code snippets in structured_text fields.
560
544
 
561
545
  Raw HTTP / JSON-RPC access:
562
546
  - Endpoint: POST <mount>/mcp for admin, POST <mount>/mcp/editor for editor
@@ -570,13 +554,25 @@ Raw HTTP / JSON-RPC access:
570
554
  .result.content[0].text | fromjson
571
555
  - For single-record tools like import_asset_from_url, create_record, and get_record, that parsed payload is the object itself, so use payload.id directly rather than looking for nested arrays
572
556
 
557
+ Draft preview:
558
+ Models can have a canonicalPathTemplate (e.g. /posts/{slug}) for preview URLs.
559
+ Tool responses include _previewPath when a template is set.
560
+ Use get_preview_url to generate a fully assembled preview link with a short-lived token.
561
+
573
562
  Slug fields:
574
563
  Set validator {"slug_source": "title"} to auto-generate from a source field.
575
564
  Create the slug field AFTER the source field.
576
565
 
577
566
  Pluralization:
578
567
  category -> allCategories, blog_post -> allBlogPosts, person -> allPeople
579
- Powered by standard English pluralization rules.`)
568
+ Powered by standard English pluralization rules.
569
+
570
+ Translation:
571
+ For translating structured_text fields, work with the markdown representation.
572
+ Translate the full markdown document — not field by field — to preserve context, tone, and flow.
573
+ Leave block sentinels (<!-- cms:block:ID -->), record links ([text](itemLink:ID)), and inline refs untouched.
574
+ The CMS reconstructs DAST from the translated markdown when you update the record.
575
+ This gives full article context for fluid translations vs. isolated sentence-level machine translation.`)
580
576
  });
581
577
  }
582
578
  function createSchemaResource() {
@@ -638,7 +634,7 @@ Follow these steps:
638
634
  3. Present your plan before executing — list models, fields, and relationships.
639
635
  4. Create models first, then fields in order (slug fields after their source).
640
636
  5. Create a few sample records to verify the schema works.
641
- 6. Publish the sample records with publish_records.
637
+ 6. Publish the sample records with set_publish_status.
642
638
  7. Show the GraphQL query that a frontend would use to fetch this content.
643
639
  Remember: api_key snake_case -> GraphQL camelCase fields, PascalCase types.`)
644
640
  });
@@ -695,6 +691,34 @@ function createMcpLayer(sqlLayer, options) {
695
691
  url
696
692
  } : asset;
697
693
  }
694
+ /** Look up canonical_path_template for a model and resolve _previewPath if set */
695
+ function addPreviewPath(modelApiKey, record) {
696
+ return Effect.gen(function* () {
697
+ if (typeof record !== "object" || record === null) return record;
698
+ const template = (yield* (yield* SqlClient.SqlClient).unsafe("SELECT canonical_path_template FROM models WHERE api_key = ?", [modelApiKey]))[0]?.canonical_path_template;
699
+ if (!template) return record;
700
+ const previewPath = resolvePreviewPath(template, record);
701
+ return {
702
+ ...record,
703
+ _previewPath: previewPath
704
+ };
705
+ });
706
+ }
707
+ function addPreviewPathToList(modelApiKey, records) {
708
+ return Effect.gen(function* () {
709
+ if (!Array.isArray(records)) return records;
710
+ const template = (yield* (yield* SqlClient.SqlClient).unsafe("SELECT canonical_path_template FROM models WHERE api_key = ?", [modelApiKey]))[0]?.canonical_path_template;
711
+ if (!template) return records;
712
+ return records.map((r) => {
713
+ if (typeof r !== "object" || r === null) return r;
714
+ const previewPath = resolvePreviewPath(template, r);
715
+ return {
716
+ ...r,
717
+ _previewPath: previewPath
718
+ };
719
+ });
720
+ });
721
+ }
698
722
  const toolHandlers = {
699
723
  schema_info: withDecoded(SchemaInfoInput, ({ filterByName, filterByType, includeFieldDetails }) => Effect.gen(function* () {
700
724
  const sql = yield* SqlClient.SqlClient;
@@ -735,6 +759,7 @@ function createMcpLayer(sqlLayer, options) {
735
759
  sortable: !!m.sortable,
736
760
  tree: !!m.tree,
737
761
  allLocalesRequired: !!m.all_locales_required,
762
+ canonicalPathTemplate: m.canonical_path_template ?? null,
738
763
  ...includeFieldDetails ? { fields: mFields.map((f) => ({
739
764
  id: f.id,
740
765
  label: f.label,
@@ -760,24 +785,27 @@ function createMcpLayer(sqlLayer, options) {
760
785
  update_field: withDecoded(UpdateFieldInput, ({ fieldId, ...rest }) => updateField(fieldId, rest)),
761
786
  delete_model: withDecoded(ModelIdInput, ({ modelId }) => deleteModel(modelId)),
762
787
  delete_field: withDecoded(FieldIdInput, ({ fieldId }) => deleteField(fieldId)),
763
- create_record: withDecoded(CreateRecordInput, (input) => createRecord(input, options?.actor)),
788
+ create_record: withDecoded(CreateRecordInput, (input) => createRecord(input, options?.actor).pipe(Effect.flatMap((r) => addPreviewPath(input.modelApiKey, r)))),
764
789
  update_record: withDecoded(UpdateRecordInput, ({ recordId, modelApiKey, data }) => {
765
- if (recordId) return patchRecord(recordId, {
790
+ return (recordId ? patchRecord(recordId, {
766
791
  modelApiKey,
767
792
  data
768
- }, options?.actor);
769
- return updateSingletonRecord(modelApiKey, data, options?.actor);
793
+ }, options?.actor) : updateSingletonRecord(modelApiKey, data, options?.actor)).pipe(Effect.flatMap((r) => addPreviewPath(modelApiKey, r)));
770
794
  }),
771
- patch_blocks: withDecoded(PatchBlocksInput, (input) => patchBlocksForField(input, options?.actor)),
795
+ patch_blocks: withDecoded(PatchBlocksInput, (input) => patchBlocksForField(input, options?.actor).pipe(Effect.map((record) => {
796
+ const deletedIds = Object.entries(input.blocks).filter(([, v]) => v === null).map(([k]) => k);
797
+ return compactPatchBlocksResponse(record, input.fieldApiKey, deletedIds);
798
+ }))),
772
799
  delete_record: withDecoded(DeleteRecordInput, ({ recordId, modelApiKey }) => removeRecord(modelApiKey, recordId)),
773
- get_record: withDecoded(GetRecordInput, ({ recordId, modelApiKey }) => getRecord(modelApiKey, recordId)),
774
- query_records: withDecoded(QueryRecordsInput, ({ modelApiKey }) => listRecords(modelApiKey)),
800
+ get_record: withDecoded(GetRecordInput, ({ recordId, modelApiKey }) => getRecord(modelApiKey, recordId).pipe(Effect.flatMap((r) => addPreviewPath(modelApiKey, r)))),
801
+ query_records: withDecoded(QueryRecordsInput, ({ modelApiKey }) => listRecords(modelApiKey).pipe(Effect.flatMap((r) => addPreviewPathToList(modelApiKey, r)))),
775
802
  bulk_create_records: withDecoded(BulkCreateRecordsInput, ({ modelApiKey, records }) => bulkCreateRecords({
776
803
  modelApiKey,
777
804
  records
778
805
  }, options?.actor)),
779
- publish_records: withDecoded(PublishRecordsInput, ({ recordIds, modelApiKey }) => recordIds.length === 1 ? publishRecord(modelApiKey, recordIds[0], options?.actor) : bulkPublishRecords(modelApiKey, recordIds, options?.actor)),
780
- unpublish_records: withDecoded(PublishRecordsInput, ({ recordIds, modelApiKey }) => recordIds.length === 1 ? unpublishRecord(modelApiKey, recordIds[0], options?.actor) : bulkUnpublishRecords(modelApiKey, recordIds, options?.actor)),
806
+ set_publish_status: withDecoded(SetPublishStatusInput, ({ action, recordIds, modelApiKey }) => {
807
+ return action === "publish" ? recordIds.length === 1 ? publishRecord(modelApiKey, recordIds[0], options?.actor) : bulkPublishRecords(modelApiKey, recordIds, options?.actor) : recordIds.length === 1 ? unpublishRecord(modelApiKey, recordIds[0], options?.actor) : bulkUnpublishRecords(modelApiKey, recordIds, options?.actor);
808
+ }),
781
809
  schedule: withDecoded(ScheduleInput, ({ recordId, modelApiKey, action, at }) => {
782
810
  if (action === "clear") return clearSchedule(modelApiKey, recordId, options?.actor);
783
811
  if (action === "publish") return schedulePublish(modelApiKey, recordId, at ?? null, options?.actor);
@@ -807,88 +835,6 @@ function createMcpLayer(sqlLayer, options) {
807
835
  return removeBlockType(blockApiKey);
808
836
  }),
809
837
  remove_locale: withDecoded(LocaleIdInput, ({ localeId }) => removeLocale(localeId)),
810
- build_structured_text: withDecoded(BuildStructuredTextInput, ({ markdown, blocks, nodes }) => Effect.sync(() => {
811
- const blockMap = {};
812
- for (const b of blocks ?? []) blockMap[b.id] = {
813
- _type: b.type,
814
- ...b.data
815
- };
816
- if (markdown != null) {
817
- const doc = markdownToDast(markdown);
818
- const refs = collectBlockRefs(doc.document);
819
- assertKnownBlockRefs(blockMap, refs);
820
- assertNoUnusedBlocks(blockMap, refs);
821
- return {
822
- value: doc,
823
- blocks: blockMap
824
- };
825
- }
826
- const children = [];
827
- for (const node of nodes ?? []) switch (node.type) {
828
- case "paragraph":
829
- children.push({
830
- type: "paragraph",
831
- children: parseInlineSpans(node.text)
832
- });
833
- break;
834
- case "heading":
835
- children.push({
836
- type: "heading",
837
- level: node.level,
838
- children: parseInlineSpans(node.text)
839
- });
840
- break;
841
- case "code":
842
- children.push({
843
- type: "code",
844
- code: node.code,
845
- ...node.language ? { language: node.language } : {}
846
- });
847
- break;
848
- case "blockquote":
849
- children.push({
850
- type: "blockquote",
851
- children: [{
852
- type: "paragraph",
853
- children: parseInlineSpans(node.text)
854
- }]
855
- });
856
- break;
857
- case "list":
858
- children.push({
859
- type: "list",
860
- style: node.style ?? "bulleted",
861
- children: node.items.map((item) => ({
862
- type: "listItem",
863
- children: [{
864
- type: "paragraph",
865
- children: parseInlineSpans(item)
866
- }]
867
- }))
868
- });
869
- break;
870
- case "thematicBreak":
871
- children.push({ type: "thematicBreak" });
872
- break;
873
- case "block":
874
- children.push({
875
- type: "block",
876
- item: node.ref
877
- });
878
- break;
879
- }
880
- assertKnownBlockRefs(blockMap, collectBlockRefs(children));
881
- return {
882
- value: {
883
- schema: "dast",
884
- document: {
885
- type: "root",
886
- children
887
- }
888
- },
889
- blocks: blockMap
890
- };
891
- })),
892
838
  upload_asset: withDecoded(CreateAssetInput, (input) => createAsset(input, options?.actor).pipe(Effect.map(withAssetUrl))),
893
839
  import_asset_from_url: withDecoded(ImportAssetFromUrlInput, (input) => importAssetFromUrl(input, options?.actor).pipe(Effect.map(withAssetUrl))),
894
840
  list_assets: () => listAssets().pipe(Effect.map((assets) => assets.map((a) => {
@@ -911,6 +857,29 @@ function createMcpLayer(sqlLayer, options) {
911
857
  reindex_search: withDecoded(ReindexSearchInput, ({ modelApiKey }) => reindexAll(modelApiKey)),
912
858
  get_site_settings: () => getSiteSettings(),
913
859
  update_site_settings: withDecoded(UpdateSiteSettingsInput, updateSiteSettings),
860
+ get_preview_url: withDecoded(GetPreviewUrlInput, ({ recordId, modelApiKey }) => Effect.gen(function* () {
861
+ const model = yield* getModelByApiKey(modelApiKey);
862
+ if (!model.canonical_path_template) return yield* Effect.fail({
863
+ _tag: "ValidationError",
864
+ message: `Model '${modelApiKey}' has no canonicalPathTemplate configured`
865
+ });
866
+ const record = yield* getRecord(modelApiKey, recordId);
867
+ const recordData = typeof record === "object" && record !== null ? record : {};
868
+ const previewPath = resolvePreviewPath(model.canonical_path_template, recordData);
869
+ const { token, expiresAt } = yield* createPreviewToken();
870
+ const siteUrl = options?.siteUrl;
871
+ if (siteUrl) return {
872
+ url: `${siteUrl.replace(/\/$/, "")}/api/draft-mode/enable?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(previewPath)}`,
873
+ previewPath,
874
+ token,
875
+ expiresAt
876
+ };
877
+ return {
878
+ previewPath,
879
+ token,
880
+ expiresAt
881
+ };
882
+ })),
914
883
  editor_tokens: withDecoded(EditorTokensInput, ({ action, name, expiresIn, tokenId }) => {
915
884
  if (action === "list") return listEditorTokens();
916
885
  if (action === "create") {
@@ -978,16 +947,6 @@ function createMcpLayer(sqlLayer, options) {
978
947
  return Layer.merge(serverLayer, registeredContent).pipe(Layer.provide(fullLayer));
979
948
  }
980
949
  //#endregion
981
- //#region src/mcp/http-transport.ts
982
- /**
983
- * Create a cached Web Standard handler for the Effect-native MCP server.
984
- */
985
- function createMcpHttpHandler(sqlLayer, options) {
986
- const mcpLayer = createMcpLayer(sqlLayer, options);
987
- const { handler } = HttpLayerRouter.toWebHandler(mcpLayer, { disableLogger: true });
988
- return handler;
989
- }
990
- //#endregion
991
- export { createMcpHttpHandler };
950
+ export { getToolMeta as n, createMcpLayer as t };
992
951
 
993
- //# sourceMappingURL=http-transport-DbFCI6Cs.mjs.map
952
+ //# sourceMappingURL=server-D0XqvDjU.mjs.map