@cmssy/mcp-server 0.5.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
@@ -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 {
@@ -429,16 +416,12 @@ export function createServer(client) {
429
416
  blockVersion: b.blockVersion,
430
417
  }));
431
418
  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
- };
419
+ return jsonText(response, data.publishPage, (p) => pageMinimal(p, { published: true }));
440
420
  });
441
- 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 }) => {
442
425
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
443
426
  if (!pageData.page) {
444
427
  return {
@@ -454,16 +437,12 @@ export function createServer(client) {
454
437
  };
455
438
  }
456
439
  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
- };
440
+ return jsonText(response, data.togglePublish, (p) => pageMinimal(p, { published: false }));
465
441
  });
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 }) => {
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 }) => {
467
446
  const data = await client.query(REVERT_TO_PUBLISHED_MUTATION, { id: pageId });
468
447
  if (!data.revertToPublished) {
469
448
  return {
@@ -476,14 +455,7 @@ export function createServer(client) {
476
455
  isError: true,
477
456
  };
478
457
  }
479
- return {
480
- content: [
481
- {
482
- type: "text",
483
- text: JSON.stringify(data.revertToPublished, null, 2),
484
- },
485
- ],
486
- };
458
+ return jsonText(response, data.revertToPublished, pageMinimal);
487
459
  });
488
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 }) => {
489
461
  const data = await client.query(REMOVE_PAGE_MUTATION, { id: pageId });
@@ -499,7 +471,7 @@ export function createServer(client) {
499
471
  };
500
472
  });
501
473
  // ─── 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.", {
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.", {
503
475
  pageId: z.string().describe("Page ID"),
504
476
  layoutBlocks: z
505
477
  .array(z.record(z.string(), z.unknown()))
@@ -517,7 +489,8 @@ export function createServer(client) {
517
489
  .boolean()
518
490
  .optional()
519
491
  .describe("Whether this page inherits layout from parent"),
520
- }, async ({ pageId, layoutBlocks, layoutOverrides, inheritsLayout }) => {
492
+ response: responseModeSchema,
493
+ }, async ({ pageId, layoutBlocks, layoutOverrides, inheritsLayout, response, }) => {
521
494
  // Validate block types against workspace registry
522
495
  if (layoutBlocks) {
523
496
  const types = layoutBlocks
@@ -568,17 +541,10 @@ export function createServer(client) {
568
541
  isError: true,
569
542
  };
570
543
  }
571
- return {
572
- content: [
573
- {
574
- type: "text",
575
- text: JSON.stringify(data.updatePageLayout, null, 2),
576
- },
577
- ],
578
- };
544
+ return jsonText(response, data.updatePageLayout, pageMinimal);
579
545
  });
580
546
  // ─── 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.", {
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.", {
582
548
  pageId: z.string().describe("Page ID"),
583
549
  block: z.preprocess(jsonPreprocess, z.object({
584
550
  type: z
@@ -594,7 +560,8 @@ export function createServer(client) {
594
560
  .number()
595
561
  .optional()
596
562
  .describe("0-based position to insert at (default: end)"),
597
- }, async ({ pageId, block, position }) => {
563
+ response: responseModeSchema,
564
+ }, async ({ pageId, block, position, response }) => {
598
565
  // Validate block type against workspace registry
599
566
  const blockDef = await findBlockDef(block.type);
600
567
  if (!blockDef) {
@@ -654,14 +621,18 @@ export function createServer(client) {
654
621
  };
655
622
  const layoutBlocks = [...existingLayoutBlocks, newLayoutBlock];
656
623
  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
- };
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));
665
636
  }
666
637
  else {
667
638
  // Content block — add to blocks via savePage
@@ -692,24 +663,18 @@ export function createServer(client) {
692
663
  blocks,
693
664
  },
694
665
  });
695
- return {
696
- content: [
697
- {
698
- type: "text",
699
- text: JSON.stringify({ blockId: newBlockId, page: data.savePage }, null, 2),
700
- },
701
- ],
702
- };
666
+ return jsonText(response, { blockId: newBlockId, page: data.savePage }, () => pageBlockMinimal(data.savePage, newBlockId));
703
667
  }
704
668
  });
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.", {
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.", {
706
670
  pageId: z.string().describe("Page ID"),
707
671
  blockId: z.string().describe("Block instance ID (UUID) to update"),
708
672
  content: z
709
673
  .record(z.string(), z.unknown())
710
674
  .describe("Content to merge: { en: { title: 'New Title' } }"),
711
675
  settings: z.record(z.string(), z.unknown()).optional(),
712
- }, async ({ pageId, blockId, content, settings }) => {
676
+ response: responseModeSchema,
677
+ }, async ({ pageId, blockId, content, settings, response }) => {
713
678
  // Fetch current page
714
679
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
715
680
  if (!pageData.page) {
@@ -775,14 +740,18 @@ export function createServer(client) {
775
740
  targetArray[targetIndex] = existingBlock;
776
741
  if (isLayout) {
777
742
  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
- };
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));
786
755
  }
787
756
  else {
788
757
  const data = await client.query(SAVE_PAGE_MUTATION, {
@@ -794,14 +763,7 @@ export function createServer(client) {
794
763
  blocks: targetArray,
795
764
  },
796
765
  });
797
- return {
798
- content: [
799
- {
800
- type: "text",
801
- text: JSON.stringify(data.savePage, null, 2),
802
- },
803
- ],
804
- };
766
+ return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
805
767
  }
806
768
  });
807
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.", {
@@ -898,19 +860,22 @@ export function createServer(client) {
898
860
  isError: true,
899
861
  };
900
862
  }
863
+ // Compact JSON: patch_block_content is ack-only (no pretty-print
864
+ // needed), matching the compact shape used by minimal-mode returns.
901
865
  return {
902
866
  content: [
903
867
  {
904
868
  type: "text",
905
- text: JSON.stringify(data.patchBlockContent, null, 2),
869
+ text: JSON.stringify(data.patchBlockContent),
906
870
  },
907
871
  ],
908
872
  };
909
873
  });
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.", {
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.", {
911
875
  pageId: z.string().describe("Page ID"),
912
876
  blockId: z.string().describe("Block instance ID (UUID) to remove"),
913
- }, async ({ pageId, blockId }) => {
877
+ response: responseModeSchema,
878
+ }, async ({ pageId, blockId, response }) => {
914
879
  const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
915
880
  if (!pageData.page) {
916
881
  return {
@@ -931,27 +896,24 @@ export function createServer(client) {
931
896
  blocks: contentBlocks,
932
897
  },
933
898
  });
934
- return {
935
- content: [
936
- {
937
- type: "text",
938
- text: JSON.stringify(data.savePage, null, 2),
939
- },
940
- ],
941
- };
899
+ return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
942
900
  }
943
901
  // Try layout blocks
944
902
  const layoutBlocks = (page.layoutBlocks || []).filter((b) => b.id !== blockId);
945
903
  if (layoutBlocks.length < (page.layoutBlocks || []).length) {
946
904
  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
- };
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));
955
917
  }
956
918
  return {
957
919
  content: [
@@ -1007,7 +969,7 @@ export function createServer(client) {
1007
969
  ],
1008
970
  };
1009
971
  });
1010
- 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.", {
1011
973
  name: z.string().describe("Form name"),
1012
974
  slug: z.string().describe("URL-friendly slug (must be unique)"),
1013
975
  description: z.string().optional().describe("Form description"),
@@ -1090,7 +1052,8 @@ export function createServer(client) {
1090
1052
  }))
1091
1053
  .optional()
1092
1054
  .describe("Form settings (action type, notifications, etc.)"),
1093
- }, async ({ name, slug, description, fields, settings }) => {
1055
+ response: responseModeSchema,
1056
+ }, async ({ name, slug, description, fields, settings, response }) => {
1094
1057
  const input = { name, slug };
1095
1058
  if (description)
1096
1059
  input.description = description;
@@ -1099,16 +1062,9 @@ export function createServer(client) {
1099
1062
  if (settings)
1100
1063
  input.settings = settings;
1101
1064
  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
- };
1065
+ return jsonText(response, data.createForm, formMinimal);
1110
1066
  });
1111
- 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.", {
1112
1068
  formId: z.string().describe("Form ID to update"),
1113
1069
  name: z.string().optional().describe("New form name"),
1114
1070
  slug: z.string().optional().describe("New slug"),
@@ -1188,7 +1144,8 @@ export function createServer(client) {
1188
1144
  }))
1189
1145
  .optional()
1190
1146
  .describe("Updated settings"),
1191
- }, async ({ formId, name, slug, description, status, fields, settings }) => {
1147
+ response: responseModeSchema,
1148
+ }, async ({ formId, name, slug, description, status, fields, settings, response, }) => {
1192
1149
  const input = {};
1193
1150
  if (name !== undefined)
1194
1151
  input.name = name;
@@ -1211,14 +1168,7 @@ export function createServer(client) {
1211
1168
  isError: true,
1212
1169
  };
1213
1170
  }
1214
- return {
1215
- content: [
1216
- {
1217
- type: "text",
1218
- text: JSON.stringify(data.updateForm, null, 2),
1219
- },
1220
- ],
1221
- };
1171
+ return jsonText(response, data.updateForm, formMinimal);
1222
1172
  });
1223
1173
  server.tool("delete_form", "Permanently delete a form and all its submissions.", {
1224
1174
  formId: z.string().describe("Form ID to delete"),
@@ -1311,6 +1261,468 @@ export function createServer(client) {
1311
1261
  ],
1312
1262
  };
1313
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
+ });
1314
1726
  // ─── Resources ───────────────────────────────────────────────
1315
1727
  server.resource("sitemap", "cmssy://sitemap", {
1316
1728
  description: "Full page tree as JSON — all pages with hierarchy",