agent-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PROMPT.md +55 -0
- package/README.md +220 -0
- package/dist/handler-ClOW1ldA.mjs +5703 -0
- package/dist/http-transport-DbFCI6Cs.mjs +993 -0
- package/dist/index.d.mts +213 -0
- package/dist/index.mjs +1170 -0
- package/dist/structured-text-service-B4xSlUg_.mjs +1952 -0
- package/dist/token-service-BDjccMmz.mjs +3820 -0
- package/migrations/0000_genesis.sql +118 -0
- package/package.json +98 -0
|
@@ -0,0 +1,993 @@
|
|
|
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";
|
|
3
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
4
|
+
import { SqlClient } from "@effect/sql";
|
|
5
|
+
import * as HttpLayerRouter from "@effect/platform/HttpLayerRouter";
|
|
6
|
+
import * as McpServer from "@effect/ai/McpServer";
|
|
7
|
+
import * as McpSchema from "@effect/ai/McpSchema";
|
|
8
|
+
import * as AiTool from "@effect/ai/Tool";
|
|
9
|
+
import * as Toolkit from "@effect/ai/Toolkit";
|
|
10
|
+
//#region src/services/site-settings-service.ts
|
|
11
|
+
const fieldMap = {
|
|
12
|
+
siteName: "site_name",
|
|
13
|
+
titleSuffix: "title_suffix",
|
|
14
|
+
noIndex: "no_index",
|
|
15
|
+
faviconId: "favicon_id",
|
|
16
|
+
facebookPageUrl: "facebook_page_url",
|
|
17
|
+
twitterAccount: "twitter_account",
|
|
18
|
+
fallbackSeoTitle: "fallback_seo_title",
|
|
19
|
+
fallbackSeoDescription: "fallback_seo_description",
|
|
20
|
+
fallbackSeoImageId: "fallback_seo_image_id",
|
|
21
|
+
fallbackSeoTwitterCard: "fallback_seo_twitter_card"
|
|
22
|
+
};
|
|
23
|
+
function mapMissingTable(error) {
|
|
24
|
+
if ((error instanceof Error ? error.message : String(error)).includes("no such table: site_settings")) return new SchemaEngineError({
|
|
25
|
+
message: "site_settings table not found. Run setup/migrations before using site settings.",
|
|
26
|
+
cause: error
|
|
27
|
+
});
|
|
28
|
+
return new SchemaEngineError({
|
|
29
|
+
message: "Failed to access site settings",
|
|
30
|
+
cause: error
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function getSiteSettings() {
|
|
34
|
+
return Effect.gen(function* () {
|
|
35
|
+
const rows = yield* (yield* SqlClient.SqlClient).unsafe("SELECT * FROM site_settings LIMIT 1");
|
|
36
|
+
return rows.length > 0 ? rows[0] : { message: "No site settings configured yet. Use update_site_settings to create them." };
|
|
37
|
+
}).pipe(Effect.mapError(mapMissingTable));
|
|
38
|
+
}
|
|
39
|
+
function updateSiteSettings(args) {
|
|
40
|
+
return Effect.gen(function* () {
|
|
41
|
+
const sql = yield* SqlClient.SqlClient;
|
|
42
|
+
const sets = [];
|
|
43
|
+
const params = [];
|
|
44
|
+
for (const [key, value] of Object.entries(args)) {
|
|
45
|
+
const col = fieldMap[key];
|
|
46
|
+
sets.push(`"${col}" = ?`);
|
|
47
|
+
params.push(typeof value === "boolean" ? value ? 1 : 0 : value);
|
|
48
|
+
}
|
|
49
|
+
if (sets.length === 0) return yield* new ValidationError({ message: "No fields to update" });
|
|
50
|
+
sets.push(`"updated_at" = datetime('now')`);
|
|
51
|
+
yield* sql.unsafe(`INSERT OR IGNORE INTO site_settings (id) VALUES ('default')`);
|
|
52
|
+
yield* sql.unsafe(`UPDATE site_settings SET ${sets.join(", ")} WHERE id = 'default'`, params);
|
|
53
|
+
return (yield* sql.unsafe("SELECT * FROM site_settings WHERE id = 'default'"))[0];
|
|
54
|
+
}).pipe(Effect.mapError((error) => {
|
|
55
|
+
if (error instanceof ValidationError) return error;
|
|
56
|
+
return mapMissingTable(error);
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/mcp/server.ts
|
|
61
|
+
/**
|
|
62
|
+
* Effect-native MCP (Model Context Protocol) server for agent-cms.
|
|
63
|
+
* 3-layer architecture: Discovery -> Schema -> Content
|
|
64
|
+
*/
|
|
65
|
+
const JsonRecord = Schema.Record({
|
|
66
|
+
key: Schema.String,
|
|
67
|
+
value: Schema.Unknown
|
|
68
|
+
});
|
|
69
|
+
const CommonDependencies = [
|
|
70
|
+
SqlClient.SqlClient,
|
|
71
|
+
VectorizeContext,
|
|
72
|
+
HooksContext,
|
|
73
|
+
AssetImportContext
|
|
74
|
+
];
|
|
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
|
+
const UpdateModelInput = Schema.Struct({
|
|
107
|
+
modelId: Schema.String,
|
|
108
|
+
name: Schema.optional(Schema.String),
|
|
109
|
+
apiKey: Schema.optional(Schema.String),
|
|
110
|
+
singleton: Schema.optional(Schema.Boolean),
|
|
111
|
+
sortable: Schema.optional(Schema.Boolean),
|
|
112
|
+
hasDraft: Schema.optional(Schema.Boolean),
|
|
113
|
+
allLocalesRequired: Schema.optional(Schema.Boolean)
|
|
114
|
+
});
|
|
115
|
+
const UpdateFieldInput = Schema.Struct({
|
|
116
|
+
fieldId: Schema.String,
|
|
117
|
+
label: Schema.optional(Schema.String),
|
|
118
|
+
apiKey: Schema.optional(Schema.String),
|
|
119
|
+
validators: Schema.optional(JsonRecord),
|
|
120
|
+
hint: Schema.optional(Schema.String)
|
|
121
|
+
});
|
|
122
|
+
const ModelIdInput = Schema.Struct({ modelId: Schema.String });
|
|
123
|
+
const FieldIdInput = Schema.Struct({ fieldId: Schema.String });
|
|
124
|
+
const LocaleIdInput = Schema.Struct({ localeId: Schema.String });
|
|
125
|
+
const SchemaInfoInput = Schema.Struct({
|
|
126
|
+
filterByName: Schema.optional(Schema.String),
|
|
127
|
+
filterByType: Schema.optional(Schema.Literal("model", "block")),
|
|
128
|
+
includeFieldDetails: Schema.optionalWith(Schema.Boolean, { default: () => true })
|
|
129
|
+
});
|
|
130
|
+
const UpdateRecordInput = Schema.Struct({
|
|
131
|
+
recordId: Schema.optional(Schema.String),
|
|
132
|
+
modelApiKey: Schema.String,
|
|
133
|
+
data: Schema.optionalWith(JsonRecord, { default: () => ({}) })
|
|
134
|
+
});
|
|
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
|
+
const DeleteRecordInput = Schema.Struct({
|
|
146
|
+
recordId: Schema.String,
|
|
147
|
+
modelApiKey: Schema.String
|
|
148
|
+
});
|
|
149
|
+
const QueryRecordsInput = Schema.Struct({ modelApiKey: Schema.String });
|
|
150
|
+
const BulkCreateRecordsInput = Schema.Struct({
|
|
151
|
+
modelApiKey: Schema.String,
|
|
152
|
+
records: Schema.Array(JsonRecord)
|
|
153
|
+
});
|
|
154
|
+
const PublishRecordsInput = Schema.Struct({
|
|
155
|
+
modelApiKey: Schema.String,
|
|
156
|
+
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
|
+
});
|
|
158
|
+
const ScheduleInput = Schema.Struct({
|
|
159
|
+
recordId: Schema.String,
|
|
160
|
+
modelApiKey: Schema.String,
|
|
161
|
+
action: Schema.Literal("publish", "unpublish", "clear"),
|
|
162
|
+
at: Schema.optional(Schema.NullOr(Schema.String))
|
|
163
|
+
});
|
|
164
|
+
const RecordVersionsInput = Schema.Struct({
|
|
165
|
+
action: Schema.Literal("list", "get", "restore"),
|
|
166
|
+
modelApiKey: Schema.String,
|
|
167
|
+
recordId: Schema.String,
|
|
168
|
+
versionId: Schema.optional(Schema.String)
|
|
169
|
+
});
|
|
170
|
+
const RemoveBlockInput = Schema.Struct({
|
|
171
|
+
blockApiKey: Schema.String,
|
|
172
|
+
fieldId: Schema.optional(Schema.String)
|
|
173
|
+
});
|
|
174
|
+
const ReplaceAssetInput = Schema.Struct({
|
|
175
|
+
assetId: Schema.String,
|
|
176
|
+
...CreateAssetInput.fields
|
|
177
|
+
});
|
|
178
|
+
const SchemaIOInput = Schema.Struct({
|
|
179
|
+
action: Schema.Literal("export", "import"),
|
|
180
|
+
schema: Schema.optional(ImportSchemaInput)
|
|
181
|
+
});
|
|
182
|
+
const UpdateSiteSettingsInput = Schema.Struct({
|
|
183
|
+
siteName: Schema.optional(Schema.String),
|
|
184
|
+
titleSuffix: Schema.optional(Schema.String),
|
|
185
|
+
noIndex: Schema.optional(Schema.Boolean),
|
|
186
|
+
faviconId: Schema.optional(Schema.String),
|
|
187
|
+
facebookPageUrl: Schema.optional(Schema.String),
|
|
188
|
+
twitterAccount: Schema.optional(Schema.String),
|
|
189
|
+
fallbackSeoTitle: Schema.optional(Schema.String),
|
|
190
|
+
fallbackSeoDescription: Schema.optional(Schema.String),
|
|
191
|
+
fallbackSeoImageId: Schema.optional(Schema.String),
|
|
192
|
+
fallbackSeoTwitterCard: Schema.optional(Schema.String)
|
|
193
|
+
});
|
|
194
|
+
const EditorTokensInput = Schema.Struct({
|
|
195
|
+
action: Schema.Literal("create", "list", "revoke"),
|
|
196
|
+
name: Schema.optional(Schema.String),
|
|
197
|
+
expiresIn: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.positive())),
|
|
198
|
+
tokenId: Schema.optional(Schema.String)
|
|
199
|
+
});
|
|
200
|
+
const GetRecordInput = Schema.Struct({
|
|
201
|
+
recordId: Schema.String,
|
|
202
|
+
modelApiKey: Schema.String
|
|
203
|
+
});
|
|
204
|
+
const SetupContentModelPromptInput = Schema.Struct({ description: Schema.String });
|
|
205
|
+
const GenerateGraphqlQueriesPromptInput = Schema.Struct({ modelApiKey: Schema.String });
|
|
206
|
+
function cmsTool(name, description, parameters) {
|
|
207
|
+
let tool = AiTool.make(name, {
|
|
208
|
+
description,
|
|
209
|
+
parameters: parameters ?? {},
|
|
210
|
+
success: Schema.Unknown,
|
|
211
|
+
failure: Schema.Unknown,
|
|
212
|
+
dependencies: CommonDependencies
|
|
213
|
+
});
|
|
214
|
+
const isReadonly = name.startsWith("query_") || name.startsWith("get_") || name === "schema_info" || name === "build_structured_text" || name === "search_content";
|
|
215
|
+
tool = tool.annotate(AiTool.Readonly, isReadonly);
|
|
216
|
+
tool = tool.annotate(AiTool.Idempotent, isReadonly || name.startsWith("update_") || name.startsWith("replace_"));
|
|
217
|
+
tool = tool.annotate(AiTool.Destructive, name.startsWith("delete_") || name === "remove_block");
|
|
218
|
+
tool = tool.annotate(AiTool.OpenWorld, name === "search_content");
|
|
219
|
+
return tool;
|
|
220
|
+
}
|
|
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
|
+
function toStructuredContent(value) {
|
|
251
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
252
|
+
}
|
|
253
|
+
function isToolPayload(value) {
|
|
254
|
+
return value !== null && typeof value === "object";
|
|
255
|
+
}
|
|
256
|
+
function parseValidators(value) {
|
|
257
|
+
if (value == null || value === "") return {};
|
|
258
|
+
if (typeof value === "string") return decodeJsonRecordStringOr(value, {});
|
|
259
|
+
if (typeof value === "object" && !Array.isArray(value)) return value;
|
|
260
|
+
return {};
|
|
261
|
+
}
|
|
262
|
+
function withDecoded(schema, handler) {
|
|
263
|
+
return (params) => Schema.decodeUnknown(schema)(params).pipe(Effect.flatMap(handler));
|
|
264
|
+
}
|
|
265
|
+
function toMcpInputSchema(tool) {
|
|
266
|
+
const inputSchema = AiTool.getJsonSchema(tool);
|
|
267
|
+
return typeof inputSchema === "object" && "type" in inputSchema && inputSchema.type === "object" ? inputSchema : {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: {},
|
|
270
|
+
additionalProperties: false
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function pickToolkitHandlers(toolkit, handlers) {
|
|
274
|
+
const filtered = {};
|
|
275
|
+
for (const name of Object.keys(toolkit.tools)) filtered[name] = handlers[name];
|
|
276
|
+
return filtered;
|
|
277
|
+
}
|
|
278
|
+
const SchemaInfoTool = cmsTool("schema_info", "Get the complete CMS schema in one call — models, block types, fields, relations. The primary tool for understanding the content model. Use filterByName to find a specific model.", SchemaInfoInput.fields);
|
|
279
|
+
const CreateModelTool = cmsTool("create_model", "Create a content model or block type. Use isBlock:true for block types (embeddable in StructuredText). Use singleton:true for models with exactly one record (e.g. site settings). After creating a model, add fields with create_field.", CreateModelInput.fields);
|
|
280
|
+
const UpdateModelTool = cmsTool("update_model", "Update model properties (name, apiKey, singleton, sortable, hasDraft, allLocalesRequired, ordering). Set ordering to a default sort like 'title_ASC', '_createdAt_DESC', '_position_ASC', or null to clear.", UpdateModelInput.fields);
|
|
281
|
+
const CreateFieldTool = cmsTool("create_field", `Add a field to a model. Auto-migrates the database table (adds column).
|
|
282
|
+
|
|
283
|
+
Key validators by field type:
|
|
284
|
+
- slug: {"slug_source": "title"} — auto-generates from source field
|
|
285
|
+
- string/text/slug: {"enum": ["draft","review","published"]} — restrict allowed values
|
|
286
|
+
- string/text/slug: {"length": {"min": 10, "max": 160}} — character count limits
|
|
287
|
+
- integer/float: {"number_range": {"min": 1, "max": 5}} — numeric bounds
|
|
288
|
+
- string/text/slug: {"format": "email"} or {"format": "url"} or {"format": {"custom_pattern": "^[A-Z]{2}\\\\d{4}$"}} — string format checks
|
|
289
|
+
- date/date_time: {"date_range": {"min": "now"}} — temporal bounds
|
|
290
|
+
- link: {"item_item_type": ["model_api_key"]} — target model
|
|
291
|
+
- links: {"items_item_type": ["model_api_key"]} — target model
|
|
292
|
+
- structured_text: {"structured_text_blocks": ["block_api_key"]} — allowed block types
|
|
293
|
+
- any field: {"required": true} — field is required (provide default_value for existing records)`, {
|
|
294
|
+
modelId: Schema.String,
|
|
295
|
+
...CreateFieldInput.fields
|
|
296
|
+
});
|
|
297
|
+
const UpdateFieldTool = cmsTool("update_field", "Update field properties (label, apiKey, validators, hint)", UpdateFieldInput.fields);
|
|
298
|
+
const DeleteModelTool = cmsTool("delete_model", "Delete a model (fails if referenced)", ModelIdInput.fields);
|
|
299
|
+
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.
|
|
301
|
+
|
|
302
|
+
Validation note:
|
|
303
|
+
- For models with drafts (has_draft=true), required-field validation is deferred until publish_records.
|
|
304
|
+
- For models without drafts (has_draft=false), required fields are enforced during create_record.
|
|
305
|
+
|
|
306
|
+
Field value formats:
|
|
307
|
+
- media: asset ID string, or {"upload_id":"<asset_id>","alt":"...","title":"...","focal_point":{"x":0.5,"y":0.2},"custom_data":{...}}
|
|
308
|
+
- media_gallery: array of asset IDs and/or media override objects
|
|
309
|
+
- link: record ID string
|
|
310
|
+
- links: array of record ID strings
|
|
311
|
+
- seo: {"title":"...","description":"...","image":"<asset_id>","twitterCard":"summary_large_image"}
|
|
312
|
+
- structured_text: {"value":{"schema":"dast","document":{...}},"blocks":{"<id>":{"_type":"block_api_key",...}}}
|
|
313
|
+
- color: {"red":255,"green":0,"blue":0,"alpha":255}
|
|
314
|
+
- 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);
|
|
316
|
+
const PatchBlocksTool = cmsTool("patch_blocks", `Partially update blocks in a structured text field without resending the entire content tree.
|
|
317
|
+
|
|
318
|
+
You can target block IDs from either:
|
|
319
|
+
- the field's top-level \`blocks\` map, or
|
|
320
|
+
- nested structured_text sub-fields stored inside those blocks
|
|
321
|
+
|
|
322
|
+
Patch map semantics for each block ID:
|
|
323
|
+
- string value (the block ID) → keep block unchanged
|
|
324
|
+
- object with field overrides → merge into existing block (only specified fields updated)
|
|
325
|
+
- null → delete block and auto-prune from the relevant DAST tree
|
|
326
|
+
|
|
327
|
+
Block IDs not in the patch map are kept unchanged.
|
|
328
|
+
|
|
329
|
+
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
|
+
|
|
331
|
+
Optionally provide a new top-level DAST \`value\`. If omitted, the existing DAST is preserved (with deleted top-level blocks auto-pruned).
|
|
332
|
+
|
|
333
|
+
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);
|
|
335
|
+
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);
|
|
338
|
+
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
|
+
|
|
340
|
+
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);
|
|
343
|
+
const ScheduleTool = cmsTool("schedule", `Schedule a record to publish or unpublish at a future ISO datetime, or clear both schedules.
|
|
344
|
+
|
|
345
|
+
action: "publish" | "unpublish" | "clear"
|
|
346
|
+
- publish/unpublish: provide at (ISO datetime string)
|
|
347
|
+
- clear: at is ignored`, ScheduleInput.fields);
|
|
348
|
+
const RecordVersionsTool = cmsTool("record_versions", `Manage record versions: list all versions, get a snapshot, or restore a previous version.
|
|
349
|
+
|
|
350
|
+
action: "list" | "get" | "restore"
|
|
351
|
+
- list: returns all version snapshots for a record, newest first
|
|
352
|
+
- get: returns a specific version snapshot (provide versionId)
|
|
353
|
+
- restore: restores a previous version (provide versionId). Current state is versioned first, so restore is always reversible.`, RecordVersionsInput.fields);
|
|
354
|
+
const ReorderRecordsTool = cmsTool("reorder_records", "Reorder records in a sortable/tree model by providing ordered record IDs", ReorderInput.fields);
|
|
355
|
+
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
|
+
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
|
+
const UploadAssetTool = cmsTool("upload_asset", `Register an asset after uploading the original file to R2 out of band.
|
|
389
|
+
|
|
390
|
+
Upload flow:
|
|
391
|
+
1. Upload the original file to R2
|
|
392
|
+
2. Call this tool with the r2Key, filename, mimeType, and image dimensions
|
|
393
|
+
3. The asset metadata is registered and can be referenced in media fields by its ID`, CreateAssetInput.fields);
|
|
394
|
+
const ImportAssetFromUrlTool = cmsTool("import_asset_from_url", `Download an asset from a public URL, store it in R2, and register it in one step.
|
|
395
|
+
|
|
396
|
+
Use this when you have an image URL and want an agent-friendly path.
|
|
397
|
+
|
|
398
|
+
Flow:
|
|
399
|
+
1. Provide the source URL
|
|
400
|
+
2. The CMS fetches the file (following normal public HTTP redirects), stores it in R2, and creates the asset record
|
|
401
|
+
3. Use the returned asset ID in media fields (e.g. {image: "<asset_id>"})
|
|
402
|
+
|
|
403
|
+
The response includes id, r2Key, url (full public URL), and metadata. The id is what you pass to media fields — the CMS validates that the asset exists when creating/updating records.`, ImportAssetFromUrlInput.fields);
|
|
404
|
+
const ListAssetsTool = cmsTool("list_assets", "List all assets with their IDs, filenames, and R2 keys");
|
|
405
|
+
const ReplaceAssetTool = cmsTool("replace_asset", `Replace an asset's file metadata while keeping the same ID and URL. All content references remain stable.
|
|
406
|
+
|
|
407
|
+
Flow:
|
|
408
|
+
1. Upload the new original file to R2
|
|
409
|
+
2. Call this tool with the asset ID and new file metadata
|
|
410
|
+
3. The asset URL stays the same — no broken links in content`, ReplaceAssetInput.fields);
|
|
411
|
+
const SchemaIOTool = cmsTool("schema_io", `Export or import the full CMS schema as portable JSON.
|
|
412
|
+
|
|
413
|
+
action: "export" | "import"
|
|
414
|
+
- export: returns schema JSON (models, fields, locales). No IDs — references use api_keys.
|
|
415
|
+
- import: creates all locales, models, and fields in dependency order from provided schema. Use on a fresh/empty CMS.
|
|
416
|
+
Schema format: { "version": 1, "locales": [...], "models": [{ "name", "apiKey", "fields": [...] }] }`, SchemaIOInput.fields);
|
|
417
|
+
const SearchContentTool = cmsTool("search_content", `Search content records. Supports keyword search (FTS5), semantic search (Vectorize), or hybrid (both combined with rank fusion).
|
|
418
|
+
|
|
419
|
+
Keyword mode: phrases ("exact match"), prefix (word*), boolean (AND/OR).
|
|
420
|
+
Semantic mode: finds conceptually related content even when vocabulary differs (requires AI+Vectorize bindings).
|
|
421
|
+
Hybrid mode (default when Vectorize available): combines both for best results.
|
|
422
|
+
Results include modelApiKey, recordId, title when available, rank, and snippet.`, SearchInput.fields);
|
|
423
|
+
const ReindexSearchTool = cmsTool("reindex_search", "Rebuild FTS5 + Vectorize search indexes. Use after deploying search to a CMS with existing content, or to recover from index drift. Scoped to a single model or all content models.", ReindexSearchInput.fields);
|
|
424
|
+
const GetSiteSettingsTool = cmsTool("get_site_settings", "Get global site settings from the built-in site_settings table (site name, title suffix, global SEO, favicon, social accounts). This is separate from any content-model singleton also named site_settings.");
|
|
425
|
+
const UpdateSiteSettingsTool = cmsTool("update_site_settings", `Update global site settings in the built-in site_settings table. These power the _site GraphQL query (globalSeo, faviconMetaTags).
|
|
426
|
+
|
|
427
|
+
Use this tool for fields like siteName, titleSuffix, fallbackSeoTitle, and fallbackSeoDescription.
|
|
428
|
+
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
|
+
When the task is specifically about the singleton record, avoid mixing both surfaces unless the user explicitly asks for both.`, UpdateSiteSettingsInput.fields);
|
|
430
|
+
const AdminTools = [
|
|
431
|
+
SchemaInfoTool,
|
|
432
|
+
CreateModelTool,
|
|
433
|
+
UpdateModelTool,
|
|
434
|
+
CreateFieldTool,
|
|
435
|
+
UpdateFieldTool,
|
|
436
|
+
DeleteModelTool,
|
|
437
|
+
DeleteFieldTool,
|
|
438
|
+
CreateRecordTool,
|
|
439
|
+
UpdateRecordTool,
|
|
440
|
+
PatchBlocksTool,
|
|
441
|
+
DeleteRecordTool,
|
|
442
|
+
GetRecordTool,
|
|
443
|
+
QueryRecordsTool,
|
|
444
|
+
BulkCreateRecordsTool,
|
|
445
|
+
PublishRecordsTool,
|
|
446
|
+
UnpublishRecordsTool,
|
|
447
|
+
ScheduleTool,
|
|
448
|
+
RecordVersionsTool,
|
|
449
|
+
ReorderRecordsTool,
|
|
450
|
+
RemoveBlockTool,
|
|
451
|
+
RemoveLocaleTool,
|
|
452
|
+
BuildStructuredTextTool,
|
|
453
|
+
UploadAssetTool,
|
|
454
|
+
ImportAssetFromUrlTool,
|
|
455
|
+
ListAssetsTool,
|
|
456
|
+
ReplaceAssetTool,
|
|
457
|
+
SchemaIOTool,
|
|
458
|
+
SearchContentTool,
|
|
459
|
+
ReindexSearchTool,
|
|
460
|
+
GetSiteSettingsTool,
|
|
461
|
+
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)
|
|
468
|
+
];
|
|
469
|
+
const EditorTools = [
|
|
470
|
+
SchemaInfoTool,
|
|
471
|
+
CreateRecordTool,
|
|
472
|
+
UpdateRecordTool,
|
|
473
|
+
PatchBlocksTool,
|
|
474
|
+
DeleteRecordTool,
|
|
475
|
+
GetRecordTool,
|
|
476
|
+
QueryRecordsTool,
|
|
477
|
+
BulkCreateRecordsTool,
|
|
478
|
+
PublishRecordsTool,
|
|
479
|
+
UnpublishRecordsTool,
|
|
480
|
+
ScheduleTool,
|
|
481
|
+
RecordVersionsTool,
|
|
482
|
+
ReorderRecordsTool,
|
|
483
|
+
BuildStructuredTextTool,
|
|
484
|
+
UploadAssetTool,
|
|
485
|
+
ImportAssetFromUrlTool,
|
|
486
|
+
ListAssetsTool,
|
|
487
|
+
ReplaceAssetTool,
|
|
488
|
+
SchemaIOTool,
|
|
489
|
+
SearchContentTool,
|
|
490
|
+
GetSiteSettingsTool,
|
|
491
|
+
UpdateSiteSettingsTool
|
|
492
|
+
];
|
|
493
|
+
const CmsToolkit = Toolkit.make(...AdminTools);
|
|
494
|
+
const EditorToolkit = Toolkit.make(...EditorTools);
|
|
495
|
+
function createGuideResource() {
|
|
496
|
+
return McpServer.resource({
|
|
497
|
+
uri: "agent-cms://guide",
|
|
498
|
+
name: "agent-cms-guide",
|
|
499
|
+
description: "Orientation guide for agents: workflow, naming conventions, field formats, and lifecycle",
|
|
500
|
+
mimeType: "text/plain",
|
|
501
|
+
content: Effect.succeed(`agent-cms — Agent Orientation Guide
|
|
502
|
+
|
|
503
|
+
Server boundary:
|
|
504
|
+
- Admin MCP: /mcp — includes schema mutation tools like create_model, create_field, delete_model, delete_field, schema_io, and token management.
|
|
505
|
+
- 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
|
+
|
|
507
|
+
Workflow order:
|
|
508
|
+
schema_info -> create_model -> create_field -> create_record -> publish_records
|
|
509
|
+
|
|
510
|
+
Naming conventions:
|
|
511
|
+
- api_key: snake_case (e.g. blog_post, cover_image)
|
|
512
|
+
- GraphQL types: PascalCase (BlogPost, CoverImageRecord)
|
|
513
|
+
- GraphQL fields: camelCase (coverImage, blogPost)
|
|
514
|
+
- GraphQL list queries: allBlogPosts, allCategories
|
|
515
|
+
- GraphQL single queries: blogPost, category
|
|
516
|
+
- Block types get "Record" suffix in GraphQL: code_block -> CodeBlockRecord
|
|
517
|
+
|
|
518
|
+
Field value formats (composite types):
|
|
519
|
+
- media: asset ID string, or {"upload_id":"<asset_id>","alt":"...","title":"...","focal_point":{"x":0.5,"y":0.2},"custom_data":{...}}
|
|
520
|
+
- media_gallery: array of asset IDs and/or media override objects
|
|
521
|
+
- link: record ID string
|
|
522
|
+
- links: array of record ID strings
|
|
523
|
+
- seo: {"title":"...","description":"...","image":"<asset_id>","twitterCard":"summary_large_image"}
|
|
524
|
+
- structured_text: {"value":{"schema":"dast","document":{...}},"blocks":{"<id>":{"_type":"block_api_key",...}}}
|
|
525
|
+
- color: {"red":255,"green":0,"blue":0,"alpha":255}
|
|
526
|
+
- lat_lon: {"latitude":64.13,"longitude":-21.89}
|
|
527
|
+
|
|
528
|
+
Structured text editing notes:
|
|
529
|
+
- patch_blocks can target both top-level blocks and nested blocks inside structured_text sub-fields.
|
|
530
|
+
- If the same nested block ID exists in multiple locations, patch_blocks will ask you to patch the parent block explicitly.
|
|
531
|
+
- get_record is the fastest way to inspect one known record's full materialized structured_text after search_content returns its id.
|
|
532
|
+
- query_records materializes structured_text fields for inspection; on published records, _published_snapshot remains useful as a raw snapshot of what is live.
|
|
533
|
+
|
|
534
|
+
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.
|
|
537
|
+
Required-field validation for draft-enabled models happens at publish time, not create_record time.
|
|
538
|
+
Edits after publishing create a new draft version — publish again to update.
|
|
539
|
+
GraphQL serves published content by default; use X-Include-Drafts header for previews.
|
|
540
|
+
|
|
541
|
+
Singletons and site settings:
|
|
542
|
+
- 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.
|
|
543
|
+
- get_site_settings/update_site_settings operate on the built-in global site_settings table used by the _site GraphQL query. That surface uses fields like siteName, titleSuffix, fallbackSeoTitle, and fallbackSeoDescription.
|
|
544
|
+
|
|
545
|
+
Asset upload flow:
|
|
546
|
+
Preferred:
|
|
547
|
+
1. Call import_asset_from_url with a public file URL (normal public redirects are followed automatically)
|
|
548
|
+
2. The parsed tool payload is the asset object itself; read its top-level id field
|
|
549
|
+
3. Use that returned asset ID in media/media_gallery fields
|
|
550
|
+
|
|
551
|
+
Manual fallback:
|
|
552
|
+
1. Upload file to R2 out of band
|
|
553
|
+
2. Register with upload_asset tool (pass r2Key, filename, mimeType, dimensions)
|
|
554
|
+
3. Use returned asset ID in media/media_gallery fields
|
|
555
|
+
|
|
556
|
+
Tool argument encoding:
|
|
557
|
+
- Some MCP clients XML-encode tool arguments before they reach the server.
|
|
558
|
+
- 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.
|
|
560
|
+
|
|
561
|
+
Raw HTTP / JSON-RPC access:
|
|
562
|
+
- Endpoint: POST <mount>/mcp for admin, POST <mount>/mcp/editor for editor
|
|
563
|
+
- The mount point may be nested (for example /cms/mcp), so scripts should reuse the exact MCP URL already configured in the client instead of assuming root-level /mcp
|
|
564
|
+
- Auth: Authorization: Bearer <token>
|
|
565
|
+
- For standalone curl/HTTP scripts, you can call tools/call directly; do not assume an initialize round-trip is required unless your specific client library expects it
|
|
566
|
+
- Typical tool call body:
|
|
567
|
+
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"create_record","arguments":{...}},"id":1}
|
|
568
|
+
- Tool results usually come back in result.content[0].text as a JSON string payload
|
|
569
|
+
- jq extraction example:
|
|
570
|
+
.result.content[0].text | fromjson
|
|
571
|
+
- 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
|
+
|
|
573
|
+
Slug fields:
|
|
574
|
+
Set validator {"slug_source": "title"} to auto-generate from a source field.
|
|
575
|
+
Create the slug field AFTER the source field.
|
|
576
|
+
|
|
577
|
+
Pluralization:
|
|
578
|
+
category -> allCategories, blog_post -> allBlogPosts, person -> allPeople
|
|
579
|
+
Powered by standard English pluralization rules.`)
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
function createSchemaResource() {
|
|
583
|
+
return McpServer.resource({
|
|
584
|
+
uri: "agent-cms://schema",
|
|
585
|
+
name: "agent-cms-schema",
|
|
586
|
+
description: "Current CMS schema: models, fields, and locales as JSON",
|
|
587
|
+
mimeType: "application/json",
|
|
588
|
+
content: Effect.gen(function* () {
|
|
589
|
+
const sql = yield* SqlClient.SqlClient;
|
|
590
|
+
const models = yield* sql.unsafe("SELECT * FROM models ORDER BY is_block, created_at");
|
|
591
|
+
const fields = yield* sql.unsafe("SELECT * FROM fields ORDER BY model_id, position");
|
|
592
|
+
const locales = yield* sql.unsafe("SELECT * FROM locales ORDER BY position");
|
|
593
|
+
const fieldsByModel = /* @__PURE__ */ new Map();
|
|
594
|
+
for (const f of fields) {
|
|
595
|
+
const list = fieldsByModel.get(f.model_id) ?? [];
|
|
596
|
+
list.push(f);
|
|
597
|
+
fieldsByModel.set(f.model_id, list);
|
|
598
|
+
}
|
|
599
|
+
return encodeJson({
|
|
600
|
+
locales: locales.map((l) => ({
|
|
601
|
+
code: l.code,
|
|
602
|
+
position: l.position,
|
|
603
|
+
fallbackLocaleId: l.fallback_locale_id
|
|
604
|
+
})),
|
|
605
|
+
models: models.map((m) => ({
|
|
606
|
+
id: m.id,
|
|
607
|
+
name: m.name,
|
|
608
|
+
apiKey: m.api_key,
|
|
609
|
+
isBlock: !!m.is_block,
|
|
610
|
+
singleton: !!m.singleton,
|
|
611
|
+
fields: (fieldsByModel.get(m.id) ?? []).map((f) => ({
|
|
612
|
+
id: f.id,
|
|
613
|
+
apiKey: f.api_key,
|
|
614
|
+
label: f.label,
|
|
615
|
+
type: f.field_type,
|
|
616
|
+
localized: !!f.localized,
|
|
617
|
+
validators: parseValidators(f.validators)
|
|
618
|
+
}))
|
|
619
|
+
}))
|
|
620
|
+
});
|
|
621
|
+
})
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function createSetupContentModelPrompt() {
|
|
625
|
+
return McpServer.prompt({
|
|
626
|
+
name: "setup-content-model",
|
|
627
|
+
description: "Guide an agent through designing and creating content models from a description",
|
|
628
|
+
parameters: SetupContentModelPromptInput,
|
|
629
|
+
content: ({ description }) => Effect.succeed(`Set up content models for: ${description}
|
|
630
|
+
|
|
631
|
+
Follow these steps:
|
|
632
|
+
1. Call schema_info to check existing models — avoid duplicates.
|
|
633
|
+
2. Design the models and fields needed. Consider:
|
|
634
|
+
- Which are content models vs block types (for StructuredText embedding)?
|
|
635
|
+
- Which fields need slug (add after the source field with slug_source validator)?
|
|
636
|
+
- Which fields reference other models (link/links with item_item_type validator)?
|
|
637
|
+
- Which fields need structured_text (with structured_text_blocks validator for allowed blocks)?
|
|
638
|
+
3. Present your plan before executing — list models, fields, and relationships.
|
|
639
|
+
4. Create models first, then fields in order (slug fields after their source).
|
|
640
|
+
5. Create a few sample records to verify the schema works.
|
|
641
|
+
6. Publish the sample records with publish_records.
|
|
642
|
+
7. Show the GraphQL query that a frontend would use to fetch this content.
|
|
643
|
+
Remember: api_key snake_case -> GraphQL camelCase fields, PascalCase types.`)
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
function createGenerateGraphqlQueriesPrompt() {
|
|
647
|
+
return McpServer.prompt({
|
|
648
|
+
name: "generate-graphql-queries",
|
|
649
|
+
description: "Generate GraphQL queries for a content model with proper naming conventions",
|
|
650
|
+
parameters: GenerateGraphqlQueriesPromptInput,
|
|
651
|
+
content: ({ modelApiKey }) => Effect.succeed(`Generate GraphQL queries for the "${modelApiKey}" model.
|
|
652
|
+
|
|
653
|
+
Steps:
|
|
654
|
+
1. Call schema_info with filterByName "${modelApiKey}" to get the full field list.
|
|
655
|
+
2. Map field names from snake_case (api_key) to camelCase (GraphQL).
|
|
656
|
+
3. Generate these queries:
|
|
657
|
+
a. List query: all_<pluralized> with pagination, filtering, and ordering
|
|
658
|
+
b. Single query: <model_api_key> by ID or filter
|
|
659
|
+
c. Meta query: _all_<pluralized>_meta for total count
|
|
660
|
+
4. For each field type, use the right GraphQL fragment:
|
|
661
|
+
- media -> { url width height alt title }
|
|
662
|
+
- structured_text -> { value blocks { ... on <BlockType>Record { id <fields> } } }
|
|
663
|
+
- link -> { id <fields of target model> }
|
|
664
|
+
- links -> same as link but array
|
|
665
|
+
- seo -> { title description image { url } twitterCard }
|
|
666
|
+
- color -> { red green blue alpha hex }
|
|
667
|
+
- lat_lon -> { latitude longitude }
|
|
668
|
+
5. Include both a "full" query with all fields and a "list" query with essential fields only.`)
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
function createMcpLayer(sqlLayer, options) {
|
|
672
|
+
const mode = options?.mode ?? "admin";
|
|
673
|
+
const path = options?.path ?? (mode === "editor" ? "/mcp/editor" : "/mcp");
|
|
674
|
+
const toolkitAny = mode === "editor" ? EditorToolkit : CmsToolkit;
|
|
675
|
+
const defaultVectorizeLayer = Layer.succeed(VectorizeContext, Option.none());
|
|
676
|
+
const defaultHooksLayer = Layer.succeed(HooksContext, Option.none());
|
|
677
|
+
const defaultAssetImportLayer = Layer.succeed(AssetImportContext, {
|
|
678
|
+
r2Bucket: options?.r2Bucket,
|
|
679
|
+
fetch: options?.fetch ?? globalThis.fetch
|
|
680
|
+
});
|
|
681
|
+
const fullLayer = Layer.merge(Layer.merge(Layer.merge(defaultVectorizeLayer, defaultHooksLayer), defaultAssetImportLayer), sqlLayer);
|
|
682
|
+
const serverLayer = McpServer.layerHttpRouter({
|
|
683
|
+
name: mode === "editor" ? "agent-cms-editor" : "agent-cms",
|
|
684
|
+
version: "0.1.0",
|
|
685
|
+
path
|
|
686
|
+
});
|
|
687
|
+
function assetUrl(r2Key) {
|
|
688
|
+
if (!options?.assetBaseUrl) return void 0;
|
|
689
|
+
return `${options.assetBaseUrl.replace(/\/$/, "")}/${r2Key}`;
|
|
690
|
+
}
|
|
691
|
+
function withAssetUrl(asset) {
|
|
692
|
+
const url = assetUrl(asset.r2Key);
|
|
693
|
+
return url ? {
|
|
694
|
+
...asset,
|
|
695
|
+
url
|
|
696
|
+
} : asset;
|
|
697
|
+
}
|
|
698
|
+
const toolHandlers = {
|
|
699
|
+
schema_info: withDecoded(SchemaInfoInput, ({ filterByName, filterByType, includeFieldDetails }) => Effect.gen(function* () {
|
|
700
|
+
const sql = yield* SqlClient.SqlClient;
|
|
701
|
+
let modelQuery = "SELECT * FROM models";
|
|
702
|
+
const conditions = [];
|
|
703
|
+
const params = [];
|
|
704
|
+
if (filterByType === "model") conditions.push("is_block = 0");
|
|
705
|
+
if (filterByType === "block") conditions.push("is_block = 1");
|
|
706
|
+
if (filterByName) {
|
|
707
|
+
conditions.push("LOWER(name) LIKE ?");
|
|
708
|
+
params.push(`%${filterByName.toLowerCase()}%`);
|
|
709
|
+
}
|
|
710
|
+
if (conditions.length > 0) modelQuery += ` WHERE ${conditions.join(" AND ")}`;
|
|
711
|
+
modelQuery += " ORDER BY is_block, created_at";
|
|
712
|
+
const models = yield* sql.unsafe(modelQuery, params);
|
|
713
|
+
const allFields = yield* sql.unsafe("SELECT * FROM fields ORDER BY model_id, position");
|
|
714
|
+
const locales = yield* sql.unsafe("SELECT * FROM locales ORDER BY position");
|
|
715
|
+
const fieldsByModel = /* @__PURE__ */ new Map();
|
|
716
|
+
for (const f of allFields) {
|
|
717
|
+
const list = fieldsByModel.get(f.model_id) ?? [];
|
|
718
|
+
list.push(f);
|
|
719
|
+
fieldsByModel.set(f.model_id, list);
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
locales: locales.map((l) => ({
|
|
723
|
+
code: l.code,
|
|
724
|
+
position: l.position,
|
|
725
|
+
fallbackLocaleId: l.fallback_locale_id
|
|
726
|
+
})),
|
|
727
|
+
models: models.map((m) => {
|
|
728
|
+
const mFields = fieldsByModel.get(m.id) ?? [];
|
|
729
|
+
return {
|
|
730
|
+
id: m.id,
|
|
731
|
+
name: m.name,
|
|
732
|
+
apiKey: m.api_key,
|
|
733
|
+
isBlock: !!m.is_block,
|
|
734
|
+
singleton: !!m.singleton,
|
|
735
|
+
sortable: !!m.sortable,
|
|
736
|
+
tree: !!m.tree,
|
|
737
|
+
allLocalesRequired: !!m.all_locales_required,
|
|
738
|
+
...includeFieldDetails ? { fields: mFields.map((f) => ({
|
|
739
|
+
id: f.id,
|
|
740
|
+
label: f.label,
|
|
741
|
+
apiKey: f.api_key,
|
|
742
|
+
type: f.field_type,
|
|
743
|
+
localized: !!f.localized,
|
|
744
|
+
validators: parseValidators(f.validators),
|
|
745
|
+
hint: f.hint
|
|
746
|
+
})) } : {
|
|
747
|
+
fieldCount: mFields.length,
|
|
748
|
+
fieldNames: mFields.map((f) => f.api_key)
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
})
|
|
752
|
+
};
|
|
753
|
+
})),
|
|
754
|
+
create_model: withDecoded(CreateModelInput, createModel),
|
|
755
|
+
update_model: withDecoded(UpdateModelInput, ({ modelId, ...rest }) => updateModel(modelId, rest)),
|
|
756
|
+
create_field: withDecoded(Schema.Struct({
|
|
757
|
+
modelId: Schema.String,
|
|
758
|
+
...CreateFieldInput.fields
|
|
759
|
+
}), ({ modelId, ...rest }) => createField(modelId, rest)),
|
|
760
|
+
update_field: withDecoded(UpdateFieldInput, ({ fieldId, ...rest }) => updateField(fieldId, rest)),
|
|
761
|
+
delete_model: withDecoded(ModelIdInput, ({ modelId }) => deleteModel(modelId)),
|
|
762
|
+
delete_field: withDecoded(FieldIdInput, ({ fieldId }) => deleteField(fieldId)),
|
|
763
|
+
create_record: withDecoded(CreateRecordInput, (input) => createRecord(input, options?.actor)),
|
|
764
|
+
update_record: withDecoded(UpdateRecordInput, ({ recordId, modelApiKey, data }) => {
|
|
765
|
+
if (recordId) return patchRecord(recordId, {
|
|
766
|
+
modelApiKey,
|
|
767
|
+
data
|
|
768
|
+
}, options?.actor);
|
|
769
|
+
return updateSingletonRecord(modelApiKey, data, options?.actor);
|
|
770
|
+
}),
|
|
771
|
+
patch_blocks: withDecoded(PatchBlocksInput, (input) => patchBlocksForField(input, options?.actor)),
|
|
772
|
+
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)),
|
|
775
|
+
bulk_create_records: withDecoded(BulkCreateRecordsInput, ({ modelApiKey, records }) => bulkCreateRecords({
|
|
776
|
+
modelApiKey,
|
|
777
|
+
records
|
|
778
|
+
}, 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)),
|
|
781
|
+
schedule: withDecoded(ScheduleInput, ({ recordId, modelApiKey, action, at }) => {
|
|
782
|
+
if (action === "clear") return clearSchedule(modelApiKey, recordId, options?.actor);
|
|
783
|
+
if (action === "publish") return schedulePublish(modelApiKey, recordId, at ?? null, options?.actor);
|
|
784
|
+
return scheduleUnpublish(modelApiKey, recordId, at ?? null, options?.actor);
|
|
785
|
+
}),
|
|
786
|
+
record_versions: withDecoded(RecordVersionsInput, ({ action, modelApiKey, recordId, versionId }) => {
|
|
787
|
+
if (action === "list") return listVersions(modelApiKey, recordId);
|
|
788
|
+
if (action === "get") {
|
|
789
|
+
if (!versionId) return Effect.fail({
|
|
790
|
+
_tag: "ValidationError",
|
|
791
|
+
message: "versionId is required for get action"
|
|
792
|
+
});
|
|
793
|
+
return getVersion(versionId);
|
|
794
|
+
}
|
|
795
|
+
if (!versionId) return Effect.fail({
|
|
796
|
+
_tag: "ValidationError",
|
|
797
|
+
message: "versionId is required for restore action"
|
|
798
|
+
});
|
|
799
|
+
return restoreVersion(modelApiKey, recordId, versionId, options?.actor);
|
|
800
|
+
}),
|
|
801
|
+
reorder_records: withDecoded(ReorderInput, ({ modelApiKey, recordIds }) => reorderRecords(modelApiKey, recordIds, options?.actor)),
|
|
802
|
+
remove_block: withDecoded(RemoveBlockInput, ({ blockApiKey, fieldId }) => {
|
|
803
|
+
if (fieldId) return removeBlockFromWhitelist({
|
|
804
|
+
fieldId,
|
|
805
|
+
blockApiKey
|
|
806
|
+
});
|
|
807
|
+
return removeBlockType(blockApiKey);
|
|
808
|
+
}),
|
|
809
|
+
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
|
+
upload_asset: withDecoded(CreateAssetInput, (input) => createAsset(input, options?.actor).pipe(Effect.map(withAssetUrl))),
|
|
893
|
+
import_asset_from_url: withDecoded(ImportAssetFromUrlInput, (input) => importAssetFromUrl(input, options?.actor).pipe(Effect.map(withAssetUrl))),
|
|
894
|
+
list_assets: () => listAssets().pipe(Effect.map((assets) => assets.map((a) => {
|
|
895
|
+
const url = assetUrl(a.r2_key);
|
|
896
|
+
return url ? {
|
|
897
|
+
...a,
|
|
898
|
+
url
|
|
899
|
+
} : a;
|
|
900
|
+
}))),
|
|
901
|
+
replace_asset: withDecoded(ReplaceAssetInput, ({ assetId, ...rest }) => replaceAsset(assetId, rest, options?.actor).pipe(Effect.map(withAssetUrl))),
|
|
902
|
+
schema_io: withDecoded(SchemaIOInput, ({ action, schema }) => {
|
|
903
|
+
if (action === "export") return exportSchema();
|
|
904
|
+
if (!schema) return Effect.fail({
|
|
905
|
+
_tag: "ValidationError",
|
|
906
|
+
message: "schema is required for import action"
|
|
907
|
+
});
|
|
908
|
+
return importSchema(schema);
|
|
909
|
+
}),
|
|
910
|
+
search_content: withDecoded(SearchInput, search),
|
|
911
|
+
reindex_search: withDecoded(ReindexSearchInput, ({ modelApiKey }) => reindexAll(modelApiKey)),
|
|
912
|
+
get_site_settings: () => getSiteSettings(),
|
|
913
|
+
update_site_settings: withDecoded(UpdateSiteSettingsInput, updateSiteSettings),
|
|
914
|
+
editor_tokens: withDecoded(EditorTokensInput, ({ action, name, expiresIn, tokenId }) => {
|
|
915
|
+
if (action === "list") return listEditorTokens();
|
|
916
|
+
if (action === "create") {
|
|
917
|
+
if (!name) return Effect.fail({
|
|
918
|
+
_tag: "ValidationError",
|
|
919
|
+
message: "name is required for create action"
|
|
920
|
+
});
|
|
921
|
+
return createEditorToken({
|
|
922
|
+
name,
|
|
923
|
+
expiresIn
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
if (!tokenId) return Effect.fail({
|
|
927
|
+
_tag: "ValidationError",
|
|
928
|
+
message: "tokenId is required for revoke action"
|
|
929
|
+
});
|
|
930
|
+
return revokeEditorToken(tokenId);
|
|
931
|
+
})
|
|
932
|
+
};
|
|
933
|
+
const toolkitHandlers = toolkitAny.toLayer(pickToolkitHandlers(toolkitAny, toolHandlers));
|
|
934
|
+
const toolkitRegistration = Layer.effectDiscard(Effect.gen(function* () {
|
|
935
|
+
const registry = yield* McpServer.McpServer;
|
|
936
|
+
const built = yield* toolkitAny;
|
|
937
|
+
const context = yield* Effect.context();
|
|
938
|
+
for (const tool of Object.values(built.tools)) {
|
|
939
|
+
const mcpTool = new McpSchema.Tool({
|
|
940
|
+
name: tool.name,
|
|
941
|
+
description: tool.description,
|
|
942
|
+
inputSchema: toMcpInputSchema(tool),
|
|
943
|
+
annotations: new McpSchema.ToolAnnotations({
|
|
944
|
+
...Context.getOption(tool.annotations, AiTool.Title).pipe(Option.map((title) => ({ title })), Option.getOrUndefined),
|
|
945
|
+
readOnlyHint: Context.get(tool.annotations, AiTool.Readonly),
|
|
946
|
+
destructiveHint: Context.get(tool.annotations, AiTool.Destructive),
|
|
947
|
+
idempotentHint: Context.get(tool.annotations, AiTool.Idempotent),
|
|
948
|
+
openWorldHint: Context.get(tool.annotations, AiTool.OpenWorld)
|
|
949
|
+
})
|
|
950
|
+
});
|
|
951
|
+
yield* registry.addTool({
|
|
952
|
+
tool: mcpTool,
|
|
953
|
+
handle(payload) {
|
|
954
|
+
const params = isToolPayload(payload) ? payload : {};
|
|
955
|
+
return built.handle(tool.name, params).pipe(Effect.provide(context), Effect.match({
|
|
956
|
+
onFailure: (error) => new McpSchema.CallToolResult({
|
|
957
|
+
isError: true,
|
|
958
|
+
structuredContent: toStructuredContent(error),
|
|
959
|
+
content: [{
|
|
960
|
+
type: "text",
|
|
961
|
+
text: encodeJson(error)
|
|
962
|
+
}]
|
|
963
|
+
}),
|
|
964
|
+
onSuccess: (result) => new McpSchema.CallToolResult({
|
|
965
|
+
isError: false,
|
|
966
|
+
structuredContent: toStructuredContent(result.encodedResult),
|
|
967
|
+
content: [{
|
|
968
|
+
type: "text",
|
|
969
|
+
text: encodeJson(result.encodedResult)
|
|
970
|
+
}]
|
|
971
|
+
})
|
|
972
|
+
}));
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
})).pipe(Layer.provide(toolkitHandlers));
|
|
977
|
+
const registeredContent = Layer.mergeAll(toolkitRegistration, createGuideResource(), createSchemaResource(), createSetupContentModelPrompt(), createGenerateGraphqlQueriesPrompt()).pipe(Layer.provide(serverLayer));
|
|
978
|
+
return Layer.merge(serverLayer, registeredContent).pipe(Layer.provide(fullLayer));
|
|
979
|
+
}
|
|
980
|
+
//#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 };
|
|
992
|
+
|
|
993
|
+
//# sourceMappingURL=http-transport-DbFCI6Cs.mjs.map
|