@cmssy/mcp-server 0.16.2 → 0.17.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
@@ -2,10 +2,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { z } from "zod";
6
- import { createRecordTool, createDiscountTool, createPageTool, createFormTool, createModelTool, updateModelTool, updateRecordTool, } from "@cmssy/ai-tools";
5
+ import { listPagesTool, getPageTool, getSiteConfigTool, getWorkspaceInfoTool, listMediaTool, createPageTool, updatePageBlocksTool, updatePageSettingsTool, listPageTypesTool, createPageTypeTool, publishPageTool, unpublishPageTool, revertToPublishedTool, deletePageTool, updatePageLayoutTool, addBlockToPageTool, updateBlockContentTool, patchBlockContentTool, removeBlockFromPageTool, listFormsTool, getFormTool, createFormTool, updateFormTool, deleteFormTool, listFormSubmissionsTool, getFormSubmissionTool, updateFormSubmissionStatusTool, deleteFormSubmissionTool, listModelsTool, getModelTool, createModelTool, updateModelTool, deleteModelTool, listRecordsTool, getRecordTool, createRecordTool, updateRecordTool, deleteRecordTool, importRecordsTool, listOrdersTool, getOrderTool, getOrderPipelineTool, createManualOrderTool, editOrderTool, updateOrderDetailsTool, markOrderPaidTool, recordOrderPaymentTool, refundOrderTool, cancelOrderTool, transitionOrderFulfillmentTool, setOrderPipelineStageTool, recordOrderInvoiceTool, listCartsTool, listDiscountsTool, getDiscountTool, createDiscountTool, updateDiscountTool, setDiscountEnabledTool, listProductsTool, bulkUpdateProductsTool, bulkDeleteProductsTool, listWebhooksTool, listWebhookDeliveriesTool, listWebhookEventTypesTool, createWebhookTool, updateWebhookTool, rotateWebhookSecretTool, deleteWebhookTool, } from "@cmssy/ai-tools";
7
6
  import { createMcpWorkspaceOps } from "./ai-tools-ops.js";
8
7
  import { bindSharedTool } from "./ai-tools-binder.js";
8
+ import { PAGES_QUERY, SITE_CONFIG_QUERY, CURRENT_WORKSPACE_QUERY, } from "./queries.js";
9
9
  // Read our own version from package.json so the MCP handshake
10
10
  // advertises what the user actually installed, instead of drifting
11
11
  // whenever we bump the package.
@@ -19,1947 +19,91 @@ const PACKAGE_VERSION = (() => {
19
19
  return "0.0.0";
20
20
  }
21
21
  })();
22
- /**
23
- * Preprocess helper: parse JSON string to object if needed.
24
- * MCP clients may serialize complex nested objects as JSON strings
25
- * instead of passing them as objects. This ensures they're parsed.
26
- * Malformed JSON is passed through so Zod produces a validation error
27
- * instead of crashing the handler.
28
- */
29
- const jsonPreprocess = (val) => {
30
- if (typeof val !== "string")
31
- return val;
32
- try {
33
- return JSON.parse(val);
34
- }
35
- catch {
36
- return val;
37
- }
38
- };
39
- import { PAGES_QUERY, PAGE_BY_ID_QUERY, SITE_CONFIG_QUERY, CURRENT_WORKSPACE_QUERY, MEDIA_ASSETS_QUERY, SAVE_PAGE_MUTATION, PATCH_BLOCK_CONTENT_MUTATION, UPDATE_PAGE_SETTINGS_MUTATION, PAGE_TYPES_QUERY, CREATE_PAGE_TYPE_MUTATION, TOGGLE_PUBLISH_MUTATION, PUBLISH_PAGE_CONTENT_MUTATION, PUBLISH_PAGE_LAYOUT_MUTATION, REVERT_CONTENT_TO_PUBLISHED_MUTATION, REVERT_LAYOUT_TO_PUBLISHED_MUTATION, REMOVE_PAGE_MUTATION, UPDATE_PAGE_LAYOUT_MUTATION, FORMS_QUERY, FORM_BY_ID_QUERY, FORM_SUBMISSIONS_QUERY, FORM_SUBMISSION_BY_ID_QUERY, 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, DELETE_MODEL_DEFINITION_MUTATION, DELETE_MODEL_RECORD_MUTATION, IMPORT_MODEL_RECORDS_MUTATION, ORDERS_QUERY, ORDER_BY_ID_QUERY, ORDER_PIPELINE_QUERY, CREATE_MANUAL_ORDER_MUTATION, EDIT_ORDER_MUTATION, UPDATE_ORDER_DETAILS_MUTATION, MARK_ORDER_PAID_MUTATION, RECORD_ORDER_PAYMENT_MUTATION, REFUND_ORDER_MUTATION, CANCEL_ORDER_MUTATION, TRANSITION_ORDER_FULFILLMENT_MUTATION, SET_ORDER_PIPELINE_STAGE_MUTATION, RECORD_ORDER_INVOICE_MUTATION, ADMIN_CARTS_QUERY, DISCOUNTS_QUERY, DISCOUNT_BY_ID_QUERY, UPDATE_DISCOUNT_MUTATION, SET_DISCOUNT_ENABLED_MUTATION, WEBHOOK_ENDPOINTS_QUERY, WEBHOOK_DELIVERIES_QUERY, WEBHOOK_EVENT_TYPES_QUERY, CREATE_WEBHOOK_ENDPOINT_MUTATION, UPDATE_WEBHOOK_ENDPOINT_MUTATION, ROTATE_WEBHOOK_SECRET_MUTATION, DELETE_WEBHOOK_ENDPOINT_MUTATION, PRODUCT_CATALOG_QUERY, BULK_UPDATE_PRODUCT_RECORDS_MUTATION, BULK_DELETE_PRODUCT_RECORDS_MUTATION, } from "./queries.js";
40
- import { responseModeSchema, pageMinimal, pageBlockMinimal, formMinimal, orderMinimal, discountMinimal, jsonText, } from "./responses.js";
41
22
  export function createServer(client) {
42
23
  const server = new McpServer({
43
24
  name: "cmssy",
44
25
  version: PACKAGE_VERSION,
45
26
  });
46
27
  const sharedOps = createMcpWorkspaceOps(client);
47
- /** Check if value is null, undefined, or empty object */
48
- const isEmpty = (obj) => obj == null ||
49
- (typeof obj === "object" && Object.keys(obj).length === 0);
50
- /** Extract last slug segment - savePage expects relative slug, not fullSlug */
51
- function toRelativeSlug(slug) {
52
- if (slug === "/")
53
- return "/";
54
- return "/" + slug.split("/").filter(Boolean).pop();
55
- }
56
- /**
57
- * Optimistic-concurrency guard for a read-modify-write save. Passes the
58
- * version we just read as `expectedVersion`, so if another writer (e.g. the
59
- * web editor) changed the page in the read→save window the backend returns
60
- * VERSION_CONFLICT instead of silently clobbering it. Re-running the tool
61
- * re-reads the fresh page and succeeds.
62
- */
63
- function expectedVersionOf(page) {
64
- return page?.version != null ? Number(page.version) : undefined;
65
- }
66
28
  // ─── Read Tools ──────────────────────────────────────────────
67
- server.tool("list_pages", "List pages in the workspace. Optionally filter by search query (matches name, slug, displayName).", {
68
- search: z
69
- .string()
70
- .optional()
71
- .describe("Search query to filter pages by name, slug, or displayName"),
72
- }, async ({ search }) => {
73
- const data = await client.query(PAGES_QUERY, {
74
- search: search || undefined,
75
- });
76
- return {
77
- content: [
78
- { type: "text", text: JSON.stringify(data.pages, null, 2) },
79
- ],
80
- };
81
- });
82
- server.tool("get_page", "Get full page details with all blocks and i18n content. Provide either slug or id.", {
83
- slug: z.string().optional().describe("Page slug (e.g. '/' or 'about')"),
84
- id: z.string().optional().describe("Page ID"),
85
- }, async ({ slug, id }) => {
86
- if (!slug && !id) {
87
- return {
88
- content: [
89
- {
90
- type: "text",
91
- text: "Error: provide either 'slug' or 'id'",
92
- },
93
- ],
94
- isError: true,
95
- };
96
- }
97
- const data = await client.query(PAGE_BY_ID_QUERY, {
98
- pageId: id || slug,
99
- });
100
- const page = data.page;
101
- if (!page) {
102
- return {
103
- content: [{ type: "text", text: "Page not found" }],
104
- isError: true,
105
- };
106
- }
107
- return {
108
- content: [
109
- { type: "text", text: JSON.stringify(page, null, 2) },
110
- ],
111
- };
112
- });
113
- server.tool("get_site_config", "Get site configuration: languages, navigation, header, footer, site name, enabled features", {}, async () => {
114
- // Dynamically introspect header/footer types from the backend schema
115
- const [headerSel, footerSel] = await Promise.all([
116
- client.buildSelectionSet("SiteHeader"),
117
- client.buildSelectionSet("SiteFooter"),
118
- ]);
119
- const query = `
120
- query SiteConfig {
121
- siteConfig {
122
- id
123
- defaultLanguage
124
- enabledLanguages
125
- siteName
126
- enabledFeatures
127
- ${headerSel ? `header { ${headerSel} }` : ""}
128
- ${footerSel ? `footer { ${footerSel} }` : ""}
129
- }
130
- }
131
- `;
132
- const data = await client.query(query);
133
- return {
134
- content: [
135
- {
136
- type: "text",
137
- text: JSON.stringify(data.siteConfig, null, 2),
138
- },
139
- ],
140
- };
141
- });
142
- server.tool("get_workspace_info", "Get workspace name, plan, limits, and usage", {}, async () => {
143
- const data = await client.query(CURRENT_WORKSPACE_QUERY);
144
- return {
145
- content: [
146
- {
147
- type: "text",
148
- text: JSON.stringify(data.currentWorkspace, null, 2),
149
- },
150
- ],
151
- };
152
- });
153
- server.tool("list_media", "List media assets in the workspace (url, filename, alt, type)", {
154
- limit: z
155
- .number()
156
- .optional()
157
- .default(50)
158
- .describe("Max items to return (default 50, max 100)"),
159
- }, async ({ limit }) => {
160
- const data = await client.query(MEDIA_ASSETS_QUERY, { limit });
161
- return {
162
- content: [
163
- {
164
- type: "text",
165
- text: JSON.stringify(data.mediaAssets, null, 2),
166
- },
167
- ],
168
- };
169
- });
29
+ bindSharedTool(server, listPagesTool, sharedOps);
30
+ bindSharedTool(server, getPageTool, sharedOps);
31
+ bindSharedTool(server, getSiteConfigTool, sharedOps);
32
+ bindSharedTool(server, getWorkspaceInfoTool, sharedOps);
33
+ bindSharedTool(server, listMediaTool, sharedOps);
170
34
  // ─── Write Tools ─────────────────────────────────────────────
171
35
  bindSharedTool(server, createPageTool, sharedOps);
172
- server.tool("update_page_blocks", "Set the full content blocks array on a page. Replaces all existing content blocks. Block types are validated server-side on save. 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.", {
173
- pageId: z.string().describe("Page ID"),
174
- blocks: z.preprocess(jsonPreprocess, z
175
- .array(z.object({
176
- id: z.string().describe("Unique block instance ID (UUID)"),
177
- type: z.string().describe("Block type"),
178
- content: z.record(z.string(), z.unknown()).optional(),
179
- settings: z.record(z.string(), z.unknown()).optional(),
180
- style: z.record(z.string(), z.unknown()).optional(),
181
- advanced: z.record(z.string(), z.unknown()).optional(),
182
- translations: z
183
- .record(z.string(), z.object({ status: z.string() }))
184
- .optional(),
185
- defaultLanguage: z.string().optional(),
186
- metadata: z.record(z.string(), z.unknown()).optional(),
187
- blockVersion: z.string().optional(),
188
- }))
189
- .describe("Full array of content blocks to set on the page")),
190
- response: responseModeSchema,
191
- }, async ({ pageId, blocks, response }) => {
192
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
193
- if (!pageData.page) {
194
- return {
195
- content: [{ type: "text", text: "Page not found" }],
196
- isError: true,
197
- };
198
- }
199
- // Merge: preserve existing block data when not provided in input
200
- const existingBlocks = pageData.page.blocks || [];
201
- const mergedBlocks = blocks.map((block) => {
202
- const existing = existingBlocks.find((b) => b.id === block.id);
203
- if (!existing)
204
- return block;
205
- return {
206
- ...block,
207
- content: isEmpty(block.content) ? existing.content : block.content,
208
- settings: isEmpty(block.settings)
209
- ? existing.settings
210
- : block.settings,
211
- style: block.style ?? existing.style,
212
- advanced: block.advanced ?? existing.advanced,
213
- translations: isEmpty(block.translations)
214
- ? existing.translations
215
- : block.translations,
216
- defaultLanguage: block.defaultLanguage ?? existing.defaultLanguage,
217
- metadata: block.metadata ?? existing.metadata,
218
- blockVersion: block.blockVersion ?? existing.blockVersion,
219
- };
220
- });
221
- const data = await client.query(SAVE_PAGE_MUTATION, {
222
- input: {
223
- id: pageId,
224
- name: pageData.page.name,
225
- slug: toRelativeSlug(pageData.page.slug),
226
- parentId: pageData.page.parentId ?? undefined,
227
- blocks: mergedBlocks,
228
- expectedVersion: expectedVersionOf(pageData.page),
229
- },
230
- });
231
- return jsonText(response, data.savePage, pageMinimal);
232
- });
233
- server.tool("update_page_settings", "Update page metadata: name, slug, display name, SEO fields, and custom fields. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
234
- id: z.string().describe("Page ID"),
235
- name: z.string().optional().describe("Internal page name"),
236
- slug: z.string().optional().describe("URL slug"),
237
- displayName: z
238
- .preprocess(jsonPreprocess, z.record(z.string(), z.string()))
239
- .optional()
240
- .describe("Multilingual display name"),
241
- seoTitle: z
242
- .preprocess(jsonPreprocess, z.record(z.string(), z.string()))
243
- .optional()
244
- .describe("Multilingual SEO title"),
245
- seoDescription: z
246
- .preprocess(jsonPreprocess, z.record(z.string(), z.string()))
247
- .optional()
248
- .describe("Multilingual SEO description"),
249
- seoKeywords: z.array(z.string()).optional().describe("SEO keywords"),
250
- customFields: z
251
- .preprocess(jsonPreprocess, z.array(z.object({ fieldKey: z.string(), value: z.unknown() })))
252
- .optional()
253
- .describe("Custom field values: [{ fieldKey, value }]. value is JSON."),
254
- response: responseModeSchema,
255
- }, async ({ id, name, slug, displayName, seoTitle, seoDescription, seoKeywords, customFields, response, }) => {
256
- const input = { id };
257
- if (name !== undefined)
258
- input.name = name;
259
- if (slug !== undefined)
260
- input.slug = slug;
261
- if (displayName !== undefined)
262
- input.displayName = displayName;
263
- if (seoTitle !== undefined)
264
- input.seoTitle = seoTitle;
265
- if (seoDescription !== undefined)
266
- input.seoDescription = seoDescription;
267
- if (seoKeywords !== undefined)
268
- input.seoKeywords = seoKeywords;
269
- if (customFields !== undefined)
270
- input.customFields = customFields;
271
- const data = await client.query(UPDATE_PAGE_SETTINGS_MUTATION, { input });
272
- return jsonText(response, data.updatePageSettings, pageMinimal);
273
- });
274
- server.tool("list_page_types", "List page types in the workspace with their custom-field schema. Page types define which custom fields a page of that type carries.", {}, async () => {
275
- const data = await client.query(PAGE_TYPES_QUERY, {});
276
- return {
277
- content: [
278
- {
279
- type: "text",
280
- text: JSON.stringify(data.pageTypes, null, 2),
281
- },
282
- ],
283
- };
284
- });
285
- server.tool("create_page_type", "Create a page type with a custom-field schema. Pages created with this pageType carry these custom fields (settable via create_page/update_page_settings customFields).", {
286
- name: z.string().describe("Display name"),
287
- slug: z
288
- .string()
289
- .regex(/^[a-z][a-z0-9-]*$/, "lowercase, URL-safe: start with a letter, then letters/digits/hyphens")
290
- .describe("URL-safe slug, lowercase (e.g. 'post', 'case-study')"),
291
- description: z.string().optional().describe("Description"),
292
- icon: z.string().optional().describe("Lucide icon name"),
293
- urlPrefix: z
294
- .string()
295
- .optional()
296
- .describe("URL prefix for pages of this type (e.g. 'blog')"),
297
- allowChildren: z.boolean().optional(),
298
- fields: z
299
- .preprocess(jsonPreprocess, z.array(z.object({
300
- key: z
301
- .string()
302
- .describe("Field key (identifier in customFields)"),
303
- label: z.string().describe("Human-readable label"),
304
- type: z
305
- .enum([
306
- "string",
307
- "richtext",
308
- "number",
309
- "boolean",
310
- "date",
311
- "datetime",
312
- "email",
313
- "url",
314
- "media",
315
- "relation",
316
- "select",
317
- "multiselect",
318
- ])
319
- .describe("Field type"),
320
- required: z.boolean().optional(),
321
- description: z.string().optional(),
322
- options: z
323
- .array(z.string())
324
- .optional()
325
- .describe("For select/multiselect"),
326
- defaultValue: z.string().optional(),
327
- })))
328
- .optional()
329
- .describe("Custom-field schema for this page type"),
330
- response: responseModeSchema,
331
- }, async ({ name, slug, description, icon, urlPrefix, allowChildren, fields, response, }) => {
332
- const input = { name, slug };
333
- if (description !== undefined)
334
- input.description = description;
335
- if (icon !== undefined)
336
- input.icon = icon;
337
- if (urlPrefix !== undefined)
338
- input.urlPrefix = urlPrefix;
339
- if (allowChildren !== undefined)
340
- input.allowChildren = allowChildren;
341
- if (fields !== undefined)
342
- input.fields = fields;
343
- const data = await client.query(CREATE_PAGE_TYPE_MUTATION, { input });
344
- return jsonText(response, data.createPageType, (pt) => {
345
- const p = pt;
346
- return { id: p.id, name: p.name, slug: p.slug };
347
- });
348
- });
349
- server.tool("publish_page", "Publish a page or re-publish with latest draft changes. Publishes both content and layout axes. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
350
- pageId: z.string().describe("Page ID to publish"),
351
- response: responseModeSchema,
352
- }, async ({ pageId, response }) => {
353
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
354
- if (!pageData.page) {
355
- return {
356
- content: [{ type: "text", text: "Page not found" }],
357
- isError: true,
358
- };
359
- }
360
- const page = pageData.page;
361
- if (page.published &&
362
- !page.hasUnpublishedContentChanges &&
363
- !page.hasUnpublishedLayoutChanges) {
364
- return {
365
- content: [
366
- {
367
- type: "text",
368
- text: "Page is already published with latest content. No changes to publish.",
369
- },
370
- ],
371
- };
372
- }
373
- // Publishing content flips published=true and snapshots blocks.
374
- const contentData = await client.query(PUBLISH_PAGE_CONTENT_MUTATION, { id: pageId });
375
- // Publish layout too so no draft axis is left behind. The two axes are
376
- // separate backend mutations - if layout fails, content is already
377
- // published, so report the partial state instead of a bare throw.
378
- let layoutData;
379
- try {
380
- layoutData = await client.query(PUBLISH_PAGE_LAYOUT_MUTATION, { id: pageId });
381
- }
382
- catch (err) {
383
- const message = err instanceof Error ? err.message : String(err);
384
- return {
385
- content: [
386
- {
387
- type: "text",
388
- text: `Content published, but the layout axis failed and is still unpublished: ${message}. Note: re-running publish_page also re-publishes the current content draft, so publish any pending content edits first.`,
389
- },
390
- ],
391
- isError: true,
392
- };
393
- }
394
- if (!layoutData.publishPageLayout) {
395
- return {
396
- content: [
397
- {
398
- type: "text",
399
- text: "Content published, but the layout axis returned no result and is still unpublished. Note: re-running publish_page also re-publishes the current content draft, so publish any pending content edits first.",
400
- },
401
- ],
402
- isError: true,
403
- };
404
- }
405
- // Merge: content payload carries blocks, layout payload carries the
406
- // up-to-date layout flags (it ran last).
407
- const published = {
408
- ...contentData.publishPageContent,
409
- ...layoutData.publishPageLayout,
410
- };
411
- return jsonText(response, published, (p) => pageMinimal(p, { published: true }));
412
- });
413
- 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.", {
414
- pageId: z.string().describe("Page ID to unpublish"),
415
- response: responseModeSchema,
416
- }, async ({ pageId, response }) => {
417
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
418
- if (!pageData.page) {
419
- return {
420
- content: [{ type: "text", text: "Page not found" }],
421
- isError: true,
422
- };
423
- }
424
- if (!pageData.page.published) {
425
- return {
426
- content: [
427
- { type: "text", text: "Page is already unpublished" },
428
- ],
429
- };
430
- }
431
- const data = await client.query(TOGGLE_PUBLISH_MUTATION, { id: pageId });
432
- return jsonText(response, data.togglePublish, (p) => pageMinimal(p, { published: false }));
433
- });
434
- 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.", {
435
- pageId: z.string().describe("Page ID to revert"),
436
- response: responseModeSchema,
437
- }, async ({ pageId, response }) => {
438
- // Both revert mutations throw if the page has no published version, so
439
- // guard up front for a clean message (mirrors publish_page/unpublish_page).
440
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
441
- if (!pageData.page) {
442
- return {
443
- content: [{ type: "text", text: "Page not found" }],
444
- isError: true,
445
- };
446
- }
447
- if (!pageData.page.published) {
448
- return {
449
- content: [
450
- {
451
- type: "text",
452
- text: "Page has no published version to revert to",
453
- },
454
- ],
455
- isError: true,
456
- };
457
- }
458
- const contentData = await client.query(REVERT_CONTENT_TO_PUBLISHED_MUTATION, { id: pageId });
459
- const revertedContent = contentData.revertContentToPublished;
460
- if (!revertedContent) {
461
- return {
462
- content: [
463
- { type: "text", text: "Failed to revert page content" },
464
- ],
465
- isError: true,
466
- };
467
- }
468
- // Layout revert is a separate mutation - if it fails, content drafts are
469
- // already discarded, so report the partial state rather than a bare throw.
470
- let layoutData;
471
- try {
472
- layoutData = await client.query(REVERT_LAYOUT_TO_PUBLISHED_MUTATION, { id: pageId });
473
- }
474
- catch (err) {
475
- const message = err instanceof Error ? err.message : String(err);
476
- return {
477
- content: [
478
- {
479
- type: "text",
480
- text: `Content reverted, but the layout axis failed and still holds draft changes: ${message}. Note: re-running revert_to_published reverts the content axis again first, discarding any content edits made since this failure.`,
481
- },
482
- ],
483
- isError: true,
484
- };
485
- }
486
- if (!layoutData.revertLayoutToPublished) {
487
- return {
488
- content: [
489
- {
490
- type: "text",
491
- text: "Content reverted, but the layout axis returned no result and still holds draft changes. Note: re-running revert_to_published reverts the content axis again first, discarding any content edits made since this failure.",
492
- },
493
- ],
494
- isError: true,
495
- };
496
- }
497
- // Merge: content payload carries blocks, layout payload carries the
498
- // up-to-date layout flags (it ran last).
499
- const reverted = {
500
- ...revertedContent,
501
- ...layoutData.revertLayoutToPublished,
502
- };
503
- return jsonText(response, reverted, pageMinimal);
504
- });
505
- 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 }) => {
506
- const data = await client.query(REMOVE_PAGE_MUTATION, { id: pageId });
507
- return {
508
- content: [
509
- {
510
- type: "text",
511
- text: data.removePage
512
- ? "Page deleted successfully"
513
- : "Failed to delete page",
514
- },
515
- ],
516
- };
517
- });
36
+ bindSharedTool(server, updatePageBlocksTool, sharedOps);
37
+ bindSharedTool(server, updatePageSettingsTool, sharedOps);
38
+ bindSharedTool(server, listPageTypesTool, sharedOps);
39
+ bindSharedTool(server, createPageTypeTool, sharedOps);
40
+ bindSharedTool(server, publishPageTool, sharedOps);
41
+ bindSharedTool(server, unpublishPageTool, sharedOps);
42
+ bindSharedTool(server, revertToPublishedTool, sharedOps);
43
+ bindSharedTool(server, deletePageTool, sharedOps);
518
44
  // ─── Layout Tools ──────────────────────────────────────────
519
- server.tool("update_page_layout", "Update page-level layout settings: inheritance, overrides, or replace all layout blocks. Block types are validated server-side on save. Returns a minimal ack by default; pass response='full' for the full mutation response.", {
520
- pageId: z.string().describe("Page ID"),
521
- layoutBlocks: z
522
- .array(z.record(z.string(), z.unknown()))
523
- .optional()
524
- .describe("Full replacement array of layout blocks. Each must have 'type' (validated server-side on save). Position, order, content etc. are passed through to the backend."),
525
- layoutOverrides: z
526
- .array(z.object({
527
- position: z.string().describe("Layout position to override"),
528
- action: z.string().describe("Override action"),
529
- blockId: z.string().optional().describe("Replacement block ID"),
530
- }))
531
- .optional()
532
- .describe("Layout overrides for inherited positions"),
533
- inheritsLayout: z
534
- .boolean()
535
- .optional()
536
- .describe("Whether this page inherits layout from parent"),
537
- response: responseModeSchema,
538
- }, async ({ pageId, layoutBlocks, layoutOverrides, inheritsLayout, response, }) => {
539
- // Merge layout blocks: preserve existing content when not provided
540
- let mergedLayoutBlocks = layoutBlocks;
541
- if (layoutBlocks) {
542
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
543
- const existingLayoutBlocks = pageData.page?.layoutBlocks ?? [];
544
- mergedLayoutBlocks = layoutBlocks.map((block) => {
545
- const existing = existingLayoutBlocks.find((b) => b.id === block.id);
546
- if (!existing)
547
- return block;
548
- return {
549
- ...block,
550
- content: isEmpty(block.content) ? existing.content : block.content,
551
- settings: isEmpty(block.settings)
552
- ? existing.settings
553
- : block.settings,
554
- translations: isEmpty(block.translations)
555
- ? existing.translations
556
- : block.translations,
557
- };
558
- });
559
- }
560
- const input = { pageId };
561
- if (mergedLayoutBlocks !== undefined)
562
- input.layoutBlocks = mergedLayoutBlocks;
563
- if (layoutOverrides !== undefined)
564
- input.layoutOverrides = layoutOverrides;
565
- if (inheritsLayout !== undefined)
566
- input.inheritsLayout = inheritsLayout;
567
- const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input });
568
- if (!data.updatePageLayout) {
569
- return {
570
- content: [
571
- { type: "text", text: "Page not found or update failed" },
572
- ],
573
- isError: true,
574
- };
575
- }
576
- return jsonText(response, data.updatePageLayout, pageMinimal);
577
- });
45
+ bindSharedTool(server, updatePageLayoutTool, sharedOps);
578
46
  // ─── Block Helper Tools (read-modify-write) ─────────────────
579
- server.tool("add_block_to_page", "Add a block to a page. Auto-generates UUID and translation status. Pass layoutPosition (e.g. 'header'/'footer') to add a layout block; omit it for a content block in the page body. Returns a minimal ack by default ({pageId, blockId, hasUnpublishedChanges, updatedAt}); pass response='full' for the full mutation response.", {
580
- pageId: z.string().describe("Page ID"),
581
- block: z.preprocess(jsonPreprocess, z.object({
582
- type: z.string().describe("Block type"),
583
- content: z
584
- .record(z.string(), z.unknown())
585
- .describe("Language-keyed content: { en: { title: '...' }, pl: { title: '...' } }"),
586
- settings: z.record(z.string(), z.unknown()).optional(),
587
- style: z.record(z.string(), z.unknown()).optional(),
588
- })),
589
- layoutPosition: z
590
- .string()
591
- .trim()
592
- .min(1)
593
- .optional()
594
- .describe("Layout position (e.g. 'header', 'footer') to add a layout block; omit for a content block"),
595
- position: z
596
- .number()
597
- .optional()
598
- .describe("0-based position to insert at (default: end). Content blocks only - layout blocks are always appended within their position"),
599
- response: responseModeSchema,
600
- }, async ({ pageId, block, layoutPosition, position, response }) => {
601
- // Fetch current page
602
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
603
- if (!pageData.page) {
604
- return {
605
- content: [{ type: "text", text: "Page not found" }],
606
- isError: true,
607
- };
608
- }
609
- // Get site config for languages
610
- const configData = await client.query(SITE_CONFIG_QUERY);
611
- const defaultLanguage = configData.siteConfig?.defaultLanguage ?? "en";
612
- const enabledLanguages = configData.siteConfig?.enabledLanguages ?? [
613
- defaultLanguage,
614
- ];
615
- // Build translations object
616
- const translations = {};
617
- for (const lang of enabledLanguages) {
618
- translations[lang] = {
619
- status: block.content[lang] ? "completed" : "pending",
620
- };
621
- }
622
- const newBlockId = crypto.randomUUID();
623
- if (layoutPosition) {
624
- // Layout block — add to layoutBlocks via updatePageLayout
625
- const existingLayoutBlocks = pageData.page.layoutBlocks || [];
626
- // Append after existing blocks in same position
627
- const maxOrder = existingLayoutBlocks
628
- .filter((b) => b.position === layoutPosition)
629
- .reduce((max, b) => Math.max(max, b.order), -1);
630
- const newLayoutBlock = {
631
- id: newBlockId,
632
- type: block.type,
633
- position: layoutPosition,
634
- order: maxOrder + 1,
635
- isActive: true,
636
- content: block.content,
637
- settings: block.settings,
638
- style: block.style,
639
- translations,
640
- defaultLanguage,
641
- };
642
- const layoutBlocks = [...existingLayoutBlocks, newLayoutBlock];
643
- const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks } });
644
- if (!data.updatePageLayout) {
645
- return {
646
- content: [
647
- {
648
- type: "text",
649
- text: `Failed to update layout while adding block to page '${pageId}'`,
650
- },
651
- ],
652
- isError: true,
653
- };
654
- }
655
- return jsonText(response, { blockId: newBlockId, page: data.updatePageLayout }, () => pageBlockMinimal(data.updatePageLayout, newBlockId));
656
- }
657
- else {
658
- // Content block — add to blocks via savePage
659
- const newBlock = {
660
- id: newBlockId,
661
- type: block.type,
662
- content: block.content,
663
- settings: block.settings,
664
- style: block.style,
665
- translations,
666
- defaultLanguage,
667
- };
668
- const blocks = [...pageData.page.blocks];
669
- if (position !== undefined &&
670
- position >= 0 &&
671
- position < blocks.length) {
672
- blocks.splice(position, 0, newBlock);
673
- }
674
- else {
675
- blocks.push(newBlock);
676
- }
677
- const data = await client.query(SAVE_PAGE_MUTATION, {
678
- input: {
679
- id: pageId,
680
- name: pageData.page.name,
681
- slug: toRelativeSlug(pageData.page.slug),
682
- parentId: pageData.page.parentId ?? undefined,
683
- blocks,
684
- expectedVersion: expectedVersionOf(pageData.page),
685
- },
686
- });
687
- return jsonText(response, { blockId: newBlockId, page: data.savePage }, () => pageBlockMinimal(data.savePage, newBlockId));
688
- }
689
- });
690
- 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.", {
691
- pageId: z.string().describe("Page ID"),
692
- blockId: z.string().describe("Block instance ID (UUID) to update"),
693
- content: z
694
- .record(z.string(), z.unknown())
695
- .describe("Content to merge: { en: { title: 'New Title' } }"),
696
- settings: z.record(z.string(), z.unknown()).optional(),
697
- response: responseModeSchema,
698
- }, async ({ pageId, blockId, content, settings, response }) => {
699
- // Fetch current page
700
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
701
- if (!pageData.page) {
702
- return {
703
- content: [{ type: "text", text: "Page not found" }],
704
- isError: true,
705
- };
706
- }
707
- const page = pageData.page;
708
- // Search in content blocks first, then layout blocks
709
- const contentIdx = page.blocks.findIndex((b) => b.id === blockId);
710
- const layoutIdx = contentIdx === -1
711
- ? (page.layoutBlocks || []).findIndex((b) => b.id === blockId)
712
- : -1;
713
- if (contentIdx === -1 && layoutIdx === -1) {
714
- return {
715
- content: [
716
- {
717
- type: "text",
718
- text: `Block '${blockId}' not found on page (checked both content and layout blocks)`,
719
- },
720
- ],
721
- isError: true,
722
- };
723
- }
724
- const isLayout = layoutIdx !== -1;
725
- const targetArray = isLayout
726
- ? [...(page.layoutBlocks || [])]
727
- : [...page.blocks];
728
- const targetIndex = isLayout ? layoutIdx : contentIdx;
729
- // Merge content
730
- const existingBlock = { ...targetArray[targetIndex] };
731
- const mergedContent = { ...(existingBlock.content ?? {}) };
732
- for (const [lang, langContent] of Object.entries(content)) {
733
- if (typeof langContent === "object" &&
734
- langContent !== null &&
735
- typeof mergedContent[lang] === "object" &&
736
- mergedContent[lang] !== null) {
737
- mergedContent[lang] = {
738
- ...mergedContent[lang],
739
- ...langContent,
740
- };
741
- }
742
- else {
743
- mergedContent[lang] = langContent;
744
- }
745
- }
746
- existingBlock.content = mergedContent;
747
- if (settings) {
748
- existingBlock.settings = {
749
- ...(existingBlock.settings ?? {}),
750
- ...settings,
751
- };
752
- }
753
- // Update translation status
754
- if (existingBlock.translations) {
755
- for (const lang of Object.keys(content)) {
756
- if (existingBlock.translations[lang]) {
757
- existingBlock.translations[lang] = { status: "completed" };
758
- }
759
- }
760
- }
761
- targetArray[targetIndex] = existingBlock;
762
- if (isLayout) {
763
- const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks: targetArray } });
764
- if (!data.updatePageLayout) {
765
- return {
766
- content: [
767
- {
768
- type: "text",
769
- text: `Failed to update layout while editing block '${blockId}' on page '${pageId}'`,
770
- },
771
- ],
772
- isError: true,
773
- };
774
- }
775
- return jsonText(response, data.updatePageLayout, (p) => pageBlockMinimal(p, blockId));
776
- }
777
- else {
778
- const data = await client.query(SAVE_PAGE_MUTATION, {
779
- input: {
780
- id: pageId,
781
- name: page.name,
782
- slug: toRelativeSlug(page.slug),
783
- parentId: page.parentId ?? undefined,
784
- blocks: targetArray,
785
- expectedVersion: expectedVersionOf(page),
786
- },
787
- });
788
- return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
789
- }
790
- });
791
- 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.", {
792
- pageId: z.string().describe("Page ID"),
793
- blockId: z
794
- .string()
795
- .describe("Block instance ID (UUID) on the page. Layout blocks (header/footer) aren't supported - use update_block_content for those."),
796
- locale: z
797
- .string()
798
- .describe("Language code matching the workspace's enabledLanguages (e.g. 'en', 'pl', 'zh'). Check get_site_config first if unsure."),
799
- fieldPath: z
800
- .string()
801
- .optional()
802
- .describe("Field inside content[locale] to patch (default: 'content', the HTML body of docs-article blocks). Must resolve to a string."),
803
- expectedVersion: z
804
- .number()
805
- .int()
806
- .optional()
807
- .describe("Optimistic-concurrency guard: the page `version` you last read (e.g. from get_page). If set and the page changed since, the patch is rejected with VERSION_CONFLICT instead of overwriting concurrent edits - refetch the page and retry."),
808
- // `jsonPreprocess` matches the other tools in this file that accept
809
- // complex inputs - MCP clients sometimes JSON-serialize nested
810
- // arrays/objects instead of passing raw JSON. Without the preprocess
811
- // those clients fail validation before the handler ever runs.
812
- operations: z.preprocess(jsonPreprocess, z
813
- .array(
814
- // Discriminated on `op` so clients can't pass e.g. `insert_before`
815
- // with a `startMarker` - the backend would reject, but it's
816
- // cleaner (and cheaper) to fail at MCP boundary.
817
- z
818
- .discriminatedUnion("op", [
819
- z
820
- .object({
821
- op: z
822
- .literal("insert_before")
823
- .describe("Insert html immediately before `marker`"),
824
- marker: z
825
- .string()
826
- .describe("Unique anchor substring. Must match exactly one location in the field (zero or multiple → error)."),
827
- html: z.string().describe("HTML fragment to insert"),
828
- })
829
- .strict(),
830
- z
831
- .object({
832
- op: z
833
- .literal("insert_after")
834
- .describe("Insert html immediately after `marker`"),
835
- marker: z
836
- .string()
837
- .describe("Unique anchor substring. Must match exactly one location in the field (zero or multiple → error)."),
838
- html: z.string().describe("HTML fragment to insert"),
839
- })
840
- .strict(),
841
- z
842
- .object({
843
- op: z
844
- .literal("replace_section")
845
- .describe("Replace everything from startMarker (inclusive) up to endMarker (exclusive)"),
846
- startMarker: z
847
- .string()
848
- .describe("Inclusive lower bound. Must match exactly one location."),
849
- endMarker: z
850
- .string()
851
- .describe("Exclusive upper bound. Must appear exactly once after startMarker."),
852
- html: z
853
- .string()
854
- .describe("HTML fragment to replace the section with"),
855
- })
856
- .strict(),
857
- ])
858
- .describe("A single patch op. Ops apply in order; any failure aborts the whole patch (no half-applied state)."))
859
- .min(1)
860
- .describe("Ordered list of patch operations. Apply smallest first - each op must still find its markers in the string after previous ops have applied.")),
861
- }, async ({ pageId, blockId, locale, fieldPath, operations, expectedVersion, }) => {
862
- // Zod's discriminatedUnion + .strict() already shapes each op
863
- // exactly like the backend's PatchOperationInput (insert_*: op,
864
- // marker, html; replace_section: op, startMarker, endMarker, html).
865
- // Pass the array straight through - a manual map here is both
866
- // redundant AND unsafe (a switch without an exhaustiveness check
867
- // could return undefined on an unexpected op, which would then
868
- // serialize as null in the mutation variables and blow up server-side
869
- // with a hard-to-trace error).
870
- const data = await client.query(PATCH_BLOCK_CONTENT_MUTATION, {
871
- input: {
872
- pageId,
873
- blockId,
874
- locale,
875
- ...(fieldPath ? { fieldPath } : {}),
876
- ...(expectedVersion !== undefined ? { expectedVersion } : {}),
877
- operations,
878
- },
879
- });
880
- if (!data.patchBlockContent) {
881
- return {
882
- content: [
883
- {
884
- type: "text",
885
- text: "Page not found or patch failed",
886
- },
887
- ],
888
- isError: true,
889
- };
890
- }
891
- // Compact JSON: patch_block_content is ack-only (no pretty-print
892
- // needed), matching the compact shape used by minimal-mode returns.
893
- const patched = data.patchBlockContent;
894
- return {
895
- content: [
896
- {
897
- type: "text",
898
- text: JSON.stringify({
899
- id: patched.id,
900
- slug: patched.slug,
901
- hasUnpublishedChanges: Boolean(patched.hasUnpublishedContentChanges) ||
902
- Boolean(patched.hasUnpublishedLayoutChanges),
903
- updatedAt: patched.updatedAt,
904
- }),
905
- },
906
- ],
907
- };
908
- });
909
- 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.", {
910
- pageId: z.string().describe("Page ID"),
911
- blockId: z.string().describe("Block instance ID (UUID) to remove"),
912
- response: responseModeSchema,
913
- }, async ({ pageId, blockId, response }) => {
914
- const pageData = await client.query(PAGE_BY_ID_QUERY, { pageId });
915
- if (!pageData.page) {
916
- return {
917
- content: [{ type: "text", text: "Page not found" }],
918
- isError: true,
919
- };
920
- }
921
- const page = pageData.page;
922
- // Try content blocks first
923
- const contentBlocks = page.blocks.filter((b) => b.id !== blockId);
924
- if (contentBlocks.length < page.blocks.length) {
925
- const data = await client.query(SAVE_PAGE_MUTATION, {
926
- input: {
927
- id: pageId,
928
- name: page.name,
929
- slug: toRelativeSlug(page.slug),
930
- parentId: page.parentId ?? undefined,
931
- blocks: contentBlocks,
932
- expectedVersion: expectedVersionOf(page),
933
- },
934
- });
935
- return jsonText(response, data.savePage, (p) => pageBlockMinimal(p, blockId));
936
- }
937
- // Try layout blocks
938
- const layoutBlocks = (page.layoutBlocks || []).filter((b) => b.id !== blockId);
939
- if (layoutBlocks.length < (page.layoutBlocks || []).length) {
940
- const data = await client.query(UPDATE_PAGE_LAYOUT_MUTATION, { input: { pageId, layoutBlocks } });
941
- if (!data.updatePageLayout) {
942
- return {
943
- content: [
944
- {
945
- type: "text",
946
- text: `Failed to update layout while removing block '${blockId}' from page '${pageId}'`,
947
- },
948
- ],
949
- isError: true,
950
- };
951
- }
952
- return jsonText(response, data.updatePageLayout, (p) => pageBlockMinimal(p, blockId));
953
- }
954
- return {
955
- content: [
956
- {
957
- type: "text",
958
- text: `Block '${blockId}' not found on page (checked both content and layout blocks)`,
959
- },
960
- ],
961
- isError: true,
962
- };
963
- });
47
+ bindSharedTool(server, addBlockToPageTool, sharedOps);
48
+ bindSharedTool(server, updateBlockContentTool, sharedOps);
49
+ bindSharedTool(server, patchBlockContentTool, sharedOps);
50
+ bindSharedTool(server, removeBlockFromPageTool, sharedOps);
964
51
  // ─── Form Tools ──────────────────────────────────────────────
965
- server.tool("list_forms", "List forms in the workspace with optional status filter and pagination.", {
966
- status: z
967
- .enum(["draft", "published", "archived"])
968
- .optional()
969
- .describe("Filter by form status"),
970
- skip: z
971
- .number()
972
- .optional()
973
- .default(0)
974
- .describe("Number of items to skip"),
975
- limit: z
976
- .number()
977
- .optional()
978
- .default(50)
979
- .describe("Max items to return (default 50)"),
980
- }, async ({ status, skip, limit }) => {
981
- const data = await client.query(FORMS_QUERY, {
982
- status: status || undefined,
983
- skip,
984
- limit,
985
- });
986
- return {
987
- content: [
988
- { type: "text", text: JSON.stringify(data.forms, null, 2) },
989
- ],
990
- };
991
- });
992
- server.tool("get_form", "Get full form details including fields, settings, and i18n content.", {
993
- formId: z.string().describe("Form ID"),
994
- }, async ({ formId }) => {
995
- const data = await client.query(FORM_BY_ID_QUERY, { formId });
996
- if (!data.form) {
997
- return {
998
- content: [{ type: "text", text: "Form not found" }],
999
- isError: true,
1000
- };
1001
- }
1002
- return {
1003
- content: [
1004
- { type: "text", text: JSON.stringify(data.form, null, 2) },
1005
- ],
1006
- };
1007
- });
52
+ bindSharedTool(server, listFormsTool, sharedOps);
53
+ bindSharedTool(server, getFormTool, sharedOps);
1008
54
  bindSharedTool(server, createFormTool, sharedOps);
1009
- 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.", {
1010
- formId: z.string().describe("Form ID to update"),
1011
- name: z.string().optional().describe("New form name"),
1012
- slug: z.string().optional().describe("New slug"),
1013
- description: z.string().optional().describe("New description"),
1014
- status: z
1015
- .enum(["draft", "published", "archived"])
1016
- .optional()
1017
- .describe("New status"),
1018
- fields: z
1019
- .preprocess(jsonPreprocess, z.array(z.object({
1020
- id: z.string(),
1021
- name: z.string(),
1022
- fieldType: z
1023
- .enum([
1024
- "text",
1025
- "email",
1026
- "password",
1027
- "textarea",
1028
- "number",
1029
- "phone",
1030
- "url",
1031
- "date",
1032
- "datetime",
1033
- "select",
1034
- "multiselect",
1035
- "checkbox",
1036
- "radio",
1037
- "file",
1038
- "hidden",
1039
- ])
1040
- .optional(),
1041
- label: z.record(z.string(), z.unknown()).optional(),
1042
- placeholder: z.record(z.string(), z.unknown()).optional(),
1043
- helpText: z.record(z.string(), z.unknown()).optional(),
1044
- defaultValue: z.string().optional(),
1045
- validation: z
1046
- .object({
1047
- required: z.boolean().optional(),
1048
- minLength: z.number().optional(),
1049
- maxLength: z.number().optional(),
1050
- minValue: z.number().optional(),
1051
- maxValue: z.number().optional(),
1052
- pattern: z.string().optional(),
1053
- customMessage: z.string().optional(),
1054
- })
1055
- .optional(),
1056
- options: z
1057
- .array(z.object({
1058
- value: z.string(),
1059
- label: z.record(z.string(), z.unknown()).optional(),
1060
- disabled: z.boolean().optional(),
1061
- }))
1062
- .optional(),
1063
- width: z.enum(["full", "half", "third"]).optional(),
1064
- order: z.number().optional(),
1065
- showIf: z.record(z.string(), z.unknown()).optional(),
1066
- })))
1067
- .optional()
1068
- .describe("Replacement fields array"),
1069
- settings: z
1070
- .preprocess(jsonPreprocess, z.object({
1071
- actionType: z
1072
- .enum(["login", "register", "newsletter", "contact", "custom"])
1073
- .optional(),
1074
- webhookUrl: z.string().optional(),
1075
- emailRecipients: z.array(z.string()).optional(),
1076
- newsletterListId: z.string().optional(),
1077
- submitButtonLabel: z.record(z.string(), z.unknown()).optional(),
1078
- successMessage: z.record(z.string(), z.unknown()).optional(),
1079
- errorMessage: z.record(z.string(), z.unknown()).optional(),
1080
- redirectUrl: z.string().optional(),
1081
- enableCaptcha: z.boolean().optional(),
1082
- requireLogin: z.boolean().optional(),
1083
- saveSubmissions: z.boolean().optional(),
1084
- sendEmailNotification: z.boolean().optional(),
1085
- emailConfigurationId: z.string().optional(),
1086
- }))
1087
- .optional()
1088
- .describe("Updated settings"),
1089
- response: responseModeSchema,
1090
- }, async ({ formId, name, slug, description, status, fields, settings, response, }) => {
1091
- const input = {};
1092
- if (name !== undefined)
1093
- input.name = name;
1094
- if (slug !== undefined)
1095
- input.slug = slug;
1096
- if (description !== undefined)
1097
- input.description = description;
1098
- if (status !== undefined)
1099
- input.status = status;
1100
- if (fields !== undefined)
1101
- input.fields = fields;
1102
- if (settings !== undefined)
1103
- input.settings = settings;
1104
- const data = await client.query(UPDATE_FORM_MUTATION, { formId, input });
1105
- if (!data.updateForm) {
1106
- return {
1107
- content: [
1108
- { type: "text", text: "Form not found or update failed" },
1109
- ],
1110
- isError: true,
1111
- };
1112
- }
1113
- return jsonText(response, data.updateForm, formMinimal);
1114
- });
1115
- server.tool("delete_form", "Permanently delete a form and all its submissions.", {
1116
- formId: z.string().describe("Form ID to delete"),
1117
- }, async ({ formId }) => {
1118
- const data = await client.query(DELETE_FORM_MUTATION, { formId });
1119
- return {
1120
- content: [
1121
- {
1122
- type: "text",
1123
- text: data.deleteForm
1124
- ? "Form deleted successfully"
1125
- : "Failed to delete form",
1126
- },
1127
- ],
1128
- };
1129
- });
1130
- server.tool("list_form_submissions", "List form submissions with optional filters and pagination.", {
1131
- formId: z.string().optional().describe("Filter by form ID"),
1132
- status: z
1133
- .enum(["pending", "processed", "spam", "archived"])
1134
- .optional()
1135
- .describe("Filter by submission status"),
1136
- skip: z.number().optional().default(0),
1137
- limit: z.number().optional().default(50),
1138
- }, async ({ formId, status, skip, limit }) => {
1139
- const data = await client.query(FORM_SUBMISSIONS_QUERY, {
1140
- formId: formId || undefined,
1141
- status: status || undefined,
1142
- skip,
1143
- limit,
1144
- });
1145
- return {
1146
- content: [
1147
- {
1148
- type: "text",
1149
- text: JSON.stringify(data.formSubmissions, null, 2),
1150
- },
1151
- ],
1152
- };
1153
- });
1154
- server.tool("get_form_submission", "Get a specific form submission by ID.", {
1155
- submissionId: z.string().describe("Submission ID"),
1156
- }, async ({ submissionId }) => {
1157
- const data = await client.query(FORM_SUBMISSION_BY_ID_QUERY, { submissionId });
1158
- if (!data.formSubmission) {
1159
- return {
1160
- content: [{ type: "text", text: "Submission not found" }],
1161
- isError: true,
1162
- };
1163
- }
1164
- return {
1165
- content: [
1166
- {
1167
- type: "text",
1168
- text: JSON.stringify(data.formSubmission, null, 2),
1169
- },
1170
- ],
1171
- };
1172
- });
1173
- server.tool("update_form_submission_status", "Update a form submission's status (pending, processed, spam, archived).", {
1174
- submissionId: z.string().describe("Submission ID"),
1175
- status: z
1176
- .enum(["pending", "processed", "spam", "archived"])
1177
- .describe("New status"),
1178
- }, async ({ submissionId, status }) => {
1179
- const data = await client.query(UPDATE_FORM_SUBMISSION_STATUS_MUTATION, { submissionId, status });
1180
- return {
1181
- content: [
1182
- {
1183
- type: "text",
1184
- text: data.updateFormSubmissionStatus
1185
- ? "Submission status updated"
1186
- : "Failed to update submission status",
1187
- },
1188
- ],
1189
- };
1190
- });
1191
- server.tool("delete_form_submission", "Permanently delete a form submission.", {
1192
- submissionId: z.string().describe("Submission ID to delete"),
1193
- }, async ({ submissionId }) => {
1194
- const data = await client.query(DELETE_FORM_SUBMISSION_MUTATION, { submissionId });
1195
- return {
1196
- content: [
1197
- {
1198
- type: "text",
1199
- text: data.deleteFormSubmission
1200
- ? "Submission deleted successfully"
1201
- : "Failed to delete submission",
1202
- },
1203
- ],
1204
- };
1205
- });
55
+ bindSharedTool(server, updateFormTool, sharedOps);
56
+ bindSharedTool(server, deleteFormTool, sharedOps);
57
+ bindSharedTool(server, listFormSubmissionsTool, sharedOps);
58
+ bindSharedTool(server, getFormSubmissionTool, sharedOps);
59
+ bindSharedTool(server, updateFormSubmissionStatusTool, sharedOps);
60
+ bindSharedTool(server, deleteFormSubmissionTool, sharedOps);
1206
61
  // ─── Model Tools (Custom Data Models) ────────────────────────
1207
- const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
1208
- server.tool("list_models", "List all Custom Data Models (ModelDefinition) in the workspace.", {}, async () => {
1209
- const data = await client.query(MODEL_DEFINITIONS_QUERY);
1210
- return {
1211
- content: [
1212
- {
1213
- type: "text",
1214
- text: JSON.stringify(data.modelDefinitions, null, 2),
1215
- },
1216
- ],
1217
- };
1218
- });
1219
- server.tool("get_model", "Get a ModelDefinition by id (24-hex ObjectId) or by slug. Returns full definition including fields, statusField, and defaultSort.", {
1220
- idOrSlug: z.string().describe("Model id (ObjectId) or slug"),
1221
- }, async ({ idOrSlug }) => {
1222
- if (OBJECT_ID_RE.test(idOrSlug)) {
1223
- const data = await client.query(MODEL_DEFINITION_BY_ID_QUERY, { id: idOrSlug });
1224
- if (data.modelDefinition) {
1225
- return {
1226
- content: [
1227
- {
1228
- type: "text",
1229
- text: JSON.stringify(data.modelDefinition, null, 2),
1230
- },
1231
- ],
1232
- };
1233
- }
1234
- }
1235
- // Fallback: resolve slug → id via a lightweight list (id + slug only),
1236
- // then fetch the full definition for that single model. Avoids
1237
- // pulling every model's fields when only one is needed.
1238
- const index = await client.query(MODEL_DEFINITIONS_BY_SLUG_INDEX_QUERY);
1239
- const match = index.modelDefinitions.find((m) => m.slug === idOrSlug);
1240
- if (!match) {
1241
- return {
1242
- content: [
1243
- { type: "text", text: `Model '${idOrSlug}' not found` },
1244
- ],
1245
- isError: true,
1246
- };
1247
- }
1248
- const full = await client.query(MODEL_DEFINITION_BY_ID_QUERY, { id: match.id });
1249
- if (!full.modelDefinition) {
1250
- return {
1251
- content: [
1252
- { type: "text", text: `Model '${idOrSlug}' not found` },
1253
- ],
1254
- isError: true,
1255
- };
1256
- }
1257
- return {
1258
- content: [
1259
- {
1260
- type: "text",
1261
- text: JSON.stringify(full.modelDefinition, null, 2),
1262
- },
1263
- ],
1264
- };
1265
- });
62
+ bindSharedTool(server, listModelsTool, sharedOps);
63
+ bindSharedTool(server, getModelTool, sharedOps);
1266
64
  bindSharedTool(server, createModelTool, sharedOps);
1267
65
  bindSharedTool(server, updateModelTool, sharedOps);
1268
- server.tool("delete_model", "Delete a ModelDefinition. WARNING: cascades - ALL records of this model are deleted.", {
1269
- id: z.string().describe("Model id (ObjectId) to delete"),
1270
- }, async ({ id }) => {
1271
- const data = await client.query(DELETE_MODEL_DEFINITION_MUTATION, { id });
1272
- return {
1273
- content: [
1274
- {
1275
- type: "text",
1276
- text: data.deleteModelDefinition
1277
- ? "Model deleted (records cascaded)"
1278
- : "Failed to delete model",
1279
- },
1280
- ],
1281
- isError: !data.deleteModelDefinition,
1282
- };
1283
- });
1284
- 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.", {
1285
- modelId: z.string().describe("Target model id (ObjectId)"),
1286
- filter: z
1287
- .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1288
- .optional()
1289
- .describe("Plain object filter, e.g. {status: 'active'}"),
1290
- limit: z.number().int().optional().default(50),
1291
- offset: z.number().int().optional().default(0),
1292
- sort: z
1293
- .string()
1294
- .optional()
1295
- .describe("Sort expression, e.g. 'createdAt' or '-updatedAt'"),
1296
- populate: z
1297
- .array(z.string())
1298
- .optional()
1299
- .describe("Field keys of type=relation to populate"),
1300
- }, async ({ modelId, filter, limit, offset, sort, populate }) => {
1301
- const data = await client.query(MODEL_RECORDS_QUERY, {
1302
- modelId,
1303
- filter,
1304
- limit,
1305
- offset,
1306
- sort,
1307
- populate,
1308
- });
1309
- return {
1310
- content: [
1311
- {
1312
- type: "text",
1313
- text: JSON.stringify(data.modelRecords, null, 2),
1314
- },
1315
- ],
1316
- };
1317
- });
1318
- server.tool("get_record", "Get a single record by id.", {
1319
- id: z.string().describe("Record id (ObjectId)"),
1320
- }, async ({ id }) => {
1321
- const data = await client.query(MODEL_RECORD_BY_ID_QUERY, { id });
1322
- if (!data.modelRecord) {
1323
- return {
1324
- content: [{ type: "text", text: "Record not found" }],
1325
- isError: true,
1326
- };
1327
- }
1328
- return {
1329
- content: [
1330
- {
1331
- type: "text",
1332
- text: JSON.stringify(data.modelRecord, null, 2),
1333
- },
1334
- ],
1335
- };
1336
- });
66
+ bindSharedTool(server, deleteModelTool, sharedOps);
67
+ bindSharedTool(server, listRecordsTool, sharedOps);
68
+ bindSharedTool(server, getRecordTool, sharedOps);
1337
69
  bindSharedTool(server, createRecordTool, sharedOps);
1338
70
  bindSharedTool(server, updateRecordTool, sharedOps);
1339
- server.tool("delete_record", "Delete a record permanently.", {
1340
- id: z.string().describe("Record id (ObjectId) to delete"),
1341
- }, async ({ id }) => {
1342
- const data = await client.query(DELETE_MODEL_RECORD_MUTATION, { id });
1343
- return {
1344
- content: [
1345
- {
1346
- type: "text",
1347
- text: data.deleteModelRecord
1348
- ? "Record deleted"
1349
- : "Failed to delete record",
1350
- },
1351
- ],
1352
- isError: !data.deleteModelRecord,
1353
- };
1354
- });
1355
- 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.", {
1356
- modelId: z.string().describe("Target model id (ObjectId)"),
1357
- rows: z
1358
- .preprocess(jsonPreprocess, z
1359
- .array(z.record(z.string(), z.unknown()))
1360
- .min(1, "Provide at least 1 row")
1361
- .max(1000, "Maximum 1000 rows per import"))
1362
- .describe("Array of plain objects keyed by model field keys"),
1363
- }, async ({ modelId, rows }) => {
1364
- const data = await client.query(IMPORT_MODEL_RECORDS_MUTATION, { input: { modelId, rows } });
1365
- return {
1366
- content: [
1367
- {
1368
- type: "text",
1369
- text: JSON.stringify(data.importModelRecords, null, 2),
1370
- },
1371
- ],
1372
- };
1373
- });
71
+ bindSharedTool(server, deleteRecordTool, sharedOps);
72
+ bindSharedTool(server, importRecordsTool, sharedOps);
1374
73
  // ─── Commerce: Orders ────────────────────────────────────────
1375
- //
1376
- // workspaceId is required by every commerce resolver and cross-checked
1377
- // against the token's workspace context, so it always comes from the
1378
- // client (the same value sent in the x-workspace-id header) - it is not
1379
- // a caller-supplied arg. All money fields are integer minor units (cents).
1380
- const PAYMENT_STATUS = [
1381
- "unpaid",
1382
- "partially_paid",
1383
- "authorized",
1384
- "paid",
1385
- "partially_refunded",
1386
- "refunded",
1387
- ];
1388
- const FULFILLMENT_STATUS = [
1389
- "unfulfilled",
1390
- "partially_fulfilled",
1391
- "fulfilled",
1392
- "returned",
1393
- ];
1394
- const CART_STATUS = [
1395
- "active",
1396
- "saved",
1397
- "quote_requested",
1398
- "merged",
1399
- "expired",
1400
- "abandoned",
1401
- "ordered",
1402
- ];
1403
- const DISCOUNT_TYPE = ["percentage", "fixed", "free_shipping"];
1404
- const orderItemSchema = z
1405
- .object({
1406
- recordId: z
1407
- .string()
1408
- .optional()
1409
- .describe("Product record id (ObjectId) the line refers to"),
1410
- quantity: z.number().int().positive(),
1411
- variantSelections: z
1412
- .preprocess(jsonPreprocess, z.record(z.string(), z.unknown()))
1413
- .optional()
1414
- .describe("Selected variant options, e.g. {size: 'M', color: 'red'}"),
1415
- name: z.string().optional().describe("Custom line name (ad-hoc item)"),
1416
- price: z
1417
- .number()
1418
- .int()
1419
- .optional()
1420
- .describe("Custom unit price in minor units (ad-hoc item)"),
1421
- })
1422
- .refine((item) => item.recordId != null || (item.name != null && item.price != null), {
1423
- message: "Each item needs a recordId, or both name and price for an ad-hoc line",
1424
- });
1425
- server.tool("list_orders", "List orders with optional filters and pagination. Money fields are integer minor units (cents). Pass pipelineStageId='__unassigned__' to list orders with no pipeline stage.", {
1426
- paymentStatus: z.enum(PAYMENT_STATUS).optional(),
1427
- fulfillmentStatus: z.enum(FULFILLMENT_STATUS).optional(),
1428
- customerId: z.string().optional(),
1429
- search: z.string().optional(),
1430
- pipelineStageId: z.string().optional(),
1431
- dateFrom: z
1432
- .string()
1433
- .optional()
1434
- .describe("ISO date-time lower bound (inclusive)"),
1435
- dateTo: z
1436
- .string()
1437
- .optional()
1438
- .describe("ISO date-time upper bound (inclusive)"),
1439
- skip: z.number().int().optional().default(0),
1440
- limit: z.number().int().optional().default(20),
1441
- }, async (args) => {
1442
- const data = await client.query(ORDERS_QUERY, { workspaceId: client.workspaceId, ...args });
1443
- return {
1444
- content: [
1445
- { type: "text", text: JSON.stringify(data.orders, null, 2) },
1446
- ],
1447
- };
1448
- });
1449
- server.tool("get_order", "Get a single order by id, including items, payments, and tax summary.", {
1450
- id: z.string().describe("Order id"),
1451
- }, async ({ id }) => {
1452
- const data = await client.query(ORDER_BY_ID_QUERY, { workspaceId: client.workspaceId, id });
1453
- if (!data.order) {
1454
- return {
1455
- content: [{ type: "text", text: "Order not found" }],
1456
- isError: true,
1457
- };
1458
- }
1459
- return {
1460
- content: [
1461
- { type: "text", text: JSON.stringify(data.order, null, 2) },
1462
- ],
1463
- };
1464
- });
1465
- server.tool("get_order_pipeline", "Get the workspace order pipeline (the configurable stages orders move through).", {}, async () => {
1466
- const data = await client.query(ORDER_PIPELINE_QUERY, { workspaceId: client.workspaceId });
1467
- return {
1468
- content: [
1469
- {
1470
- type: "text",
1471
- text: JSON.stringify(data.orderPipeline, null, 2),
1472
- },
1473
- ],
1474
- };
1475
- });
1476
- server.tool("create_manual_order", "Create a manual (admin-entered) order. Each item references a product recordId or is an ad-hoc line with name+price (minor units). Returns a minimal ack by default; pass response='full' for the full order.", {
1477
- customerEmail: z.string().describe("Customer email for the order"),
1478
- customerId: z.string().optional(),
1479
- items: z.array(orderItemSchema).min(1, "Provide at least 1 item"),
1480
- response: responseModeSchema,
1481
- }, async ({ customerEmail, customerId, items, response }) => {
1482
- const result = await client.query(CREATE_MANUAL_ORDER_MUTATION, {
1483
- input: {
1484
- workspaceId: client.workspaceId,
1485
- customerEmail,
1486
- customerId,
1487
- items,
1488
- },
1489
- });
1490
- return jsonText(response, result.createManualOrder, orderMinimal);
1491
- });
1492
- server.tool("edit_order", "Replace an order's line items (full replace of the items array). Recomputes totals. Returns a minimal ack by default; pass response='full' for the full order.", {
1493
- orderId: z.string(),
1494
- items: z.array(orderItemSchema).min(1, "Provide at least 1 item"),
1495
- response: responseModeSchema,
1496
- }, async ({ orderId, items, response }) => {
1497
- const result = await client.query(EDIT_ORDER_MUTATION, { input: { workspaceId: client.workspaceId, orderId, items } });
1498
- return jsonText(response, result.editOrder, orderMinimal);
1499
- });
1500
- server.tool("update_order_details", "Update order metadata: customer email, internal notes, and shipment tracking. Only provided fields change.", {
1501
- orderId: z.string(),
1502
- customerEmail: z.string().optional(),
1503
- notes: z.string().optional(),
1504
- trackingNumber: z.string().optional(),
1505
- trackingCarrier: z.string().optional(),
1506
- response: responseModeSchema,
1507
- }, async ({ orderId, response, ...fields }) => {
1508
- if (Object.keys(fields).length === 0) {
1509
- return {
1510
- content: [
1511
- {
1512
- type: "text",
1513
- text: "Provide at least one field to update",
1514
- },
1515
- ],
1516
- isError: true,
1517
- };
1518
- }
1519
- const result = await client.query(UPDATE_ORDER_DETAILS_MUTATION, { input: { workspaceId: client.workspaceId, orderId, ...fields } });
1520
- return jsonText(response, result.updateOrderDetails, orderMinimal);
1521
- });
1522
- server.tool("mark_order_paid", "Record a full payment for an order (does NOT verify with a payment provider - admin/manual reconciliation). amount is in minor units.", {
1523
- orderId: z.string(),
1524
- amount: z.number().int().describe("Amount in minor units (cents)"),
1525
- reference: z.string().describe("Payment reference / receipt id"),
1526
- provider: z.string().describe("Payment provider label, e.g. 'manual'"),
1527
- response: responseModeSchema,
1528
- }, async ({ orderId, amount, reference, provider, response }) => {
1529
- const result = await client.query(MARK_ORDER_PAID_MUTATION, {
1530
- input: {
1531
- workspaceId: client.workspaceId,
1532
- orderId,
1533
- amount,
1534
- reference,
1535
- provider,
1536
- },
1537
- });
1538
- return jsonText(response, result.markOrderPaid, orderMinimal);
1539
- });
1540
- server.tool("record_order_payment", "Record a (possibly partial) payment against an order's outstanding balance. amount is in minor units and capped at the balance due.", {
1541
- orderId: z.string(),
1542
- amount: z.number().int().describe("Amount in minor units (cents)"),
1543
- reference: z.string().describe("Payment reference / receipt id"),
1544
- provider: z.string().optional(),
1545
- response: responseModeSchema,
1546
- }, async ({ orderId, amount, reference, provider, response }) => {
1547
- const result = await client.query(RECORD_ORDER_PAYMENT_MUTATION, {
1548
- input: {
1549
- workspaceId: client.workspaceId,
1550
- orderId,
1551
- amount,
1552
- reference,
1553
- provider,
1554
- },
1555
- });
1556
- return jsonText(response, result.recordOrderPayment, orderMinimal);
1557
- });
1558
- server.tool("refund_order", "Refund an order. Omit amount for a full refund; pass amount (minor units) for a partial refund.", {
1559
- orderId: z.string(),
1560
- reference: z.string().describe("Refund reference / receipt id"),
1561
- amount: z
1562
- .number()
1563
- .int()
1564
- .optional()
1565
- .describe("Partial refund amount in minor units; omit for full refund"),
1566
- response: responseModeSchema,
1567
- }, async ({ orderId, reference, amount, response }) => {
1568
- const result = await client.query(REFUND_ORDER_MUTATION, {
1569
- input: {
1570
- workspaceId: client.workspaceId,
1571
- orderId,
1572
- reference,
1573
- amount,
1574
- },
1575
- });
1576
- return jsonText(response, result.refundOrder, orderMinimal);
1577
- });
1578
- server.tool("cancel_order", "Cancel an order.", {
1579
- orderId: z.string(),
1580
- response: responseModeSchema,
1581
- }, async ({ orderId, response }) => {
1582
- const result = await client.query(CANCEL_ORDER_MUTATION, { input: { workspaceId: client.workspaceId, orderId } });
1583
- return jsonText(response, result.cancelOrder, orderMinimal);
1584
- });
1585
- server.tool("transition_order_fulfillment", "Move an order to a new fulfillment status. Optionally attach tracking when marking fulfilled.", {
1586
- orderId: z.string(),
1587
- status: z.enum(FULFILLMENT_STATUS),
1588
- trackingNumber: z.string().optional(),
1589
- trackingCarrier: z.string().optional(),
1590
- response: responseModeSchema,
1591
- }, async ({ orderId, status, trackingNumber, trackingCarrier, response }) => {
1592
- const result = await client.query(TRANSITION_ORDER_FULFILLMENT_MUTATION, {
1593
- input: {
1594
- workspaceId: client.workspaceId,
1595
- orderId,
1596
- status,
1597
- trackingNumber,
1598
- trackingCarrier,
1599
- },
1600
- });
1601
- return jsonText(response, result.transitionOrderFulfillment, orderMinimal);
1602
- });
1603
- server.tool("set_order_pipeline_stage", "Move an order to a pipeline stage (use get_order_pipeline for valid stage ids).", {
1604
- orderId: z.string(),
1605
- stageId: z.string(),
1606
- response: responseModeSchema,
1607
- }, async ({ orderId, stageId, response }) => {
1608
- const result = await client.query(SET_ORDER_PIPELINE_STAGE_MUTATION, { input: { workspaceId: client.workspaceId, orderId, stageId } });
1609
- return jsonText(response, result.setOrderPipelineStage, orderMinimal);
1610
- });
1611
- server.tool("record_order_invoice", "Attach an invoice (number, optional URL and provider) to an order.", {
1612
- orderId: z.string(),
1613
- number: z.string().describe("Invoice number"),
1614
- url: z.string().optional(),
1615
- provider: z.string().optional(),
1616
- response: responseModeSchema,
1617
- }, async ({ orderId, number, url, provider, response }) => {
1618
- const result = await client.query(RECORD_ORDER_INVOICE_MUTATION, {
1619
- input: {
1620
- workspaceId: client.workspaceId,
1621
- orderId,
1622
- number,
1623
- url,
1624
- provider,
1625
- },
1626
- });
1627
- return jsonText(response, result.recordOrderInvoice, orderMinimal);
1628
- });
74
+ bindSharedTool(server, listOrdersTool, sharedOps);
75
+ bindSharedTool(server, getOrderTool, sharedOps);
76
+ bindSharedTool(server, getOrderPipelineTool, sharedOps);
77
+ bindSharedTool(server, createManualOrderTool, sharedOps);
78
+ bindSharedTool(server, editOrderTool, sharedOps);
79
+ bindSharedTool(server, updateOrderDetailsTool, sharedOps);
80
+ bindSharedTool(server, markOrderPaidTool, sharedOps);
81
+ bindSharedTool(server, recordOrderPaymentTool, sharedOps);
82
+ bindSharedTool(server, refundOrderTool, sharedOps);
83
+ bindSharedTool(server, cancelOrderTool, sharedOps);
84
+ bindSharedTool(server, transitionOrderFulfillmentTool, sharedOps);
85
+ bindSharedTool(server, setOrderPipelineStageTool, sharedOps);
86
+ bindSharedTool(server, recordOrderInvoiceTool, sharedOps);
1629
87
  // ─── Commerce: Carts ─────────────────────────────────────────
1630
- server.tool("list_carts", "List shopping carts (admin view) with optional status filter and pagination. totalValue is in minor units.", {
1631
- status: z.enum(CART_STATUS).optional(),
1632
- skip: z.number().int().optional().default(0),
1633
- limit: z.number().int().optional().default(20),
1634
- }, async (args) => {
1635
- const data = await client.query(ADMIN_CARTS_QUERY, { workspaceId: client.workspaceId, ...args });
1636
- return {
1637
- content: [
1638
- {
1639
- type: "text",
1640
- text: JSON.stringify(data.adminCarts, null, 2),
1641
- },
1642
- ],
1643
- };
1644
- });
88
+ bindSharedTool(server, listCartsTool, sharedOps);
1645
89
  // ─── Commerce: Discounts ─────────────────────────────────────
1646
- server.tool("list_discounts", "List discount codes with optional filters and pagination.", {
1647
- enabled: z.boolean().optional(),
1648
- type: z.enum(DISCOUNT_TYPE).optional(),
1649
- search: z.string().optional().describe("Substring match on code"),
1650
- limit: z.number().int().optional().default(25),
1651
- offset: z.number().int().optional().default(0),
1652
- }, async (args) => {
1653
- const data = await client.query(DISCOUNTS_QUERY, { workspaceId: client.workspaceId, ...args });
1654
- return {
1655
- content: [
1656
- {
1657
- type: "text",
1658
- text: JSON.stringify(data.discounts, null, 2),
1659
- },
1660
- ],
1661
- };
1662
- });
1663
- server.tool("get_discount", "Get a single discount by id.", {
1664
- id: z.string(),
1665
- }, async ({ id }) => {
1666
- const data = await client.query(DISCOUNT_BY_ID_QUERY, { workspaceId: client.workspaceId, id });
1667
- if (!data.discount) {
1668
- return {
1669
- content: [{ type: "text", text: "Discount not found" }],
1670
- isError: true,
1671
- };
1672
- }
1673
- return {
1674
- content: [
1675
- {
1676
- type: "text",
1677
- text: JSON.stringify(data.discount, null, 2),
1678
- },
1679
- ],
1680
- };
1681
- });
90
+ bindSharedTool(server, listDiscountsTool, sharedOps);
91
+ bindSharedTool(server, getDiscountTool, sharedOps);
1682
92
  bindSharedTool(server, createDiscountTool, sharedOps);
1683
- server.tool("update_discount", "Update a discount (partial - only provided fields change). code/type/currency become immutable once the discount has been used at least once.", {
1684
- id: z.string(),
1685
- code: z.string().optional(),
1686
- type: z.enum(DISCOUNT_TYPE).optional(),
1687
- value: z.number().optional(),
1688
- currency: z.string().optional(),
1689
- minSubtotal: z.number().optional(),
1690
- maxUses: z.number().int().optional(),
1691
- maxUsesPerUser: z.number().int().optional(),
1692
- startsAt: z.string().optional().describe("ISO date-time"),
1693
- endsAt: z.string().optional().describe("ISO date-time"),
1694
- enabled: z.boolean().optional(),
1695
- response: responseModeSchema,
1696
- }, async ({ id, response, ...input }) => {
1697
- if (Object.keys(input).length === 0) {
1698
- return {
1699
- content: [
1700
- {
1701
- type: "text",
1702
- text: "Provide at least one field to update",
1703
- },
1704
- ],
1705
- isError: true,
1706
- };
1707
- }
1708
- const result = await client.query(UPDATE_DISCOUNT_MUTATION, { workspaceId: client.workspaceId, id, input });
1709
- return jsonText(response, result.updateDiscount, discountMinimal);
1710
- });
1711
- server.tool("set_discount_enabled", "Enable or disable a discount code.", {
1712
- id: z.string(),
1713
- enabled: z.boolean(),
1714
- response: responseModeSchema,
1715
- }, async ({ id, enabled, response }) => {
1716
- const result = await client.query(SET_DISCOUNT_ENABLED_MUTATION, { workspaceId: client.workspaceId, id, enabled });
1717
- return jsonText(response, result.setDiscountEnabled, discountMinimal);
1718
- });
93
+ bindSharedTool(server, updateDiscountTool, sharedOps);
94
+ bindSharedTool(server, setDiscountEnabledTool, sharedOps);
1719
95
  // ─── Commerce: Products (catalog over model records) ─────────
1720
- //
1721
- // Products are stored as records of a Custom Data Model. These tools add
1722
- // product-aware reads/writes (stock + variants) on top of the generic
1723
- // record tools. Money fields are integer minor units (cents).
1724
- const productFilterSchema = z.object({
1725
- search: z.string().optional(),
1726
- status: z.string().optional(),
1727
- stockState: z
1728
- .enum(["in", "low", "out"])
1729
- .optional()
1730
- .describe("in stock / low stock / out of stock"),
1731
- priceMin: z.number().optional(),
1732
- priceMax: z.number().optional(),
1733
- hasVariants: z.boolean().optional(),
1734
- });
1735
- server.tool("list_products", "List a product model's catalog with stock and variant info. modelId is the Custom Data Model holding the products. Returns CatalogItem rows (onHand/reserved/available + per-variant stock) plus the workspace lowStockThreshold.", {
1736
- modelId: z.string().describe("Product model id (ObjectId)"),
1737
- filter: productFilterSchema.optional(),
1738
- limit: z.number().int().optional().default(50),
1739
- offset: z.number().int().optional().default(0),
1740
- sort: z
1741
- .string()
1742
- .optional()
1743
- .describe("Sort expression, e.g. 'createdAt' or '-updatedAt'"),
1744
- }, async ({ modelId, filter, limit, offset, sort }) => {
1745
- const data = await client.query(PRODUCT_CATALOG_QUERY, { modelId, filter, limit, offset, sort });
1746
- return {
1747
- content: [
1748
- {
1749
- type: "text",
1750
- text: JSON.stringify(data.productCatalog, null, 2),
1751
- },
1752
- ],
1753
- };
1754
- });
1755
- // Selection model shared by the bulk product mutations: target an explicit
1756
- // list of ids, OR everything matching a filter (allMatching=true). One of
1757
- // the two is required by the backend.
1758
- const productSelectionSchema = z
1759
- .object({
1760
- ids: z
1761
- .array(z.string())
1762
- .optional()
1763
- .describe("Explicit record ids to target"),
1764
- allMatching: z
1765
- .boolean()
1766
- .optional()
1767
- .describe("Target every record matching filter (ignores ids)"),
1768
- filter: productFilterSchema.optional(),
1769
- })
1770
- .refine((s) => (s.ids?.length ?? 0) > 0 || s.allMatching === true, {
1771
- message: "Provide a non-empty 'ids' array or set 'allMatching: true'",
1772
- })
1773
- .describe("Provide non-empty 'ids' OR 'allMatching: true'");
1774
- server.tool("bulk_update_products", "Bulk-update selected product records with a single patch applied uniformly (status, set/adjust stock, set/adjust price). Stock fields are units; price fields are minor units. Returns the number of records updated.", {
1775
- modelId: z.string().describe("Product model id (ObjectId)"),
1776
- selection: productSelectionSchema,
1777
- patch: z
1778
- .object({
1779
- status: z.string().optional(),
1780
- setStock: z.number().int().optional().describe("Set absolute stock"),
1781
- adjustStock: z
1782
- .number()
1783
- .int()
1784
- .optional()
1785
- .describe("Delta to stock (+/-)"),
1786
- setPrice: z
1787
- .number()
1788
- .int()
1789
- .optional()
1790
- .describe("Set absolute price (minor units)"),
1791
- adjustPrice: z
1792
- .number()
1793
- .int()
1794
- .optional()
1795
- .describe("Delta to price (minor units, +/-)"),
1796
- })
1797
- .describe("Single patch applied to every selected record"),
1798
- }, async ({ modelId, selection, patch }) => {
1799
- if (Object.keys(patch).length === 0) {
1800
- return {
1801
- content: [
1802
- {
1803
- type: "text",
1804
- text: "Provide at least one patch field (status, set/adjust stock or price)",
1805
- },
1806
- ],
1807
- isError: true,
1808
- };
1809
- }
1810
- const data = await client.query(BULK_UPDATE_PRODUCT_RECORDS_MUTATION, { modelId, selection, patch });
1811
- return {
1812
- content: [
1813
- {
1814
- type: "text",
1815
- text: JSON.stringify({ updated: data.bulkUpdateProductRecords }),
1816
- },
1817
- ],
1818
- };
1819
- });
1820
- server.tool("bulk_delete_products", "Bulk-delete selected product records permanently. Returns the number of records deleted.", {
1821
- modelId: z.string().describe("Product model id (ObjectId)"),
1822
- selection: productSelectionSchema,
1823
- }, async ({ modelId, selection }) => {
1824
- const data = await client.query(BULK_DELETE_PRODUCT_RECORDS_MUTATION, { modelId, selection });
1825
- return {
1826
- content: [
1827
- {
1828
- type: "text",
1829
- text: JSON.stringify({ deleted: data.bulkDeleteProductRecords }),
1830
- },
1831
- ],
1832
- };
1833
- });
96
+ bindSharedTool(server, listProductsTool, sharedOps);
97
+ bindSharedTool(server, bulkUpdateProductsTool, sharedOps);
98
+ bindSharedTool(server, bulkDeleteProductsTool, sharedOps);
1834
99
  // ─── Webhooks ────────────────────────────────────────────────
1835
- //
1836
- // Outbound event webhooks. Like commerce, workspaceId is required by every
1837
- // resolver and sourced from the client. createWebhookEndpoint and
1838
- // rotateWebhookSecret return the signing secret ONCE - it is surfaced in
1839
- // full and cannot be retrieved again.
1840
- server.tool("list_webhooks", "List the workspace's webhook endpoints (signing secrets are never returned here).", {}, async () => {
1841
- const data = await client.query(WEBHOOK_ENDPOINTS_QUERY, { workspaceId: client.workspaceId });
1842
- return {
1843
- content: [
1844
- {
1845
- type: "text",
1846
- text: JSON.stringify(data.webhookEndpoints, null, 2),
1847
- },
1848
- ],
1849
- };
1850
- });
1851
- server.tool("list_webhook_deliveries", "List recent webhook delivery attempts (status: pending/success/failed) for debugging.", {
1852
- limit: z.number().int().optional().default(50),
1853
- }, async ({ limit }) => {
1854
- const data = await client.query(WEBHOOK_DELIVERIES_QUERY, { workspaceId: client.workspaceId, limit });
1855
- return {
1856
- content: [
1857
- {
1858
- type: "text",
1859
- text: JSON.stringify(data.webhookDeliveries, null, 2),
1860
- },
1861
- ],
1862
- };
1863
- });
1864
- server.tool("list_webhook_event_types", "List the event types a webhook can subscribe to (the authoritative allowlist).", {}, async () => {
1865
- const data = await client.query(WEBHOOK_EVENT_TYPES_QUERY, { workspaceId: client.workspaceId });
1866
- return {
1867
- content: [
1868
- {
1869
- type: "text",
1870
- text: JSON.stringify(data.webhookEventTypes, null, 2),
1871
- },
1872
- ],
1873
- };
1874
- });
1875
- // The backend validates events against its allowlist; list_webhook_event_types
1876
- // is authoritative. These are the known values at time of writing.
1877
- const WEBHOOK_EVENTS_HINT = "Event names, e.g. order.created, order.paid, order.refunded, order.canceled, order.fulfilled, order.returned, order.edited, order.pipeline_changed. Use list_webhook_event_types for the authoritative list.";
1878
- server.tool("create_webhook", "Create a webhook endpoint subscribed to one or more events. The URL must be a public https endpoint. Returns the endpoint AND its signing secret - the secret is shown only once.", {
1879
- url: z.string().describe("Public https endpoint URL"),
1880
- events: z
1881
- .array(z.string())
1882
- .min(1, "Subscribe to at least 1 event")
1883
- .describe(WEBHOOK_EVENTS_HINT),
1884
- description: z.string().optional(),
1885
- }, async ({ url, events, description }) => {
1886
- const data = await client.query(CREATE_WEBHOOK_ENDPOINT_MUTATION, {
1887
- input: { workspaceId: client.workspaceId, url, events, description },
1888
- });
1889
- return {
1890
- content: [
1891
- {
1892
- type: "text",
1893
- text: JSON.stringify(data.createWebhookEndpoint, null, 2),
1894
- },
1895
- ],
1896
- };
1897
- });
1898
- server.tool("update_webhook", "Update a webhook endpoint (partial - only provided fields change). Pass enabled to enable/disable. Pass description=null to clear it. The signing secret is not returned.", {
1899
- id: z.string(),
1900
- url: z.string().optional(),
1901
- events: z
1902
- .array(z.string())
1903
- .min(1)
1904
- .optional()
1905
- .describe(WEBHOOK_EVENTS_HINT),
1906
- enabled: z.boolean().optional(),
1907
- description: z
1908
- .string()
1909
- .nullable()
1910
- .optional()
1911
- .describe("New description; null clears it"),
1912
- }, async ({ id, ...fields }) => {
1913
- if (Object.keys(fields).length === 0) {
1914
- return {
1915
- content: [
1916
- {
1917
- type: "text",
1918
- text: "Provide at least one field to update",
1919
- },
1920
- ],
1921
- isError: true,
1922
- };
1923
- }
1924
- const data = await client.query(UPDATE_WEBHOOK_ENDPOINT_MUTATION, { input: { workspaceId: client.workspaceId, id, ...fields } });
1925
- return {
1926
- content: [
1927
- {
1928
- type: "text",
1929
- text: JSON.stringify(data.updateWebhookEndpoint, null, 2),
1930
- },
1931
- ],
1932
- };
1933
- });
1934
- server.tool("rotate_webhook_secret", "Rotate a webhook endpoint's signing secret. Returns the endpoint and the NEW secret - shown only once.", {
1935
- id: z.string(),
1936
- }, async ({ id }) => {
1937
- const data = await client.query(ROTATE_WEBHOOK_SECRET_MUTATION, { workspaceId: client.workspaceId, id });
1938
- return {
1939
- content: [
1940
- {
1941
- type: "text",
1942
- text: JSON.stringify(data.rotateWebhookSecret, null, 2),
1943
- },
1944
- ],
1945
- };
1946
- });
1947
- server.tool("delete_webhook", "Delete a webhook endpoint permanently.", {
1948
- id: z.string(),
1949
- }, async ({ id }) => {
1950
- const data = await client.query(DELETE_WEBHOOK_ENDPOINT_MUTATION, { workspaceId: client.workspaceId, id });
1951
- return {
1952
- content: [
1953
- {
1954
- type: "text",
1955
- text: data.deleteWebhookEndpoint
1956
- ? "Webhook deleted"
1957
- : "Failed to delete webhook",
1958
- },
1959
- ],
1960
- isError: !data.deleteWebhookEndpoint,
1961
- };
1962
- });
100
+ bindSharedTool(server, listWebhooksTool, sharedOps);
101
+ bindSharedTool(server, listWebhookDeliveriesTool, sharedOps);
102
+ bindSharedTool(server, listWebhookEventTypesTool, sharedOps);
103
+ bindSharedTool(server, createWebhookTool, sharedOps);
104
+ bindSharedTool(server, updateWebhookTool, sharedOps);
105
+ bindSharedTool(server, rotateWebhookSecretTool, sharedOps);
106
+ bindSharedTool(server, deleteWebhookTool, sharedOps);
1963
107
  // ─── Resources ───────────────────────────────────────────────
1964
108
  server.resource("sitemap", "cmssy://sitemap", {
1965
109
  description: "Full page tree as JSON — all pages with hierarchy",