@cmssy/mcp-server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,16 +1,45 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { z } from "zod";
6
+ import { mediaTypeValues } from "@cmssy/types";
7
+ // Read our own version from package.json so the MCP handshake
8
+ // advertises what the user actually installed, instead of drifting
9
+ // whenever we bump the package.
10
+ const PACKAGE_VERSION = (() => {
11
+ try {
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const pkg = JSON.parse(readFileSync(resolve(here, "../package.json"), "utf8"));
14
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
15
+ }
16
+ catch {
17
+ return "0.0.0";
18
+ }
19
+ })();
3
20
  /**
4
21
  * Preprocess helper: parse JSON string to object if needed.
5
22
  * MCP clients may serialize complex nested objects as JSON strings
6
23
  * instead of passing them as objects. This ensures they're parsed.
24
+ * Malformed JSON is passed through so Zod produces a validation error
25
+ * instead of crashing the handler.
7
26
  */
8
- const jsonPreprocess = (val) => typeof val === "string" ? JSON.parse(val) : val;
9
- import { PAGES_QUERY, PAGE_BY_ID_QUERY, WORKSPACE_BLOCKS_QUERY, WORKSPACE_BLOCK_BY_TYPE_QUERY, SITE_CONFIG_QUERY, CURRENT_WORKSPACE_QUERY, MEDIA_ASSETS_QUERY, SAVE_PAGE_MUTATION, UPDATE_PAGE_SETTINGS_MUTATION, TOGGLE_PUBLISH_MUTATION, PUBLISH_PAGE_MUTATION, REVERT_TO_PUBLISHED_MUTATION, REMOVE_PAGE_MUTATION, UPDATE_PAGE_LAYOUT_MUTATION, FORMS_QUERY, FORM_BY_ID_QUERY, FORM_SUBMISSIONS_QUERY, FORM_SUBMISSION_BY_ID_QUERY, CREATE_FORM_MUTATION, UPDATE_FORM_MUTATION, DELETE_FORM_MUTATION, UPDATE_FORM_SUBMISSION_STATUS_MUTATION, DELETE_FORM_SUBMISSION_MUTATION, } from "./queries.js";
27
+ const jsonPreprocess = (val) => {
28
+ if (typeof val !== "string")
29
+ return val;
30
+ try {
31
+ return JSON.parse(val);
32
+ }
33
+ catch {
34
+ return val;
35
+ }
36
+ };
37
+ import { PAGES_QUERY, PAGE_BY_ID_QUERY, WORKSPACE_BLOCKS_QUERY, WORKSPACE_BLOCK_BY_TYPE_QUERY, SITE_CONFIG_QUERY, CURRENT_WORKSPACE_QUERY, MEDIA_ASSETS_QUERY, SAVE_PAGE_MUTATION, PATCH_BLOCK_CONTENT_MUTATION, UPDATE_PAGE_SETTINGS_MUTATION, TOGGLE_PUBLISH_MUTATION, PUBLISH_PAGE_MUTATION, REVERT_TO_PUBLISHED_MUTATION, REMOVE_PAGE_MUTATION, UPDATE_PAGE_LAYOUT_MUTATION, FORMS_QUERY, FORM_BY_ID_QUERY, FORM_SUBMISSIONS_QUERY, FORM_SUBMISSION_BY_ID_QUERY, CREATE_FORM_MUTATION, UPDATE_FORM_MUTATION, DELETE_FORM_MUTATION, UPDATE_FORM_SUBMISSION_STATUS_MUTATION, DELETE_FORM_SUBMISSION_MUTATION, MODEL_DEFINITIONS_QUERY, MODEL_DEFINITIONS_BY_SLUG_INDEX_QUERY, MODEL_DEFINITION_BY_ID_QUERY, MODEL_RECORDS_QUERY, MODEL_RECORD_BY_ID_QUERY, MODEL_TEMPLATES_QUERY, CREATE_MODEL_DEFINITION_MUTATION, UPDATE_MODEL_DEFINITION_MUTATION, DELETE_MODEL_DEFINITION_MUTATION, CREATE_MODEL_RECORD_MUTATION, UPDATE_MODEL_RECORD_MUTATION, UPDATE_MODEL_RECORD_STATUS_MUTATION, DELETE_MODEL_RECORD_MUTATION, IMPORT_MODEL_RECORDS_MUTATION, INSTALL_MODEL_TEMPLATE_MUTATION, } from "./queries.js";
38
+ import { responseModeSchema, pageMinimal, pageBlockMinimal, formMinimal, modelMinimal, recordMinimal, jsonText, } from "./responses.js";
10
39
  export function createServer(client) {
11
40
  const server = new McpServer({
12
41
  name: "cmssy",
13
- version: "0.1.0",
42
+ version: PACKAGE_VERSION,
14
43
  });
15
44
  // ─── Workspace Block Registry (cached with TTL) ──────────────
16
45
  let workspaceBlocksCache = null;
@@ -205,7 +234,7 @@ export function createServer(client) {
205
234
  };
206
235
  });
207
236
  // ─── Write Tools ─────────────────────────────────────────────
208
- server.tool("create_page", "Create a new page. Returns the created page with its ID.", {
237
+ server.tool("create_page", "Create a new page. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
209
238
  name: z.string().describe("Internal page name"),
210
239
  slug: z.string().describe("URL slug (e.g. 'about', 'features')"),
211
240
  parentId: z
@@ -229,7 +258,8 @@ export function createServer(client) {
229
258
  .preprocess(jsonPreprocess, z.record(z.string(), z.string()))
230
259
  .optional()
231
260
  .describe("Multilingual SEO description"),
232
- }, async ({ name, slug, parentId, pageType, displayName, seoTitle, seoDescription, }) => {
261
+ response: responseModeSchema,
262
+ }, async ({ name, slug, parentId, pageType, displayName, seoTitle, seoDescription, response, }) => {
233
263
  const input = { name, slug };
234
264
  if (parentId)
235
265
  input.parentId = parentId;
@@ -244,16 +274,9 @@ export function createServer(client) {
244
274
  const data = await client.query(SAVE_PAGE_MUTATION, {
245
275
  input,
246
276
  });
247
- return {
248
- content: [
249
- {
250
- type: "text",
251
- text: JSON.stringify(data.savePage, null, 2),
252
- },
253
- ],
254
- };
277
+ return jsonText(response, data.savePage, pageMinimal);
255
278
  });
256
- server.tool("update_page_blocks", "Set the full content blocks array on a page. Replaces all existing content blocks. Block types are validated against workspace config. Blocks with matching IDs preserve their existing content/settings when not explicitly provided.", {
279
+ server.tool("update_page_blocks", "Set the full content blocks array on a page. Replaces all existing content blocks. Block types are validated against workspace config. Blocks with matching IDs preserve their existing content/settings when not explicitly provided. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
257
280
  pageId: z.string().describe("Page ID"),
258
281
  blocks: z.preprocess(jsonPreprocess, z
259
282
  .array(z.object({
@@ -273,7 +296,8 @@ export function createServer(client) {
273
296
  blockVersion: z.string().optional(),
274
297
  }))
275
298
  .describe("Full array of content blocks to set on the page")),
276
- }, async ({ pageId, blocks }) => {
299
+ response: responseModeSchema,
300
+ }, async ({ pageId, blocks, response }) => {
277
301
  // Validate all block types against workspace registry
278
302
  const validation = await validateBlockTypes(blocks.map((b) => b.type));
279
303
  if (!validation.valid) {
@@ -320,16 +344,9 @@ export function createServer(client) {
320
344
  blocks: mergedBlocks,
321
345
  },
322
346
  });
323
- return {
324
- content: [
325
- {
326
- type: "text",
327
- text: JSON.stringify(data.savePage, null, 2),
328
- },
329
- ],
330
- };
347
+ return jsonText(response, data.savePage, pageMinimal);
331
348
  });
332
- server.tool("update_page_settings", "Update page metadata: name, slug, display name, SEO fields", {
349
+ server.tool("update_page_settings", "Update page metadata: name, slug, display name, SEO fields. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
333
350
  id: z.string().describe("Page ID"),
334
351
  name: z.string().optional().describe("Internal page name"),
335
352
  slug: z.string().optional().describe("URL slug"),
@@ -346,7 +363,8 @@ export function createServer(client) {
346
363
  .optional()
347
364
  .describe("Multilingual SEO description"),
348
365
  seoKeywords: z.array(z.string()).optional().describe("SEO keywords"),
349
- }, async ({ id, name, slug, displayName, seoTitle, seoDescription, seoKeywords, }) => {
366
+ response: responseModeSchema,
367
+ }, async ({ id, name, slug, displayName, seoTitle, seoDescription, seoKeywords, response, }) => {
350
368
  const input = { id };
351
369
  if (name !== undefined)
352
370
  input.name = name;
@@ -361,16 +379,12 @@ export function createServer(client) {
361
379
  if (seoKeywords !== undefined)
362
380
  input.seoKeywords = seoKeywords;
363
381
  const data = await client.query(UPDATE_PAGE_SETTINGS_MUTATION, { input });
364
- return {
365
- content: [
366
- {
367
- type: "text",
368
- text: JSON.stringify(data.updatePageSettings, null, 2),
369
- },
370
- ],
371
- };
382
+ return jsonText(response, data.updatePageSettings, pageMinimal);
372
383
  });
373
- server.tool("publish_page", "Publish a page or re-publish with latest draft changes. Uses atomic publishPage mutation.", { pageId: z.string().describe("Page ID to publish") }, async ({ pageId }) => {
384
+ server.tool("publish_page", "Publish a page or re-publish with latest draft changes. Uses atomic publishPage mutation. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
385
+ pageId: z.string().describe("Page ID to publish"),
386
+ response: responseModeSchema,
387
+ }, async ({ pageId, response }) => {
374
388
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
375
389
  if (!pageData.page) {
376
390
  return {
@@ -402,16 +416,12 @@ export function createServer(client) {
402
416
  blockVersion: b.blockVersion,
403
417
  }));
404
418
  const data = await client.query(PUBLISH_PAGE_MUTATION, { id: pageId, blocks });
405
- return {
406
- content: [
407
- {
408
- type: "text",
409
- text: JSON.stringify(data.publishPage, null, 2),
410
- },
411
- ],
412
- };
419
+ return jsonText(response, data.publishPage, (p) => pageMinimal(p, { published: true }));
413
420
  });
414
- server.tool("unpublish_page", "Unpublish a published page (toggles published state off).", { pageId: z.string().describe("Page ID to unpublish") }, async ({ pageId }) => {
421
+ server.tool("unpublish_page", "Unpublish a published page (toggles published state off). Returns a minimal ack by default; pass response='full' for the full mutation response.", {
422
+ pageId: z.string().describe("Page ID to unpublish"),
423
+ response: responseModeSchema,
424
+ }, async ({ pageId, response }) => {
415
425
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
416
426
  if (!pageData.page) {
417
427
  return {
@@ -427,16 +437,12 @@ export function createServer(client) {
427
437
  };
428
438
  }
429
439
  const data = await client.query(TOGGLE_PUBLISH_MUTATION, { id: pageId });
430
- return {
431
- content: [
432
- {
433
- type: "text",
434
- text: JSON.stringify(data.togglePublish, null, 2),
435
- },
436
- ],
437
- };
440
+ return jsonText(response, data.togglePublish, (p) => pageMinimal(p, { published: false }));
438
441
  });
439
- server.tool("revert_to_published", "Discard all draft changes and revert a page to its last published version.", { pageId: z.string().describe("Page ID to revert") }, async ({ pageId }) => {
442
+ server.tool("revert_to_published", "Discard all draft changes and revert a page to its last published version. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
443
+ pageId: z.string().describe("Page ID to revert"),
444
+ response: responseModeSchema,
445
+ }, async ({ pageId, response }) => {
440
446
  const data = await client.query(REVERT_TO_PUBLISHED_MUTATION, { id: pageId });
441
447
  if (!data.revertToPublished) {
442
448
  return {
@@ -449,14 +455,7 @@ export function createServer(client) {
449
455
  isError: true,
450
456
  };
451
457
  }
452
- return {
453
- content: [
454
- {
455
- type: "text",
456
- text: JSON.stringify(data.revertToPublished, null, 2),
457
- },
458
- ],
459
- };
458
+ return jsonText(response, data.revertToPublished, pageMinimal);
460
459
  });
461
460
  server.tool("delete_page", "Permanently delete a page and all its descendants. Cannot delete the homepage.", { pageId: z.string().describe("Page ID to delete") }, async ({ pageId }) => {
462
461
  const data = await client.query(REMOVE_PAGE_MUTATION, { id: pageId });
@@ -472,7 +471,7 @@ export function createServer(client) {
472
471
  };
473
472
  });
474
473
  // ─── Layout Tools ──────────────────────────────────────────
475
- server.tool("update_page_layout", "Update page-level layout settings: inheritance, overrides, or replace all layout blocks. Block types are validated against workspace config.", {
474
+ server.tool("update_page_layout", "Update page-level layout settings: inheritance, overrides, or replace all layout blocks. Block types are validated against workspace config. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
476
475
  pageId: z.string().describe("Page ID"),
477
476
  layoutBlocks: z
478
477
  .array(z.record(z.string(), z.unknown()))
@@ -490,7 +489,8 @@ export function createServer(client) {
490
489
  .boolean()
491
490
  .optional()
492
491
  .describe("Whether this page inherits layout from parent"),
493
- }, async ({ pageId, layoutBlocks, layoutOverrides, inheritsLayout }) => {
492
+ response: responseModeSchema,
493
+ }, async ({ pageId, layoutBlocks, layoutOverrides, inheritsLayout, response, }) => {
494
494
  // Validate block types against workspace registry
495
495
  if (layoutBlocks) {
496
496
  const types = layoutBlocks
@@ -541,17 +541,10 @@ export function createServer(client) {
541
541
  isError: true,
542
542
  };
543
543
  }
544
- return {
545
- content: [
546
- {
547
- type: "text",
548
- text: JSON.stringify(data.updatePageLayout, null, 2),
549
- },
550
- ],
551
- };
544
+ return jsonText(response, data.updatePageLayout, pageMinimal);
552
545
  });
553
546
  // ─── Block Helper Tools (read-modify-write) ─────────────────
554
- server.tool("add_block_to_page", "Add a block to a page. Automatically detects layout vs content block from workspace config. Auto-generates UUID and translation status.", {
547
+ server.tool("add_block_to_page", "Add a block to a page. Automatically detects layout vs content block from workspace config. Auto-generates UUID and translation status. Returns a minimal ack by default ({pageId, blockId, hasUnpublishedChanges, updatedAt}); pass response='full' for the full mutation response.", {
555
548
  pageId: z.string().describe("Page ID"),
556
549
  block: z.preprocess(jsonPreprocess, z.object({
557
550
  type: z
@@ -567,7 +560,8 @@ export function createServer(client) {
567
560
  .number()
568
561
  .optional()
569
562
  .describe("0-based position to insert at (default: end)"),
570
- }, async ({ pageId, block, position }) => {
563
+ response: responseModeSchema,
564
+ }, async ({ pageId, block, position, response }) => {
571
565
  // Validate block type against workspace registry
572
566
  const blockDef = await findBlockDef(block.type);
573
567
  if (!blockDef) {
@@ -627,14 +621,18 @@ export function createServer(client) {
627
621
  };
628
622
  const layoutBlocks = [...existingLayoutBlocks, newLayoutBlock];
629
623
  const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks } });
630
- return {
631
- content: [
632
- {
633
- type: "text",
634
- text: JSON.stringify({ blockId: newBlockId, page: data.updatePageLayout }, null, 2),
635
- },
636
- ],
637
- };
624
+ if (!data.updatePageLayout) {
625
+ return {
626
+ content: [
627
+ {
628
+ type: "text",
629
+ text: `Failed to update layout while adding block to page '${pageId}'`,
630
+ },
631
+ ],
632
+ isError: true,
633
+ };
634
+ }
635
+ return jsonText(response, { blockId: newBlockId, page: data.updatePageLayout }, () => pageBlockMinimal(data.updatePageLayout, newBlockId));
638
636
  }
639
637
  else {
640
638
  // Content block — add to blocks via savePage
@@ -665,24 +663,18 @@ export function createServer(client) {
665
663
  blocks,
666
664
  },
667
665
  });
668
- return {
669
- content: [
670
- {
671
- type: "text",
672
- text: JSON.stringify({ blockId: newBlockId, page: data.savePage }, null, 2),
673
- },
674
- ],
675
- };
666
+ return jsonText(response, { blockId: newBlockId, page: data.savePage }, () => pageBlockMinimal(data.savePage, newBlockId));
676
667
  }
677
668
  });
678
- server.tool("update_block_content", "Update a specific block's content on a page. Merges with existing content. Works for both content and layout blocks.", {
669
+ server.tool("update_block_content", "Update a specific block's content on a page. Merges with existing content. Works for both content and layout blocks. Returns a minimal ack by default ({pageId, blockId, hasUnpublishedChanges, updatedAt}); pass response='full' for the full mutation response.", {
679
670
  pageId: z.string().describe("Page ID"),
680
671
  blockId: z.string().describe("Block instance ID (UUID) to update"),
681
672
  content: z
682
673
  .record(z.string(), z.unknown())
683
674
  .describe("Content to merge: { en: { title: 'New Title' } }"),
684
675
  settings: z.record(z.string(), z.unknown()).optional(),
685
- }, async ({ pageId, blockId, content, settings }) => {
676
+ response: responseModeSchema,
677
+ }, async ({ pageId, blockId, content, settings, response }) => {
686
678
  // Fetch current page
687
679
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
688
680
  if (!pageData.page) {
@@ -748,14 +740,18 @@ export function createServer(client) {
748
740
  targetArray[targetIndex] = existingBlock;
749
741
  if (isLayout) {
750
742
  const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks: targetArray } });
751
- return {
752
- content: [
753
- {
754
- type: "text",
755
- text: JSON.stringify(data.updatePageLayout, null, 2),
756
- },
757
- ],
758
- };
743
+ if (!data.updatePageLayout) {
744
+ return {
745
+ content: [
746
+ {
747
+ type: "text",
748
+ text: `Failed to update layout while editing block '${blockId}' on page '${pageId}'`,
749
+ },
750
+ ],
751
+ isError: true,
752
+ };
753
+ }
754
+ return jsonText(response, data.updatePageLayout, (p) => pageBlockMinimal(p, blockId));
759
755
  }
760
756
  else {
761
757
  const data = await client.query(SAVE_PAGE_MUTATION, {
@@ -767,20 +763,119 @@ export function createServer(client) {
767
763
  blocks: targetArray,
768
764
  },
769
765
  });
766
+ return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
767
+ }
768
+ });
769
+ server.tool("patch_block_content", "Apply surgical HTML edits (insert_before/insert_after/replace_section) to a block's localized content string (e.g. a docs-article HTML body) without re-sending the full content. Use this for small edits where you have a unique anchor substring in the existing HTML - it's ~10x cheaper in tokens than update_block_content and rejects any op whose marker is missing or ambiguous. For replace_section: startMarker is inclusive, endMarker is exclusive.", {
770
+ pageId: z.string().describe("Page ID"),
771
+ blockId: z
772
+ .string()
773
+ .describe("Block instance ID (UUID) on the page. Layout blocks (header/footer) aren't supported - use update_block_content for those."),
774
+ locale: z
775
+ .string()
776
+ .describe("Language code matching the workspace's enabledLanguages (e.g. 'en', 'pl', 'zh'). Check get_site_config first if unsure."),
777
+ fieldPath: z
778
+ .string()
779
+ .optional()
780
+ .describe("Field inside content[locale] to patch (default: 'content', the HTML body of docs-article blocks). Must resolve to a string."),
781
+ // `jsonPreprocess` matches the other tools in this file that accept
782
+ // complex inputs - MCP clients sometimes JSON-serialize nested
783
+ // arrays/objects instead of passing raw JSON. Without the preprocess
784
+ // those clients fail validation before the handler ever runs.
785
+ operations: z.preprocess(jsonPreprocess, z
786
+ .array(
787
+ // Discriminated on `op` so clients can't pass e.g. `insert_before`
788
+ // with a `startMarker` - the backend would reject, but it's
789
+ // cleaner (and cheaper) to fail at MCP boundary.
790
+ z
791
+ .discriminatedUnion("op", [
792
+ z
793
+ .object({
794
+ op: z
795
+ .literal("insert_before")
796
+ .describe("Insert html immediately before `marker`"),
797
+ marker: z
798
+ .string()
799
+ .describe("Unique anchor substring. Must match exactly one location in the field (zero or multiple → error)."),
800
+ html: z.string().describe("HTML fragment to insert"),
801
+ })
802
+ .strict(),
803
+ z
804
+ .object({
805
+ op: z
806
+ .literal("insert_after")
807
+ .describe("Insert html immediately after `marker`"),
808
+ marker: z
809
+ .string()
810
+ .describe("Unique anchor substring. Must match exactly one location in the field (zero or multiple → error)."),
811
+ html: z.string().describe("HTML fragment to insert"),
812
+ })
813
+ .strict(),
814
+ z
815
+ .object({
816
+ op: z
817
+ .literal("replace_section")
818
+ .describe("Replace everything from startMarker (inclusive) up to endMarker (exclusive)"),
819
+ startMarker: z
820
+ .string()
821
+ .describe("Inclusive lower bound. Must match exactly one location."),
822
+ endMarker: z
823
+ .string()
824
+ .describe("Exclusive upper bound. Must appear exactly once after startMarker."),
825
+ html: z
826
+ .string()
827
+ .describe("HTML fragment to replace the section with"),
828
+ })
829
+ .strict(),
830
+ ])
831
+ .describe("A single patch op. Ops apply in order; any failure aborts the whole patch (no half-applied state)."))
832
+ .min(1)
833
+ .describe("Ordered list of patch operations. Apply smallest first - each op must still find its markers in the string after previous ops have applied.")),
834
+ }, async ({ pageId, blockId, locale, fieldPath, operations }) => {
835
+ // Zod's discriminatedUnion + .strict() already shapes each op
836
+ // exactly like the backend's PatchOperationInput (insert_*: op,
837
+ // marker, html; replace_section: op, startMarker, endMarker, html).
838
+ // Pass the array straight through - a manual map here is both
839
+ // redundant AND unsafe (a switch without an exhaustiveness check
840
+ // could return undefined on an unexpected op, which would then
841
+ // serialize as null in the mutation variables and blow up server-side
842
+ // with a hard-to-trace error).
843
+ const data = await client.query(PATCH_BLOCK_CONTENT_MUTATION, {
844
+ input: {
845
+ pageId,
846
+ blockId,
847
+ locale,
848
+ ...(fieldPath ? { fieldPath } : {}),
849
+ operations,
850
+ },
851
+ });
852
+ if (!data.patchBlockContent) {
770
853
  return {
771
854
  content: [
772
855
  {
773
856
  type: "text",
774
- text: JSON.stringify(data.savePage, null, 2),
857
+ text: "Page not found or patch failed",
775
858
  },
776
859
  ],
860
+ isError: true,
777
861
  };
778
862
  }
863
+ // Compact JSON: patch_block_content is ack-only (no pretty-print
864
+ // needed), matching the compact shape used by minimal-mode returns.
865
+ return {
866
+ content: [
867
+ {
868
+ type: "text",
869
+ text: JSON.stringify(data.patchBlockContent),
870
+ },
871
+ ],
872
+ };
779
873
  });
780
- server.tool("remove_block_from_page", "Remove a specific block from a page by its instance ID. Works for both content and layout blocks.", {
874
+ server.tool("remove_block_from_page", "Remove a specific block from a page by its instance ID. Works for both content and layout blocks. Returns a minimal ack by default ({pageId, blockId, hasUnpublishedChanges, updatedAt}); pass response='full' for the full mutation response.", {
781
875
  pageId: z.string().describe("Page ID"),
782
876
  blockId: z.string().describe("Block instance ID (UUID) to remove"),
783
- }, async ({ pageId, blockId }) => {
877
+ response: responseModeSchema,
878
+ }, async ({ pageId, blockId, response }) => {
784
879
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
785
880
  if (!pageData.page) {
786
881
  return {
@@ -801,27 +896,24 @@ export function createServer(client) {
801
896
  blocks: contentBlocks,
802
897
  },
803
898
  });
804
- return {
805
- content: [
806
- {
807
- type: "text",
808
- text: JSON.stringify(data.savePage, null, 2),
809
- },
810
- ],
811
- };
899
+ return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
812
900
  }
813
901
  // Try layout blocks
814
902
  const layoutBlocks = (page.layoutBlocks || []).filter((b) => b.id !== blockId);
815
903
  if (layoutBlocks.length < (page.layoutBlocks || []).length) {
816
904
  const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks } });
817
- return {
818
- content: [
819
- {
820
- type: "text",
821
- text: JSON.stringify(data.updatePageLayout, null, 2),
822
- },
823
- ],
824
- };
905
+ if (!data.updatePageLayout) {
906
+ return {
907
+ content: [
908
+ {
909
+ type: "text",
910
+ text: `Failed to update layout while removing block '${blockId}' from page '${pageId}'`,
911
+ },
912
+ ],
913
+ isError: true,
914
+ };
915
+ }
916
+ return jsonText(response, data.updatePageLayout, (p) => pageBlockMinimal(p, blockId));
825
917
  }
826
918
  return {
827
919
  content: [
@@ -877,7 +969,7 @@ export function createServer(client) {
877
969
  ],
878
970
  };
879
971
  });
880
- server.tool("create_form", "Create a new form with fields and settings. Returns the created form.", {
972
+ server.tool("create_form", "Create a new form with fields and settings. Returns a minimal ack by default; pass response='full' for the full form.", {
881
973
  name: z.string().describe("Form name"),
882
974
  slug: z.string().describe("URL-friendly slug (must be unique)"),
883
975
  description: z.string().optional().describe("Form description"),
@@ -960,7 +1052,8 @@ export function createServer(client) {
960
1052
  }))
961
1053
  .optional()
962
1054
  .describe("Form settings (action type, notifications, etc.)"),
963
- }, async ({ name, slug, description, fields, settings }) => {
1055
+ response: responseModeSchema,
1056
+ }, async ({ name, slug, description, fields, settings, response }) => {
964
1057
  const input = { name, slug };
965
1058
  if (description)
966
1059
  input.description = description;
@@ -969,16 +1062,9 @@ export function createServer(client) {
969
1062
  if (settings)
970
1063
  input.settings = settings;
971
1064
  const data = await client.query(CREATE_FORM_MUTATION, { input });
972
- return {
973
- content: [
974
- {
975
- type: "text",
976
- text: JSON.stringify(data.createForm, null, 2),
977
- },
978
- ],
979
- };
1065
+ return jsonText(response, data.createForm, formMinimal);
980
1066
  });
981
- server.tool("update_form", "Update an existing form's name, slug, status, fields, or settings.", {
1067
+ server.tool("update_form", "Update an existing form's name, slug, status, fields, or settings. Returns a minimal ack by default; pass response='full' for the full form.", {
982
1068
  formId: z.string().describe("Form ID to update"),
983
1069
  name: z.string().optional().describe("New form name"),
984
1070
  slug: z.string().optional().describe("New slug"),
@@ -1058,7 +1144,8 @@ export function createServer(client) {
1058
1144
  }))
1059
1145
  .optional()
1060
1146
  .describe("Updated settings"),
1061
- }, async ({ formId, name, slug, description, status, fields, settings }) => {
1147
+ response: responseModeSchema,
1148
+ }, async ({ formId, name, slug, description, status, fields, settings, response, }) => {
1062
1149
  const input = {};
1063
1150
  if (name !== undefined)
1064
1151
  input.name = name;
@@ -1081,14 +1168,7 @@ export function createServer(client) {
1081
1168
  isError: true,
1082
1169
  };
1083
1170
  }
1084
- return {
1085
- content: [
1086
- {
1087
- type: "text",
1088
- text: JSON.stringify(data.updateForm, null, 2),
1089
- },
1090
- ],
1091
- };
1171
+ return jsonText(response, data.updateForm, formMinimal);
1092
1172
  });
1093
1173
  server.tool("delete_form", "Permanently delete a form and all its submissions.", {
1094
1174
  formId: z.string().describe("Form ID to delete"),
@@ -1181,6 +1261,468 @@ export function createServer(client) {
1181
1261
  ],
1182
1262
  };
1183
1263
  });
1264
+ // ─── Model Tools (Custom Data Models) ────────────────────────
1265
+ const propertyFieldTypes = [
1266
+ "string",
1267
+ "richtext",
1268
+ "number",
1269
+ "boolean",
1270
+ "date",
1271
+ "datetime",
1272
+ "email",
1273
+ "url",
1274
+ "media",
1275
+ "relation",
1276
+ "select",
1277
+ "multiselect",
1278
+ "object",
1279
+ "list",
1280
+ ];
1281
+ const relationTypes = ["hasOne", "hasMany", "manyToMany"];
1282
+ const propertyFieldSchema = z.lazy(() => z.object({
1283
+ key: z.string().describe("Field key (identifier in record data)"),
1284
+ label: z.string().describe("Human-readable field label"),
1285
+ type: z
1286
+ .enum(propertyFieldTypes)
1287
+ .describe("Field type — controls validation + UI"),
1288
+ required: z.boolean().optional(),
1289
+ description: z.string().optional(),
1290
+ defaultValue: z.string().optional(),
1291
+ options: z
1292
+ .array(z.string())
1293
+ .optional()
1294
+ .describe("For select/multiselect: allowed values"),
1295
+ fields: z
1296
+ .array(propertyFieldSchema)
1297
+ .optional()
1298
+ .describe("For type=object: nested fields"),
1299
+ itemType: z
1300
+ .enum(propertyFieldTypes)
1301
+ .optional()
1302
+ .describe("For type=list: item type"),
1303
+ itemFields: z
1304
+ .array(propertyFieldSchema)
1305
+ .optional()
1306
+ .describe("For type=list of objects: nested field defs"),
1307
+ relationTo: z
1308
+ .string()
1309
+ .optional()
1310
+ .describe("For type=relation: target model slug"),
1311
+ relationType: z.enum(relationTypes).optional(),
1312
+ acceptedTypes: z
1313
+ .array(z.enum(mediaTypeValues))
1314
+ .optional()
1315
+ .describe("For type=media: allowed media kinds (empty = all)"),
1316
+ multiple: z
1317
+ .boolean()
1318
+ .optional()
1319
+ .describe("For type=media: true = gallery, false = single"),
1320
+ schemaProperty: z.string().optional(),
1321
+ minLength: z.number().int().optional(),
1322
+ maxLength: z.number().int().optional(),
1323
+ minValue: z.number().optional(),
1324
+ maxValue: z.number().optional(),
1325
+ pattern: z.string().optional(),
1326
+ }));
1327
+ const statusFieldSchema = z.object({
1328
+ enabled: z.boolean().optional(),
1329
+ values: z
1330
+ .array(z.string())
1331
+ .optional()
1332
+ .describe("Allowed status values (e.g. ['draft','active'])"),
1333
+ defaultValue: z.string().optional(),
1334
+ transitions: z
1335
+ .array(z.object({
1336
+ from: z.string(),
1337
+ to: z.array(z.string()),
1338
+ }))
1339
+ .optional()
1340
+ .describe("Allowed transitions: from one state to a set of states"),
1341
+ });
1342
+ const defaultSortSchema = z.object({
1343
+ field: z.string(),
1344
+ direction: z.enum(["asc", "desc"]),
1345
+ });
1346
+ const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
1347
+ // Match backend: apps/backend/src/models/model-definition.ts
1348
+ const SLUG_RE = /^[a-z][a-z0-9-]*$/;
1349
+ const SLUG_MSG = "Slug must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens";
1350
+ server.tool("list_models", "List all Custom Data Models (ModelDefinition) in the workspace.", {}, async () => {
1351
+ const data = await client.query(MODEL_DEFINITIONS_QUERY);
1352
+ return {
1353
+ content: [
1354
+ {
1355
+ type: "text",
1356
+ text: JSON.stringify(data.modelDefinitions, null, 2),
1357
+ },
1358
+ ],
1359
+ };
1360
+ });
1361
+ server.tool("get_model", "Get a ModelDefinition by id (24-hex ObjectId) or by slug. Returns full definition including fields, statusField, and defaultSort.", {
1362
+ idOrSlug: z.string().describe("Model id (ObjectId) or slug"),
1363
+ }, async ({ idOrSlug }) => {
1364
+ if (OBJECT_ID_RE.test(idOrSlug)) {
1365
+ const data = await client.query(MODEL_DEFINITION_BY_ID_QUERY, { id: idOrSlug });
1366
+ if (data.modelDefinition) {
1367
+ return {
1368
+ content: [
1369
+ {
1370
+ type: "text",
1371
+ text: JSON.stringify(data.modelDefinition, null, 2),
1372
+ },
1373
+ ],
1374
+ };
1375
+ }
1376
+ }
1377
+ // Fallback: resolve slug → id via a lightweight list (id + slug only),
1378
+ // then fetch the full definition for that single model. Avoids
1379
+ // pulling every model's fields when only one is needed.
1380
+ const index = await client.query(MODEL_DEFINITIONS_BY_SLUG_INDEX_QUERY);
1381
+ const match = index.modelDefinitions.find((m) => m.slug === idOrSlug);
1382
+ if (!match) {
1383
+ return {
1384
+ content: [
1385
+ { type: "text", text: `Model '${idOrSlug}' not found` },
1386
+ ],
1387
+ isError: true,
1388
+ };
1389
+ }
1390
+ const full = await client.query(MODEL_DEFINITION_BY_ID_QUERY, { id: match.id });
1391
+ if (!full.modelDefinition) {
1392
+ return {
1393
+ content: [
1394
+ { type: "text", text: `Model '${idOrSlug}' not found` },
1395
+ ],
1396
+ isError: true,
1397
+ };
1398
+ }
1399
+ return {
1400
+ content: [
1401
+ {
1402
+ type: "text",
1403
+ text: JSON.stringify(full.modelDefinition, null, 2),
1404
+ },
1405
+ ],
1406
+ };
1407
+ });
1408
+ server.tool("create_model", "Create a new Custom Data Model (ModelDefinition). Returns a minimal ack by default; pass response='full' for the full model.", {
1409
+ name: z.string().describe("Display name"),
1410
+ slug: z
1411
+ .string()
1412
+ .trim()
1413
+ .regex(SLUG_RE, SLUG_MSG)
1414
+ .describe("URL-safe slug, lowercase (regex: ^[a-z][a-z0-9-]*$)"),
1415
+ description: z.string().optional(),
1416
+ icon: z
1417
+ .string()
1418
+ .optional()
1419
+ .describe("Lucide icon name, defaults to 'database'"),
1420
+ color: z.string().optional(),
1421
+ displayField: z
1422
+ .string()
1423
+ .optional()
1424
+ .describe("Field key used as the record's display label in UI"),
1425
+ defaultSort: z.preprocess(jsonPreprocess, defaultSortSchema).optional(),
1426
+ fields: z
1427
+ .preprocess(jsonPreprocess, z.array(propertyFieldSchema))
1428
+ .optional()
1429
+ .describe("Field definitions. Validates record data on write."),
1430
+ statusField: z
1431
+ .preprocess(jsonPreprocess, statusFieldSchema)
1432
+ .optional()
1433
+ .describe("Enable record lifecycle states with allowed transitions"),
1434
+ response: responseModeSchema,
1435
+ }, async ({ name, slug, description, icon, color, displayField, defaultSort, fields, statusField, response, }) => {
1436
+ const input = { name, slug };
1437
+ if (description !== undefined)
1438
+ input.description = description;
1439
+ if (icon !== undefined)
1440
+ input.icon = icon;
1441
+ if (color !== undefined)
1442
+ input.color = color;
1443
+ if (displayField !== undefined)
1444
+ input.displayField = displayField;
1445
+ if (defaultSort !== undefined)
1446
+ input.defaultSort = defaultSort;
1447
+ if (fields !== undefined)
1448
+ input.fields = fields;
1449
+ if (statusField !== undefined)
1450
+ input.statusField = statusField;
1451
+ const data = await client.query(CREATE_MODEL_DEFINITION_MUTATION, { input });
1452
+ return jsonText(response, data.createModelDefinition, modelMinimal);
1453
+ });
1454
+ server.tool("update_model", "Update any field of an existing ModelDefinition. Changing 'fields' migrates the schema - existing records are re-validated on next write. Returns a minimal ack by default; pass response='full' for the full model.", {
1455
+ id: z.string().describe("Model id (ObjectId) to update"),
1456
+ name: z.string().optional(),
1457
+ slug: z.string().trim().regex(SLUG_RE, SLUG_MSG).optional(),
1458
+ description: z.string().optional(),
1459
+ icon: z.string().optional(),
1460
+ color: z.string().optional(),
1461
+ displayField: z.string().optional(),
1462
+ defaultSort: z.preprocess(jsonPreprocess, defaultSortSchema).optional(),
1463
+ fields: z
1464
+ .preprocess(jsonPreprocess, z.array(propertyFieldSchema))
1465
+ .optional(),
1466
+ statusField: z.preprocess(jsonPreprocess, statusFieldSchema).optional(),
1467
+ response: responseModeSchema,
1468
+ }, async (args) => {
1469
+ const input = { id: args.id };
1470
+ for (const key of [
1471
+ "name",
1472
+ "slug",
1473
+ "description",
1474
+ "icon",
1475
+ "color",
1476
+ "displayField",
1477
+ "defaultSort",
1478
+ "fields",
1479
+ "statusField",
1480
+ ]) {
1481
+ if (args[key] !== undefined)
1482
+ input[key] = args[key];
1483
+ }
1484
+ const data = await client.query(UPDATE_MODEL_DEFINITION_MUTATION, { input });
1485
+ if (!data.updateModelDefinition) {
1486
+ return {
1487
+ content: [{ type: "text", text: "Model not found" }],
1488
+ isError: true,
1489
+ };
1490
+ }
1491
+ return jsonText(args.response, data.updateModelDefinition, modelMinimal);
1492
+ });
1493
+ server.tool("delete_model", "Delete a ModelDefinition. WARNING: cascades - ALL records of this model are deleted.", {
1494
+ id: z.string().describe("Model id (ObjectId) to delete"),
1495
+ }, async ({ id }) => {
1496
+ const data = await client.query(DELETE_MODEL_DEFINITION_MUTATION, { id });
1497
+ return {
1498
+ content: [
1499
+ {
1500
+ type: "text",
1501
+ text: data.deleteModelDefinition
1502
+ ? "Model deleted (records cascaded)"
1503
+ : "Failed to delete model",
1504
+ },
1505
+ ],
1506
+ isError: !data.deleteModelDefinition,
1507
+ };
1508
+ });
1509
+ server.tool("list_records", "List records of a model with filter, sort, and pagination. Filter is a MongoDB-style plain object. Sort is a string (e.g. '-createdAt' for desc). Populate loads relations by field key.", {
1510
+ modelId: z.string().describe("Target model id (ObjectId)"),
1511
+ filter: z
1512
+ .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1513
+ .optional()
1514
+ .describe("Plain object filter, e.g. {status: 'active'}"),
1515
+ limit: z.number().int().optional().default(50),
1516
+ offset: z.number().int().optional().default(0),
1517
+ sort: z
1518
+ .string()
1519
+ .optional()
1520
+ .describe("Sort expression, e.g. 'createdAt' or '-updatedAt'"),
1521
+ populate: z
1522
+ .array(z.string())
1523
+ .optional()
1524
+ .describe("Field keys of type=relation to populate"),
1525
+ }, async ({ modelId, filter, limit, offset, sort, populate }) => {
1526
+ const data = await client.query(MODEL_RECORDS_QUERY, {
1527
+ modelId,
1528
+ filter,
1529
+ limit,
1530
+ offset,
1531
+ sort,
1532
+ populate,
1533
+ });
1534
+ return {
1535
+ content: [
1536
+ {
1537
+ type: "text",
1538
+ text: JSON.stringify(data.modelRecords, null, 2),
1539
+ },
1540
+ ],
1541
+ };
1542
+ });
1543
+ server.tool("get_record", "Get a single record by id.", {
1544
+ id: z.string().describe("Record id (ObjectId)"),
1545
+ }, async ({ id }) => {
1546
+ const data = await client.query(MODEL_RECORD_BY_ID_QUERY, { id });
1547
+ if (!data.modelRecord) {
1548
+ return {
1549
+ content: [{ type: "text", text: "Record not found" }],
1550
+ isError: true,
1551
+ };
1552
+ }
1553
+ return {
1554
+ content: [
1555
+ {
1556
+ type: "text",
1557
+ text: JSON.stringify(data.modelRecord, null, 2),
1558
+ },
1559
+ ],
1560
+ };
1561
+ });
1562
+ server.tool("create_record", "Create a new record in a model. Data keys must match field keys of the model; backend validates against the ModelDefinition. Returns a minimal ack by default; pass response='full' for the full record.", {
1563
+ modelId: z.string().describe("Target model id (ObjectId)"),
1564
+ data: z
1565
+ .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1566
+ .describe("Record data keyed by model field keys"),
1567
+ response: responseModeSchema,
1568
+ }, async ({ modelId, data: recordData, response }) => {
1569
+ const result = await client.query(CREATE_MODEL_RECORD_MUTATION, {
1570
+ input: { modelId, data: recordData },
1571
+ });
1572
+ return jsonText(response, result.createModelRecord, recordMinimal);
1573
+ });
1574
+ server.tool("update_record", "Update a record. Pass 'data' for a full data replace. Pass 'status' to transition the record's status (validated against statusField.transitions). At least one of data/status is required. Returns a minimal ack by default; pass response='full' for the full record.", {
1575
+ id: z.string().describe("Record id (ObjectId)"),
1576
+ data: z
1577
+ .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1578
+ .optional()
1579
+ .describe("New data (full replace)"),
1580
+ status: z
1581
+ .string()
1582
+ .optional()
1583
+ .describe("New status value (must be allowed by model.statusField)"),
1584
+ response: responseModeSchema,
1585
+ }, async ({ id, data: recordData, status, response }) => {
1586
+ if (recordData === undefined && status === undefined) {
1587
+ return {
1588
+ content: [
1589
+ {
1590
+ type: "text",
1591
+ text: "Provide 'data' and/or 'status' to update",
1592
+ },
1593
+ ],
1594
+ isError: true,
1595
+ };
1596
+ }
1597
+ let dataResult = null;
1598
+ let statusResult = null;
1599
+ if (recordData !== undefined) {
1600
+ const res = await client.query(UPDATE_MODEL_RECORD_MUTATION, {
1601
+ input: { id, data: recordData },
1602
+ });
1603
+ dataResult = res.updateModelRecord;
1604
+ if (!dataResult) {
1605
+ return {
1606
+ content: [{ type: "text", text: "Record not found" }],
1607
+ isError: true,
1608
+ };
1609
+ }
1610
+ }
1611
+ if (status !== undefined) {
1612
+ // Data update (if any) already committed. Wrap status call so a
1613
+ // failed transition reports partial-success clearly instead of
1614
+ // hiding the completed data write behind a bare throw.
1615
+ try {
1616
+ const res = await client.query(UPDATE_MODEL_RECORD_STATUS_MUTATION, {
1617
+ input: { id, status },
1618
+ });
1619
+ statusResult = res.updateModelRecordStatus;
1620
+ if (!statusResult) {
1621
+ if (dataResult) {
1622
+ return {
1623
+ content: [
1624
+ {
1625
+ type: "text",
1626
+ text: JSON.stringify({
1627
+ partialSuccess: true,
1628
+ message: "Data updated, but record not found when applying status.",
1629
+ dataResult,
1630
+ }, null, 2),
1631
+ },
1632
+ ],
1633
+ isError: true,
1634
+ };
1635
+ }
1636
+ return {
1637
+ content: [{ type: "text", text: "Record not found" }],
1638
+ isError: true,
1639
+ };
1640
+ }
1641
+ }
1642
+ catch (error) {
1643
+ if (dataResult) {
1644
+ return {
1645
+ content: [
1646
+ {
1647
+ type: "text",
1648
+ text: JSON.stringify({
1649
+ partialSuccess: true,
1650
+ message: "Data updated, but status transition failed. Retry status only.",
1651
+ dataResult,
1652
+ statusError: error instanceof Error ? error.message : String(error),
1653
+ }, null, 2),
1654
+ },
1655
+ ],
1656
+ isError: true,
1657
+ };
1658
+ }
1659
+ throw error;
1660
+ }
1661
+ }
1662
+ const finalResult = (statusResult ?? dataResult);
1663
+ return jsonText(response, finalResult, recordMinimal);
1664
+ });
1665
+ server.tool("delete_record", "Delete a record permanently.", {
1666
+ id: z.string().describe("Record id (ObjectId) to delete"),
1667
+ }, async ({ id }) => {
1668
+ const data = await client.query(DELETE_MODEL_RECORD_MUTATION, { id });
1669
+ return {
1670
+ content: [
1671
+ {
1672
+ type: "text",
1673
+ text: data.deleteModelRecord
1674
+ ? "Record deleted"
1675
+ : "Failed to delete record",
1676
+ },
1677
+ ],
1678
+ isError: !data.deleteModelRecord,
1679
+ };
1680
+ });
1681
+ server.tool("import_records", "Bulk import records into a model. Accepts up to 1000 rows as plain objects. Returns { importedCount, errors[{row, message}] } - same shape as CSV/XLSX import.", {
1682
+ modelId: z.string().describe("Target model id (ObjectId)"),
1683
+ rows: z
1684
+ .preprocess(jsonPreprocess, z
1685
+ .array(z.record(z.string(), z.unknown()))
1686
+ .min(1, "Provide at least 1 row")
1687
+ .max(1000, "Maximum 1000 rows per import"))
1688
+ .describe("Array of plain objects keyed by model field keys"),
1689
+ }, async ({ modelId, rows }) => {
1690
+ const data = await client.query(IMPORT_MODEL_RECORDS_MUTATION, { input: { modelId, rows } });
1691
+ return {
1692
+ content: [
1693
+ {
1694
+ type: "text",
1695
+ text: JSON.stringify(data.importModelRecords, null, 2),
1696
+ },
1697
+ ],
1698
+ };
1699
+ });
1700
+ server.tool("list_model_templates", "List available model templates (E-commerce, Blog, etc.). Each template installs one or more ModelDefinitions.", {}, async () => {
1701
+ const data = await client.query(MODEL_TEMPLATES_QUERY);
1702
+ return {
1703
+ content: [
1704
+ {
1705
+ type: "text",
1706
+ text: JSON.stringify(data.modelTemplates, null, 2),
1707
+ },
1708
+ ],
1709
+ };
1710
+ });
1711
+ server.tool("create_model_from_template", "Install a model template into the workspace. Creates all models defined by the template; skips any whose slug already exists. Returns { templateId, installedCount, skippedSlugs }.", {
1712
+ templateId: z
1713
+ .string()
1714
+ .describe("Template id (from list_model_templates)"),
1715
+ }, async ({ templateId }) => {
1716
+ const data = await client.query(INSTALL_MODEL_TEMPLATE_MUTATION, { templateId });
1717
+ return {
1718
+ content: [
1719
+ {
1720
+ type: "text",
1721
+ text: JSON.stringify(data.installModelTemplate, null, 2),
1722
+ },
1723
+ ],
1724
+ };
1725
+ });
1184
1726
  // ─── Resources ───────────────────────────────────────────────
1185
1727
  server.resource("sitemap", "cmssy://sitemap", {
1186
1728
  description: "Full page tree as JSON — all pages with hierarchy",