@cmssy/mcp-server 0.5.0 → 0.7.1

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
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { z } from "zod";
6
+ import { mediaTypeValues } from "@cmssy/types";
6
7
  // Read our own version from package.json so the MCP handshake
7
8
  // advertises what the user actually installed, instead of drifting
8
9
  // whenever we bump the package.
@@ -33,7 +34,8 @@ const jsonPreprocess = (val) => {
33
34
  return val;
34
35
  }
35
36
  };
36
- 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, } from "./queries.js";
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";
37
39
  export function createServer(client) {
38
40
  const server = new McpServer({
39
41
  name: "cmssy",
@@ -232,7 +234,7 @@ export function createServer(client) {
232
234
  };
233
235
  });
234
236
  // ─── Write Tools ─────────────────────────────────────────────
235
- 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.", {
236
238
  name: z.string().describe("Internal page name"),
237
239
  slug: z.string().describe("URL slug (e.g. 'about', 'features')"),
238
240
  parentId: z
@@ -256,7 +258,8 @@ export function createServer(client) {
256
258
  .preprocess(jsonPreprocess, z.record(z.string(), z.string()))
257
259
  .optional()
258
260
  .describe("Multilingual SEO description"),
259
- }, async ({ name, slug, parentId, pageType, displayName, seoTitle, seoDescription, }) => {
261
+ response: responseModeSchema,
262
+ }, async ({ name, slug, parentId, pageType, displayName, seoTitle, seoDescription, response, }) => {
260
263
  const input = { name, slug };
261
264
  if (parentId)
262
265
  input.parentId = parentId;
@@ -271,16 +274,9 @@ export function createServer(client) {
271
274
  const data = await client.query(SAVE_PAGE_MUTATION, {
272
275
  input,
273
276
  });
274
- return {
275
- content: [
276
- {
277
- type: "text",
278
- text: JSON.stringify(data.savePage, null, 2),
279
- },
280
- ],
281
- };
277
+ return jsonText(response, data.savePage, pageMinimal);
282
278
  });
283
- 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.", {
284
280
  pageId: z.string().describe("Page ID"),
285
281
  blocks: z.preprocess(jsonPreprocess, z
286
282
  .array(z.object({
@@ -300,7 +296,8 @@ export function createServer(client) {
300
296
  blockVersion: z.string().optional(),
301
297
  }))
302
298
  .describe("Full array of content blocks to set on the page")),
303
- }, async ({ pageId, blocks }) => {
299
+ response: responseModeSchema,
300
+ }, async ({ pageId, blocks, response }) => {
304
301
  // Validate all block types against workspace registry
305
302
  const validation = await validateBlockTypes(blocks.map((b) => b.type));
306
303
  if (!validation.valid) {
@@ -347,16 +344,9 @@ export function createServer(client) {
347
344
  blocks: mergedBlocks,
348
345
  },
349
346
  });
350
- return {
351
- content: [
352
- {
353
- type: "text",
354
- text: JSON.stringify(data.savePage, null, 2),
355
- },
356
- ],
357
- };
347
+ return jsonText(response, data.savePage, pageMinimal);
358
348
  });
359
- 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.", {
360
350
  id: z.string().describe("Page ID"),
361
351
  name: z.string().optional().describe("Internal page name"),
362
352
  slug: z.string().optional().describe("URL slug"),
@@ -373,7 +363,8 @@ export function createServer(client) {
373
363
  .optional()
374
364
  .describe("Multilingual SEO description"),
375
365
  seoKeywords: z.array(z.string()).optional().describe("SEO keywords"),
376
- }, async ({ id, name, slug, displayName, seoTitle, seoDescription, seoKeywords, }) => {
366
+ response: responseModeSchema,
367
+ }, async ({ id, name, slug, displayName, seoTitle, seoDescription, seoKeywords, response, }) => {
377
368
  const input = { id };
378
369
  if (name !== undefined)
379
370
  input.name = name;
@@ -388,16 +379,12 @@ export function createServer(client) {
388
379
  if (seoKeywords !== undefined)
389
380
  input.seoKeywords = seoKeywords;
390
381
  const data = await client.query(UPDATE_PAGE_SETTINGS_MUTATION, { input });
391
- return {
392
- content: [
393
- {
394
- type: "text",
395
- text: JSON.stringify(data.updatePageSettings, null, 2),
396
- },
397
- ],
398
- };
382
+ return jsonText(response, data.updatePageSettings, pageMinimal);
399
383
  });
400
- 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 }) => {
401
388
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
402
389
  if (!pageData.page) {
403
390
  return {
@@ -406,7 +393,9 @@ export function createServer(client) {
406
393
  };
407
394
  }
408
395
  const page = pageData.page;
409
- if (page.published && !page.hasUnpublishedChanges) {
396
+ if (page.published &&
397
+ !page.hasUnpublishedContentChanges &&
398
+ !page.hasUnpublishedLayoutChanges) {
410
399
  return {
411
400
  content: [
412
401
  {
@@ -429,16 +418,12 @@ export function createServer(client) {
429
418
  blockVersion: b.blockVersion,
430
419
  }));
431
420
  const data = await client.query(PUBLISH_PAGE_MUTATION, { id: pageId, blocks });
432
- return {
433
- content: [
434
- {
435
- type: "text",
436
- text: JSON.stringify(data.publishPage, null, 2),
437
- },
438
- ],
439
- };
421
+ return jsonText(response, data.publishPage, (p) => pageMinimal(p, { published: true }));
440
422
  });
441
- server.tool("unpublish_page", "Unpublish a published page (toggles published state off).", { pageId: z.string().describe("Page ID to unpublish") }, async ({ pageId }) => {
423
+ 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.", {
424
+ pageId: z.string().describe("Page ID to unpublish"),
425
+ response: responseModeSchema,
426
+ }, async ({ pageId, response }) => {
442
427
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
443
428
  if (!pageData.page) {
444
429
  return {
@@ -454,16 +439,12 @@ export function createServer(client) {
454
439
  };
455
440
  }
456
441
  const data = await client.query(TOGGLE_PUBLISH_MUTATION, { id: pageId });
457
- return {
458
- content: [
459
- {
460
- type: "text",
461
- text: JSON.stringify(data.togglePublish, null, 2),
462
- },
463
- ],
464
- };
442
+ return jsonText(response, data.togglePublish, (p) => pageMinimal(p, { published: false }));
465
443
  });
466
- 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 }) => {
444
+ 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.", {
445
+ pageId: z.string().describe("Page ID to revert"),
446
+ response: responseModeSchema,
447
+ }, async ({ pageId, response }) => {
467
448
  const data = await client.query(REVERT_TO_PUBLISHED_MUTATION, { id: pageId });
468
449
  if (!data.revertToPublished) {
469
450
  return {
@@ -476,14 +457,7 @@ export function createServer(client) {
476
457
  isError: true,
477
458
  };
478
459
  }
479
- return {
480
- content: [
481
- {
482
- type: "text",
483
- text: JSON.stringify(data.revertToPublished, null, 2),
484
- },
485
- ],
486
- };
460
+ return jsonText(response, data.revertToPublished, pageMinimal);
487
461
  });
488
462
  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 }) => {
489
463
  const data = await client.query(REMOVE_PAGE_MUTATION, { id: pageId });
@@ -499,7 +473,7 @@ export function createServer(client) {
499
473
  };
500
474
  });
501
475
  // ─── Layout Tools ──────────────────────────────────────────
502
- server.tool("update_page_layout", "Update page-level layout settings: inheritance, overrides, or replace all layout blocks. Block types are validated against workspace config.", {
476
+ 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.", {
503
477
  pageId: z.string().describe("Page ID"),
504
478
  layoutBlocks: z
505
479
  .array(z.record(z.string(), z.unknown()))
@@ -517,7 +491,8 @@ export function createServer(client) {
517
491
  .boolean()
518
492
  .optional()
519
493
  .describe("Whether this page inherits layout from parent"),
520
- }, async ({ pageId, layoutBlocks, layoutOverrides, inheritsLayout }) => {
494
+ response: responseModeSchema,
495
+ }, async ({ pageId, layoutBlocks, layoutOverrides, inheritsLayout, response, }) => {
521
496
  // Validate block types against workspace registry
522
497
  if (layoutBlocks) {
523
498
  const types = layoutBlocks
@@ -568,17 +543,10 @@ export function createServer(client) {
568
543
  isError: true,
569
544
  };
570
545
  }
571
- return {
572
- content: [
573
- {
574
- type: "text",
575
- text: JSON.stringify(data.updatePageLayout, null, 2),
576
- },
577
- ],
578
- };
546
+ return jsonText(response, data.updatePageLayout, pageMinimal);
579
547
  });
580
548
  // ─── Block Helper Tools (read-modify-write) ─────────────────
581
- 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.", {
549
+ 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.", {
582
550
  pageId: z.string().describe("Page ID"),
583
551
  block: z.preprocess(jsonPreprocess, z.object({
584
552
  type: z
@@ -594,7 +562,8 @@ export function createServer(client) {
594
562
  .number()
595
563
  .optional()
596
564
  .describe("0-based position to insert at (default: end)"),
597
- }, async ({ pageId, block, position }) => {
565
+ response: responseModeSchema,
566
+ }, async ({ pageId, block, position, response }) => {
598
567
  // Validate block type against workspace registry
599
568
  const blockDef = await findBlockDef(block.type);
600
569
  if (!blockDef) {
@@ -654,14 +623,18 @@ export function createServer(client) {
654
623
  };
655
624
  const layoutBlocks = [...existingLayoutBlocks, newLayoutBlock];
656
625
  const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks } });
657
- return {
658
- content: [
659
- {
660
- type: "text",
661
- text: JSON.stringify({ blockId: newBlockId, page: data.updatePageLayout }, null, 2),
662
- },
663
- ],
664
- };
626
+ if (!data.updatePageLayout) {
627
+ return {
628
+ content: [
629
+ {
630
+ type: "text",
631
+ text: `Failed to update layout while adding block to page '${pageId}'`,
632
+ },
633
+ ],
634
+ isError: true,
635
+ };
636
+ }
637
+ return jsonText(response, { blockId: newBlockId, page: data.updatePageLayout }, () => pageBlockMinimal(data.updatePageLayout, newBlockId));
665
638
  }
666
639
  else {
667
640
  // Content block — add to blocks via savePage
@@ -692,24 +665,18 @@ export function createServer(client) {
692
665
  blocks,
693
666
  },
694
667
  });
695
- return {
696
- content: [
697
- {
698
- type: "text",
699
- text: JSON.stringify({ blockId: newBlockId, page: data.savePage }, null, 2),
700
- },
701
- ],
702
- };
668
+ return jsonText(response, { blockId: newBlockId, page: data.savePage }, () => pageBlockMinimal(data.savePage, newBlockId));
703
669
  }
704
670
  });
705
- 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.", {
671
+ 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.", {
706
672
  pageId: z.string().describe("Page ID"),
707
673
  blockId: z.string().describe("Block instance ID (UUID) to update"),
708
674
  content: z
709
675
  .record(z.string(), z.unknown())
710
676
  .describe("Content to merge: { en: { title: 'New Title' } }"),
711
677
  settings: z.record(z.string(), z.unknown()).optional(),
712
- }, async ({ pageId, blockId, content, settings }) => {
678
+ response: responseModeSchema,
679
+ }, async ({ pageId, blockId, content, settings, response }) => {
713
680
  // Fetch current page
714
681
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
715
682
  if (!pageData.page) {
@@ -775,14 +742,18 @@ export function createServer(client) {
775
742
  targetArray[targetIndex] = existingBlock;
776
743
  if (isLayout) {
777
744
  const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks: targetArray } });
778
- return {
779
- content: [
780
- {
781
- type: "text",
782
- text: JSON.stringify(data.updatePageLayout, null, 2),
783
- },
784
- ],
785
- };
745
+ if (!data.updatePageLayout) {
746
+ return {
747
+ content: [
748
+ {
749
+ type: "text",
750
+ text: `Failed to update layout while editing block '${blockId}' on page '${pageId}'`,
751
+ },
752
+ ],
753
+ isError: true,
754
+ };
755
+ }
756
+ return jsonText(response, data.updatePageLayout, (p) => pageBlockMinimal(p, blockId));
786
757
  }
787
758
  else {
788
759
  const data = await client.query(SAVE_PAGE_MUTATION, {
@@ -794,14 +765,7 @@ export function createServer(client) {
794
765
  blocks: targetArray,
795
766
  },
796
767
  });
797
- return {
798
- content: [
799
- {
800
- type: "text",
801
- text: JSON.stringify(data.savePage, null, 2),
802
- },
803
- ],
804
- };
768
+ return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
805
769
  }
806
770
  });
807
771
  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.", {
@@ -898,19 +862,29 @@ export function createServer(client) {
898
862
  isError: true,
899
863
  };
900
864
  }
865
+ // Compact JSON: patch_block_content is ack-only (no pretty-print
866
+ // needed), matching the compact shape used by minimal-mode returns.
867
+ const patched = data.patchBlockContent;
901
868
  return {
902
869
  content: [
903
870
  {
904
871
  type: "text",
905
- text: JSON.stringify(data.patchBlockContent, null, 2),
872
+ text: JSON.stringify({
873
+ id: patched.id,
874
+ slug: patched.slug,
875
+ hasUnpublishedChanges: Boolean(patched.hasUnpublishedContentChanges) ||
876
+ Boolean(patched.hasUnpublishedLayoutChanges),
877
+ updatedAt: patched.updatedAt,
878
+ }),
906
879
  },
907
880
  ],
908
881
  };
909
882
  });
910
- server.tool("remove_block_from_page", "Remove a specific block from a page by its instance ID. Works for both content and layout blocks.", {
883
+ 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.", {
911
884
  pageId: z.string().describe("Page ID"),
912
885
  blockId: z.string().describe("Block instance ID (UUID) to remove"),
913
- }, async ({ pageId, blockId }) => {
886
+ response: responseModeSchema,
887
+ }, async ({ pageId, blockId, response }) => {
914
888
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
915
889
  if (!pageData.page) {
916
890
  return {
@@ -931,27 +905,24 @@ export function createServer(client) {
931
905
  blocks: contentBlocks,
932
906
  },
933
907
  });
934
- return {
935
- content: [
936
- {
937
- type: "text",
938
- text: JSON.stringify(data.savePage, null, 2),
939
- },
940
- ],
941
- };
908
+ return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
942
909
  }
943
910
  // Try layout blocks
944
911
  const layoutBlocks = (page.layoutBlocks || []).filter((b) => b.id !== blockId);
945
912
  if (layoutBlocks.length < (page.layoutBlocks || []).length) {
946
913
  const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks } });
947
- return {
948
- content: [
949
- {
950
- type: "text",
951
- text: JSON.stringify(data.updatePageLayout, null, 2),
952
- },
953
- ],
954
- };
914
+ if (!data.updatePageLayout) {
915
+ return {
916
+ content: [
917
+ {
918
+ type: "text",
919
+ text: `Failed to update layout while removing block '${blockId}' from page '${pageId}'`,
920
+ },
921
+ ],
922
+ isError: true,
923
+ };
924
+ }
925
+ return jsonText(response, data.updatePageLayout, (p) => pageBlockMinimal(p, blockId));
955
926
  }
956
927
  return {
957
928
  content: [
@@ -1007,7 +978,7 @@ export function createServer(client) {
1007
978
  ],
1008
979
  };
1009
980
  });
1010
- server.tool("create_form", "Create a new form with fields and settings. Returns the created form.", {
981
+ 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.", {
1011
982
  name: z.string().describe("Form name"),
1012
983
  slug: z.string().describe("URL-friendly slug (must be unique)"),
1013
984
  description: z.string().optional().describe("Form description"),
@@ -1090,7 +1061,8 @@ export function createServer(client) {
1090
1061
  }))
1091
1062
  .optional()
1092
1063
  .describe("Form settings (action type, notifications, etc.)"),
1093
- }, async ({ name, slug, description, fields, settings }) => {
1064
+ response: responseModeSchema,
1065
+ }, async ({ name, slug, description, fields, settings, response }) => {
1094
1066
  const input = { name, slug };
1095
1067
  if (description)
1096
1068
  input.description = description;
@@ -1099,16 +1071,9 @@ export function createServer(client) {
1099
1071
  if (settings)
1100
1072
  input.settings = settings;
1101
1073
  const data = await client.query(CREATE_FORM_MUTATION, { input });
1102
- return {
1103
- content: [
1104
- {
1105
- type: "text",
1106
- text: JSON.stringify(data.createForm, null, 2),
1107
- },
1108
- ],
1109
- };
1074
+ return jsonText(response, data.createForm, formMinimal);
1110
1075
  });
1111
- server.tool("update_form", "Update an existing form's name, slug, status, fields, or settings.", {
1076
+ 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.", {
1112
1077
  formId: z.string().describe("Form ID to update"),
1113
1078
  name: z.string().optional().describe("New form name"),
1114
1079
  slug: z.string().optional().describe("New slug"),
@@ -1188,7 +1153,8 @@ export function createServer(client) {
1188
1153
  }))
1189
1154
  .optional()
1190
1155
  .describe("Updated settings"),
1191
- }, async ({ formId, name, slug, description, status, fields, settings }) => {
1156
+ response: responseModeSchema,
1157
+ }, async ({ formId, name, slug, description, status, fields, settings, response, }) => {
1192
1158
  const input = {};
1193
1159
  if (name !== undefined)
1194
1160
  input.name = name;
@@ -1211,14 +1177,7 @@ export function createServer(client) {
1211
1177
  isError: true,
1212
1178
  };
1213
1179
  }
1214
- return {
1215
- content: [
1216
- {
1217
- type: "text",
1218
- text: JSON.stringify(data.updateForm, null, 2),
1219
- },
1220
- ],
1221
- };
1180
+ return jsonText(response, data.updateForm, formMinimal);
1222
1181
  });
1223
1182
  server.tool("delete_form", "Permanently delete a form and all its submissions.", {
1224
1183
  formId: z.string().describe("Form ID to delete"),
@@ -1311,6 +1270,468 @@ export function createServer(client) {
1311
1270
  ],
1312
1271
  };
1313
1272
  });
1273
+ // ─── Model Tools (Custom Data Models) ────────────────────────
1274
+ const propertyFieldTypes = [
1275
+ "string",
1276
+ "richtext",
1277
+ "number",
1278
+ "boolean",
1279
+ "date",
1280
+ "datetime",
1281
+ "email",
1282
+ "url",
1283
+ "media",
1284
+ "relation",
1285
+ "select",
1286
+ "multiselect",
1287
+ "object",
1288
+ "list",
1289
+ ];
1290
+ const relationTypes = ["hasOne", "hasMany", "manyToMany"];
1291
+ const propertyFieldSchema = z.lazy(() => z.object({
1292
+ key: z.string().describe("Field key (identifier in record data)"),
1293
+ label: z.string().describe("Human-readable field label"),
1294
+ type: z
1295
+ .enum(propertyFieldTypes)
1296
+ .describe("Field type — controls validation + UI"),
1297
+ required: z.boolean().optional(),
1298
+ description: z.string().optional(),
1299
+ defaultValue: z.string().optional(),
1300
+ options: z
1301
+ .array(z.string())
1302
+ .optional()
1303
+ .describe("For select/multiselect: allowed values"),
1304
+ fields: z
1305
+ .array(propertyFieldSchema)
1306
+ .optional()
1307
+ .describe("For type=object: nested fields"),
1308
+ itemType: z
1309
+ .enum(propertyFieldTypes)
1310
+ .optional()
1311
+ .describe("For type=list: item type"),
1312
+ itemFields: z
1313
+ .array(propertyFieldSchema)
1314
+ .optional()
1315
+ .describe("For type=list of objects: nested field defs"),
1316
+ relationTo: z
1317
+ .string()
1318
+ .optional()
1319
+ .describe("For type=relation: target model slug"),
1320
+ relationType: z.enum(relationTypes).optional(),
1321
+ acceptedTypes: z
1322
+ .array(z.enum(mediaTypeValues))
1323
+ .optional()
1324
+ .describe("For type=media: allowed media kinds (empty = all)"),
1325
+ multiple: z
1326
+ .boolean()
1327
+ .optional()
1328
+ .describe("For type=media: true = gallery, false = single"),
1329
+ schemaProperty: z.string().optional(),
1330
+ minLength: z.number().int().optional(),
1331
+ maxLength: z.number().int().optional(),
1332
+ minValue: z.number().optional(),
1333
+ maxValue: z.number().optional(),
1334
+ pattern: z.string().optional(),
1335
+ }));
1336
+ const statusFieldSchema = z.object({
1337
+ enabled: z.boolean().optional(),
1338
+ values: z
1339
+ .array(z.string())
1340
+ .optional()
1341
+ .describe("Allowed status values (e.g. ['draft','active'])"),
1342
+ defaultValue: z.string().optional(),
1343
+ transitions: z
1344
+ .array(z.object({
1345
+ from: z.string(),
1346
+ to: z.array(z.string()),
1347
+ }))
1348
+ .optional()
1349
+ .describe("Allowed transitions: from one state to a set of states"),
1350
+ });
1351
+ const defaultSortSchema = z.object({
1352
+ field: z.string(),
1353
+ direction: z.enum(["asc", "desc"]),
1354
+ });
1355
+ const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
1356
+ // Match backend: apps/backend/src/models/model-definition.ts
1357
+ const SLUG_RE = /^[a-z][a-z0-9-]*$/;
1358
+ const SLUG_MSG = "Slug must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens";
1359
+ server.tool("list_models", "List all Custom Data Models (ModelDefinition) in the workspace.", {}, async () => {
1360
+ const data = await client.query(MODEL_DEFINITIONS_QUERY);
1361
+ return {
1362
+ content: [
1363
+ {
1364
+ type: "text",
1365
+ text: JSON.stringify(data.modelDefinitions, null, 2),
1366
+ },
1367
+ ],
1368
+ };
1369
+ });
1370
+ server.tool("get_model", "Get a ModelDefinition by id (24-hex ObjectId) or by slug. Returns full definition including fields, statusField, and defaultSort.", {
1371
+ idOrSlug: z.string().describe("Model id (ObjectId) or slug"),
1372
+ }, async ({ idOrSlug }) => {
1373
+ if (OBJECT_ID_RE.test(idOrSlug)) {
1374
+ const data = await client.query(MODEL_DEFINITION_BY_ID_QUERY, { id: idOrSlug });
1375
+ if (data.modelDefinition) {
1376
+ return {
1377
+ content: [
1378
+ {
1379
+ type: "text",
1380
+ text: JSON.stringify(data.modelDefinition, null, 2),
1381
+ },
1382
+ ],
1383
+ };
1384
+ }
1385
+ }
1386
+ // Fallback: resolve slug → id via a lightweight list (id + slug only),
1387
+ // then fetch the full definition for that single model. Avoids
1388
+ // pulling every model's fields when only one is needed.
1389
+ const index = await client.query(MODEL_DEFINITIONS_BY_SLUG_INDEX_QUERY);
1390
+ const match = index.modelDefinitions.find((m) => m.slug === idOrSlug);
1391
+ if (!match) {
1392
+ return {
1393
+ content: [
1394
+ { type: "text", text: `Model '${idOrSlug}' not found` },
1395
+ ],
1396
+ isError: true,
1397
+ };
1398
+ }
1399
+ const full = await client.query(MODEL_DEFINITION_BY_ID_QUERY, { id: match.id });
1400
+ if (!full.modelDefinition) {
1401
+ return {
1402
+ content: [
1403
+ { type: "text", text: `Model '${idOrSlug}' not found` },
1404
+ ],
1405
+ isError: true,
1406
+ };
1407
+ }
1408
+ return {
1409
+ content: [
1410
+ {
1411
+ type: "text",
1412
+ text: JSON.stringify(full.modelDefinition, null, 2),
1413
+ },
1414
+ ],
1415
+ };
1416
+ });
1417
+ server.tool("create_model", "Create a new Custom Data Model (ModelDefinition). Returns a minimal ack by default; pass response='full' for the full model.", {
1418
+ name: z.string().describe("Display name"),
1419
+ slug: z
1420
+ .string()
1421
+ .trim()
1422
+ .regex(SLUG_RE, SLUG_MSG)
1423
+ .describe("URL-safe slug, lowercase (regex: ^[a-z][a-z0-9-]*$)"),
1424
+ description: z.string().optional(),
1425
+ icon: z
1426
+ .string()
1427
+ .optional()
1428
+ .describe("Lucide icon name, defaults to 'database'"),
1429
+ color: z.string().optional(),
1430
+ displayField: z
1431
+ .string()
1432
+ .optional()
1433
+ .describe("Field key used as the record's display label in UI"),
1434
+ defaultSort: z.preprocess(jsonPreprocess, defaultSortSchema).optional(),
1435
+ fields: z
1436
+ .preprocess(jsonPreprocess, z.array(propertyFieldSchema))
1437
+ .optional()
1438
+ .describe("Field definitions. Validates record data on write."),
1439
+ statusField: z
1440
+ .preprocess(jsonPreprocess, statusFieldSchema)
1441
+ .optional()
1442
+ .describe("Enable record lifecycle states with allowed transitions"),
1443
+ response: responseModeSchema,
1444
+ }, async ({ name, slug, description, icon, color, displayField, defaultSort, fields, statusField, response, }) => {
1445
+ const input = { name, slug };
1446
+ if (description !== undefined)
1447
+ input.description = description;
1448
+ if (icon !== undefined)
1449
+ input.icon = icon;
1450
+ if (color !== undefined)
1451
+ input.color = color;
1452
+ if (displayField !== undefined)
1453
+ input.displayField = displayField;
1454
+ if (defaultSort !== undefined)
1455
+ input.defaultSort = defaultSort;
1456
+ if (fields !== undefined)
1457
+ input.fields = fields;
1458
+ if (statusField !== undefined)
1459
+ input.statusField = statusField;
1460
+ const data = await client.query(CREATE_MODEL_DEFINITION_MUTATION, { input });
1461
+ return jsonText(response, data.createModelDefinition, modelMinimal);
1462
+ });
1463
+ 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.", {
1464
+ id: z.string().describe("Model id (ObjectId) to update"),
1465
+ name: z.string().optional(),
1466
+ slug: z.string().trim().regex(SLUG_RE, SLUG_MSG).optional(),
1467
+ description: z.string().optional(),
1468
+ icon: z.string().optional(),
1469
+ color: z.string().optional(),
1470
+ displayField: z.string().optional(),
1471
+ defaultSort: z.preprocess(jsonPreprocess, defaultSortSchema).optional(),
1472
+ fields: z
1473
+ .preprocess(jsonPreprocess, z.array(propertyFieldSchema))
1474
+ .optional(),
1475
+ statusField: z.preprocess(jsonPreprocess, statusFieldSchema).optional(),
1476
+ response: responseModeSchema,
1477
+ }, async (args) => {
1478
+ const input = { id: args.id };
1479
+ for (const key of [
1480
+ "name",
1481
+ "slug",
1482
+ "description",
1483
+ "icon",
1484
+ "color",
1485
+ "displayField",
1486
+ "defaultSort",
1487
+ "fields",
1488
+ "statusField",
1489
+ ]) {
1490
+ if (args[key] !== undefined)
1491
+ input[key] = args[key];
1492
+ }
1493
+ const data = await client.query(UPDATE_MODEL_DEFINITION_MUTATION, { input });
1494
+ if (!data.updateModelDefinition) {
1495
+ return {
1496
+ content: [{ type: "text", text: "Model not found" }],
1497
+ isError: true,
1498
+ };
1499
+ }
1500
+ return jsonText(args.response, data.updateModelDefinition, modelMinimal);
1501
+ });
1502
+ server.tool("delete_model", "Delete a ModelDefinition. WARNING: cascades - ALL records of this model are deleted.", {
1503
+ id: z.string().describe("Model id (ObjectId) to delete"),
1504
+ }, async ({ id }) => {
1505
+ const data = await client.query(DELETE_MODEL_DEFINITION_MUTATION, { id });
1506
+ return {
1507
+ content: [
1508
+ {
1509
+ type: "text",
1510
+ text: data.deleteModelDefinition
1511
+ ? "Model deleted (records cascaded)"
1512
+ : "Failed to delete model",
1513
+ },
1514
+ ],
1515
+ isError: !data.deleteModelDefinition,
1516
+ };
1517
+ });
1518
+ 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.", {
1519
+ modelId: z.string().describe("Target model id (ObjectId)"),
1520
+ filter: z
1521
+ .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1522
+ .optional()
1523
+ .describe("Plain object filter, e.g. {status: 'active'}"),
1524
+ limit: z.number().int().optional().default(50),
1525
+ offset: z.number().int().optional().default(0),
1526
+ sort: z
1527
+ .string()
1528
+ .optional()
1529
+ .describe("Sort expression, e.g. 'createdAt' or '-updatedAt'"),
1530
+ populate: z
1531
+ .array(z.string())
1532
+ .optional()
1533
+ .describe("Field keys of type=relation to populate"),
1534
+ }, async ({ modelId, filter, limit, offset, sort, populate }) => {
1535
+ const data = await client.query(MODEL_RECORDS_QUERY, {
1536
+ modelId,
1537
+ filter,
1538
+ limit,
1539
+ offset,
1540
+ sort,
1541
+ populate,
1542
+ });
1543
+ return {
1544
+ content: [
1545
+ {
1546
+ type: "text",
1547
+ text: JSON.stringify(data.modelRecords, null, 2),
1548
+ },
1549
+ ],
1550
+ };
1551
+ });
1552
+ server.tool("get_record", "Get a single record by id.", {
1553
+ id: z.string().describe("Record id (ObjectId)"),
1554
+ }, async ({ id }) => {
1555
+ const data = await client.query(MODEL_RECORD_BY_ID_QUERY, { id });
1556
+ if (!data.modelRecord) {
1557
+ return {
1558
+ content: [{ type: "text", text: "Record not found" }],
1559
+ isError: true,
1560
+ };
1561
+ }
1562
+ return {
1563
+ content: [
1564
+ {
1565
+ type: "text",
1566
+ text: JSON.stringify(data.modelRecord, null, 2),
1567
+ },
1568
+ ],
1569
+ };
1570
+ });
1571
+ 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.", {
1572
+ modelId: z.string().describe("Target model id (ObjectId)"),
1573
+ data: z
1574
+ .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1575
+ .describe("Record data keyed by model field keys"),
1576
+ response: responseModeSchema,
1577
+ }, async ({ modelId, data: recordData, response }) => {
1578
+ const result = await client.query(CREATE_MODEL_RECORD_MUTATION, {
1579
+ input: { modelId, data: recordData },
1580
+ });
1581
+ return jsonText(response, result.createModelRecord, recordMinimal);
1582
+ });
1583
+ 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.", {
1584
+ id: z.string().describe("Record id (ObjectId)"),
1585
+ data: z
1586
+ .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1587
+ .optional()
1588
+ .describe("New data (full replace)"),
1589
+ status: z
1590
+ .string()
1591
+ .optional()
1592
+ .describe("New status value (must be allowed by model.statusField)"),
1593
+ response: responseModeSchema,
1594
+ }, async ({ id, data: recordData, status, response }) => {
1595
+ if (recordData === undefined && status === undefined) {
1596
+ return {
1597
+ content: [
1598
+ {
1599
+ type: "text",
1600
+ text: "Provide 'data' and/or 'status' to update",
1601
+ },
1602
+ ],
1603
+ isError: true,
1604
+ };
1605
+ }
1606
+ let dataResult = null;
1607
+ let statusResult = null;
1608
+ if (recordData !== undefined) {
1609
+ const res = await client.query(UPDATE_MODEL_RECORD_MUTATION, {
1610
+ input: { id, data: recordData },
1611
+ });
1612
+ dataResult = res.updateModelRecord;
1613
+ if (!dataResult) {
1614
+ return {
1615
+ content: [{ type: "text", text: "Record not found" }],
1616
+ isError: true,
1617
+ };
1618
+ }
1619
+ }
1620
+ if (status !== undefined) {
1621
+ // Data update (if any) already committed. Wrap status call so a
1622
+ // failed transition reports partial-success clearly instead of
1623
+ // hiding the completed data write behind a bare throw.
1624
+ try {
1625
+ const res = await client.query(UPDATE_MODEL_RECORD_STATUS_MUTATION, {
1626
+ input: { id, status },
1627
+ });
1628
+ statusResult = res.updateModelRecordStatus;
1629
+ if (!statusResult) {
1630
+ if (dataResult) {
1631
+ return {
1632
+ content: [
1633
+ {
1634
+ type: "text",
1635
+ text: JSON.stringify({
1636
+ partialSuccess: true,
1637
+ message: "Data updated, but record not found when applying status.",
1638
+ dataResult,
1639
+ }, null, 2),
1640
+ },
1641
+ ],
1642
+ isError: true,
1643
+ };
1644
+ }
1645
+ return {
1646
+ content: [{ type: "text", text: "Record not found" }],
1647
+ isError: true,
1648
+ };
1649
+ }
1650
+ }
1651
+ catch (error) {
1652
+ if (dataResult) {
1653
+ return {
1654
+ content: [
1655
+ {
1656
+ type: "text",
1657
+ text: JSON.stringify({
1658
+ partialSuccess: true,
1659
+ message: "Data updated, but status transition failed. Retry status only.",
1660
+ dataResult,
1661
+ statusError: error instanceof Error ? error.message : String(error),
1662
+ }, null, 2),
1663
+ },
1664
+ ],
1665
+ isError: true,
1666
+ };
1667
+ }
1668
+ throw error;
1669
+ }
1670
+ }
1671
+ const finalResult = (statusResult ?? dataResult);
1672
+ return jsonText(response, finalResult, recordMinimal);
1673
+ });
1674
+ server.tool("delete_record", "Delete a record permanently.", {
1675
+ id: z.string().describe("Record id (ObjectId) to delete"),
1676
+ }, async ({ id }) => {
1677
+ const data = await client.query(DELETE_MODEL_RECORD_MUTATION, { id });
1678
+ return {
1679
+ content: [
1680
+ {
1681
+ type: "text",
1682
+ text: data.deleteModelRecord
1683
+ ? "Record deleted"
1684
+ : "Failed to delete record",
1685
+ },
1686
+ ],
1687
+ isError: !data.deleteModelRecord,
1688
+ };
1689
+ });
1690
+ 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.", {
1691
+ modelId: z.string().describe("Target model id (ObjectId)"),
1692
+ rows: z
1693
+ .preprocess(jsonPreprocess, z
1694
+ .array(z.record(z.string(), z.unknown()))
1695
+ .min(1, "Provide at least 1 row")
1696
+ .max(1000, "Maximum 1000 rows per import"))
1697
+ .describe("Array of plain objects keyed by model field keys"),
1698
+ }, async ({ modelId, rows }) => {
1699
+ const data = await client.query(IMPORT_MODEL_RECORDS_MUTATION, { input: { modelId, rows } });
1700
+ return {
1701
+ content: [
1702
+ {
1703
+ type: "text",
1704
+ text: JSON.stringify(data.importModelRecords, null, 2),
1705
+ },
1706
+ ],
1707
+ };
1708
+ });
1709
+ server.tool("list_model_templates", "List available model templates (E-commerce, Blog, etc.). Each template installs one or more ModelDefinitions.", {}, async () => {
1710
+ const data = await client.query(MODEL_TEMPLATES_QUERY);
1711
+ return {
1712
+ content: [
1713
+ {
1714
+ type: "text",
1715
+ text: JSON.stringify(data.modelTemplates, null, 2),
1716
+ },
1717
+ ],
1718
+ };
1719
+ });
1720
+ 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 }.", {
1721
+ templateId: z
1722
+ .string()
1723
+ .describe("Template id (from list_model_templates)"),
1724
+ }, async ({ templateId }) => {
1725
+ const data = await client.query(INSTALL_MODEL_TEMPLATE_MUTATION, { templateId });
1726
+ return {
1727
+ content: [
1728
+ {
1729
+ type: "text",
1730
+ text: JSON.stringify(data.installModelTemplate, null, 2),
1731
+ },
1732
+ ],
1733
+ };
1734
+ });
1314
1735
  // ─── Resources ───────────────────────────────────────────────
1315
1736
  server.resource("sitemap", "cmssy://sitemap", {
1316
1737
  description: "Full page tree as JSON — all pages with hierarchy",