@cloudhal/mcp-server 0.0.0-stage → 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.
Files changed (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/src/analytics-reports.d.ts +164 -0
  4. package/dist/src/analytics-reports.js +632 -0
  5. package/dist/src/analytics-tools.d.ts +4 -0
  6. package/dist/src/analytics-tools.js +597 -0
  7. package/dist/src/asset-tools.d.ts +5 -0
  8. package/dist/src/asset-tools.js +685 -0
  9. package/dist/src/body-tools.d.ts +46 -0
  10. package/dist/src/body-tools.js +708 -0
  11. package/dist/src/changes.d.ts +10 -0
  12. package/dist/src/changes.js +112 -0
  13. package/dist/src/chrome-tools.d.ts +8 -0
  14. package/dist/src/chrome-tools.js +207 -0
  15. package/dist/src/comment-tools.d.ts +9 -0
  16. package/dist/src/comment-tools.js +743 -0
  17. package/dist/src/comments.d.ts +184 -0
  18. package/dist/src/comments.js +408 -0
  19. package/dist/src/common.d.ts +258 -0
  20. package/dist/src/common.js +488 -0
  21. package/dist/src/content-shared.d.ts +50 -0
  22. package/dist/src/content-shared.js +102 -0
  23. package/dist/src/design-tools.d.ts +9 -0
  24. package/dist/src/design-tools.js +349 -0
  25. package/dist/src/elevation-tools.d.ts +3 -0
  26. package/dist/src/elevation-tools.js +331 -0
  27. package/dist/src/entry-terms.d.ts +14 -0
  28. package/dist/src/entry-terms.js +51 -0
  29. package/dist/src/entry-tools.d.ts +4 -0
  30. package/dist/src/entry-tools.js +442 -0
  31. package/dist/src/http.d.ts +48 -0
  32. package/dist/src/http.js +101 -0
  33. package/dist/src/index.d.ts +10 -0
  34. package/dist/src/index.js +10 -0
  35. package/dist/src/menu-tools.d.ts +41 -0
  36. package/dist/src/menu-tools.js +1058 -0
  37. package/dist/src/page-tools.d.ts +5 -0
  38. package/dist/src/page-tools.js +311 -0
  39. package/dist/src/preview-doc.d.ts +9 -0
  40. package/dist/src/preview-doc.js +56 -0
  41. package/dist/src/preview-view.d.ts +19 -0
  42. package/dist/src/preview-view.js +71 -0
  43. package/dist/src/publish-tools.d.ts +10 -0
  44. package/dist/src/publish-tools.js +248 -0
  45. package/dist/src/render/cdp.d.ts +29 -0
  46. package/dist/src/render/cdp.js +190 -0
  47. package/dist/src/render/checks/components.d.ts +5 -0
  48. package/dist/src/render/checks/components.js +95 -0
  49. package/dist/src/render/checks/content.d.ts +5 -0
  50. package/dist/src/render/checks/content.js +63 -0
  51. package/dist/src/render/checks/contrast.d.ts +5 -0
  52. package/dist/src/render/checks/contrast.js +72 -0
  53. package/dist/src/render/checks/index.d.ts +1 -0
  54. package/dist/src/render/checks/index.js +29 -0
  55. package/dist/src/render/checks/layout.d.ts +5 -0
  56. package/dist/src/render/checks/layout.js +44 -0
  57. package/dist/src/render/checks/motion.d.ts +5 -0
  58. package/dist/src/render/checks/motion.js +66 -0
  59. package/dist/src/render/checks/navigation.d.ts +5 -0
  60. package/dist/src/render/checks/navigation.js +78 -0
  61. package/dist/src/render/checks/prelude.d.ts +1 -0
  62. package/dist/src/render/checks/prelude.js +64 -0
  63. package/dist/src/render/limiter.d.ts +11 -0
  64. package/dist/src/render/limiter.js +29 -0
  65. package/dist/src/render/page.d.ts +19 -0
  66. package/dist/src/render/page.js +135 -0
  67. package/dist/src/render/types.d.ts +89 -0
  68. package/dist/src/render/types.js +140 -0
  69. package/dist/src/render-tools.d.ts +14 -0
  70. package/dist/src/render-tools.js +314 -0
  71. package/dist/src/render.d.ts +14 -0
  72. package/dist/src/render.js +33 -0
  73. package/dist/src/schedule-tools.d.ts +2 -0
  74. package/dist/src/schedule-tools.js +1596 -0
  75. package/dist/src/schedules.d.ts +292 -0
  76. package/dist/src/schedules.js +890 -0
  77. package/dist/src/schema-transforms.d.ts +14 -0
  78. package/dist/src/schema-transforms.js +50 -0
  79. package/dist/src/search-tools.d.ts +2 -0
  80. package/dist/src/search-tools.js +112 -0
  81. package/dist/src/server-approvals.d.ts +51 -0
  82. package/dist/src/server-approvals.js +273 -0
  83. package/dist/src/server-call.d.ts +28 -0
  84. package/dist/src/server-call.js +259 -0
  85. package/dist/src/server-instructions.d.ts +4 -0
  86. package/dist/src/server-instructions.js +74 -0
  87. package/dist/src/server-resources.d.ts +4 -0
  88. package/dist/src/server-resources.js +83 -0
  89. package/dist/src/server-schema.d.ts +20 -0
  90. package/dist/src/server-schema.js +82 -0
  91. package/dist/src/server.d.ts +80 -0
  92. package/dist/src/server.js +135 -0
  93. package/dist/src/stdio.d.ts +9 -0
  94. package/dist/src/stdio.js +18 -0
  95. package/dist/src/surface-tools.d.ts +4 -0
  96. package/dist/src/surface-tools.js +357 -0
  97. package/dist/src/taxonomy-tools.d.ts +16 -0
  98. package/dist/src/taxonomy-tools.js +1433 -0
  99. package/dist/src/terms.d.ts +47 -0
  100. package/dist/src/terms.js +271 -0
  101. package/dist/src/tools.d.ts +39 -0
  102. package/dist/src/tools.js +511 -0
  103. package/dist/src/translation-tools.d.ts +3 -0
  104. package/dist/src/translation-tools.js +422 -0
  105. package/package.json +86 -3
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Body tools (RFC-0014 §4, ADR-0028): read a markdown body as blocks, edit it by block, and see
3
+ * what changed in one page or article between two versions.
4
+ *
5
+ * - `get_body` returns the blocks with their ids and hashes (and who last wrote each).
6
+ * - `edit_body` applies up to 50 operations in one write. A stale `rev` is rebased when every
7
+ * block the operations name still exists and carries the `ifHash` the agent read; anything else
8
+ * is a conflict with the current blocks to read again. Every write is snapshotted. With
9
+ * `mode: "propose"` it files the operations for a person instead (any mode, `plan` included).
10
+ * - `diff_node` compares a node across snapshots (or with its current draft), body block by block.
11
+ */
12
+
13
+ import { SnapshotSchema } from "@cloudhal/core-contracts";
14
+ import {
15
+ applyBodyOps,
16
+
17
+
18
+ BodyOpError,
19
+ bodyDiff,
20
+ currentBlocks,
21
+
22
+ expandBlockOps,
23
+ MAX_BODY_OPS,
24
+ opTargets,
25
+ unknownBlockTypes,
26
+ } from "@cloudhal/core-ts";
27
+ import { directiveProps, parseDirective } from "@cloudhal/core-ts/directives";
28
+ import {
29
+ autoSnapshot,
30
+ entryLimitCheck,
31
+ id,
32
+
33
+ mapError,
34
+ obj,
35
+ quote,
36
+ str,
37
+ strip,
38
+
39
+
40
+ ToolError,
41
+ } from "./common.js";
42
+
43
+ const SNAPSHOT = strip(SnapshotSchema);
44
+
45
+ /** Where a body lives: an article's, or one rich-text section of a page. */
46
+ const TARGET = {
47
+ entryId: { ...id, description: "The article whose body to read or edit" },
48
+ pageId: { ...id, description: "With sectionId: a page's rich-text section instead" },
49
+ sectionId: { ...id, description: "The rich-text section of pageId" },
50
+ };
51
+
52
+ const BLOCK_ID = { type: "string", pattern: "^blk_[0-9A-Za-z]{8,24}$" };
53
+ const HASH = { type: "string", pattern: "^[0-9a-f]{64}$" };
54
+
55
+ const OPS = {
56
+ type: "array",
57
+ minItems: 1,
58
+ maxItems: MAX_BODY_OPS,
59
+ description:
60
+ "Applied in order. Ids come from get_body. `ifHash` (the block's hash from get_body) refuses the op if someone changed the block since, and lets the server rebase a stale rev.",
61
+ items: {
62
+ oneOf: [
63
+ obj(
64
+ {
65
+ op: { type: "string", enum: ["replace"] },
66
+ block: BLOCK_ID,
67
+ ifHash: HASH,
68
+ markdown: str("The block's new markdown (exactly one block)"),
69
+ },
70
+ ["op", "block", "markdown"],
71
+ ),
72
+ obj(
73
+ {
74
+ op: { type: "string", enum: ["replaceText"] },
75
+ block: BLOCK_ID,
76
+ ifHash: HASH,
77
+ find: str("Text that occurs exactly once in the block", { minLength: 1 }),
78
+ replace: str("What replaces it"),
79
+ },
80
+ ["op", "block", "find", "replace"],
81
+ ),
82
+ obj(
83
+ {
84
+ op: { type: "string", enum: ["insert"] },
85
+ after: { ...BLOCK_ID, type: ["string", "null"], description: "null: at the start" },
86
+ markdown: str("One or more new blocks"),
87
+ },
88
+ ["op", "after", "markdown"],
89
+ ),
90
+ obj(
91
+ {
92
+ op: { type: "string", enum: ["move"] },
93
+ block: BLOCK_ID,
94
+ ifHash: HASH,
95
+ after: { ...BLOCK_ID, type: ["string", "null"], description: "null: to the start" },
96
+ },
97
+ ["op", "block", "after"],
98
+ ),
99
+ obj({ op: { type: "string", enum: ["delete"] }, block: BLOCK_ID, ifHash: HASH }, [
100
+ "op",
101
+ "block",
102
+ ]),
103
+ obj(
104
+ {
105
+ op: { type: "string", enum: ["insertBlock"] },
106
+ after: { ...BLOCK_ID, type: ["string", "null"], description: "null: at the start" },
107
+ type: str("A block type allowed in bodies (list_block_types)"),
108
+ props: {
109
+ type: "object",
110
+ description:
111
+ "The type's props (scalars only in bodies); written as the block's attributes for you",
112
+ },
113
+ markdown: str(
114
+ "A container type's inner text (markdown); leave out for types without text",
115
+ ),
116
+ },
117
+ ["op", "after", "type", "props"],
118
+ ),
119
+ obj(
120
+ {
121
+ op: { type: "string", enum: ["setProps"] },
122
+ block: BLOCK_ID,
123
+ ifHash: HASH,
124
+ props: {
125
+ type: "object",
126
+ description:
127
+ "Props to change, merged onto the block's (null removes one); its inner text stays",
128
+ },
129
+ },
130
+ ["op", "block", "props"],
131
+ ),
132
+ ],
133
+ },
134
+ };
135
+
136
+ const BLOCK_OUT = obj(
137
+ {
138
+ id: { type: "string" },
139
+ hash: { type: "string" },
140
+ type: { type: "string" },
141
+ markdown: { type: "string" },
142
+ props: { type: "object", description: "A typed block's props (type directive:<name>)" },
143
+ by: { type: "object" },
144
+ via: { type: "string" },
145
+ at: { type: "string" },
146
+ },
147
+ ["id", "hash", "type", "markdown"],
148
+ );
149
+
150
+ /** The body a call addresses, and how to write it back. */
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+ async function locate(a , ctx ) {
164
+ if (typeof a.entryId === "string") {
165
+ if (a.pageId !== undefined || a.sectionId !== undefined)
166
+ throw new ToolError("invalid", "give entryId, or pageId with sectionId, not both");
167
+ const entry = await ctx.store.getContentEntry(a.entryId);
168
+ if (!entry || entry.siteId !== a.siteId)
169
+ throw new ToolError("not_found", `entry ${a.entryId} not found`);
170
+ if (entry.body.format !== "markdown")
171
+ throw new ToolError(
172
+ "invalid",
173
+ `entry ${entry.id} has an HTML body, which only a person can change in Studio; agents write markdown`,
174
+ );
175
+ return {
176
+ siteId: entry.siteId,
177
+ rev: entry.rev,
178
+ markdown: entry.body.value,
179
+ index: entry.body.blocks,
180
+ title: entry.title,
181
+ kind: "article",
182
+ entry,
183
+ write: async (markdown, writer) =>
184
+ ctx.store.updateContentEntry(
185
+ { ...entry, body: { ...entry.body, value: markdown } },
186
+ { writer },
187
+ ),
188
+ };
189
+ }
190
+ if (typeof a.pageId !== "string" || typeof a.sectionId !== "string")
191
+ throw new ToolError("invalid", "give entryId, or pageId with sectionId");
192
+ const page = await ctx.store.getPage(a.pageId);
193
+ if (!page || page.siteId !== a.siteId)
194
+ throw new ToolError("not_found", `page ${a.pageId} not found`);
195
+ const section = page.sections.find((s) => s.id === a.sectionId);
196
+ if (!section) throw new ToolError("not_found", `section ${a.sectionId} not found on ${page.id}`);
197
+ if (section.kind !== "richText" || section.format !== "markdown")
198
+ throw new ToolError(
199
+ "invalid",
200
+ `section ${section.id} is not a markdown rich-text section; edit other sections with update_page`,
201
+ );
202
+ return {
203
+ siteId: page.siteId,
204
+ rev: page.rev,
205
+ markdown: section.body,
206
+ index: section.blocks,
207
+ title: page.title,
208
+ kind: "page",
209
+ page,
210
+ write: async (markdown, writer) =>
211
+ ctx.store.updatePage(
212
+ {
213
+ ...page,
214
+ sections: page.sections.map((s) =>
215
+ s.id === section.id && s.kind === "richText" ? { ...s, body: markdown } : s,
216
+ ),
217
+ },
218
+ { writer },
219
+ ),
220
+ };
221
+ }
222
+
223
+ const bodyError = (e ) =>
224
+ new ToolError(
225
+ e.code,
226
+ `${e.op !== undefined ? `op ${e.op}: ` : ""}${e.message}${e.code === "conflict" || e.code === "not_found" ? "; read the body again (get_body)" : ""}`,
227
+ e.op !== undefined ? { op: e.op } : {},
228
+ );
229
+
230
+ /**
231
+ * The new markdown for `ops`, rebasing a stale `rev` when that is safe: every block an op names
232
+ * still exists, and every op that replaces, moves or deletes a block carries the `ifHash` it read.
233
+ */
234
+ async function plan(body , rev , edits , ctx ) {
235
+ const blocks = await currentBlocks(body.markdown, body.index);
236
+ let ops ;
237
+ try {
238
+ ops = expandBlockOps(edits, blocks, ctx.store.blocks);
239
+ } catch (e) {
240
+ if (e instanceof BodyOpError) throw bodyError(e);
241
+ throw e;
242
+ }
243
+ if (rev !== body.rev) {
244
+ const ids = new Set(blocks.map((b) => b.id));
245
+ const missing = ops.flatMap(opTargets).filter((t) => !ids.has(t));
246
+ const unverified = ops.filter(
247
+ (op) => op.op !== "insert" && op.op !== "replaceText" && op.ifHash === undefined,
248
+ );
249
+ if (missing.length || unverified.length)
250
+ throw new ToolError(
251
+ "conflict",
252
+ `the ${body.kind} is at rev ${body.rev}, you sent ${rev}${missing.length ? `; blocks ${missing.join(", ")} are gone` : "; pass ifHash on replace, move and delete to let the server rebase"}. Read it again with get_body`,
253
+ { rev: body.rev },
254
+ );
255
+ }
256
+ try {
257
+ return { blocks, ops, markdown: applyBodyOps(body.markdown, blocks, ops) };
258
+ } catch (e) {
259
+ if (e instanceof BodyOpError) throw bodyError(e);
260
+ throw e;
261
+ }
262
+ }
263
+
264
+ function summary(ops ) {
265
+ const count = (kind ) => ops.filter((o) => o.op === kind).length;
266
+ const parts = [
267
+ [count("replace") + count("replaceText") + count("setProps"), "edit"],
268
+ [count("insert") + count("insertBlock"), "insert"],
269
+ [count("move"), "move"],
270
+ [count("delete"), "delete"],
271
+ ]
272
+ .filter(([n]) => (n ) > 0)
273
+ .map(([n, verb]) => `${verb} ${n} block${n === 1 ? "" : "s"}`);
274
+ return parts.join(", ");
275
+ }
276
+
277
+ /** The body a call names, checked against the caller's own limit, and what `ops` make of it. */
278
+ async function prepare(a , ctx ) {
279
+ const body = await locate(a, ctx);
280
+ if (ctx.limit) {
281
+ if (!body.entry)
282
+ throw new ToolError("denied", "pages are edited by Editors; you may edit your own articles");
283
+ await entryLimitCheck(ctx, "update", body.entry);
284
+ }
285
+ const { blocks, ops, markdown } = await plan(body, a.rev , a.ops , ctx);
286
+ if (markdown === body.markdown) throw new ToolError("invalid", "the operations change nothing");
287
+ return { body, ops, blocks, markdown };
288
+ }
289
+
290
+ /** What a body edit wrote: the new `rev`, the block ids it changed, added and removed. */
291
+
292
+
293
+
294
+
295
+
296
+
297
+  
298
+
299
+
300
+
301
+ /**
302
+ * Applies `a.ops` to the body `a` names (`entryId`, or `pageId` with `sectionId`) at `a.rev`, in
303
+ * one write as `writer` (RFC-0014 §4): `edit_body` and REST `PATCH …/body` share it. Own limits
304
+ * come from `ctx.limit`; snapshotting is the caller's.
305
+ */
306
+ export async function editBody(
307
+ a ,
308
+ ctx ,
309
+ writer ,
310
+ ) {
311
+ const { body, blocks, markdown } = await prepare(a, ctx);
312
+ try {
313
+ const saved = await body.write(markdown, writer);
314
+ const located = await locate(a, ctx);
315
+ const after = await currentBlocks(located.markdown, located.index);
316
+ const before = new Map(blocks.map((b) => [b.id, b.hash]));
317
+ const now = new Set(after.map((b) => b.id));
318
+ return {
319
+ rev: saved.rev,
320
+ changed: after
321
+ .filter((b) => before.has(b.id) && before.get(b.id) !== b.hash)
322
+ .map((b) => b.id),
323
+ added: after.filter((b) => !before.has(b.id)).map((b) => b.id),
324
+ removed: blocks.filter((b) => !now.has(b.id)).map((b) => b.id),
325
+ words: bodyDiff(body.markdown, markdown).words,
326
+ ...(() => {
327
+ const names = unknownBlockTypes(ctx.store.blocks, { markdown });
328
+ return names.length ? { unknownBlocks: names } : {};
329
+ })(),
330
+ };
331
+ } catch (e) {
332
+ mapError(e);
333
+ }
334
+ }
335
+
336
+ export function bodyTools() {
337
+ return [
338
+ {
339
+ name: "get_theme_settings",
340
+ title: "Read the design settings",
341
+ description:
342
+ "The theme's design settings (RFC-0017): their schema (colors as #rrggbb, enums, numbers; per-locale titles), the contrast rules colors must meet, and the site's current values. Change them with set_theme_settings; they show on the site after a publish.",
343
+ inputSchema: obj({ siteId: id }, ["siteId"]),
344
+ outputSchema: obj(
345
+ {
346
+ theme: { type: "string" },
347
+ schema: { type: ["object", "null"] },
348
+ values: { type: "object" },
349
+ rev: { type: "integer" },
350
+ },
351
+ ["theme", "schema", "values", "rev"],
352
+ ),
353
+ annotations: {
354
+ readOnlyHint: true,
355
+ destructiveHint: false,
356
+ idempotentHint: true,
357
+ openWorldHint: false,
358
+ },
359
+ scopes: ["content:read"],
360
+ handler: async (a, ctx) => {
361
+ const site = await ctx.store.getSite(a.siteId );
362
+ if (!site) throw new ToolError("not_found", `site ${a.siteId} not found`);
363
+ return {
364
+ theme: site.theme.name,
365
+ schema: ctx.store.themeSettings ?? null,
366
+ values: site.theme.config ?? {},
367
+ rev: site.rev,
368
+ };
369
+ },
370
+ },
371
+ {
372
+ name: "set_theme_settings",
373
+ title: "Change the design settings",
374
+ description:
375
+ "Change the site's design settings (colors, type, spacing: get_theme_settings lists them). Values are merged onto the current ones; null removes one. They are checked against the theme's schema and contrast rules, and show on the site after a publish. Always waits for a person.",
376
+ inputSchema: obj(
377
+ {
378
+ siteId: id,
379
+ rev: {
380
+ type: "integer",
381
+ minimum: 0,
382
+ description: "The site's rev from get_theme_settings",
383
+ },
384
+ values: { type: "object", description: "Settings to change; null removes one" },
385
+ },
386
+ ["siteId", "rev", "values"],
387
+ ),
388
+ outputSchema: obj({ values: { type: "object" }, rev: { type: "integer" } }, [
389
+ "values",
390
+ "rev",
391
+ ]),
392
+ annotations: {
393
+ readOnlyHint: false,
394
+ destructiveHint: false,
395
+ idempotentHint: true,
396
+ openWorldHint: false,
397
+ },
398
+ scopes: ["site:write"],
399
+ requiresApproval: true,
400
+ approvalPrompt: async (a) => {
401
+ const keys = Object.keys((a.values ?? {}) );
402
+ return `Change the site's design settings (${keys.join(", ") || "none"})?`;
403
+ },
404
+ handler: async (a, ctx) => {
405
+ const site = await ctx.store.getSite(a.siteId );
406
+ if (!site) throw new ToolError("not_found", `site ${a.siteId} not found`);
407
+ if (!ctx.store.themeSettings)
408
+ throw new ToolError("invalid", "this theme declares no design settings");
409
+ const values = { ...(site.theme.config ?? {}) };
410
+ for (const [k, v] of Object.entries((a.values ?? {}) ))
411
+ if (v === null) delete values[k];
412
+ else values[k] = v;
413
+ try {
414
+ const saved = await ctx.store.updateSite({
415
+ ...site,
416
+ rev: a.rev ,
417
+ theme: { ...site.theme, config: values },
418
+ });
419
+ return { values: saved.theme.config ?? {}, rev: saved.rev };
420
+ } catch (e) {
421
+ mapError(e);
422
+ }
423
+ },
424
+ },
425
+ {
426
+ name: "list_block_types",
427
+ title: "List block types",
428
+ description:
429
+ "The typed blocks this site can use (RFC-0015): callouts, buttons, video embeds, FAQs and the theme's own. Each has a name, where it may go (page sections with kind \"component\", or bodies through edit_body's insertBlock), its props as JSON Schema, whether it wraps text, and an example. Names are plain English and shared across themes.",
430
+ inputSchema: obj(
431
+ {
432
+ siteId: id,
433
+ where: {
434
+ type: "string",
435
+ enum: ["section", "body"],
436
+ description: "Only the types allowed there",
437
+ },
438
+ },
439
+ ["siteId"],
440
+ ),
441
+ outputSchema: obj({ types: { type: "array", items: { type: "object" } } }, ["types"]),
442
+ annotations: {
443
+ readOnlyHint: true,
444
+ destructiveHint: false,
445
+ idempotentHint: true,
446
+ openWorldHint: false,
447
+ },
448
+ scopes: ["content:read"],
449
+ handler: async (a, ctx) => {
450
+ const site = await ctx.store.getSite(a.siteId );
451
+ if (!site) throw new ToolError("not_found", `site ${a.siteId} not found`);
452
+ const where = a.where ;
453
+ return {
454
+ types: ctx.store.blocks.list(where).map((t) => ({
455
+ name: t.name,
456
+ title: t.title,
457
+ ...(t.description ? { description: t.description } : {}),
458
+ where: t.where,
459
+ content: t.content ?? "none",
460
+ props: t.props,
461
+ example: t.example,
462
+ })),
463
+ };
464
+ },
465
+ },
466
+ {
467
+ name: "get_body",
468
+ title: "Read body by blocks",
469
+ description:
470
+ "Read a markdown body (an article's, or a page's rich-text section) as blocks: each with its id, hash, type, markdown and who last changed it. Use the ids and hashes with edit_body.",
471
+ inputSchema: obj({ siteId: id, ...TARGET }, ["siteId"]),
472
+ outputSchema: obj(
473
+ {
474
+ rev: { type: "integer" },
475
+ profile: { type: "integer" },
476
+ markdown: { type: "string" },
477
+ blocks: { type: "array", items: BLOCK_OUT },
478
+ },
479
+ ["rev", "markdown", "blocks"],
480
+ ),
481
+ annotations: {
482
+ readOnlyHint: true,
483
+ destructiveHint: false,
484
+ idempotentHint: true,
485
+ openWorldHint: false,
486
+ },
487
+ scopes: ["content:read"],
488
+ handler: async (a, ctx) => {
489
+ const body = await locate(a, ctx);
490
+ return {
491
+ rev: body.rev,
492
+ profile: 1,
493
+ markdown: body.markdown,
494
+ blocks: (await currentBlocks(body.markdown, body.index)).map((b) => {
495
+ // Typed blocks (RFC-0015 §2): their props, read for the agent.
496
+ const d = b.type.startsWith("directive:") ? parseDirective(b.markdown) : undefined;
497
+ return d ? { ...b, props: directiveProps(ctx.store.blocks.get(d.name), d.attrs) } : b;
498
+ }),
499
+ };
500
+ },
501
+ },
502
+ {
503
+ name: "edit_body",
504
+ title: "Edit body by blocks",
505
+ description: `Change a markdown body by block instead of rewriting it: replace a block, replace text inside one, insert, move or delete blocks, insert a typed block (insertBlock: a callout, a button, …; list_block_types) or change one's props (setProps) without writing its syntax (up to ${MAX_BODY_OPS} operations, applied in order, one write). Read the ids with get_body first. A stale rev is rebased when every named block still exists and carries ifHash; otherwise you get a conflict. Other blocks keep their bytes and their authors. Snapshots afterwards. With mode "propose" nothing is written: a person accepts or rejects the operations in Studio.`,
506
+ inputSchema: obj(
507
+ {
508
+ siteId: id,
509
+ ...TARGET,
510
+ rev: { type: "integer", minimum: 0 },
511
+ ops: OPS,
512
+ mode: {
513
+ type: "string",
514
+ enum: ["apply", "propose"],
515
+ description:
516
+ "apply (default) writes now; propose files the operations for a person to accept or reject in Studio and changes nothing (allowed in plan mode)",
517
+ },
518
+ note: str("With propose: why, in a sentence the person will read", { maxLength: 1000 }),
519
+ },
520
+ ["siteId", "rev", "ops"],
521
+ ),
522
+ outputSchema: obj({
523
+ rev: { type: "integer" },
524
+ changed: { type: "array", items: { type: "string" } },
525
+ added: { type: "array", items: { type: "string" } },
526
+ removed: { type: "array", items: { type: "string" } },
527
+ words: obj({ added: { type: "integer" }, removed: { type: "integer" } }),
528
+ unknownBlocks: { type: "array", items: { type: "string" } },
529
+ snapshot: SNAPSHOT,
530
+ proposal: obj({ id: { type: "string" }, status: { type: "string" } }, ["id", "status"]),
531
+ }),
532
+ annotations: {
533
+ readOnlyHint: false,
534
+ destructiveHint: false,
535
+ idempotentHint: false,
536
+ openWorldHint: false,
537
+ },
538
+ scopes: ["content:write"],
539
+ own: "entry",
540
+ // A proposal changes nothing until a person accepts it: every mode may propose (ADR-0028).
541
+ classify: (a) => (a.mode === "propose" ? "propose" : undefined),
542
+ proposeScopes: ["content:read", "content:propose"],
543
+ precheck: async (a, ctx) => {
544
+ if (!ctx.limit) return;
545
+ if (typeof a.entryId !== "string")
546
+ throw new ToolError(
547
+ "denied",
548
+ "pages are edited by Editors; you may edit your own articles",
549
+ );
550
+ const entry = await ctx.store.getContentEntry(a.entryId);
551
+ if (entry && entry.siteId === a.siteId) await entryLimitCheck(ctx, "update", entry);
552
+ },
553
+ approvalPrompt: async (a, ctx) => {
554
+ const ops = (a.ops ?? []) ;
555
+ try {
556
+ const body = await locate(a, ctx);
557
+ const { markdown } = await plan(body, a.rev , ops, ctx);
558
+ const d = bodyDiff(body.markdown, markdown);
559
+ const what = body.kind === "article" ? "article" : "page";
560
+ return `Edit the body of ${what} "${quote(body.title)}" (${summary(ops)}: +${d.words.added} / −${d.words.removed} words)?`;
561
+ } catch {
562
+ return `Edit a body (${summary(ops)})?`;
563
+ }
564
+ },
565
+ handler: async (a, ctx) => {
566
+ if (a.mode === "propose") {
567
+ const { body, ops } = await prepare(a, ctx);
568
+ try {
569
+ const proposal = await ctx.store.proposals.create({
570
+ siteId: body.siteId,
571
+ nodeId: (a.entryId ?? a.pageId) ,
572
+ sectionId: a.sectionId ,
573
+ ops,
574
+ actor: ctx.principal.actor,
575
+ note: a.note ,
576
+ });
577
+ return { proposal: { id: proposal.id, status: proposal.status } };
578
+ } catch (e) {
579
+ if (e instanceof BodyOpError) throw bodyError(e);
580
+ mapError(e);
581
+ }
582
+ }
583
+ const result = await editBody(a, ctx, { actor: ctx.principal.actor, via: "mcp" });
584
+ try {
585
+ return { ...result, snapshot: await autoSnapshot(ctx, a.siteId , "edit_body") };
586
+ } catch (e) {
587
+ mapError(e);
588
+ }
589
+ },
590
+ changes: async (a, ctx) => {
591
+ const { body, markdown } = await prepare(a, ctx);
592
+ return {
593
+ kind: "body",
594
+ nodeId: (a.entryId ?? a.pageId) ,
595
+ ...(typeof a.sectionId === "string" ? { sectionId: a.sectionId } : {}),
596
+ title: body.title,
597
+ diff: bodyDiff(body.markdown, markdown),
598
+ };
599
+ },
600
+ },
601
+ {
602
+ name: "diff_node",
603
+ title: "What changed in a page or article",
604
+ description:
605
+ "Compare one page or article between two versions: the fields that changed, and its body (or each rich-text section) block by block, with the words that changed inside edited blocks. `from` defaults to the live (published) snapshot, `to` to the current draft.",
606
+ inputSchema: obj(
607
+ {
608
+ siteId: id,
609
+ nodeId: id,
610
+ fromSnapshotId: { ...id, description: "Default: the live snapshot" },
611
+ toSnapshotId: { ...id, description: "Default: the current draft" },
612
+ },
613
+ ["siteId", "nodeId"],
614
+ ),
615
+ outputSchema: obj(
616
+ {
617
+ from: { type: "string" },
618
+ to: { type: "string" },
619
+ fields: { type: "array", items: { type: "string" } },
620
+ bodies: { type: "array" },
621
+ },
622
+ ["from", "to", "fields", "bodies"],
623
+ ),
624
+ annotations: {
625
+ readOnlyHint: true,
626
+ destructiveHint: false,
627
+ idempotentHint: true,
628
+ openWorldHint: false,
629
+ },
630
+ scopes: ["content:read"],
631
+ handler: async (a, ctx) => {
632
+ const siteId = a.siteId ;
633
+ const nodeId = a.nodeId ;
634
+ const site = await ctx.store.getSite(siteId);
635
+ if (!site) throw new ToolError("not_found", `site ${siteId} not found`);
636
+ const at = async (snapshotId ) => {
637
+ if (!snapshotId) {
638
+ const node =
639
+ (await ctx.store.getContentEntry(nodeId)) ?? (await ctx.store.getPage(nodeId));
640
+ return node && node.siteId === siteId ? node : undefined;
641
+ }
642
+ const snap = await ctx.store.getSnapshot(snapshotId);
643
+ if (!snap || snap.siteId !== siteId)
644
+ throw new ToolError("not_found", `snapshot ${snapshotId} not found`);
645
+ const ref = snap.nodes.find((n) => n.id === nodeId);
646
+ return ref && (ref.kind === "contentEntry" || ref.kind === "page")
647
+ ? await ctx.store.readSnapshotNode (ref)
648
+ : undefined;
649
+ };
650
+ const fromId = (a.fromSnapshotId ) ?? site.publishedSnapshotId;
651
+ const toId = a.toSnapshotId ;
652
+ const [before, after] = await Promise.all([at(fromId), at(toId)]);
653
+ if (!before && !after)
654
+ throw new ToolError(
655
+ "not_found",
656
+ `${nodeId} is in neither version (pages and articles only)`,
657
+ );
658
+ return {
659
+ from: fromId ?? "none",
660
+ to: toId ?? "draft",
661
+ ...nodeChanges(before, after),
662
+ };
663
+ },
664
+ },
665
+ ];
666
+ }
667
+
668
+
669
+ const IGNORED = new Set(["rev", "updatedAt", "createdAt", "schemaVersion"]);
670
+
671
+ /** Changed fields, and each markdown body's block diff (unchanged blocks without their text). */
672
+ export function nodeChanges(before , after ) {
673
+ const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
674
+ const get = (n , k ) => (n )?.[k];
675
+ const fields = [...keys]
676
+ .filter(
677
+ (k) => !IGNORED.has(k) && JSON.stringify(get(before, k)) !== JSON.stringify(get(after, k)),
678
+ )
679
+ .sort();
680
+ const bodies = [];
681
+ const add = (field , a , b ) => {
682
+ if (a === b) return;
683
+ const d = bodyDiff(a, b);
684
+ bodies.push({
685
+ field,
686
+ counts: d.counts,
687
+ words: d.words,
688
+ blocks: d.blocks.map((x) => (x.kind === "same" ? { kind: "same" } : x)),
689
+ });
690
+ };
691
+ const text = (n ) =>
692
+ n && "body" in n && n.body.format === "markdown" ? n.body.value : "";
693
+ if ((before && "collection" in before) || (after && "collection" in after))
694
+ add("body", text(before), text(after));
695
+ else {
696
+ const sections = (n ) =>
697
+ new Map(
698
+ ((n )?.sections ?? []).flatMap((s) =>
699
+ s.kind === "richText" && s.format === "markdown" ? [[s.id, s.body] ] : [],
700
+ ),
701
+ );
702
+ const a = sections(before);
703
+ const b = sections(after);
704
+ for (const id of new Set([...a.keys(), ...b.keys()]))
705
+ add(`sections.${id}.body`, a.get(id) ?? "", b.get(id) ?? "");
706
+ }
707
+ return { fields, bodies };
708
+ }