@kezlahd/atlas-mcp 0.1.0 → 0.2.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/index.js CHANGED
@@ -20,11 +20,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
20
20
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
21
21
  import { apiRequest, ApiError, resolveConfig } from "./api.js";
22
22
  import { SECTION_CATALOG, findSection } from "./section-catalog.js";
23
+ import { READ_ACHIEVEMENTS_TOOL, readAchievements, } from "./tools/read-achievements.js";
23
24
  /* ─── Tool definitions ─────────────────────────────────────────────── */
24
25
  const TOOLS = [
25
26
  {
26
27
  name: "atlas_list_docs",
27
- description: "List docs in the current Atlas workspace. Returns { docs, next_cursor }. Use this to find a docId before pulling a template. To page through more, pass the previous next_cursor back in as `cursor`.",
28
+ description: "List docs in the current Atlas workspace. Returns { docs, next_cursor }. Use this to find a docId before pulling a template. To page through more, pass the previous next_cursor back in as `cursor`. Pass `deleted: true` to list the Recently deleted bucket instead of live docs.",
28
29
  inputSchema: {
29
30
  type: "object",
30
31
  properties: {
@@ -38,7 +39,114 @@ const TOOLS = [
38
39
  type: "string",
39
40
  description: "Opaque next_cursor from a previous response. Omit for the first page.",
40
41
  },
42
+ deleted: {
43
+ type: "boolean",
44
+ description: "When true, list ONLY soft-deleted docs (the Recently deleted bucket). Default: false — live docs only.",
45
+ },
46
+ },
47
+ },
48
+ },
49
+ {
50
+ name: "atlas_doc_create",
51
+ description: "Create a new doc in the current workspace. Returns the fresh doc's id + etag so you can pipe straight into atlas_update_section / atlas_update_template. Default kind is 'template' (the doc-crafter surface); pass 'freeform' for a Tiptap doc. Optional folder_id / shoot_id slot the doc into the same tree the web UI uses. Pass `preset_id` to stamp a server-side preset skeleton (see atlas_list_doc_presets for the catalogue) — mutually exclusive with hand-rolled template_content (template_content wins if both are set).",
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {
55
+ title: { type: "string", description: "Human-readable title (≤ 200 chars)." },
56
+ doc_kind: {
57
+ type: "string",
58
+ enum: ["template", "freeform"],
59
+ description: "Defaults to 'template' if omitted.",
60
+ },
61
+ folder_id: {
62
+ type: "string",
63
+ description: "Optional doc_folders id to nest under.",
64
+ },
65
+ shoot_id: {
66
+ type: "string",
67
+ description: "Optional shoots id — attach the doc to a shoot.",
68
+ },
69
+ template_content: {
70
+ type: "object",
71
+ description: "Optional starter { cover?, docSettings?, sections: [] } payload. Omit to create empty and fill later.",
72
+ },
73
+ preset_id: {
74
+ type: "string",
75
+ description: "Optional preset id from atlas_list_doc_presets. Stamps that preset's section skeleton into the new doc. Ignored when template_content is also supplied.",
76
+ },
77
+ },
78
+ required: ["title"],
79
+ },
80
+ },
81
+ {
82
+ name: "atlas_list_doc_presets",
83
+ description: "List every doc preset available in Atlas (shoot-day, deliverable, planning templates). Returns the full catalogue with each preset's id, label, blurb, category, estimated fill time, default title, and the ordered list of sections it'll stamp. Use this to show the user options before calling atlas_create_doc or atlas_create_doc_from_preset.",
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {},
87
+ },
88
+ },
89
+ {
90
+ name: "atlas_create_doc_from_preset",
91
+ description: "Convenience wrapper over atlas_doc_create — creates a new doc and stamps the chosen preset's section skeleton in one call. Use when the user says 'create me a model shoot day doc' or similar. Title defaults to the preset's defaultTitle; override to taste. Returns the created doc record (same shape as atlas_doc_create).",
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ preset_id: {
96
+ type: "string",
97
+ description: "Preset id from atlas_list_doc_presets (e.g. 'model-shoot-day', 'brand-narrative-drop').",
98
+ },
99
+ title: {
100
+ type: "string",
101
+ description: "Override for the doc title. Defaults to the preset's defaultTitle when omitted.",
102
+ },
103
+ folder_id: {
104
+ type: "string",
105
+ description: "Optional doc_folders id to nest under.",
106
+ },
107
+ shoot_id: {
108
+ type: "string",
109
+ description: "Optional shoots id — attach the doc to a shoot.",
110
+ },
111
+ },
112
+ required: ["preset_id"],
113
+ },
114
+ },
115
+ {
116
+ name: "atlas_doc_delete",
117
+ description: "Soft-delete a doc — moves it to the workspace's Recently deleted bucket for 30 days. Reversible with atlas_restore_doc. Pass `purge: true` to hard-delete forever instead (requires the doc is already soft-deleted). Destructive — always confirm with the user before calling. Returns { ok, id, title, deleted, recoverable_until? } or { ok, id, purged: true }.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {
121
+ docId: { type: "string", description: "Doc id from atlas_list_docs." },
122
+ purge: {
123
+ type: "boolean",
124
+ description: "When true, hard-delete forever (requires the doc is already soft-deleted). Default: false — soft-delete only.",
125
+ },
126
+ },
127
+ required: ["docId"],
128
+ },
129
+ },
130
+ {
131
+ name: "atlas_restore_doc",
132
+ description: "Restore a soft-deleted doc from the Recently deleted bucket. Requires templates:update. Silent no-op on a live doc (responds with restored:false).",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: {
136
+ docId: { type: "string", description: "Doc id from atlas_list_docs?deleted=1." },
137
+ },
138
+ required: ["docId"],
139
+ },
140
+ },
141
+ {
142
+ name: "atlas_purge_doc",
143
+ description: "Hard-delete a soft-deleted doc — permanent, no 30-day undo. Requires the doc is already in the trash (returns 409 otherwise, prompting you to atlas_doc_delete first). Always double-confirm with the user before calling.",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ docId: { type: "string", description: "Doc id (must already be soft-deleted)." },
41
148
  },
149
+ required: ["docId"],
42
150
  },
43
151
  },
44
152
  {
@@ -65,7 +173,7 @@ const TOOLS = [
65
173
  },
66
174
  {
67
175
  name: "atlas_update_section",
68
- description: "Edit a single section by id. Fetches current template, splices in the new section.data (and optionally style), writes back with the fresh etag. Safer than atlas_update_template for scoped edits — won't touch other sections.",
176
+ description: "Edit a single section by id. Uses the section-scoped PATCH endpoint — the server merges just this section into template_content, so concurrent Claude sessions editing different sections no longer clobber each other. `data` replaces the section's data object; `style` merges over existing style keys.",
69
177
  inputSchema: {
70
178
  type: "object",
71
179
  properties: {
@@ -79,8 +187,12 @@ const TOOLS = [
79
187
  type: "object",
80
188
  description: "Optional section.style overrides (titleFont, titleSize, breakBefore, etc). Merged into existing style.",
81
189
  },
190
+ hidden: {
191
+ type: "boolean",
192
+ description: "Optional — mark section hidden or visible.",
193
+ },
82
194
  },
83
- required: ["docId", "sectionId", "data"],
195
+ required: ["docId", "sectionId"],
84
196
  },
85
197
  },
86
198
  {
@@ -178,7 +290,7 @@ const TOOLS = [
178
290
  },
179
291
  {
180
292
  name: "atlas_list_gear",
181
- description: "List gear items (brand, name, quantity, photo) in the workspace. Returns { items, next_cursor }. Each item has a 1-hour signed image_url when it has an image. To page through more, pass the previous next_cursor back in as `cursor`.",
293
+ description: "List gear items (brand, name, quantity, photo) in the workspace. Returns { items, next_cursor }. Each item has a 1-hour signed image_url when it has an image. To page through more, pass the previous next_cursor back in as `cursor`. Pass `deleted: true` to list the Recently deleted bucket instead of live gear.",
182
294
  inputSchema: {
183
295
  type: "object",
184
296
  properties: {
@@ -187,6 +299,10 @@ const TOOLS = [
187
299
  type: "string",
188
300
  description: "Opaque next_cursor from a previous response. Omit for the first page.",
189
301
  },
302
+ deleted: {
303
+ type: "boolean",
304
+ description: "When true, list ONLY soft-deleted gear (the Recently deleted bucket). Default: false — live gear only.",
305
+ },
190
306
  },
191
307
  },
192
308
  },
@@ -234,7 +350,33 @@ const TOOLS = [
234
350
  },
235
351
  {
236
352
  name: "atlas_delete_gear",
237
- description: "Delete a gear item by id.",
353
+ description: "Soft-delete a gear item — moves it to the Recently deleted bucket for 30 days. Reversible with atlas_restore_gear. Pass `purge: true` to hard-delete forever (requires the item is already soft-deleted). Always confirm with the user before calling.",
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {
357
+ gearId: { type: "string" },
358
+ purge: {
359
+ type: "boolean",
360
+ description: "When true, hard-delete forever (requires the item is already soft-deleted). Default: false — soft-delete only.",
361
+ },
362
+ },
363
+ required: ["gearId"],
364
+ },
365
+ },
366
+ {
367
+ name: "atlas_restore_gear",
368
+ description: "Restore a soft-deleted gear item from the Recently deleted bucket. Requires gear:update.",
369
+ inputSchema: {
370
+ type: "object",
371
+ properties: {
372
+ gearId: { type: "string", description: "Gear id (from atlas_list_gear with deleted:true)." },
373
+ },
374
+ required: ["gearId"],
375
+ },
376
+ },
377
+ {
378
+ name: "atlas_purge_gear",
379
+ description: "Hard-delete a soft-deleted gear item — permanent, no undo. Requires the item is already in the trash (409 otherwise). Always double-confirm with the user.",
238
380
  inputSchema: {
239
381
  type: "object",
240
382
  properties: {
@@ -262,188 +404,1394 @@ const TOOLS = [
262
404
  },
263
405
  },
264
406
  {
265
- name: "atlas_rename_file",
266
- description: "Rename a workspace file. Requires files:rename. Pass just the new filename — the workspace prefix + folder are preserved. Extension changes are refused server-side.",
407
+ name: "atlas_list_shots",
408
+ description: "List shot items (still/video with reference photo + linked gear) in the workspace. Requires shots:list. Returns { items, next_cursor }. Each item has a 1-hour signed image_url when it has an image and a gear_ids array of gear_items UUIDs. To page through more, pass the previous next_cursor back in as `cursor`. Pass `deleted: true` to list the Recently deleted bucket instead of live shots.",
267
409
  inputSchema: {
268
410
  type: "object",
269
411
  properties: {
270
- path: { type: "string", description: "Full storage key returned by atlas_list_files." },
271
- new_name: { type: "string", description: "New filename only, no folder path. Keep the same extension." },
412
+ limit: { type: "number", description: "Default 50, max 500." },
413
+ cursor: {
414
+ type: "string",
415
+ description: "Opaque next_cursor from a previous response. Omit for the first page.",
416
+ },
417
+ deleted: {
418
+ type: "boolean",
419
+ description: "When true, list ONLY soft-deleted shots (the Recently deleted bucket). Default: false — live shots only.",
420
+ },
272
421
  },
273
- required: ["path", "new_name"],
274
422
  },
275
423
  },
276
424
  {
277
- name: "atlas_list_revisions",
278
- description: "List revision history for a doc template. Requires templates:list-revisions. Returns revisions newest first with id, source (ui/api/cli), author, based_on_updated_at, created_at.",
425
+ name: "atlas_add_shot",
426
+ description: "Add a single shot item. Requires shots:add. For many items at once, use atlas_import_shots. `gear_ids` must resolve to gear_items in this workspace — unknown ids surface as a 400 with the offending ids listed.",
279
427
  inputSchema: {
280
428
  type: "object",
281
429
  properties: {
282
- docId: { type: "string", description: "Doc id from atlas_list_docs." },
283
- limit: { type: "number", description: "Max rows (default 50, max 200)." },
430
+ name: { type: "string", description: "Required. ≤ 160 chars." },
431
+ kind: {
432
+ type: "string",
433
+ enum: ["still", "video"],
434
+ description: "Defaults to 'still' when omitted.",
435
+ },
436
+ notes: { type: "string", description: "Optional library-general notes. ≤ 2000 chars." },
437
+ image_path: {
438
+ type: "string",
439
+ description: "Optional. Storage key from atlas_upload_file. Must be in this workspace's prefix.",
440
+ },
441
+ gear_ids: {
442
+ type: "array",
443
+ items: { type: "string" },
444
+ description: "Optional. gear_items UUIDs to link to this shot (from atlas_list_gear).",
445
+ },
284
446
  },
285
- required: ["docId"],
447
+ required: ["name"],
286
448
  },
287
449
  },
288
450
  {
289
- name: "atlas_restore_revision",
290
- description: "Restore a doc template to an earlier revision. Requires templates:restore-revision. Overwrites the current template_content. The pre-restore state is journaled first so the restore is itself reversible.",
451
+ name: "atlas_update_shot",
452
+ description: "Update an existing shot item. Requires shots:update. Only provided fields are patched — send just the fields you want to change. Empty string on `image_path` or `notes` clears them. `gear_ids` replaces the existing link array (pass [] to clear all links).",
291
453
  inputSchema: {
292
454
  type: "object",
293
455
  properties: {
294
- docId: { type: "string", description: "Doc id from atlas_list_docs." },
295
- revisionId: { type: "string", description: "Revision id from atlas_list_revisions." },
456
+ shotId: { type: "string", description: "Shot item id from atlas_list_shots." },
457
+ name: { type: "string", description: "New shot name (≤ 160 chars)." },
458
+ kind: {
459
+ type: "string",
460
+ enum: ["still", "video"],
461
+ },
462
+ notes: {
463
+ type: "string",
464
+ description: "New library-general notes (≤ 2000 chars). Empty string clears.",
465
+ },
466
+ image_path: {
467
+ type: "string",
468
+ description: "Storage path from atlas_upload_file. Must live in this workspace's prefix. Empty string clears the image.",
469
+ },
470
+ gear_ids: {
471
+ type: "array",
472
+ items: { type: "string" },
473
+ description: "Full replacement array of gear_items UUIDs. Unknown ids for the workspace return a 400.",
474
+ },
296
475
  },
297
- required: ["docId", "revisionId"],
476
+ required: ["shotId"],
298
477
  },
299
478
  },
300
- ];
301
- /* ─── Server setup ─────────────────────────────────────────────────── */
302
- const server = new Server({ name: "atlas-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
303
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
304
- tools: TOOLS,
305
- }));
306
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
307
- const name = req.params.name;
308
- const args = (req.params.arguments ?? {});
309
- try {
310
- const result = await dispatch(name, args);
311
- return {
312
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
313
- };
314
- }
315
- catch (e) {
316
- const msg = e instanceof Error ? e.message : String(e);
317
- return {
318
- isError: true,
319
- content: [{ type: "text", text: `error: ${msg}` }],
320
- };
321
- }
322
- });
323
- async function dispatch(name, args) {
324
- switch (name) {
325
- case "atlas_list_docs":
326
- return listDocs(args);
327
- case "atlas_get_template":
328
- return getTemplate(args);
329
- case "atlas_list_sections":
330
- return listSections(args);
331
- case "atlas_update_section":
332
- return updateSection(args);
333
- case "atlas_list_files":
334
- return listFiles(args);
335
- case "atlas_upload_file":
336
- return uploadFile(args);
337
- case "atlas_get_file_url":
338
- return getFileUrl(args);
339
- case "atlas_set_file_alt":
340
- return setFileAlt(args);
341
- case "atlas_delete_file":
342
- return deleteFile(args);
343
- case "atlas_list_gear":
344
- return listGear(args);
345
- case "atlas_add_gear":
346
- return addGear(args);
347
- case "atlas_import_gear":
348
- return importGear(args);
349
- case "atlas_delete_gear":
350
- return deleteGear(args);
351
- case "atlas_update_template":
352
- return updateTemplate(args);
353
- case "atlas_describe_section_type":
354
- return describeSectionType(args);
355
- case "atlas_update_gear":
356
- return updateGear(args);
357
- case "atlas_rename_file":
358
- return renameFile(args);
359
- case "atlas_list_revisions":
360
- return listRevisions(args);
361
- case "atlas_restore_revision":
362
- return restoreRevision(args);
363
- default:
364
- throw new Error(`Unknown tool: ${name}`);
365
- }
366
- }
367
- /* ─── Tool implementations ─────────────────────────────────────────── */
368
- async function listDocs(args) {
369
- const cfg = resolveConfig();
370
- const query = {};
371
- if (typeof args.kind === "string")
372
- query.kind = args.kind;
373
- if (typeof args.limit === "number")
374
- query.limit = args.limit;
375
- if (typeof args.cursor === "string")
376
- query.cursor = args.cursor;
377
- const { data } = await apiRequest(cfg, "/api/v1/docs", { query });
378
- return data;
379
- }
380
- async function getTemplate(args) {
381
- const cfg = resolveConfig();
382
- const docId = requireString(args, "docId");
383
- const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`);
384
- return data;
385
- }
386
- async function listSections(args) {
387
- const tpl = await getTemplate(args);
388
- const sections = tpl.template_content?.sections ?? [];
389
- const summary = sections.map((s) => ({
390
- id: s.id,
391
- type: s.type,
392
- // Best-effort title extraction — most sections stash a title on data.title.
393
- title: (s.data && typeof s.data.title === "string"
394
- ? s.data.title
395
- : null) ?? "",
396
- hidden: !!s.hidden,
397
- }));
398
- return { docId: tpl.id, title: tpl.title, section_count: summary.length, sections: summary };
399
- }
400
- async function updateSection(args) {
401
- const cfg = resolveConfig();
402
- const docId = requireString(args, "docId");
403
- const sectionId = requireString(args, "sectionId");
404
- const newData = args.data;
405
- if (!isRecord(newData))
406
- throw new Error("`data` must be an object.");
407
- const newStyle = isRecord(args.style) ? args.style : null;
408
- const current = await getTemplate({ docId });
409
- const template = current.template_content;
410
- const sections = template.sections ?? [];
411
- const idx = sections.findIndex((s) => s.id === sectionId);
412
- if (idx === -1) {
413
- throw new Error(`Section '${sectionId}' not found in doc '${docId}'. Call atlas_list_sections to see current section ids.`);
414
- }
415
- const patched = { ...sections[idx], data: newData };
416
- if (newStyle) {
417
- patched.style = { ...(sections[idx].style ?? {}), ...newStyle };
418
- }
419
- const nextSections = [...sections];
420
- nextSections[idx] = patched;
421
- const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
422
- method: "PUT",
423
- body: { template_content: { ...template, sections: nextSections } },
424
- ifMatch: current.etag,
425
- });
426
- return {
427
- updated_section: sectionId,
428
- etag: data.etag,
429
- updated_at: data.updated_at,
430
- };
431
- }
432
- async function updateTemplate(args) {
433
- const cfg = resolveConfig();
434
- const docId = requireString(args, "docId");
435
- const templateContent = args.template_content;
436
- if (!isRecord(templateContent)) {
437
- throw new Error("`template_content` must be an object matching TemplateContent shape.");
438
- }
439
- const title = typeof args.title === "string" ? args.title : undefined;
440
- // Get-then-put for etag freshness.
441
- const current = await getTemplate({ docId });
442
- try {
443
- const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
444
- method: "PUT",
445
- body: { template_content: templateContent, ...(title ? { title } : {}) },
446
- ifMatch: current.etag,
479
+ {
480
+ name: "atlas_delete_shot",
481
+ description: "Soft-delete a shot item — moves it to the Recently deleted bucket for 30 days. Reversible with atlas_restore_shot. Pass `purge: true` to hard-delete forever (requires the item is already soft-deleted). Reference image in workspace-photos is preserved. Requires shots:delete. Always confirm with the user.",
482
+ inputSchema: {
483
+ type: "object",
484
+ properties: {
485
+ shotId: { type: "string" },
486
+ purge: {
487
+ type: "boolean",
488
+ description: "When true, hard-delete forever (requires the item is already soft-deleted). Default: false — soft-delete only.",
489
+ },
490
+ },
491
+ required: ["shotId"],
492
+ },
493
+ },
494
+ {
495
+ name: "atlas_restore_shot",
496
+ description: "Restore a soft-deleted shot item from the Recently deleted bucket. Requires shots:update.",
497
+ inputSchema: {
498
+ type: "object",
499
+ properties: {
500
+ shotId: { type: "string" },
501
+ },
502
+ required: ["shotId"],
503
+ },
504
+ },
505
+ {
506
+ name: "atlas_purge_shot",
507
+ description: "Hard-delete a soft-deleted shot item — permanent, no undo. Requires the item is already in the trash (409 otherwise). Always double-confirm with the user.",
508
+ inputSchema: {
509
+ type: "object",
510
+ properties: {
511
+ shotId: { type: "string" },
512
+ },
513
+ required: ["shotId"],
514
+ },
515
+ },
516
+ {
517
+ name: "atlas_import_shots",
518
+ description: "Bulk-import shot rows in one shot. Requires shots:import. Faster than calling atlas_add_shot per item. `mode: 'replace'` clears the workspace's shot library before inserting; `'append'` (default) adds on top. The endpoint only imports name/kind/notes — wire gear links up per-row via atlas_update_shot after the bulk insert.",
519
+ inputSchema: {
520
+ type: "object",
521
+ properties: {
522
+ mode: { type: "string", enum: ["append", "replace"] },
523
+ rows: {
524
+ type: "array",
525
+ description: "Up to 2000 rows. Each: { name (required), kind?, notes? }.",
526
+ items: {
527
+ type: "object",
528
+ properties: {
529
+ name: { type: "string" },
530
+ kind: { type: "string", enum: ["still", "video"] },
531
+ notes: { type: "string" },
532
+ },
533
+ required: ["name"],
534
+ },
535
+ },
536
+ },
537
+ required: ["rows"],
538
+ },
539
+ },
540
+ {
541
+ name: "atlas_list_events",
542
+ description: "List calendar events (shoots) in the workspace. Requires events:list. Returns { items, next_cursor }. Each item is a ShootSummary: id, title, starts_at, ends_at, all_day, location, shoot_type, colour_key, colour_hex, status, team_count, objectives_total/done. Filter by date range with `from`/`to` (ISO 8601), by status, or by colour_key. To page through more, pass the previous next_cursor back in as `cursor`.",
543
+ inputSchema: {
544
+ type: "object",
545
+ properties: {
546
+ limit: { type: "number", description: "Default 50, max 500." },
547
+ cursor: {
548
+ type: "string",
549
+ description: "Opaque next_cursor from a previous response. Omit for the first page.",
550
+ },
551
+ from: {
552
+ type: "string",
553
+ description: "ISO 8601 lower bound on starts_at (inclusive).",
554
+ },
555
+ to: {
556
+ type: "string",
557
+ description: "ISO 8601 upper bound on starts_at (inclusive).",
558
+ },
559
+ status: {
560
+ type: "array",
561
+ items: {
562
+ type: "string",
563
+ enum: ["planned", "confirmed", "wrapped", "cancelled"],
564
+ },
565
+ description: "Filter to these statuses only.",
566
+ },
567
+ colour: {
568
+ type: "array",
569
+ items: {
570
+ type: "string",
571
+ enum: ["rose", "amber", "sky", "violet", "lime", "stone"],
572
+ },
573
+ description: "Filter to these colour keys only.",
574
+ },
575
+ },
576
+ },
577
+ },
578
+ {
579
+ name: "atlas_get_event",
580
+ description: "Fetch a single calendar event + its crew assignments. Requires events:get. Returns { event, assignments }. Each assignment carries the workspace member's display_name, email, shoot_role, responsibilities[] and per-assignment notes.",
581
+ inputSchema: {
582
+ type: "object",
583
+ properties: {
584
+ eventId: {
585
+ type: "string",
586
+ description: "Event id from atlas_list_events.",
587
+ },
588
+ },
589
+ required: ["eventId"],
590
+ },
591
+ },
592
+ {
593
+ name: "atlas_create_event",
594
+ description: "Schedule a new calendar event. Requires events:create. `title` and `starts_at` (ISO 8601) are required. Timed events must be at least 30 minutes — the API rejects shorter durations. Defaults: `status` = 'planned', `all_day` = false. Returns { event: ShootSummary }.",
595
+ inputSchema: {
596
+ type: "object",
597
+ properties: {
598
+ title: { type: "string", description: "Event title. Required." },
599
+ starts_at: {
600
+ type: "string",
601
+ description: "ISO 8601 start timestamp. Required.",
602
+ },
603
+ ends_at: { type: "string", description: "ISO 8601 end timestamp." },
604
+ all_day: { type: "boolean", description: "Mark as an all-day event." },
605
+ location: { type: "string" },
606
+ shoot_type: {
607
+ type: "string",
608
+ description: "Free-form shoot type label.",
609
+ },
610
+ colour_key: {
611
+ type: "string",
612
+ enum: ["rose", "amber", "sky", "violet", "lime", "stone"],
613
+ },
614
+ colour_hex: {
615
+ type: "string",
616
+ description: "Custom colour hex, e.g. #ff8899.",
617
+ },
618
+ status: {
619
+ type: "string",
620
+ enum: ["planned", "confirmed", "wrapped", "cancelled"],
621
+ },
622
+ notes: { type: "string", description: "Internal notes." },
623
+ description: { type: "string", description: "Public description." },
624
+ },
625
+ required: ["title", "starts_at"],
626
+ },
627
+ },
628
+ {
629
+ name: "atlas_update_event",
630
+ description: "Patch a calendar event. Requires events:update. Only provided fields are updated — send just the fields you want to change. Same validation as create (timed events ≥ 30 minutes).",
631
+ inputSchema: {
632
+ type: "object",
633
+ properties: {
634
+ eventId: {
635
+ type: "string",
636
+ description: "Event id from atlas_list_events.",
637
+ },
638
+ title: { type: "string" },
639
+ starts_at: { type: "string", description: "New ISO 8601 start." },
640
+ ends_at: { type: "string", description: "New ISO 8601 end." },
641
+ all_day: { type: "boolean" },
642
+ location: { type: "string" },
643
+ shoot_type: { type: "string" },
644
+ colour_key: {
645
+ type: "string",
646
+ enum: ["rose", "amber", "sky", "violet", "lime", "stone"],
647
+ },
648
+ colour_hex: { type: "string" },
649
+ status: {
650
+ type: "string",
651
+ enum: ["planned", "confirmed", "wrapped", "cancelled"],
652
+ },
653
+ notes: { type: "string" },
654
+ description: { type: "string" },
655
+ },
656
+ required: ["eventId"],
657
+ },
658
+ },
659
+ {
660
+ name: "atlas_delete_event",
661
+ description: "Permanently delete a calendar event. Requires events:delete. Permanent — no trash for events, no atlas_restore_event. Always confirm with the user before calling; there is no undo.",
662
+ inputSchema: {
663
+ type: "object",
664
+ properties: {
665
+ eventId: {
666
+ type: "string",
667
+ description: "Event id from atlas_list_events.",
668
+ },
669
+ },
670
+ required: ["eventId"],
671
+ },
672
+ },
673
+ {
674
+ name: "atlas_assign_event",
675
+ description: "Assign a workspace member to a calendar event. Requires events:assign. Idempotent — assigning the same user twice returns the existing assignment row rather than duplicating. Returns { assignment }.",
676
+ inputSchema: {
677
+ type: "object",
678
+ properties: {
679
+ eventId: {
680
+ type: "string",
681
+ description: "Event id from atlas_list_events.",
682
+ },
683
+ user_id: {
684
+ type: "string",
685
+ description: "Workspace member user id. Required.",
686
+ },
687
+ shoot_role: {
688
+ type: "string",
689
+ description: "Role label (e.g. Photographer, Assistant).",
690
+ },
691
+ responsibilities: {
692
+ type: "array",
693
+ items: { type: "string" },
694
+ description: "Responsibility bullets for this assignment.",
695
+ },
696
+ notes: {
697
+ type: "string",
698
+ description: "Per-assignment notes.",
699
+ },
700
+ },
701
+ required: ["eventId", "user_id"],
702
+ },
703
+ },
704
+ {
705
+ name: "atlas_unassign_event",
706
+ description: "Remove a crew assignment from a calendar event. Requires events:assign. Confirm with the user before calling — unassigning is immediate.",
707
+ inputSchema: {
708
+ type: "object",
709
+ properties: {
710
+ eventId: {
711
+ type: "string",
712
+ description: "Event id from atlas_list_events.",
713
+ },
714
+ assignmentId: {
715
+ type: "string",
716
+ description: "Assignment id from atlas_get_event's assignments[].",
717
+ },
718
+ },
719
+ required: ["eventId", "assignmentId"],
720
+ },
721
+ },
722
+ {
723
+ name: "atlas_rename_file",
724
+ description: "Rename a workspace file. Requires files:rename. Pass just the new filename — the workspace prefix + folder are preserved. Extension changes are refused server-side.",
725
+ inputSchema: {
726
+ type: "object",
727
+ properties: {
728
+ path: { type: "string", description: "Full storage key returned by atlas_list_files." },
729
+ new_name: { type: "string", description: "New filename only, no folder path. Keep the same extension." },
730
+ },
731
+ required: ["path", "new_name"],
732
+ },
733
+ },
734
+ {
735
+ name: "atlas_list_revisions",
736
+ description: "List revision history for a doc template. Requires templates:list-revisions. Returns revisions newest first with id, source (ui/api/cli), author, based_on_updated_at, created_at.",
737
+ inputSchema: {
738
+ type: "object",
739
+ properties: {
740
+ docId: { type: "string", description: "Doc id from atlas_list_docs." },
741
+ limit: { type: "number", description: "Max rows (default 50, max 200)." },
742
+ },
743
+ required: ["docId"],
744
+ },
745
+ },
746
+ {
747
+ name: "atlas_restore_revision",
748
+ description: "Restore a doc template to an earlier revision. Requires templates:restore-revision. Overwrites the current template_content. The pre-restore state is journaled first so the restore is itself reversible.",
749
+ inputSchema: {
750
+ type: "object",
751
+ properties: {
752
+ docId: { type: "string", description: "Doc id from atlas_list_docs." },
753
+ revisionId: { type: "string", description: "Revision id from atlas_list_revisions." },
754
+ },
755
+ required: ["docId", "revisionId"],
756
+ },
757
+ },
758
+ {
759
+ name: "atlas_whoami",
760
+ description: "Describe the current token — workspace, user, granted scopes, expiry. Requires no specific scope. Call this before attempting a mutation if you're unsure which capabilities the token has; a missing scope will surface as an obvious gap in the returned scopes array rather than a 403 mid-flight.",
761
+ inputSchema: {
762
+ type: "object",
763
+ properties: {},
764
+ },
765
+ },
766
+ {
767
+ name: "atlas_verify_connection",
768
+ description: "Prove to Atlas that the Claude Desktop MCP config actually works — call this ONCE at the end of the onboarding walkthrough. Stamps workspace_members.onboarding.mcp_verified_at for the caller so the home page's Connect-Claude step auto-completes. Distinct from atlas_whoami: only this endpoint ticks the walkthrough's final phase, so accidental whoami calls won't false-positive the setup flow.",
769
+ inputSchema: { type: "object", properties: {} },
770
+ },
771
+ {
772
+ name: "atlas_list_metafield_definitions",
773
+ description: "List every metafield display definition in the workspace. Definitions tell Atlas how to render each Shopify metafield on the product page. Returns { definitions: [{ id, namespace, key, label, description, section, display_style, ... }] }.",
774
+ inputSchema: { type: "object", properties: {} },
775
+ },
776
+ {
777
+ name: "atlas_suggest_metafields",
778
+ description: "Return the shipped preset library — a curated set of Shopify metafields worth adding to a store (secondary gallery, size chart, composition, care, drop badge, etc.) with sensible display style + section pre-picked. Use this to see the full menu before deciding what to create.",
779
+ inputSchema: { type: "object", properties: {} },
780
+ },
781
+ {
782
+ name: "atlas_scan_shopify_metafields",
783
+ description: "Scan the workspace's live Shopify store (first 40 products) and return every unique metafield actually present with a sample value + product count. Great to call before creating definitions so you know what real data exists.",
784
+ inputSchema: { type: "object", properties: {} },
785
+ },
786
+ {
787
+ name: "atlas_create_metafield_definition",
788
+ description: "Create or upsert a metafield display definition. Two modes: (a) fast path — pass `preset` set to an id from atlas_suggest_metafields; (b) manual — pass namespace, key, label, section, display_style. Upserts on (workspace, namespace, key) so calling with the same coordinates edits the existing row.",
789
+ inputSchema: {
790
+ type: "object",
791
+ properties: {
792
+ preset: {
793
+ type: "string",
794
+ description: "Preset id from atlas_suggest_metafields (fast path).",
795
+ },
796
+ namespace: { type: "string", description: "Shopify namespace (e.g. custom)." },
797
+ key: { type: "string", description: "Shopify key (e.g. size_chart)." },
798
+ label: { type: "string", description: "Display label shown to team members." },
799
+ description: { type: "string", description: "Optional helper text." },
800
+ section: {
801
+ type: "string",
802
+ enum: ["overview", "gallery", "sizing", "care", "details", "specs", "custom"],
803
+ description: "Where on the product page this metafield renders.",
804
+ },
805
+ display_style: {
806
+ type: "string",
807
+ enum: [
808
+ "text",
809
+ "rich_text",
810
+ "bullet_list",
811
+ "image",
812
+ "image_gallery",
813
+ "video",
814
+ "file_download",
815
+ "badge",
816
+ "badge_list",
817
+ "key_value_table",
818
+ "measurement_table",
819
+ "specs_grid",
820
+ "number_with_unit",
821
+ "rating",
822
+ "link",
823
+ "link_list",
824
+ "color_swatches",
825
+ "product_reference",
826
+ ],
827
+ },
828
+ },
829
+ },
830
+ },
831
+ {
832
+ name: "atlas_delete_metafield_definition",
833
+ description: "Delete a metafield definition by id (from atlas_list_metafield_definitions). Shopify data unaffected — this only removes Atlas' display rule for the metafield.",
834
+ inputSchema: {
835
+ type: "object",
836
+ properties: { id: { type: "string" } },
837
+ required: ["id"],
838
+ },
839
+ },
840
+ {
841
+ name: "atlas_list_loyalty_tiers",
842
+ description: "Return the current loyalty tier ladder (Diamond / Gold / Silver / Bronze or workspace-custom). Sorted highest sort_order first; `source: \"defaults\"` when the workspace inherits the Atlas ladder. Safe read-only call — no confirmation needed before invoking.",
843
+ inputSchema: { type: "object", properties: {} },
844
+ },
845
+ {
846
+ name: "atlas_set_loyalty_tiers",
847
+ description: "Atomically replace the workspace loyalty tier ladder. **Destructive** — server deletes every existing tier for the workspace and inserts the payload, so a partial or wrong payload wipes real merchant config. ALWAYS call atlas_list_loyalty_tiers first, show the user a diff (old vs. new), and only PUT after they confirm. Requires an API token minted by an admin/owner.",
848
+ inputSchema: {
849
+ type: "object",
850
+ properties: {
851
+ tiers: {
852
+ type: "array",
853
+ description: "Full replacement array. Each entry: { key: string (unique), name: string, minSpendCents: int (>=0), minOrders: int|null, metallic: 'diamond'|'gold'|'silver'|'bronze'|null, sortOrder: int (higher = higher priority) }. Pass [] to clear the ladder — but prefer atlas_reset_loyalty_tiers for that.",
854
+ items: { type: "object" },
855
+ },
856
+ },
857
+ required: ["tiers"],
858
+ },
859
+ },
860
+ {
861
+ name: "atlas_reset_loyalty_tiers",
862
+ description: "Clear the workspace's tier ladder so it falls back to Atlas defaults. Destructive — confirm with the user first. Requires admin/owner.",
863
+ inputSchema: { type: "object", properties: {} },
864
+ },
865
+ {
866
+ name: "atlas_get_analytics_prefs",
867
+ description: "Read the workspace analytics defaults — excluded order tags, excluded Shopify financial statuses, excluded customer-level tags, and the three customer-level boolean flags (exclude_non_subscribers, exclude_guest_checkouts, exclude_unverified_emails). Applied to every roll-up on the customer intelligence page. Safe read-only call.",
868
+ inputSchema: { type: "object", properties: {} },
869
+ },
870
+ {
871
+ name: "atlas_set_analytics_prefs",
872
+ description: "Update the workspace analytics defaults. Patch semantics — omitted fields are unchanged; pass an empty array or explicit `false` to clear. Common flow: user says 'exclude non-subscribers from analytics' → call atlas_get_analytics_prefs, show current state, then PUT with exclude_non_subscribers=true after confirmation. Requires admin/owner. Financial statuses must be Shopify's canonical set: voided, refunded, partially_refunded, pending, paid.",
873
+ inputSchema: {
874
+ type: "object",
875
+ properties: {
876
+ excluded_tags: {
877
+ type: "array",
878
+ items: { type: "string" },
879
+ description: "Order tags to strip from analytics (e.g. ['PR', 'UGC']).",
880
+ },
881
+ excluded_statuses: {
882
+ type: "array",
883
+ items: { type: "string" },
884
+ description: "Shopify financial statuses to strip. Typical: voided, refunded, partially_refunded, pending.",
885
+ },
886
+ excluded_customer_tags: {
887
+ type: "array",
888
+ items: { type: "string" },
889
+ description: "Shopify customer-level tags to hide (e.g. ['wholesale', 'staff']). Distinct from order tags — matches on the customer record.",
890
+ },
891
+ exclude_non_subscribers: {
892
+ type: "boolean",
893
+ description: "Hide customers not opted in to email marketing (reads email_marketing_consent.state, falls back to accepts_marketing).",
894
+ },
895
+ exclude_guest_checkouts: {
896
+ type: "boolean",
897
+ description: "Hide customers whose Shopify state is 'disabled' — i.e. they checked out without creating an account.",
898
+ },
899
+ exclude_unverified_emails: {
900
+ type: "boolean",
901
+ description: "Hide customers where verified_email is false.",
902
+ },
903
+ },
904
+ },
905
+ },
906
+ {
907
+ name: "atlas_get_workspace",
908
+ description: "Fetch the current workspace's identity — id, name, slug, logo_url (storage path), logo_bg (hex/rgba/transparent/null), theme_json (HSL triplets + `_raw` hex block), and a signed logo URL. Requires workspace:read. The `logo_signed_url` expires in 1 hour — re-call this tool to refresh it if you need to render the logo again later.",
909
+ inputSchema: {
910
+ type: "object",
911
+ properties: {},
912
+ },
913
+ },
914
+ {
915
+ name: "atlas_update_workspace",
916
+ description: "Patch the current workspace. Only the fields you send are updated — omitted fields are untouched. Slug renames cascade across every URL under the workspace, so double-check with the user before changing it. Theme changes are merged over the existing `_raw` block (so passing just `theme.accent` keeps bg/primary/radius/font). Requires workspace:write.",
917
+ inputSchema: {
918
+ type: "object",
919
+ properties: {
920
+ name: { type: "string", description: "New workspace name (1..80 chars, trimmed)." },
921
+ slug: {
922
+ type: "string",
923
+ description: "New workspace slug — lowercase letters, digits, hyphens (2..48, must start + end alnum). Rejected if reserved or already taken.",
924
+ },
925
+ logo_bg: {
926
+ type: ["string", "null"],
927
+ description: "Tile background behind the logo. Accepts `#rrggbb`, `rgba(r, g, b, a)`, the literal `\"transparent\"`, or `null` to clear.",
928
+ },
929
+ theme: {
930
+ type: "object",
931
+ description: "Brand theme partial. Any provided field is merged over the existing `_raw` block; omitted fields keep their current values.",
932
+ properties: {
933
+ bg: { type: "string", description: "Brand background (#rrggbb)." },
934
+ primary: { type: "string", description: "Brand primary (#rrggbb)." },
935
+ accent: { type: "string", description: "Brand accent (#rrggbb)." },
936
+ radiusPx: {
937
+ type: "number",
938
+ description: "Border-radius in px — integer 0..24.",
939
+ },
940
+ font: {
941
+ type: "string",
942
+ description: "Brand font family — must match one of the shipped families in lib/fonts.ts (Inter, Manrope, DM Sans, IBM Plex Sans, Space Grotesk, Bricolage Grotesque, Instrument Serif, Fraunces, Playfair Display, JetBrains Mono).",
943
+ },
944
+ },
945
+ },
946
+ },
947
+ },
948
+ },
949
+ {
950
+ name: "atlas_export_doc",
951
+ description: "Render a doc to PDF via the PAT-auth export endpoint and write it to a local file on the machine running this MCP server. Streams the same NDJSON pipeline the CLI uses (status / warn / done events). On success returns { out_path, pages, bytes } and, when `open` is true (default), spawns the OS default PDF viewer. Use for 'export this doc' / 'save as PDF' requests. The file lives at `out_path` (resolved relative to cwd when not absolute).",
952
+ inputSchema: {
953
+ type: "object",
954
+ properties: {
955
+ doc_id: { type: "string", description: "Doc id from atlas_list_docs." },
956
+ out_path: {
957
+ type: "string",
958
+ description: "Where to write the PDF. Defaults to '<sanitised-title>.pdf' in the MCP's cwd.",
959
+ },
960
+ open: {
961
+ type: "boolean",
962
+ description: "Open the file in the OS default viewer after writing. Default true.",
963
+ },
964
+ },
965
+ required: ["doc_id"],
966
+ },
967
+ },
968
+ {
969
+ name: "atlas_build_moodboard",
970
+ description: "Assemble a moodboard section in one call. Uploads any local image paths through the signed-URL flow, re-uses already-uploaded storage keys, builds a rows×cols grid padded with nulls, and appends (or inserts at `position`) a new moodboard section. Returns { section_id, uploaded_count, total_slots }. Default grid 3×3, aspect 'square'.",
971
+ inputSchema: {
972
+ type: "object",
973
+ properties: {
974
+ doc_id: { type: "string" },
975
+ image_paths: {
976
+ type: "array",
977
+ items: { type: "string" },
978
+ description: "Local file paths OR already-uploaded storage keys (e.g. '<workspace-uuid>/files/...').",
979
+ },
980
+ rows: { type: "number", description: "Default 3." },
981
+ cols: { type: "number", description: "Default 3." },
982
+ aspect: {
983
+ type: "string",
984
+ enum: ["square", "portrait", "landscape"],
985
+ description: "Default 'square'.",
986
+ },
987
+ title: { type: "string", description: "Section title. Default 'Moodboard'." },
988
+ position: {
989
+ type: "number",
990
+ description: "Insert at this section index. Omit to append.",
991
+ },
992
+ spacing: { type: "number", description: "Cell spacing in px. Default 8." },
993
+ radius: { type: "number", description: "Cell corner radius in px. Default 8." },
994
+ },
995
+ required: ["doc_id", "image_paths"],
996
+ },
997
+ },
998
+ {
999
+ name: "atlas_extract_palette",
1000
+ description: "Extract a colour palette from a moodboard section via server-side node-vibrant. Pulls each image, extracts up to 6 swatches per image, aggregates + perceptually de-dupes to rows×cols colours, and either patches an existing palette section wired to that moodboard (sourceSectionId match) or appends a new one right below it. Returns { palette_section_id, colours: string[] }.",
1001
+ inputSchema: {
1002
+ type: "object",
1003
+ properties: {
1004
+ doc_id: { type: "string" },
1005
+ moodboard_section_id: {
1006
+ type: "string",
1007
+ description: "Section id of the moodboard to extract from.",
1008
+ },
1009
+ rows: { type: "number", description: "Default 3." },
1010
+ cols: { type: "number", description: "Default 3." },
1011
+ },
1012
+ required: ["doc_id", "moodboard_section_id"],
1013
+ },
1014
+ },
1015
+ {
1016
+ name: "atlas_search_products",
1017
+ description: "Search the workspace's connected Shopify catalogue by free-form text (title / handle / vendor / tags). Returns up to `limit` matches, each with { shopifyProductId, title, handle, vendor, price, imageUrl, status }. Requires the products:search scope AND an active Shopify connection.",
1018
+ inputSchema: {
1019
+ type: "object",
1020
+ properties: {
1021
+ query: { type: "string", description: "Search text. Empty = most recent 'any' status." },
1022
+ limit: { type: "number", description: "Default 25, max 100." },
1023
+ },
1024
+ },
1025
+ },
1026
+ {
1027
+ name: "atlas_add_products_to_doc",
1028
+ description: "Snapshot a list of Shopify products into a Products section. Each product's title/handle/price/image/vendor is embedded so the tile renders even if the Shopify connection later drops. If `section_id` is provided and refers to an existing Products section, items are appended; otherwise a new section is created and appended to the doc.",
1029
+ inputSchema: {
1030
+ type: "object",
1031
+ properties: {
1032
+ doc_id: { type: "string" },
1033
+ shopify_product_ids: {
1034
+ type: "array",
1035
+ items: { type: "string" },
1036
+ description: "Shopify product ids (string form).",
1037
+ },
1038
+ section_id: {
1039
+ type: "string",
1040
+ description: "Append to this existing products section if set.",
1041
+ },
1042
+ title: {
1043
+ type: "string",
1044
+ description: "Section title when creating a new section (default 'Products').",
1045
+ },
1046
+ },
1047
+ required: ["doc_id", "shopify_product_ids"],
1048
+ },
1049
+ },
1050
+ {
1051
+ name: "atlas_resolve_location",
1052
+ description: "Resolve a free-form address or place name via Google Places Text Search (server-side). Returns up to 5 matches: { place_id, text, lat, lng, verified: true }.",
1053
+ inputSchema: {
1054
+ type: "object",
1055
+ properties: {
1056
+ query: { type: "string", description: "Search text, e.g. 'Bondi Icebergs' or '123 George St'." },
1057
+ },
1058
+ required: ["query"],
1059
+ },
1060
+ },
1061
+ {
1062
+ name: "atlas_add_location_to_doc",
1063
+ description: "Resolve an address (via place_id or free-form text) and append it to a Locations section. Creates a new section if `section_id` is omitted. Captures place_id + lat/lng so the Atlas map preview can render without a second geocode on the next doc open.",
1064
+ inputSchema: {
1065
+ type: "object",
1066
+ properties: {
1067
+ doc_id: { type: "string" },
1068
+ address: { type: "string", description: "Free-form address." },
1069
+ place_id: { type: "string", description: "Google Places place_id (preferred when known)." },
1070
+ nickname: { type: "string", description: "Display name for the card." },
1071
+ notes: { type: "string", description: "Per-location notes." },
1072
+ section_id: { type: "string", description: "Append to this existing locations section." },
1073
+ title: { type: "string", description: "Section title when creating a new section." },
1074
+ },
1075
+ required: ["doc_id"],
1076
+ },
1077
+ },
1078
+ {
1079
+ name: "atlas_add_gear_to_doc",
1080
+ description: "Add existing gear library items to a gear section by referencing their gear_items UUIDs. Gear sections store `itemIds` only (the renderer joins against the workspace gear library at open time), so this is a thin append to that array. If `section_id` is omitted a new gear section is appended.",
1081
+ inputSchema: {
1082
+ type: "object",
1083
+ properties: {
1084
+ doc_id: { type: "string" },
1085
+ gear_ids: {
1086
+ type: "array",
1087
+ items: { type: "string" },
1088
+ description: "gear_items UUIDs from atlas_list_gear.",
1089
+ },
1090
+ section_id: { type: "string", description: "Append to this existing gear section." },
1091
+ title: { type: "string", description: "Section title when creating (default 'Gear')." },
1092
+ format: {
1093
+ type: "string",
1094
+ enum: ["table", "bullets", "images"],
1095
+ description: "Section layout when creating. Default 'table'.",
1096
+ },
1097
+ },
1098
+ required: ["doc_id", "gear_ids"],
1099
+ },
1100
+ },
1101
+ {
1102
+ name: "atlas_add_shots_to_doc",
1103
+ description: "Snapshot shots from the workspace library into a shot-list section. Each snapshot copies name + kind + notes into a row; the referenced library shot can later diverge without affecting the doc. If `section_id` is omitted a new shot-list section is appended.",
1104
+ inputSchema: {
1105
+ type: "object",
1106
+ properties: {
1107
+ doc_id: { type: "string" },
1108
+ shot_ids: {
1109
+ type: "array",
1110
+ items: { type: "string" },
1111
+ description: "shot library ids from atlas_list_shots.",
1112
+ },
1113
+ section_id: { type: "string", description: "Append to this existing shot-list section." },
1114
+ title: { type: "string", description: "Section title when creating (default 'Shot list')." },
1115
+ },
1116
+ required: ["doc_id", "shot_ids"],
1117
+ },
1118
+ },
1119
+ {
1120
+ name: "atlas_upload_workspace_logo",
1121
+ description: "Upload a local image file as the workspace's logo. Multipart POST — reads the file at `path` and streams it to /api/v1/workspace/logo. Accepts PNG, JPG, or WebP up to 2 MB. Returns the new `logo_url` + a 1-hour signed URL. Requires workspace:write. `path` is a local file path (same shape as atlas_upload_file's `local_path`).",
1122
+ inputSchema: {
1123
+ type: "object",
1124
+ properties: {
1125
+ path: {
1126
+ type: "string",
1127
+ description: "Absolute or CWD-relative path to a PNG/JPG/WebP file on the machine running this MCP server.",
1128
+ },
1129
+ },
1130
+ required: ["path"],
1131
+ },
1132
+ },
1133
+ READ_ACHIEVEMENTS_TOOL,
1134
+ {
1135
+ name: "atlas_list_mindmaps",
1136
+ description: "List mindmaps (planning boards) in the current Atlas workspace. Requires mindmaps:list. Returns { items, next_cursor } — each item is a MindmapSummary (id, title, description, shoot_id, node_count, node_done_count, created_at, updated_at). Pass `cursor` to page.",
1137
+ inputSchema: {
1138
+ type: "object",
1139
+ properties: {
1140
+ limit: { type: "number", description: "Default 50, max 500." },
1141
+ cursor: {
1142
+ type: "string",
1143
+ description: "Opaque next_cursor from a previous response. Omit for the first page.",
1144
+ },
1145
+ },
1146
+ },
1147
+ },
1148
+ {
1149
+ name: "atlas_get_mindmap",
1150
+ description: "Fetch a full mindmap — metadata + every node + every edge. Requires mindmaps:read. Returns { mindmap, nodes[], edges[] }. Nodes carry x/y/title/kind/colour_key; edges connect two node ids. Use before atlas_update_mindmap_node / atlas_delete_mindmap_node so you have fresh ids.",
1151
+ inputSchema: {
1152
+ type: "object",
1153
+ properties: {
1154
+ mindmapId: { type: "string", description: "Mindmap id from atlas_list_mindmaps." },
1155
+ },
1156
+ required: ["mindmapId"],
1157
+ },
1158
+ },
1159
+ {
1160
+ name: "atlas_create_mindmap",
1161
+ description: "Create a new mindmap. Requires mindmaps:write. `title` is required; `description` and `shoot_id` are optional. Optional `nodes[]` and `edges[]` seed the canvas atomically — edges may reference node ids supplied inline (we remap client-provided ids to the generated UUIDs). Returns the new mindmap record.",
1162
+ inputSchema: {
1163
+ type: "object",
1164
+ properties: {
1165
+ title: { type: "string", description: "Mindmap title (≤ 200 chars)." },
1166
+ description: { type: "string", description: "Optional description (≤ 2000 chars)." },
1167
+ shoot_id: {
1168
+ type: "string",
1169
+ description: "Optional shoots id to attach the mindmap to.",
1170
+ },
1171
+ nodes: {
1172
+ type: "array",
1173
+ description: "Optional seed nodes. Each: { title (required) | label, x?, y?, width?, height?, kind?, shape?, colour_key?, description?, image_url?, image_alt?, font_size?, text_color?, bg_color?, id? (used to wire inline edges) }.",
1174
+ items: { type: "object" },
1175
+ },
1176
+ edges: {
1177
+ type: "array",
1178
+ description: "Optional seed edges. Each: { from_node_id, to_node_id, label? }. Ids may reference either existing nodes OR the `id` field of a node in the inline `nodes` array.",
1179
+ items: { type: "object" },
1180
+ },
1181
+ },
1182
+ required: ["title"],
1183
+ },
1184
+ },
1185
+ {
1186
+ name: "atlas_update_mindmap",
1187
+ description: "Patch a mindmap's metadata (title / description / shoot link). Requires mindmaps:write. Only the fields you send are updated. Pass `shoot_id: null` to detach the current shoot link.",
1188
+ inputSchema: {
1189
+ type: "object",
1190
+ properties: {
1191
+ mindmapId: { type: "string", description: "Mindmap id." },
1192
+ title: { type: "string", description: "New title (≤ 200 chars)." },
1193
+ description: {
1194
+ type: ["string", "null"],
1195
+ description: "New description (≤ 2000 chars). Pass null to clear.",
1196
+ },
1197
+ shoot_id: {
1198
+ type: ["string", "null"],
1199
+ description: "shoots id to attach, or null to detach.",
1200
+ },
1201
+ },
1202
+ required: ["mindmapId"],
1203
+ },
1204
+ },
1205
+ {
1206
+ name: "atlas_delete_mindmap",
1207
+ description: "Hard-delete a mindmap. Requires mindmaps:delete. Nodes, edges, and freehand strokes cascade via FK — no undo. Always confirm with the user before calling.",
1208
+ inputSchema: {
1209
+ type: "object",
1210
+ properties: {
1211
+ mindmapId: { type: "string", description: "Mindmap id from atlas_list_mindmaps." },
1212
+ },
1213
+ required: ["mindmapId"],
1214
+ },
1215
+ },
1216
+ {
1217
+ name: "atlas_add_mindmap_node",
1218
+ description: "Append a single node to a mindmap. Requires mindmaps:write. `label` (or `title`) is required; everything else defaults. Returns the inserted node row. order_index is auto-computed as max+1 so the node lands at the end of the canvas.",
1219
+ inputSchema: {
1220
+ type: "object",
1221
+ properties: {
1222
+ mindmapId: { type: "string", description: "Mindmap id." },
1223
+ label: { type: "string", description: "Node label (synonym for title). One of label/title required." },
1224
+ title: { type: "string", description: "Node title. One of label/title required." },
1225
+ x: { type: "number", description: "Canvas x coordinate (default 0)." },
1226
+ y: { type: "number", description: "Canvas y coordinate (default 0)." },
1227
+ width: { type: "number", description: "Node width (default 200)." },
1228
+ height: { type: "number", description: "Node height (default 120)." },
1229
+ kind: {
1230
+ type: "string",
1231
+ enum: ["text", "shape", "image", "textbox"],
1232
+ description: "Node kind. Default 'text'.",
1233
+ },
1234
+ shape: {
1235
+ type: "string",
1236
+ enum: ["rect", "circle", "diamond", "ellipse", "triangle", "rounded"],
1237
+ description: "Required when kind='shape'.",
1238
+ },
1239
+ colour_key: {
1240
+ type: "string",
1241
+ enum: ["rose", "amber", "sky", "violet", "lime", "stone"],
1242
+ },
1243
+ description: { type: "string" },
1244
+ image_url: { type: "string", description: "https: URL (image nodes only)." },
1245
+ image_alt: { type: "string" },
1246
+ font_size: { type: "number", description: "8..96" },
1247
+ text_color: { type: "string", description: "Hex colour #rrggbb (or #rgb / with alpha)." },
1248
+ bg_color: { type: "string", description: "Hex colour #rrggbb." },
1249
+ },
1250
+ required: ["mindmapId"],
1251
+ },
1252
+ },
1253
+ {
1254
+ name: "atlas_update_mindmap_node",
1255
+ description: "Patch a mindmap node. Requires mindmaps:write. Only the fields you send are updated. `label` is a synonym for `title`. Set `completed: true` to mark a node done.",
1256
+ inputSchema: {
1257
+ type: "object",
1258
+ properties: {
1259
+ mindmapId: { type: "string" },
1260
+ nodeId: { type: "string" },
1261
+ label: { type: "string" },
1262
+ title: { type: "string" },
1263
+ x: { type: "number" },
1264
+ y: { type: "number" },
1265
+ width: { type: "number" },
1266
+ height: { type: "number" },
1267
+ kind: {
1268
+ type: "string",
1269
+ enum: ["text", "shape", "image", "textbox"],
1270
+ },
1271
+ shape: {
1272
+ type: "string",
1273
+ enum: ["rect", "circle", "diamond", "ellipse", "triangle", "rounded"],
1274
+ },
1275
+ colour_key: {
1276
+ type: "string",
1277
+ enum: ["rose", "amber", "sky", "violet", "lime", "stone"],
1278
+ },
1279
+ description: { type: "string" },
1280
+ completed: { type: "boolean" },
1281
+ image_url: { type: "string" },
1282
+ image_alt: { type: "string" },
1283
+ font_size: { type: "number" },
1284
+ text_color: { type: "string" },
1285
+ bg_color: { type: "string" },
1286
+ order_index: { type: "number" },
1287
+ },
1288
+ required: ["mindmapId", "nodeId"],
1289
+ },
1290
+ },
1291
+ {
1292
+ name: "atlas_delete_mindmap_node",
1293
+ description: "Delete one node from a mindmap. Requires mindmaps:delete. Edges touching this node cascade-delete via FK. Confirm with the user before calling.",
1294
+ inputSchema: {
1295
+ type: "object",
1296
+ properties: {
1297
+ mindmapId: { type: "string" },
1298
+ nodeId: { type: "string" },
1299
+ },
1300
+ required: ["mindmapId", "nodeId"],
1301
+ },
1302
+ },
1303
+ {
1304
+ name: "atlas_add_mindmap_edge",
1305
+ description: "Create an edge between two nodes in a mindmap. Requires mindmaps:write. Both nodes must belong to the same mindmap. The DB enforces UNIQUE(mindmap_id, from_node_id, to_node_id) — duplicates return 409.",
1306
+ inputSchema: {
1307
+ type: "object",
1308
+ properties: {
1309
+ mindmapId: { type: "string" },
1310
+ from_node_id: { type: "string" },
1311
+ to_node_id: { type: "string" },
1312
+ label: { type: "string", description: "Optional edge label (≤ 200 chars)." },
1313
+ },
1314
+ required: ["mindmapId", "from_node_id", "to_node_id"],
1315
+ },
1316
+ },
1317
+ {
1318
+ name: "atlas_delete_mindmap_edge",
1319
+ description: "Delete a mindmap edge. Requires mindmaps:delete. Nodes untouched. Confirm with the user before calling.",
1320
+ inputSchema: {
1321
+ type: "object",
1322
+ properties: {
1323
+ mindmapId: { type: "string" },
1324
+ edgeId: { type: "string" },
1325
+ },
1326
+ required: ["mindmapId", "edgeId"],
1327
+ },
1328
+ },
1329
+ {
1330
+ name: "atlas_create_mindmap_group",
1331
+ description: "Create a mindmap group — stamps a shared `group_id` onto a set of existing nodes (and optional edges) and writes a row to mindmap_groups carrying the group's inspector-pane metadata (name, bg colour, outline, radius, padding, custom CSS). Requires mindmaps:write. All referenced ids must live in this mindmap.",
1332
+ inputSchema: {
1333
+ type: "object",
1334
+ properties: {
1335
+ mindmapId: { type: "string", description: "Mindmap id." },
1336
+ name: { type: "string", description: "Group display name (≤ 200 chars). Synonym: title." },
1337
+ title: { type: "string", description: "Alias for name." },
1338
+ nodes: {
1339
+ type: "array",
1340
+ items: { type: "string" },
1341
+ description: "mindmap_nodes UUIDs to tag with the new group id. Non-empty.",
1342
+ },
1343
+ edges: {
1344
+ type: "array",
1345
+ items: { type: "string" },
1346
+ description: "Optional mindmap_edges UUIDs to tag with the new group id.",
1347
+ },
1348
+ bg_color: { type: "string", description: "Group background colour (#rrggbb / #rrggbbaa)." },
1349
+ outline_color: { type: "string", description: "Group outline colour (#rrggbb / #rrggbbaa)." },
1350
+ outline_width: { type: "number", description: "Outline width in px (0–32)." },
1351
+ corner_radius: { type: "number", description: "Corner radius in px (0–128)." },
1352
+ padding: { type: "number", description: "Padding in px (0–256)." },
1353
+ custom_css: { type: "string", description: "Arbitrary CSS block (≤ 4000 chars)." },
1354
+ },
1355
+ required: ["mindmapId", "nodes"],
1356
+ },
1357
+ },
1358
+ {
1359
+ name: "atlas_list_activity",
1360
+ description: "List workspace activity-log entries, newest first. Requires activity:list. Returns { items, next_cursor }. Each item: { id, workspace_id, actor_user_id, source (ui|cli|mcp|system), action, resource_type, resource_id, summary, meta, created_at }. Optional filters: `since`/`until` (ISO 8601), `user` (uuid OR email), `source` (csv of ui,cli,mcp,system), `action` (exact verb like doc.create), `resource_type` (exact kind like doc, gear, mindmap).",
1361
+ inputSchema: {
1362
+ type: "object",
1363
+ properties: {
1364
+ limit: { type: "number", description: "Default 50, max 500." },
1365
+ cursor: {
1366
+ type: "string",
1367
+ description: "Opaque next_cursor from a previous response. Omit for the first page.",
1368
+ },
1369
+ since: {
1370
+ type: "string",
1371
+ description: "ISO 8601 lower bound on created_at (inclusive).",
1372
+ },
1373
+ until: {
1374
+ type: "string",
1375
+ description: "ISO 8601 upper bound on created_at (exclusive).",
1376
+ },
1377
+ user: {
1378
+ type: "string",
1379
+ description: "Restrict to this actor — accepts a user uuid OR an email address.",
1380
+ },
1381
+ source: {
1382
+ type: "string",
1383
+ description: "Comma-separated source filter: any of ui, cli, mcp, system.",
1384
+ },
1385
+ action: {
1386
+ type: "string",
1387
+ description: "Exact-match verb filter, e.g. 'doc.create', 'mindmap.update'.",
1388
+ },
1389
+ resource_type: {
1390
+ type: "string",
1391
+ description: "Exact-match on resource_type: 'doc', 'gear', 'mindmap', etc.",
1392
+ },
1393
+ },
1394
+ },
1395
+ },
1396
+ {
1397
+ name: "atlas_get_activity",
1398
+ description: "Fetch a single workspace activity-log entry plus the (best-effort) hydrated actor record. Requires activity:read. Returns { entry, actor } where actor may be null (system events, deleted users).",
1399
+ inputSchema: {
1400
+ type: "object",
1401
+ properties: {
1402
+ entryId: { type: "string", description: "Activity-log entry id." },
1403
+ },
1404
+ required: ["entryId"],
1405
+ },
1406
+ },
1407
+ {
1408
+ name: "export_mindmap",
1409
+ description: "Export an Atlas mindmap to PDF, PNG, or SVG. SVG is server-rendered vector output (fastest, retains ink as paths). PDF + PNG spawn local Chromium via playwright-core on the machine running this MCP server. Returns { out_path, format, bytes }. Requires mindmaps:export.",
1410
+ inputSchema: {
1411
+ type: "object",
1412
+ properties: {
1413
+ mindmap_id: { type: "string" },
1414
+ format: {
1415
+ type: "string",
1416
+ enum: ["pdf", "png", "svg"],
1417
+ description: "Output format. Default pdf.",
1418
+ },
1419
+ out_path: {
1420
+ type: "string",
1421
+ description: "Absolute path to write the file to. Default: '<title>.<ext>' in cwd.",
1422
+ },
1423
+ dpi: {
1424
+ type: "number",
1425
+ enum: [1, 2, 3],
1426
+ description: "Device scale factor for PNG. Default 2.",
1427
+ },
1428
+ w: { type: "number", description: "Canvas width in CSS pixels. Default 1920." },
1429
+ h: { type: "number", description: "Canvas height in CSS pixels. Default 1080." },
1430
+ open: {
1431
+ type: "boolean",
1432
+ description: "Open the file in the OS default viewer after writing. Default true.",
1433
+ },
1434
+ },
1435
+ required: ["mindmap_id"],
1436
+ },
1437
+ },
1438
+ ];
1439
+ /* ─── Server setup ─────────────────────────────────────────────────── */
1440
+ const server = new Server({ name: "atlas-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
1441
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1442
+ tools: TOOLS,
1443
+ }));
1444
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1445
+ const name = req.params.name;
1446
+ const args = (req.params.arguments ?? {});
1447
+ try {
1448
+ const result = await dispatch(name, args);
1449
+ return {
1450
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1451
+ };
1452
+ }
1453
+ catch (e) {
1454
+ const msg = e instanceof Error ? e.message : String(e);
1455
+ return {
1456
+ isError: true,
1457
+ content: [{ type: "text", text: `error: ${msg}` }],
1458
+ };
1459
+ }
1460
+ });
1461
+ async function dispatch(name, args) {
1462
+ switch (name) {
1463
+ case "atlas_doc_create":
1464
+ return createDoc(args);
1465
+ case "atlas_list_doc_presets":
1466
+ return listDocPresets();
1467
+ case "atlas_create_doc_from_preset":
1468
+ return createDocFromPreset(args);
1469
+ case "atlas_doc_delete":
1470
+ return deleteDoc(args);
1471
+ case "atlas_restore_doc":
1472
+ return restoreDoc(args);
1473
+ case "atlas_purge_doc":
1474
+ return purgeDoc(args);
1475
+ case "atlas_list_docs":
1476
+ return listDocs(args);
1477
+ case "atlas_get_template":
1478
+ return getTemplate(args);
1479
+ case "atlas_list_sections":
1480
+ return listSections(args);
1481
+ case "atlas_update_section":
1482
+ return updateSection(args);
1483
+ case "atlas_list_files":
1484
+ return listFiles(args);
1485
+ case "atlas_upload_file":
1486
+ return uploadFile(args);
1487
+ case "atlas_get_file_url":
1488
+ return getFileUrl(args);
1489
+ case "atlas_set_file_alt":
1490
+ return setFileAlt(args);
1491
+ case "atlas_delete_file":
1492
+ return deleteFile(args);
1493
+ case "atlas_list_gear":
1494
+ return listGear(args);
1495
+ case "atlas_add_gear":
1496
+ return addGear(args);
1497
+ case "atlas_import_gear":
1498
+ return importGear(args);
1499
+ case "atlas_delete_gear":
1500
+ return deleteGear(args);
1501
+ case "atlas_restore_gear":
1502
+ return restoreGear(args);
1503
+ case "atlas_purge_gear":
1504
+ return purgeGear(args);
1505
+ case "atlas_update_template":
1506
+ return updateTemplate(args);
1507
+ case "atlas_describe_section_type":
1508
+ return describeSectionType(args);
1509
+ case "atlas_update_gear":
1510
+ return updateGear(args);
1511
+ case "atlas_list_shots":
1512
+ return listShots(args);
1513
+ case "atlas_add_shot":
1514
+ return addShot(args);
1515
+ case "atlas_update_shot":
1516
+ return updateShot(args);
1517
+ case "atlas_delete_shot":
1518
+ return deleteShot(args);
1519
+ case "atlas_restore_shot":
1520
+ return restoreShot(args);
1521
+ case "atlas_purge_shot":
1522
+ return purgeShot(args);
1523
+ case "atlas_import_shots":
1524
+ return importShots(args);
1525
+ case "atlas_list_events":
1526
+ return listEvents(args);
1527
+ case "atlas_get_event":
1528
+ return getEvent(args);
1529
+ case "atlas_create_event":
1530
+ return createEvent(args);
1531
+ case "atlas_update_event":
1532
+ return updateEvent(args);
1533
+ case "atlas_delete_event":
1534
+ return deleteEvent(args);
1535
+ case "atlas_assign_event":
1536
+ return assignEvent(args);
1537
+ case "atlas_unassign_event":
1538
+ return unassignEvent(args);
1539
+ case "atlas_rename_file":
1540
+ return renameFile(args);
1541
+ case "atlas_list_revisions":
1542
+ return listRevisions(args);
1543
+ case "atlas_restore_revision":
1544
+ return restoreRevision(args);
1545
+ case "atlas_whoami":
1546
+ return whoami();
1547
+ case "atlas_verify_connection":
1548
+ return verifyConnection();
1549
+ case "atlas_list_metafield_definitions":
1550
+ return listMetafieldDefinitions();
1551
+ case "atlas_suggest_metafields":
1552
+ return suggestMetafields();
1553
+ case "atlas_scan_shopify_metafields":
1554
+ return scanShopifyMetafields();
1555
+ case "atlas_create_metafield_definition":
1556
+ return createMetafieldDefinition(args);
1557
+ case "atlas_delete_metafield_definition":
1558
+ return deleteMetafieldDefinition(args);
1559
+ case "atlas_list_loyalty_tiers":
1560
+ return listLoyaltyTiers();
1561
+ case "atlas_set_loyalty_tiers":
1562
+ return setLoyaltyTiers(args);
1563
+ case "atlas_reset_loyalty_tiers":
1564
+ return resetLoyaltyTiers();
1565
+ case "atlas_get_analytics_prefs":
1566
+ return getAnalyticsPrefs();
1567
+ case "atlas_set_analytics_prefs":
1568
+ return setAnalyticsPrefs(args);
1569
+ case "atlas_get_workspace":
1570
+ return getWorkspace();
1571
+ case "atlas_update_workspace":
1572
+ return updateWorkspace(args);
1573
+ case "atlas_upload_workspace_logo":
1574
+ return uploadWorkspaceLogo(args);
1575
+ case "atlas_export_doc":
1576
+ return exportDocTool(args);
1577
+ case "atlas_build_moodboard":
1578
+ return buildMoodboard(args);
1579
+ case "atlas_extract_palette":
1580
+ return extractPaletteTool(args);
1581
+ case "atlas_search_products":
1582
+ return searchProducts(args);
1583
+ case "atlas_add_products_to_doc":
1584
+ return addProductsToDoc(args);
1585
+ case "atlas_resolve_location":
1586
+ return resolveLocationTool(args);
1587
+ case "atlas_add_location_to_doc":
1588
+ return addLocationToDoc(args);
1589
+ case "atlas_add_gear_to_doc":
1590
+ return addGearToDoc(args);
1591
+ case "atlas_add_shots_to_doc":
1592
+ return addShotsToDoc(args);
1593
+ case "read_achievements":
1594
+ return readAchievements();
1595
+ case "atlas_list_mindmaps":
1596
+ return listMindmaps_(args);
1597
+ case "atlas_get_mindmap":
1598
+ return getMindmap_(args);
1599
+ case "atlas_create_mindmap":
1600
+ return createMindmap_(args);
1601
+ case "atlas_update_mindmap":
1602
+ return updateMindmap_(args);
1603
+ case "atlas_delete_mindmap":
1604
+ return deleteMindmap_(args);
1605
+ case "atlas_add_mindmap_node":
1606
+ return addMindmapNode_(args);
1607
+ case "atlas_update_mindmap_node":
1608
+ return updateMindmapNode_(args);
1609
+ case "atlas_delete_mindmap_node":
1610
+ return deleteMindmapNode_(args);
1611
+ case "atlas_add_mindmap_edge":
1612
+ return addMindmapEdge_(args);
1613
+ case "atlas_delete_mindmap_edge":
1614
+ return deleteMindmapEdge_(args);
1615
+ case "atlas_create_mindmap_group":
1616
+ return createMindmapGroup_(args);
1617
+ case "atlas_list_activity":
1618
+ return listActivity_(args);
1619
+ case "atlas_get_activity":
1620
+ return getActivity_(args);
1621
+ case "export_mindmap":
1622
+ return exportMindmapTool(args);
1623
+ default:
1624
+ throw new Error(`Unknown tool: ${name}`);
1625
+ }
1626
+ }
1627
+ /* ─── Tool implementations ─────────────────────────────────────────── */
1628
+ async function listDocs(args) {
1629
+ const cfg = resolveConfig();
1630
+ const query = {};
1631
+ if (typeof args.kind === "string")
1632
+ query.kind = args.kind;
1633
+ if (typeof args.limit === "number")
1634
+ query.limit = args.limit;
1635
+ if (typeof args.cursor === "string")
1636
+ query.cursor = args.cursor;
1637
+ if (args.deleted === true)
1638
+ query.deleted = 1;
1639
+ const { data } = await apiRequest(cfg, "/api/v1/docs", { query });
1640
+ return data;
1641
+ }
1642
+ async function getTemplate(args) {
1643
+ const cfg = resolveConfig();
1644
+ const docId = requireString(args, "docId");
1645
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`);
1646
+ return data;
1647
+ }
1648
+ async function listSections(args) {
1649
+ const tpl = await getTemplate(args);
1650
+ const sections = tpl.template_content?.sections ?? [];
1651
+ const summary = sections.map((s) => ({
1652
+ id: s.id,
1653
+ type: s.type,
1654
+ // Best-effort title extraction — most sections stash a title on data.title.
1655
+ title: (s.data && typeof s.data.title === "string"
1656
+ ? s.data.title
1657
+ : null) ?? "",
1658
+ hidden: !!s.hidden,
1659
+ }));
1660
+ return { docId: tpl.id, title: tpl.title, section_count: summary.length, sections: summary };
1661
+ }
1662
+ async function updateSection(args) {
1663
+ const cfg = resolveConfig();
1664
+ const docId = requireString(args, "docId");
1665
+ const sectionId = requireString(args, "sectionId");
1666
+ const newData = isRecord(args.data) ? args.data : undefined;
1667
+ const newStyle = isRecord(args.style) ? args.style : undefined;
1668
+ const hidden = typeof args.hidden === "boolean" ? args.hidden : undefined;
1669
+ if (newData === undefined && newStyle === undefined && hidden === undefined) {
1670
+ throw new Error("Provide at least one of: data, style, hidden.");
1671
+ }
1672
+ // Fetch current etag first so the PATCH's If-Match survives a concurrent
1673
+ // web edit. On 409 we surface the message so the model can re-fetch and
1674
+ // retry.
1675
+ const current = await getTemplate({ docId });
1676
+ try {
1677
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template/sections/${sectionId}`, {
1678
+ method: "PATCH",
1679
+ body: {
1680
+ ...(newData !== undefined ? { data: newData } : {}),
1681
+ ...(newStyle !== undefined ? { style: newStyle } : {}),
1682
+ ...(hidden !== undefined ? { hidden } : {}),
1683
+ },
1684
+ ifMatch: current.etag,
1685
+ });
1686
+ return {
1687
+ updated_section: sectionId,
1688
+ section: data.section,
1689
+ etag: data.etag,
1690
+ updated_at: data.updated_at,
1691
+ };
1692
+ }
1693
+ catch (e) {
1694
+ if (e instanceof ApiError && e.status === 404) {
1695
+ throw new Error(`Section '${sectionId}' not found in doc '${docId}'. Call atlas_list_sections to see current section ids.`);
1696
+ }
1697
+ if (e instanceof ApiError && e.status === 409) {
1698
+ throw new Error("Doc was modified since we fetched it — re-run atlas_get_template to pick up the concurrent change, then retry.");
1699
+ }
1700
+ throw e;
1701
+ }
1702
+ }
1703
+ async function createDoc(args) {
1704
+ const cfg = resolveConfig();
1705
+ const title = requireString(args, "title");
1706
+ const body = { title };
1707
+ if (typeof args.doc_kind === "string")
1708
+ body.doc_kind = args.doc_kind;
1709
+ if (typeof args.folder_id === "string")
1710
+ body.folder_id = args.folder_id;
1711
+ if (typeof args.shoot_id === "string")
1712
+ body.shoot_id = args.shoot_id;
1713
+ if (isRecord(args.template_content))
1714
+ body.template_content = args.template_content;
1715
+ if (typeof args.preset_id === "string")
1716
+ body.preset_id = args.preset_id;
1717
+ const { data } = await apiRequest(cfg, "/api/v1/docs", { method: "POST", body });
1718
+ return data;
1719
+ }
1720
+ /** List the server-side preset catalogue. One GET → the full
1721
+ * `{ presets: DocPreset[] }` payload the web picker also uses. */
1722
+ async function listDocPresets() {
1723
+ const cfg = resolveConfig();
1724
+ const { data } = await apiRequest(cfg, "/api/v1/presets", { method: "GET" });
1725
+ return data;
1726
+ }
1727
+ /** Convenience wrapper — resolves the preset-id defaulted title on the
1728
+ * client so Claude doesn't have to list presets first just to grab the
1729
+ * default string. API still validates the preset_id. */
1730
+ async function createDocFromPreset(args) {
1731
+ const presetId = requireString(args, "preset_id");
1732
+ // Resolve the default title client-side so Claude can create docs
1733
+ // without a second round-trip to list presets. The API validates
1734
+ // preset_id again (and 404s on unknown ids) regardless.
1735
+ let defaultTitle = presetId;
1736
+ try {
1737
+ const list = (await listDocPresets());
1738
+ const found = list.presets?.find((p) => p.id === presetId);
1739
+ if (found?.defaultTitle)
1740
+ defaultTitle = found.defaultTitle;
1741
+ }
1742
+ catch {
1743
+ // Non-fatal — fall through to presetId as the title. API still
1744
+ // validates preset_id below.
1745
+ }
1746
+ const title = typeof args.title === "string" && args.title.trim().length > 0
1747
+ ? args.title
1748
+ : defaultTitle;
1749
+ return createDoc({
1750
+ title,
1751
+ preset_id: presetId,
1752
+ ...(typeof args.folder_id === "string" ? { folder_id: args.folder_id } : {}),
1753
+ ...(typeof args.shoot_id === "string" ? { shoot_id: args.shoot_id } : {}),
1754
+ });
1755
+ }
1756
+ async function deleteDoc(args) {
1757
+ const cfg = resolveConfig();
1758
+ const docId = requireString(args, "docId");
1759
+ const purge = args.purge === true;
1760
+ const path = purge ? `/api/v1/docs/${docId}?purge=1` : `/api/v1/docs/${docId}`;
1761
+ const { data } = await apiRequest(cfg, path, { method: "DELETE" });
1762
+ return data;
1763
+ }
1764
+ async function restoreDoc(args) {
1765
+ const cfg = resolveConfig();
1766
+ const docId = requireString(args, "docId");
1767
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/restore`, {
1768
+ method: "POST",
1769
+ });
1770
+ return data;
1771
+ }
1772
+ async function purgeDoc(args) {
1773
+ const cfg = resolveConfig();
1774
+ const docId = requireString(args, "docId");
1775
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}?purge=1`, {
1776
+ method: "DELETE",
1777
+ });
1778
+ return data;
1779
+ }
1780
+ async function updateTemplate(args) {
1781
+ const cfg = resolveConfig();
1782
+ const docId = requireString(args, "docId");
1783
+ const templateContent = args.template_content;
1784
+ if (!isRecord(templateContent)) {
1785
+ throw new Error("`template_content` must be an object matching TemplateContent shape.");
1786
+ }
1787
+ const title = typeof args.title === "string" ? args.title : undefined;
1788
+ // Get-then-put for etag freshness.
1789
+ const current = await getTemplate({ docId });
1790
+ try {
1791
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
1792
+ method: "PUT",
1793
+ body: { template_content: templateContent, ...(title ? { title } : {}) },
1794
+ ifMatch: current.etag,
447
1795
  });
448
1796
  return {
449
1797
  docId: data.id,
@@ -452,224 +1800,1494 @@ async function updateTemplate(args) {
452
1800
  updated_at: data.updated_at,
453
1801
  };
454
1802
  }
455
- catch (e) {
456
- if (e instanceof ApiError && e.status === 409) {
457
- throw new Error("Doc was modified since we fetched it — re-run atlas_get_template to pick up the concurrent change, then retry.");
1803
+ catch (e) {
1804
+ if (e instanceof ApiError && e.status === 409) {
1805
+ throw new Error("Doc was modified since we fetched it — re-run atlas_get_template to pick up the concurrent change, then retry.");
1806
+ }
1807
+ throw e;
1808
+ }
1809
+ }
1810
+ async function describeSectionType(args) {
1811
+ const type = args.type;
1812
+ if (typeof type === "string") {
1813
+ const entry = findSection(type);
1814
+ if (!entry) {
1815
+ throw new Error(`Unknown section type '${type}'. Known types: ${SECTION_CATALOG.map((s) => s.type).join(", ")}.`);
1816
+ }
1817
+ return entry;
1818
+ }
1819
+ return { types: SECTION_CATALOG };
1820
+ }
1821
+ /* ─── Files tools ──────────────────────────────────────────────────── */
1822
+ async function listFiles(args) {
1823
+ const cfg = resolveConfig();
1824
+ const query = {};
1825
+ if (typeof args.folder_id === "string")
1826
+ query.folder_id = args.folder_id;
1827
+ if (typeof args.limit === "number")
1828
+ query.limit = args.limit;
1829
+ if (typeof args.cursor === "string")
1830
+ query.cursor = args.cursor;
1831
+ const { data } = await apiRequest(cfg, "/api/v1/files", { query });
1832
+ return data;
1833
+ }
1834
+ async function uploadFile(args) {
1835
+ const cfg = resolveConfig();
1836
+ const localPath = requireString(args, "local_path");
1837
+ const alt = typeof args.alt === "string" ? args.alt : undefined;
1838
+ const { readFile, stat } = await import("node:fs/promises");
1839
+ const { basename, extname } = await import("node:path");
1840
+ const info = await stat(localPath);
1841
+ if (!info.isFile())
1842
+ throw new Error(`Not a file: ${localPath}`);
1843
+ const filename = basename(localPath);
1844
+ const ext = extname(filename).toLowerCase();
1845
+ const contentTypes = {
1846
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
1847
+ ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
1848
+ ".mp4": "video/mp4", ".mov": "video/quicktime", ".pdf": "application/pdf",
1849
+ ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json",
1850
+ ".csv": "text/csv",
1851
+ };
1852
+ const contentType = contentTypes[ext] ?? "application/octet-stream";
1853
+ // Step 1: signed URL
1854
+ const { data: signRes } = await apiRequest(cfg, "/api/v1/files/upload-url", {
1855
+ method: "POST",
1856
+ body: { filename },
1857
+ });
1858
+ // Step 2: PUT bytes
1859
+ const bytes = await readFile(localPath);
1860
+ const putRes = await fetch(signRes.upload_url, {
1861
+ method: "PUT",
1862
+ headers: { "Content-Type": contentType, "x-upsert": "false" },
1863
+ body: bytes,
1864
+ });
1865
+ if (!putRes.ok) {
1866
+ const text = await putRes.text().catch(() => "");
1867
+ throw new Error(`Storage upload failed (HTTP ${putRes.status}): ${text.slice(0, 200)}`);
1868
+ }
1869
+ // Step 3: register metadata
1870
+ const { data: reg } = await apiRequest(cfg, "/api/v1/files", {
1871
+ method: "POST",
1872
+ body: { path: signRes.path, alt },
1873
+ });
1874
+ return {
1875
+ path: signRes.path,
1876
+ size: info.size,
1877
+ content_type: contentType,
1878
+ metadata: reg.file,
1879
+ };
1880
+ }
1881
+ async function getFileUrl(args) {
1882
+ const cfg = resolveConfig();
1883
+ const path = requireString(args, "path");
1884
+ const { data } = await apiRequest(cfg, "/api/v1/files", { query: { limit: 500 } });
1885
+ const match = data.files.find((f) => f.path === path);
1886
+ if (!match)
1887
+ throw new Error(`File not found or no metadata row: ${path}`);
1888
+ return { path: match.path, url: match.signed_url, alt: match.alt };
1889
+ }
1890
+ async function setFileAlt(args) {
1891
+ const cfg = resolveConfig();
1892
+ const path = requireString(args, "path");
1893
+ const alt = typeof args.alt === "string" ? args.alt : null;
1894
+ const { data } = await apiRequest(cfg, "/api/v1/files/alt", {
1895
+ method: "POST",
1896
+ body: { path, alt },
1897
+ });
1898
+ return data;
1899
+ }
1900
+ async function deleteFile(args) {
1901
+ const cfg = resolveConfig();
1902
+ const path = requireString(args, "path");
1903
+ const { data } = await apiRequest(cfg, "/api/v1/files", {
1904
+ method: "DELETE",
1905
+ query: { path },
1906
+ });
1907
+ return data;
1908
+ }
1909
+ /* ─── Gear tools ───────────────────────────────────────────────────── */
1910
+ async function listGear(args) {
1911
+ const cfg = resolveConfig();
1912
+ const query = {};
1913
+ if (typeof args.limit === "number")
1914
+ query.limit = args.limit;
1915
+ if (typeof args.cursor === "string")
1916
+ query.cursor = args.cursor;
1917
+ if (args.deleted === true)
1918
+ query.deleted = 1;
1919
+ const { data } = await apiRequest(cfg, "/api/v1/gear", { query });
1920
+ return data;
1921
+ }
1922
+ async function addGear(args) {
1923
+ const cfg = resolveConfig();
1924
+ const body = { name: requireString(args, "name") };
1925
+ if (typeof args.brand === "string")
1926
+ body.brand = args.brand;
1927
+ if (typeof args.quantity === "number")
1928
+ body.quantity = args.quantity;
1929
+ if (typeof args.image_path === "string")
1930
+ body.image_path = args.image_path;
1931
+ const { data } = await apiRequest(cfg, "/api/v1/gear", {
1932
+ method: "POST",
1933
+ body,
1934
+ });
1935
+ return data;
1936
+ }
1937
+ async function importGear(args) {
1938
+ const cfg = resolveConfig();
1939
+ const rows = args.rows;
1940
+ if (!Array.isArray(rows))
1941
+ throw new Error("`rows` must be an array.");
1942
+ const mode = args.mode === "replace" ? "replace" : "append";
1943
+ const { data } = await apiRequest(cfg, "/api/v1/gear/import", {
1944
+ method: "POST",
1945
+ body: { mode, rows },
1946
+ });
1947
+ return data;
1948
+ }
1949
+ async function deleteGear(args) {
1950
+ const cfg = resolveConfig();
1951
+ const gearId = requireString(args, "gearId");
1952
+ const purge = args.purge === true;
1953
+ const path = purge ? `/api/v1/gear/${gearId}?purge=1` : `/api/v1/gear/${gearId}`;
1954
+ const { data } = await apiRequest(cfg, path, { method: "DELETE" });
1955
+ return data;
1956
+ }
1957
+ async function restoreGear(args) {
1958
+ const cfg = resolveConfig();
1959
+ const gearId = requireString(args, "gearId");
1960
+ const { data } = await apiRequest(cfg, `/api/v1/gear/${gearId}/restore`, {
1961
+ method: "POST",
1962
+ });
1963
+ return data;
1964
+ }
1965
+ async function purgeGear(args) {
1966
+ const cfg = resolveConfig();
1967
+ const gearId = requireString(args, "gearId");
1968
+ const { data } = await apiRequest(cfg, `/api/v1/gear/${gearId}?purge=1`, {
1969
+ method: "DELETE",
1970
+ });
1971
+ return data;
1972
+ }
1973
+ async function updateGear(args) {
1974
+ const cfg = resolveConfig();
1975
+ const gearId = requireString(args, "gearId");
1976
+ const body = {};
1977
+ if (typeof args.name === "string")
1978
+ body.name = args.name;
1979
+ if (typeof args.brand === "string")
1980
+ body.brand = args.brand;
1981
+ if (typeof args.quantity === "number")
1982
+ body.quantity = args.quantity;
1983
+ if (typeof args.image_path === "string")
1984
+ body.image_path = args.image_path;
1985
+ if (Object.keys(body).length === 0) {
1986
+ throw new Error("At least one of name, brand, quantity, image_path is required.");
1987
+ }
1988
+ const { data } = await apiRequest(cfg, `/api/v1/gear/${gearId}`, {
1989
+ method: "PUT",
1990
+ body,
1991
+ });
1992
+ return data;
1993
+ }
1994
+ /* ─── Shot list tools ──────────────────────────────────────────────── */
1995
+ async function listShots(args) {
1996
+ const cfg = resolveConfig();
1997
+ const query = {};
1998
+ if (typeof args.limit === "number")
1999
+ query.limit = args.limit;
2000
+ if (typeof args.cursor === "string")
2001
+ query.cursor = args.cursor;
2002
+ if (args.deleted === true)
2003
+ query.deleted = 1;
2004
+ const { data } = await apiRequest(cfg, "/api/v1/shots", { query });
2005
+ return data;
2006
+ }
2007
+ async function addShot(args) {
2008
+ const cfg = resolveConfig();
2009
+ const body = { name: requireString(args, "name") };
2010
+ if (typeof args.kind === "string")
2011
+ body.kind = args.kind;
2012
+ if (typeof args.notes === "string")
2013
+ body.notes = args.notes;
2014
+ if (typeof args.image_path === "string")
2015
+ body.image_path = args.image_path;
2016
+ if (Array.isArray(args.gear_ids))
2017
+ body.gear_ids = args.gear_ids;
2018
+ const { data } = await apiRequest(cfg, "/api/v1/shots", {
2019
+ method: "POST",
2020
+ body,
2021
+ });
2022
+ return data;
2023
+ }
2024
+ async function updateShot(args) {
2025
+ const cfg = resolveConfig();
2026
+ const shotId = requireString(args, "shotId");
2027
+ const body = {};
2028
+ if (typeof args.name === "string")
2029
+ body.name = args.name;
2030
+ if (typeof args.kind === "string")
2031
+ body.kind = args.kind;
2032
+ if (typeof args.notes === "string")
2033
+ body.notes = args.notes;
2034
+ if (typeof args.image_path === "string")
2035
+ body.image_path = args.image_path;
2036
+ if (Array.isArray(args.gear_ids))
2037
+ body.gear_ids = args.gear_ids;
2038
+ if (Object.keys(body).length === 0) {
2039
+ throw new Error("At least one of name, kind, notes, image_path, gear_ids is required.");
2040
+ }
2041
+ const { data } = await apiRequest(cfg, `/api/v1/shots/${shotId}`, {
2042
+ method: "PATCH",
2043
+ body,
2044
+ });
2045
+ return data;
2046
+ }
2047
+ async function deleteShot(args) {
2048
+ const cfg = resolveConfig();
2049
+ const shotId = requireString(args, "shotId");
2050
+ const purge = args.purge === true;
2051
+ const path = purge ? `/api/v1/shots/${shotId}?purge=1` : `/api/v1/shots/${shotId}`;
2052
+ const { data } = await apiRequest(cfg, path, { method: "DELETE" });
2053
+ return data;
2054
+ }
2055
+ async function restoreShot(args) {
2056
+ const cfg = resolveConfig();
2057
+ const shotId = requireString(args, "shotId");
2058
+ const { data } = await apiRequest(cfg, `/api/v1/shots/${shotId}/restore`, {
2059
+ method: "POST",
2060
+ });
2061
+ return data;
2062
+ }
2063
+ async function purgeShot(args) {
2064
+ const cfg = resolveConfig();
2065
+ const shotId = requireString(args, "shotId");
2066
+ const { data } = await apiRequest(cfg, `/api/v1/shots/${shotId}?purge=1`, {
2067
+ method: "DELETE",
2068
+ });
2069
+ return data;
2070
+ }
2071
+ async function importShots(args) {
2072
+ const cfg = resolveConfig();
2073
+ const rows = args.rows;
2074
+ if (!Array.isArray(rows))
2075
+ throw new Error("`rows` must be an array.");
2076
+ const mode = args.mode === "replace" ? "replace" : "append";
2077
+ const { data } = await apiRequest(cfg, "/api/v1/shots/import", {
2078
+ method: "POST",
2079
+ body: { mode, rows },
2080
+ });
2081
+ return data;
2082
+ }
2083
+ /* ─── Events (calendar shoots) ─────────────────────────────────────── */
2084
+ async function listEvents(args) {
2085
+ const cfg = resolveConfig();
2086
+ const query = {};
2087
+ if (typeof args.limit === "number")
2088
+ query.limit = args.limit;
2089
+ if (typeof args.cursor === "string")
2090
+ query.cursor = args.cursor;
2091
+ if (typeof args.from === "string")
2092
+ query.from = args.from;
2093
+ if (typeof args.to === "string")
2094
+ query.to = args.to;
2095
+ if (Array.isArray(args.status) && args.status.length > 0) {
2096
+ query.status = args.status.filter((s) => typeof s === "string").join(",");
2097
+ }
2098
+ if (Array.isArray(args.colour) && args.colour.length > 0) {
2099
+ query.colour = args.colour.filter((s) => typeof s === "string").join(",");
2100
+ }
2101
+ const { data } = await apiRequest(cfg, "/api/v1/events", { query });
2102
+ return data;
2103
+ }
2104
+ async function getEvent(args) {
2105
+ const cfg = resolveConfig();
2106
+ const eventId = requireString(args, "eventId");
2107
+ const { data } = await apiRequest(cfg, `/api/v1/events/${eventId}`);
2108
+ return data;
2109
+ }
2110
+ async function createEvent(args) {
2111
+ const cfg = resolveConfig();
2112
+ const body = {
2113
+ title: requireString(args, "title"),
2114
+ starts_at: requireString(args, "starts_at"),
2115
+ };
2116
+ if (typeof args.ends_at === "string")
2117
+ body.ends_at = args.ends_at;
2118
+ if (typeof args.all_day === "boolean")
2119
+ body.all_day = args.all_day;
2120
+ if (typeof args.location === "string")
2121
+ body.location = args.location;
2122
+ if (typeof args.shoot_type === "string")
2123
+ body.shoot_type = args.shoot_type;
2124
+ if (typeof args.colour_key === "string")
2125
+ body.colour_key = args.colour_key;
2126
+ if (typeof args.colour_hex === "string")
2127
+ body.colour_hex = args.colour_hex;
2128
+ if (typeof args.status === "string")
2129
+ body.status = args.status;
2130
+ if (typeof args.notes === "string")
2131
+ body.notes = args.notes;
2132
+ if (typeof args.description === "string")
2133
+ body.description = args.description;
2134
+ const { data } = await apiRequest(cfg, "/api/v1/events", {
2135
+ method: "POST",
2136
+ body,
2137
+ });
2138
+ return data;
2139
+ }
2140
+ async function updateEvent(args) {
2141
+ const cfg = resolveConfig();
2142
+ const eventId = requireString(args, "eventId");
2143
+ const body = {};
2144
+ if (typeof args.title === "string")
2145
+ body.title = args.title;
2146
+ if (typeof args.starts_at === "string")
2147
+ body.starts_at = args.starts_at;
2148
+ if (typeof args.ends_at === "string")
2149
+ body.ends_at = args.ends_at;
2150
+ if (typeof args.all_day === "boolean")
2151
+ body.all_day = args.all_day;
2152
+ if (typeof args.location === "string")
2153
+ body.location = args.location;
2154
+ if (typeof args.shoot_type === "string")
2155
+ body.shoot_type = args.shoot_type;
2156
+ if (typeof args.colour_key === "string")
2157
+ body.colour_key = args.colour_key;
2158
+ if (typeof args.colour_hex === "string")
2159
+ body.colour_hex = args.colour_hex;
2160
+ if (typeof args.status === "string")
2161
+ body.status = args.status;
2162
+ if (typeof args.notes === "string")
2163
+ body.notes = args.notes;
2164
+ if (typeof args.description === "string")
2165
+ body.description = args.description;
2166
+ if (Object.keys(body).length === 0) {
2167
+ throw new Error("At least one writable field (title, starts_at, ends_at, all_day, location, shoot_type, colour_key, colour_hex, status, notes, description) is required.");
2168
+ }
2169
+ const { data } = await apiRequest(cfg, `/api/v1/events/${eventId}`, { method: "PATCH", body });
2170
+ return data;
2171
+ }
2172
+ async function deleteEvent(args) {
2173
+ const cfg = resolveConfig();
2174
+ const eventId = requireString(args, "eventId");
2175
+ const { data } = await apiRequest(cfg, `/api/v1/events/${eventId}`, {
2176
+ method: "DELETE",
2177
+ });
2178
+ return data;
2179
+ }
2180
+ async function assignEvent(args) {
2181
+ const cfg = resolveConfig();
2182
+ const eventId = requireString(args, "eventId");
2183
+ const body = {
2184
+ user_id: requireString(args, "user_id"),
2185
+ };
2186
+ if (typeof args.shoot_role === "string")
2187
+ body.shoot_role = args.shoot_role;
2188
+ if (Array.isArray(args.responsibilities)) {
2189
+ body.responsibilities = args.responsibilities;
2190
+ }
2191
+ if (typeof args.notes === "string")
2192
+ body.notes = args.notes;
2193
+ const { data } = await apiRequest(cfg, `/api/v1/events/${eventId}/assignments`, { method: "POST", body });
2194
+ return data;
2195
+ }
2196
+ async function unassignEvent(args) {
2197
+ const cfg = resolveConfig();
2198
+ const eventId = requireString(args, "eventId");
2199
+ const assignmentId = requireString(args, "assignmentId");
2200
+ const { data } = await apiRequest(cfg, `/api/v1/events/${eventId}/assignments/${assignmentId}`, { method: "DELETE" });
2201
+ return data;
2202
+ }
2203
+ /* ─── Files rename ─────────────────────────────────────────────────── */
2204
+ async function renameFile(args) {
2205
+ const cfg = resolveConfig();
2206
+ const path = requireString(args, "path");
2207
+ const newName = requireString(args, "new_name");
2208
+ const { data } = await apiRequest(cfg, "/api/v1/files/rename", {
2209
+ method: "POST",
2210
+ body: { path, new_name: newName },
2211
+ });
2212
+ return data;
2213
+ }
2214
+ /* ─── Revisions ────────────────────────────────────────────────────── */
2215
+ async function listRevisions(args) {
2216
+ const cfg = resolveConfig();
2217
+ const docId = requireString(args, "docId");
2218
+ const query = {};
2219
+ if (typeof args.limit === "number")
2220
+ query.limit = args.limit;
2221
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/revisions`, { query });
2222
+ return data;
2223
+ }
2224
+ async function restoreRevision(args) {
2225
+ const cfg = resolveConfig();
2226
+ const docId = requireString(args, "docId");
2227
+ const revisionId = requireString(args, "revisionId");
2228
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/revisions/${revisionId}/restore`, {
2229
+ method: "POST",
2230
+ });
2231
+ return {
2232
+ docId: data.id,
2233
+ title: data.title,
2234
+ updated_at: data.updated_at,
2235
+ etag: data.etag,
2236
+ restored_from: data.restored_from,
2237
+ };
2238
+ }
2239
+ async function whoami() {
2240
+ const cfg = resolveConfig();
2241
+ const { data } = await apiRequest(cfg, "/api/v1/whoami");
2242
+ return data;
2243
+ }
2244
+ /** The dedicated "prove Claude Desktop MCP is wired up" call. Only
2245
+ * this endpoint stamps workspace_members.onboarding.mcp_verified_at,
2246
+ * which is the signal the onboarding walkthrough's phase 4 waits for.
2247
+ * Kept intentionally distinct from whoami so accidental introspection
2248
+ * calls can't false-positive the setup flow. */
2249
+ async function verifyConnection() {
2250
+ const cfg = resolveConfig();
2251
+ const { data } = await apiRequest(cfg, "/api/v1/verify", { method: "POST" });
2252
+ return data;
2253
+ }
2254
+ /* ─── Metafield definitions ────────────────────────────────────────── */
2255
+ async function listMetafieldDefinitions() {
2256
+ const cfg = resolveConfig();
2257
+ const { data } = await apiRequest(cfg, "/api/v1/metafields");
2258
+ return data;
2259
+ }
2260
+ async function suggestMetafields() {
2261
+ const cfg = resolveConfig();
2262
+ const { data } = await apiRequest(cfg, "/api/v1/metafields", { query: { suggest: "1" } });
2263
+ return data;
2264
+ }
2265
+ async function scanShopifyMetafields() {
2266
+ const cfg = resolveConfig();
2267
+ const { data } = await apiRequest(cfg, "/api/v1/metafields/scan");
2268
+ return data;
2269
+ }
2270
+ async function createMetafieldDefinition(args) {
2271
+ const cfg = resolveConfig();
2272
+ const body = {};
2273
+ if (typeof args.preset === "string")
2274
+ body.preset = args.preset;
2275
+ if (typeof args.namespace === "string")
2276
+ body.namespace = args.namespace;
2277
+ if (typeof args.key === "string")
2278
+ body.key = args.key;
2279
+ if (typeof args.label === "string")
2280
+ body.label = args.label;
2281
+ if (typeof args.description === "string")
2282
+ body.description = args.description;
2283
+ if (typeof args.section === "string")
2284
+ body.section = args.section;
2285
+ if (typeof args.display_style === "string")
2286
+ body.display_style = args.display_style;
2287
+ const { data } = await apiRequest(cfg, "/api/v1/metafields", { method: "POST", body });
2288
+ return data;
2289
+ }
2290
+ async function deleteMetafieldDefinition(args) {
2291
+ const cfg = resolveConfig();
2292
+ const id = requireString(args, "id");
2293
+ await apiRequest(cfg, `/api/v1/metafields/${encodeURIComponent(id)}`, {
2294
+ method: "DELETE",
2295
+ });
2296
+ return { ok: true };
2297
+ }
2298
+ /* ─── Helpers ──────────────────────────────────────────────────────── */
2299
+ function requireString(args, key) {
2300
+ const v = args[key];
2301
+ if (typeof v !== "string" || !v)
2302
+ throw new Error(`\`${key}\` is required.`);
2303
+ return v;
2304
+ }
2305
+ function isRecord(v) {
2306
+ return typeof v === "object" && v !== null;
2307
+ }
2308
+ /* ─── Loyalty + analytics tools ────────────────────────────────────── */
2309
+ async function listLoyaltyTiers() {
2310
+ const cfg = resolveConfig();
2311
+ const { data } = await apiRequest(cfg, "/api/v1/loyalty/tiers");
2312
+ return data;
2313
+ }
2314
+ async function setLoyaltyTiers(args) {
2315
+ const cfg = resolveConfig();
2316
+ const tiers = args.tiers;
2317
+ if (!Array.isArray(tiers))
2318
+ throw new Error("`tiers` must be an array. See atlas_set_loyalty_tiers for the shape.");
2319
+ const { data } = await apiRequest(cfg, "/api/v1/loyalty/tiers", { method: "PUT", body: { tiers } });
2320
+ return data;
2321
+ }
2322
+ async function resetLoyaltyTiers() {
2323
+ const cfg = resolveConfig();
2324
+ const { data } = await apiRequest(cfg, "/api/v1/loyalty/tiers", { method: "DELETE" });
2325
+ return data;
2326
+ }
2327
+ async function getAnalyticsPrefs() {
2328
+ const cfg = resolveConfig();
2329
+ const { data } = await apiRequest(cfg, "/api/v1/analytics/prefs");
2330
+ return data;
2331
+ }
2332
+ async function setAnalyticsPrefs(args) {
2333
+ const cfg = resolveConfig();
2334
+ const body = {};
2335
+ if (Array.isArray(args.excluded_tags))
2336
+ body.excluded_tags = args.excluded_tags;
2337
+ else if (Array.isArray(args.excludedTags))
2338
+ body.excluded_tags = args.excludedTags;
2339
+ if (Array.isArray(args.excluded_statuses))
2340
+ body.excluded_statuses = args.excluded_statuses;
2341
+ else if (Array.isArray(args.excludedStatuses))
2342
+ body.excluded_statuses = args.excludedStatuses;
2343
+ if (Array.isArray(args.excluded_customer_tags))
2344
+ body.excluded_customer_tags = args.excluded_customer_tags;
2345
+ else if (Array.isArray(args.excludedCustomerTags))
2346
+ body.excluded_customer_tags = args.excludedCustomerTags;
2347
+ if (typeof args.exclude_non_subscribers === "boolean")
2348
+ body.exclude_non_subscribers = args.exclude_non_subscribers;
2349
+ else if (typeof args.excludeNonSubscribers === "boolean")
2350
+ body.exclude_non_subscribers = args.excludeNonSubscribers;
2351
+ if (typeof args.exclude_guest_checkouts === "boolean")
2352
+ body.exclude_guest_checkouts = args.exclude_guest_checkouts;
2353
+ else if (typeof args.excludeGuestCheckouts === "boolean")
2354
+ body.exclude_guest_checkouts = args.excludeGuestCheckouts;
2355
+ if (typeof args.exclude_unverified_emails === "boolean")
2356
+ body.exclude_unverified_emails = args.exclude_unverified_emails;
2357
+ else if (typeof args.excludeUnverifiedEmails === "boolean")
2358
+ body.exclude_unverified_emails = args.excludeUnverifiedEmails;
2359
+ if (Object.keys(body).length === 0)
2360
+ throw new Error("Pass at least one exclusion field (tags, statuses, customer tags, or a customer-level boolean flag).");
2361
+ const { data } = await apiRequest(cfg, "/api/v1/analytics/prefs", {
2362
+ method: "PUT",
2363
+ body,
2364
+ });
2365
+ return data;
2366
+ }
2367
+ /* ─── Workspace tools ──────────────────────────────────────────────── */
2368
+ async function getWorkspace() {
2369
+ const cfg = resolveConfig();
2370
+ const { data } = await apiRequest(cfg, "/api/v1/workspace");
2371
+ return data;
2372
+ }
2373
+ async function updateWorkspace(args) {
2374
+ const cfg = resolveConfig();
2375
+ const body = {};
2376
+ if (typeof args.name === "string")
2377
+ body.name = args.name;
2378
+ if (typeof args.slug === "string")
2379
+ body.slug = args.slug;
2380
+ // logo_bg can be a string OR explicit null; anything else means "leave alone".
2381
+ if (typeof args.logo_bg === "string" || args.logo_bg === null) {
2382
+ body.logo_bg = args.logo_bg;
2383
+ }
2384
+ if (isRecord(args.theme)) {
2385
+ const theme = {};
2386
+ if (typeof args.theme.bg === "string")
2387
+ theme.bg = args.theme.bg;
2388
+ if (typeof args.theme.primary === "string")
2389
+ theme.primary = args.theme.primary;
2390
+ if (typeof args.theme.accent === "string")
2391
+ theme.accent = args.theme.accent;
2392
+ if (typeof args.theme.radiusPx === "number")
2393
+ theme.radiusPx = args.theme.radiusPx;
2394
+ if (typeof args.theme.font === "string")
2395
+ theme.font = args.theme.font;
2396
+ if (Object.keys(theme).length > 0)
2397
+ body.theme = theme;
2398
+ }
2399
+ if (Object.keys(body).length === 0) {
2400
+ throw new Error("Provide at least one of: name, slug, logo_bg, theme.{bg,primary,accent,radiusPx,font}.");
2401
+ }
2402
+ const { data } = await apiRequest(cfg, "/api/v1/workspace", { method: "PATCH", body });
2403
+ return data;
2404
+ }
2405
+ async function uploadWorkspaceLogo(args) {
2406
+ const cfg = resolveConfig();
2407
+ const localPath = requireString(args, "path");
2408
+ const { readFile, stat } = await import("node:fs/promises");
2409
+ const { basename, extname } = await import("node:path");
2410
+ const info = await stat(localPath);
2411
+ if (!info.isFile())
2412
+ throw new Error(`Not a file: ${localPath}`);
2413
+ const MAX = 2 * 1024 * 1024;
2414
+ if (info.size > MAX) {
2415
+ throw new Error(`Logo is ${(info.size / 1024 / 1024).toFixed(1)} MB — exceeds the 2 MB cap.`);
2416
+ }
2417
+ const filename = basename(localPath);
2418
+ const ext = extname(filename).toLowerCase();
2419
+ const contentTypes = {
2420
+ ".png": "image/png",
2421
+ ".jpg": "image/jpeg",
2422
+ ".jpeg": "image/jpeg",
2423
+ ".webp": "image/webp",
2424
+ };
2425
+ const contentType = contentTypes[ext];
2426
+ if (!contentType) {
2427
+ throw new Error(`Logo must be a PNG, JPG, or WebP image. Got ${ext || "(no extension)"}.`);
2428
+ }
2429
+ const bytes = await readFile(localPath);
2430
+ const form = new FormData();
2431
+ const blob = new Blob([new Uint8Array(bytes)], { type: contentType });
2432
+ form.set("file", blob, filename);
2433
+ const url = new URL("/api/v1/workspace/logo", cfg.apiUrl);
2434
+ const res = await fetch(url, {
2435
+ method: "POST",
2436
+ headers: {
2437
+ Authorization: `Bearer ${cfg.token}`,
2438
+ Accept: "application/json",
2439
+ "User-Agent": "atlas-mcp/0.1.0",
2440
+ },
2441
+ body: form,
2442
+ });
2443
+ const text = await res.text();
2444
+ let body = null;
2445
+ try {
2446
+ body = text ? JSON.parse(text) : null;
2447
+ }
2448
+ catch {
2449
+ body = text;
2450
+ }
2451
+ if (!res.ok) {
2452
+ const message = (isRecord(body) && typeof body.error === "string" && body.error) ||
2453
+ `HTTP ${res.status}`;
2454
+ throw new ApiError(res.status, body, message);
2455
+ }
2456
+ return body;
2457
+ }
2458
+ /* ─── Export (PDF) ─────────────────────────────────────────────────── */
2459
+ async function exportDocTool(args) {
2460
+ const cfg = resolveConfig();
2461
+ const docId = requireString(args, "doc_id");
2462
+ const open = args.open !== false;
2463
+ // Stream the NDJSON endpoint. We don't surface intermediate status
2464
+ // events to the model — the MCP returns a single JSON blob when done
2465
+ // (or throws on error). Matches the CLI's --json contract.
2466
+ const url = new URL(`/api/v1/docs/${docId}/export`, cfg.apiUrl);
2467
+ const res = await fetch(url, {
2468
+ method: "GET",
2469
+ headers: {
2470
+ Authorization: `Bearer ${cfg.token}`,
2471
+ Accept: "text/plain",
2472
+ "User-Agent": "atlas-mcp/0.1.0",
2473
+ },
2474
+ });
2475
+ if (!res.ok || !res.body) {
2476
+ const text = await res.text().catch(() => "");
2477
+ throw new ApiError(res.status, text, text || `HTTP ${res.status}`);
2478
+ }
2479
+ const reader = res.body.getReader();
2480
+ const decoder = new TextDecoder();
2481
+ let buffer = "";
2482
+ let done = null;
2483
+ while (true) {
2484
+ const { value, done: finished } = await reader.read();
2485
+ if (finished)
2486
+ break;
2487
+ buffer += decoder.decode(value, { stream: true });
2488
+ let nl;
2489
+ while ((nl = buffer.indexOf("\n")) !== -1) {
2490
+ const line = buffer.slice(0, nl).trim();
2491
+ buffer = buffer.slice(nl + 1);
2492
+ if (!line)
2493
+ continue;
2494
+ let ev;
2495
+ try {
2496
+ ev = JSON.parse(line);
2497
+ }
2498
+ catch {
2499
+ continue;
2500
+ }
2501
+ if (ev.type === "error") {
2502
+ throw new Error(typeof ev.message === "string" ? ev.message : "Export failed");
2503
+ }
2504
+ if (ev.type === "done") {
2505
+ done = ev;
2506
+ }
2507
+ }
2508
+ }
2509
+ if (buffer.trim()) {
2510
+ try {
2511
+ const ev = JSON.parse(buffer.trim());
2512
+ if (ev.type === "done")
2513
+ done = ev;
458
2514
  }
459
- throw e;
2515
+ catch { /* ignore */ }
460
2516
  }
461
- }
462
- async function describeSectionType(args) {
463
- const type = args.type;
464
- if (typeof type === "string") {
465
- const entry = findSection(type);
466
- if (!entry) {
467
- throw new Error(`Unknown section type '${type}'. Known types: ${SECTION_CATALOG.map((s) => s.type).join(", ")}.`);
2517
+ if (!done)
2518
+ throw new Error("Export stream ended without a `done` event");
2519
+ const { writeFile } = await import("node:fs/promises");
2520
+ const { resolve: resolvePath, isAbsolute } = await import("node:path");
2521
+ const outPathRaw = typeof args.out_path === "string" && args.out_path.trim()
2522
+ ? args.out_path
2523
+ : done.filename;
2524
+ const outPath = isAbsolute(outPathRaw)
2525
+ ? outPathRaw
2526
+ : resolvePath(process.cwd(), outPathRaw);
2527
+ const bytes = Buffer.from(done.pdfBase64, "base64");
2528
+ await writeFile(outPath, bytes);
2529
+ if (open) {
2530
+ const { spawn } = await import("node:child_process");
2531
+ try {
2532
+ if (process.platform === "win32") {
2533
+ spawn("cmd", ["/c", "start", "", outPath], { detached: true, stdio: "ignore" }).unref();
2534
+ }
2535
+ else if (process.platform === "darwin") {
2536
+ spawn("open", [outPath], { detached: true, stdio: "ignore" }).unref();
2537
+ }
2538
+ else {
2539
+ spawn("xdg-open", [outPath], { detached: true, stdio: "ignore" }).unref();
2540
+ }
468
2541
  }
469
- return entry;
2542
+ catch { /* non-fatal */ }
470
2543
  }
471
- return { types: SECTION_CATALOG };
472
- }
473
- /* ─── Files tools ──────────────────────────────────────────────────── */
474
- async function listFiles(args) {
475
- const cfg = resolveConfig();
476
- const query = {};
477
- if (typeof args.folder_id === "string")
478
- query.folder_id = args.folder_id;
479
- if (typeof args.limit === "number")
480
- query.limit = args.limit;
481
- if (typeof args.cursor === "string")
482
- query.cursor = args.cursor;
483
- const { data } = await apiRequest(cfg, "/api/v1/files", { query });
484
- return data;
2544
+ return { out_path: outPath, pages: done.pages, bytes: bytes.length };
485
2545
  }
486
- async function uploadFile(args) {
487
- const cfg = resolveConfig();
488
- const localPath = requireString(args, "local_path");
489
- const alt = typeof args.alt === "string" ? args.alt : undefined;
2546
+ /* ─── Moodboard, palette, products, locations, gear, shots helpers ─── */
2547
+ async function uploadLocalImage(cfg, localPath) {
490
2548
  const { readFile, stat } = await import("node:fs/promises");
491
- const { basename, extname } = await import("node:path");
492
- const info = await stat(localPath);
2549
+ const { basename, extname, isAbsolute, resolve: resolvePath } = await import("node:path");
2550
+ const abs = isAbsolute(localPath) ? localPath : resolvePath(process.cwd(), localPath);
2551
+ const info = await stat(abs);
493
2552
  if (!info.isFile())
494
2553
  throw new Error(`Not a file: ${localPath}`);
495
- const filename = basename(localPath);
2554
+ const filename = basename(abs);
496
2555
  const ext = extname(filename).toLowerCase();
497
2556
  const contentTypes = {
498
2557
  ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
499
2558
  ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
500
- ".mp4": "video/mp4", ".mov": "video/quicktime", ".pdf": "application/pdf",
501
- ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json",
502
- ".csv": "text/csv",
2559
+ ".heic": "image/heic", ".heif": "image/heif", ".avif": "image/avif",
503
2560
  };
504
2561
  const contentType = contentTypes[ext] ?? "application/octet-stream";
505
- // Step 1: signed URL
506
- const { data: signRes } = await apiRequest(cfg, "/api/v1/files/upload-url", {
507
- method: "POST",
508
- body: { filename },
509
- });
510
- // Step 2: PUT bytes
511
- const bytes = await readFile(localPath);
2562
+ const { data: signRes } = await apiRequest(cfg, "/api/v1/files/upload-url", { method: "POST", body: { filename } });
2563
+ const bytes = await readFile(abs);
512
2564
  const putRes = await fetch(signRes.upload_url, {
513
2565
  method: "PUT",
514
2566
  headers: { "Content-Type": contentType, "x-upsert": "false" },
515
2567
  body: bytes,
516
2568
  });
517
2569
  if (!putRes.ok) {
518
- const text = await putRes.text().catch(() => "");
519
- throw new Error(`Storage upload failed (HTTP ${putRes.status}): ${text.slice(0, 200)}`);
2570
+ throw new Error(`Storage upload failed (HTTP ${putRes.status}) for ${localPath}`);
520
2571
  }
521
- // Step 3: register metadata
522
- const { data: reg } = await apiRequest(cfg, "/api/v1/files", {
523
- method: "POST",
524
- body: { path: signRes.path, alt },
2572
+ try {
2573
+ await apiRequest(cfg, "/api/v1/files", {
2574
+ method: "POST",
2575
+ body: { path: signRes.path },
2576
+ });
2577
+ }
2578
+ catch { /* metadata optional */ }
2579
+ return signRes.path;
2580
+ }
2581
+ function looksLikeStorageKey(input) {
2582
+ if (/^https?:\/\//.test(input))
2583
+ return false;
2584
+ if (/^[a-zA-Z]:\\/.test(input))
2585
+ return false; // Windows absolute
2586
+ if (input.startsWith("/"))
2587
+ return false; // POSIX absolute
2588
+ if (input.startsWith("./") || input.startsWith("../"))
2589
+ return false;
2590
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}/i.test(input))
2591
+ return true;
2592
+ if (input.includes("/files/") || input.includes("/photos/"))
2593
+ return true;
2594
+ return false;
2595
+ }
2596
+ function randId(prefix) {
2597
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
2598
+ }
2599
+ /** Shared get-template → mutate sections → put-template flow. */
2600
+ async function patchTemplateSections(cfg, docId, mutate) {
2601
+ const { data: current } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`);
2602
+ const existingSections = Array.isArray(current.template_content?.sections)
2603
+ ? current.template_content.sections
2604
+ : [];
2605
+ const { sections: nextSections, message } = mutate([...existingSections]);
2606
+ const nextContent = {
2607
+ ...(current.template_content ?? { sections: [] }),
2608
+ sections: nextSections,
2609
+ };
2610
+ try {
2611
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
2612
+ method: "PUT",
2613
+ body: { template_content: nextContent, ...(message ? { message } : {}) },
2614
+ ifMatch: current.etag,
2615
+ });
2616
+ return { etag: data.etag, updated_at: data.updated_at };
2617
+ }
2618
+ catch (e) {
2619
+ if (e instanceof ApiError && e.status === 409) {
2620
+ throw new Error("Doc was modified since we fetched it — rerun to pick up concurrent edits.");
2621
+ }
2622
+ throw e;
2623
+ }
2624
+ }
2625
+ async function buildMoodboard(args) {
2626
+ const cfg = resolveConfig();
2627
+ const docId = requireString(args, "doc_id");
2628
+ const paths = args.image_paths;
2629
+ if (!Array.isArray(paths))
2630
+ throw new Error("`image_paths` must be an array of strings.");
2631
+ const rows = typeof args.rows === "number" ? Math.max(1, Math.round(args.rows)) : 3;
2632
+ const cols = typeof args.cols === "number" ? Math.max(1, Math.round(args.cols)) : 3;
2633
+ const aspect = typeof args.aspect === "string" ? args.aspect : "square";
2634
+ const title = typeof args.title === "string" ? args.title : "Moodboard";
2635
+ const spacing = typeof args.spacing === "number" ? args.spacing : 8;
2636
+ const radius = typeof args.radius === "number" ? args.radius : 8;
2637
+ const position = typeof args.position === "number" ? Math.round(args.position) : null;
2638
+ let uploadedCount = 0;
2639
+ const storageKeys = [];
2640
+ for (const p of paths) {
2641
+ if (typeof p !== "string" || !p)
2642
+ continue;
2643
+ if (looksLikeStorageKey(p)) {
2644
+ storageKeys.push(p);
2645
+ }
2646
+ else {
2647
+ const key = await uploadLocalImage(cfg, p);
2648
+ storageKeys.push(key);
2649
+ uploadedCount++;
2650
+ }
2651
+ }
2652
+ const total = rows * cols;
2653
+ const images = [];
2654
+ for (let i = 0; i < total; i++) {
2655
+ images.push(storageKeys[i] ? { url: storageKeys[i] } : null);
2656
+ }
2657
+ const sectionId = randId("sec");
2658
+ const result = await patchTemplateSections(cfg, docId, (sections) => {
2659
+ const section = {
2660
+ id: sectionId,
2661
+ type: "moodboard",
2662
+ data: { title, rows, cols, aspect, spacing, radius, images },
2663
+ };
2664
+ const next = [...sections];
2665
+ if (position !== null && Number.isFinite(position)) {
2666
+ next.splice(Math.max(0, Math.min(position, next.length)), 0, section);
2667
+ }
2668
+ else {
2669
+ next.push(section);
2670
+ }
2671
+ return { sections: next, message: "Add moodboard section" };
525
2672
  });
526
2673
  return {
527
- path: signRes.path,
528
- size: info.size,
529
- content_type: contentType,
530
- metadata: reg.file,
2674
+ section_id: sectionId,
2675
+ uploaded_count: uploadedCount,
2676
+ total_slots: total,
2677
+ etag: result.etag,
531
2678
  };
532
2679
  }
533
- async function getFileUrl(args) {
2680
+ async function extractPaletteTool(args) {
534
2681
  const cfg = resolveConfig();
535
- const path = requireString(args, "path");
536
- const { data } = await apiRequest(cfg, "/api/v1/files", { query: { limit: 500 } });
537
- const match = data.files.find((f) => f.path === path);
538
- if (!match)
539
- throw new Error(`File not found or no metadata row: ${path}`);
540
- return { path: match.path, url: match.signed_url, alt: match.alt };
2682
+ const docId = requireString(args, "doc_id");
2683
+ const sectionId = requireString(args, "moodboard_section_id");
2684
+ const body = {};
2685
+ if (typeof args.rows === "number")
2686
+ body.rows = args.rows;
2687
+ if (typeof args.cols === "number")
2688
+ body.cols = args.cols;
2689
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/sections/${sectionId}/extract-palette`, { method: "POST", body });
2690
+ return {
2691
+ palette_section_id: data.palette_section.id,
2692
+ colours: data.extracted.map((c) => c.hex),
2693
+ etag: data.etag,
2694
+ };
541
2695
  }
542
- async function setFileAlt(args) {
2696
+ async function searchProducts(args) {
543
2697
  const cfg = resolveConfig();
544
- const path = requireString(args, "path");
545
- const alt = typeof args.alt === "string" ? args.alt : null;
546
- const { data } = await apiRequest(cfg, "/api/v1/files/alt", {
547
- method: "POST",
548
- body: { path, alt },
549
- });
2698
+ const query = typeof args.query === "string" ? args.query : "";
2699
+ const limit = typeof args.limit === "number" ? args.limit : 25;
2700
+ const { data } = await apiRequest(cfg, "/api/v1/products/search", { query: { q: query, limit } });
550
2701
  return data;
551
2702
  }
552
- async function deleteFile(args) {
2703
+ async function addProductsToDoc(args) {
553
2704
  const cfg = resolveConfig();
554
- const path = requireString(args, "path");
555
- const { data } = await apiRequest(cfg, "/api/v1/files", {
556
- method: "DELETE",
557
- query: { path },
2705
+ const docId = requireString(args, "doc_id");
2706
+ const ids = args.shopify_product_ids;
2707
+ if (!Array.isArray(ids) || ids.length === 0) {
2708
+ throw new Error("`shopify_product_ids` must be a non-empty array of strings.");
2709
+ }
2710
+ const sectionId = typeof args.section_id === "string" ? args.section_id : null;
2711
+ const title = typeof args.title === "string" ? args.title : "Products";
2712
+ // Pull a wide window of live products and filter by id client-side.
2713
+ // Keeps the API surface small (no per-id endpoint yet) and the search
2714
+ // response already carries the full snapshot shape.
2715
+ const { data: searchRes } = await apiRequest(cfg, "/api/v1/products/search", { query: { q: "", limit: 100 } });
2716
+ const byId = new Map(searchRes.products.map((p) => [p.shopifyProductId, p]));
2717
+ const items = [];
2718
+ const missing = [];
2719
+ for (const id of ids) {
2720
+ if (typeof id !== "string")
2721
+ continue;
2722
+ const hit = byId.get(id);
2723
+ if (!hit) {
2724
+ missing.push(id);
2725
+ continue;
2726
+ }
2727
+ items.push({
2728
+ id: randId("prod"),
2729
+ source: "shopify",
2730
+ title: hit.title,
2731
+ ...(hit.price ? { price: hit.price } : {}),
2732
+ ...(hit.imageUrl ? { imageUrl: hit.imageUrl } : {}),
2733
+ handle: hit.handle,
2734
+ shopifyProductId: hit.shopifyProductId,
2735
+ vendor: hit.vendor,
2736
+ status: hit.status,
2737
+ });
2738
+ }
2739
+ if (items.length === 0) {
2740
+ throw new Error(`None of the requested product ids are in the first 100 live products. Missing: ${missing.join(", ")}`);
2741
+ }
2742
+ let targetId = sectionId;
2743
+ const res = await patchTemplateSections(cfg, docId, (sections) => {
2744
+ if (targetId) {
2745
+ const idx = sections.findIndex((s) => s.id === targetId);
2746
+ if (idx === -1)
2747
+ throw new Error(`Section '${targetId}' not found`);
2748
+ if (sections[idx].type !== "products") {
2749
+ throw new Error(`Section '${targetId}' is '${sections[idx].type}', not 'products'`);
2750
+ }
2751
+ const existing = (sections[idx].data ?? {});
2752
+ const prevItems = Array.isArray(existing.items) ? existing.items : [];
2753
+ sections[idx] = {
2754
+ ...sections[idx],
2755
+ data: { ...(sections[idx].data ?? {}), items: [...prevItems, ...items] },
2756
+ };
2757
+ }
2758
+ else {
2759
+ targetId = randId("sec");
2760
+ sections.push({
2761
+ id: targetId,
2762
+ type: "products",
2763
+ data: { title, items },
2764
+ });
2765
+ }
2766
+ return { sections, message: "Add products to doc" };
558
2767
  });
2768
+ return { section_id: targetId, added: items.length, missing, etag: res.etag };
2769
+ }
2770
+ async function resolveLocationTool(args) {
2771
+ const cfg = resolveConfig();
2772
+ const query = requireString(args, "query");
2773
+ const { data } = await apiRequest(cfg, "/api/v1/locations/resolve", { query: { query } });
559
2774
  return data;
560
2775
  }
561
- /* ─── Gear tools ───────────────────────────────────────────────────── */
562
- async function listGear(args) {
2776
+ async function addLocationToDoc(args) {
2777
+ const cfg = resolveConfig();
2778
+ const docId = requireString(args, "doc_id");
2779
+ const address = typeof args.address === "string" ? args.address : null;
2780
+ const placeId = typeof args.place_id === "string" ? args.place_id : null;
2781
+ if (!address && !placeId) {
2782
+ throw new Error("Provide `address` or `place_id`.");
2783
+ }
2784
+ const nickname = typeof args.nickname === "string" ? args.nickname : null;
2785
+ const notes = typeof args.notes === "string" ? args.notes : null;
2786
+ const sectionId = typeof args.section_id === "string" ? args.section_id : null;
2787
+ const title = typeof args.title === "string" ? args.title : "Locations";
2788
+ const q = address ?? placeId;
2789
+ const { data: resolved } = await apiRequest(cfg, "/api/v1/locations/resolve", { query: { query: q } });
2790
+ let hit = resolved.results[0] ?? null;
2791
+ if (placeId) {
2792
+ hit = resolved.results.find((r) => r.place_id === placeId) ?? hit;
2793
+ }
2794
+ if (!hit)
2795
+ throw new Error(`No Google Places match for '${q}'`);
2796
+ const item = {
2797
+ id: randId("loc"),
2798
+ mode: "auto",
2799
+ nickname: nickname ?? hit.text,
2800
+ ...(notes ? { notes } : {}),
2801
+ location: {
2802
+ place_id: hit.place_id,
2803
+ text: hit.text,
2804
+ lat: hit.lat,
2805
+ lng: hit.lng,
2806
+ verified: true,
2807
+ },
2808
+ };
2809
+ let targetId = sectionId;
2810
+ const res = await patchTemplateSections(cfg, docId, (sections) => {
2811
+ if (targetId) {
2812
+ const idx = sections.findIndex((s) => s.id === targetId);
2813
+ if (idx === -1)
2814
+ throw new Error(`Section '${targetId}' not found`);
2815
+ if (sections[idx].type !== "locations") {
2816
+ throw new Error(`Section '${targetId}' is '${sections[idx].type}', not 'locations'`);
2817
+ }
2818
+ const existing = (sections[idx].data ?? {});
2819
+ const prevItems = Array.isArray(existing.items) ? existing.items : [];
2820
+ sections[idx] = {
2821
+ ...sections[idx],
2822
+ data: { ...(sections[idx].data ?? {}), items: [...prevItems, item] },
2823
+ };
2824
+ }
2825
+ else {
2826
+ targetId = randId("sec");
2827
+ sections.push({
2828
+ id: targetId,
2829
+ type: "locations",
2830
+ data: { title, items: [item] },
2831
+ });
2832
+ }
2833
+ return { sections, message: "Add location to doc" };
2834
+ });
2835
+ return { section_id: targetId, item, etag: res.etag };
2836
+ }
2837
+ async function addGearToDoc(args) {
2838
+ const cfg = resolveConfig();
2839
+ const docId = requireString(args, "doc_id");
2840
+ const ids = args.gear_ids;
2841
+ if (!Array.isArray(ids) || ids.length === 0) {
2842
+ throw new Error("`gear_ids` must be a non-empty array of UUIDs.");
2843
+ }
2844
+ const gearIds = ids.filter((v) => typeof v === "string" && !!v);
2845
+ const sectionId = typeof args.section_id === "string" ? args.section_id : null;
2846
+ const title = typeof args.title === "string" ? args.title : "Gear";
2847
+ const format = typeof args.format === "string" ? args.format : "table";
2848
+ let targetId = sectionId;
2849
+ const res = await patchTemplateSections(cfg, docId, (sections) => {
2850
+ if (targetId) {
2851
+ const idx = sections.findIndex((s) => s.id === targetId);
2852
+ if (idx === -1)
2853
+ throw new Error(`Section '${targetId}' not found`);
2854
+ if (sections[idx].type !== "gear") {
2855
+ throw new Error(`Section '${targetId}' is '${sections[idx].type}', not 'gear'`);
2856
+ }
2857
+ const existing = (sections[idx].data ?? {});
2858
+ const prev = Array.isArray(existing.itemIds)
2859
+ ? existing.itemIds
2860
+ : [];
2861
+ const merged = Array.from(new Set([...prev, ...gearIds]));
2862
+ sections[idx] = {
2863
+ ...sections[idx],
2864
+ data: { ...(sections[idx].data ?? {}), itemIds: merged },
2865
+ };
2866
+ }
2867
+ else {
2868
+ targetId = randId("sec");
2869
+ sections.push({
2870
+ id: targetId,
2871
+ type: "gear",
2872
+ data: { title, itemIds: gearIds, format },
2873
+ });
2874
+ }
2875
+ return { sections, message: "Add gear to doc" };
2876
+ });
2877
+ return { section_id: targetId, added: gearIds.length, etag: res.etag };
2878
+ }
2879
+ async function addShotsToDoc(args) {
2880
+ const cfg = resolveConfig();
2881
+ const docId = requireString(args, "doc_id");
2882
+ const ids = args.shot_ids;
2883
+ if (!Array.isArray(ids) || ids.length === 0) {
2884
+ throw new Error("`shot_ids` must be a non-empty array of UUIDs.");
2885
+ }
2886
+ const shotIds = ids.filter((v) => typeof v === "string" && !!v);
2887
+ const sectionId = typeof args.section_id === "string" ? args.section_id : null;
2888
+ const title = typeof args.title === "string" ? args.title : "Shot list";
2889
+ // Pull a wide shot-library window and snapshot name/kind/notes into
2890
+ // rows. Matches the shot-list section's row shape in doc-presets.ts.
2891
+ const { data } = await apiRequest(cfg, "/api/v1/shots", { query: { limit: 500 } });
2892
+ const byId = new Map(data.items.map((s) => [s.id, s]));
2893
+ const rows = [];
2894
+ const missing = [];
2895
+ for (const id of shotIds) {
2896
+ const hit = byId.get(id);
2897
+ if (!hit) {
2898
+ missing.push(id);
2899
+ continue;
2900
+ }
2901
+ rows.push({
2902
+ id: randId("sr"),
2903
+ name: hit.name,
2904
+ kind: hit.kind ?? "",
2905
+ lens: "",
2906
+ notes: hit.notes ?? "",
2907
+ });
2908
+ }
2909
+ if (rows.length === 0) {
2910
+ throw new Error(`None of the shot ids were found. Missing: ${missing.join(", ")}`);
2911
+ }
2912
+ let targetId = sectionId;
2913
+ const res = await patchTemplateSections(cfg, docId, (sections) => {
2914
+ if (targetId) {
2915
+ const idx = sections.findIndex((s) => s.id === targetId);
2916
+ if (idx === -1)
2917
+ throw new Error(`Section '${targetId}' not found`);
2918
+ if (sections[idx].type !== "shot-list") {
2919
+ throw new Error(`Section '${targetId}' is '${sections[idx].type}', not 'shot-list'`);
2920
+ }
2921
+ const existing = (sections[idx].data ?? {});
2922
+ const prev = Array.isArray(existing.rows) ? existing.rows : [];
2923
+ sections[idx] = {
2924
+ ...sections[idx],
2925
+ data: { ...(sections[idx].data ?? {}), rows: [...prev, ...rows] },
2926
+ };
2927
+ }
2928
+ else {
2929
+ targetId = randId("sec");
2930
+ sections.push({
2931
+ id: targetId,
2932
+ type: "shot-list",
2933
+ data: { title, rows },
2934
+ });
2935
+ }
2936
+ return { sections, message: "Add shots to doc" };
2937
+ });
2938
+ return { section_id: targetId, added: rows.length, missing, etag: res.etag };
2939
+ }
2940
+ /* ─── Mindmap tools ────────────────────────────────────────────────── */
2941
+ async function listMindmaps_(args) {
563
2942
  const cfg = resolveConfig();
564
2943
  const query = {};
565
2944
  if (typeof args.limit === "number")
566
2945
  query.limit = args.limit;
567
2946
  if (typeof args.cursor === "string")
568
2947
  query.cursor = args.cursor;
569
- const { data } = await apiRequest(cfg, "/api/v1/gear", { query });
2948
+ const { data } = await apiRequest(cfg, "/api/v1/mindmaps", { query });
570
2949
  return data;
571
2950
  }
572
- async function addGear(args) {
2951
+ async function getMindmap_(args) {
573
2952
  const cfg = resolveConfig();
574
- const body = { name: requireString(args, "name") };
575
- if (typeof args.brand === "string")
576
- body.brand = args.brand;
577
- if (typeof args.quantity === "number")
578
- body.quantity = args.quantity;
579
- if (typeof args.image_path === "string")
580
- body.image_path = args.image_path;
581
- const { data } = await apiRequest(cfg, "/api/v1/gear", {
582
- method: "POST",
583
- body,
584
- });
2953
+ const mindmapId = requireString(args, "mindmapId");
2954
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}`);
585
2955
  return data;
586
2956
  }
587
- async function importGear(args) {
2957
+ async function createMindmap_(args) {
588
2958
  const cfg = resolveConfig();
589
- const rows = args.rows;
590
- if (!Array.isArray(rows))
591
- throw new Error("`rows` must be an array.");
592
- const mode = args.mode === "replace" ? "replace" : "append";
593
- const { data } = await apiRequest(cfg, "/api/v1/gear/import", {
2959
+ const title = requireString(args, "title");
2960
+ const body = { title };
2961
+ if (typeof args.description === "string")
2962
+ body.description = args.description;
2963
+ if (typeof args.shoot_id === "string")
2964
+ body.shoot_id = args.shoot_id;
2965
+ if (Array.isArray(args.nodes))
2966
+ body.nodes = args.nodes;
2967
+ if (Array.isArray(args.edges))
2968
+ body.edges = args.edges;
2969
+ const { data } = await apiRequest(cfg, "/api/v1/mindmaps", {
594
2970
  method: "POST",
595
- body: { mode, rows },
2971
+ body,
596
2972
  });
597
2973
  return data;
598
2974
  }
599
- async function deleteGear(args) {
2975
+ async function deleteMindmap_(args) {
600
2976
  const cfg = resolveConfig();
601
- const gearId = requireString(args, "gearId");
602
- const { data } = await apiRequest(cfg, `/api/v1/gear/${gearId}`, {
2977
+ const mindmapId = requireString(args, "mindmapId");
2978
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}`, {
603
2979
  method: "DELETE",
604
2980
  });
605
2981
  return data;
606
2982
  }
607
- async function updateGear(args) {
2983
+ async function updateMindmap_(args) {
608
2984
  const cfg = resolveConfig();
609
- const gearId = requireString(args, "gearId");
2985
+ const mindmapId = requireString(args, "mindmapId");
610
2986
  const body = {};
611
- if (typeof args.name === "string")
612
- body.name = args.name;
613
- if (typeof args.brand === "string")
614
- body.brand = args.brand;
615
- if (typeof args.quantity === "number")
616
- body.quantity = args.quantity;
617
- if (typeof args.image_path === "string")
618
- body.image_path = args.image_path;
2987
+ if (typeof args.title === "string")
2988
+ body.title = args.title;
2989
+ if (args.description === null || typeof args.description === "string") {
2990
+ body.description = args.description;
2991
+ }
2992
+ if (args.shoot_id === null || typeof args.shoot_id === "string") {
2993
+ body.shoot_id = args.shoot_id;
2994
+ }
619
2995
  if (Object.keys(body).length === 0) {
620
- throw new Error("At least one of name, brand, quantity, image_path is required.");
2996
+ throw new Error("Provide at least one of title, description, shoot_id.");
621
2997
  }
622
- const { data } = await apiRequest(cfg, `/api/v1/gear/${gearId}`, {
623
- method: "PUT",
2998
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}`, {
2999
+ method: "PATCH",
624
3000
  body,
625
3001
  });
626
3002
  return data;
627
3003
  }
628
- /* ─── Files rename ─────────────────────────────────────────────────── */
629
- async function renameFile(args) {
3004
+ async function addMindmapNode_(args) {
630
3005
  const cfg = resolveConfig();
631
- const path = requireString(args, "path");
632
- const newName = requireString(args, "new_name");
633
- const { data } = await apiRequest(cfg, "/api/v1/files/rename", {
3006
+ const mindmapId = requireString(args, "mindmapId");
3007
+ const body = {};
3008
+ for (const key of [
3009
+ "label",
3010
+ "title",
3011
+ "description",
3012
+ "shape",
3013
+ "kind",
3014
+ "colour_key",
3015
+ "image_url",
3016
+ "image_alt",
3017
+ "text_color",
3018
+ "bg_color",
3019
+ ]) {
3020
+ if (typeof args[key] === "string")
3021
+ body[key] = args[key];
3022
+ }
3023
+ for (const key of ["x", "y", "width", "height", "font_size", "order_index"]) {
3024
+ if (typeof args[key] === "number")
3025
+ body[key] = args[key];
3026
+ }
3027
+ if (typeof args.completed === "boolean")
3028
+ body.completed = args.completed;
3029
+ if (!body.label && !body.title) {
3030
+ throw new Error("`label` or `title` is required.");
3031
+ }
3032
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}/nodes`, {
634
3033
  method: "POST",
635
- body: { path, new_name: newName },
3034
+ body,
636
3035
  });
637
3036
  return data;
638
3037
  }
639
- /* ─── Revisions ────────────────────────────────────────────────────── */
640
- async function listRevisions(args) {
3038
+ async function updateMindmapNode_(args) {
3039
+ const cfg = resolveConfig();
3040
+ const mindmapId = requireString(args, "mindmapId");
3041
+ const nodeId = requireString(args, "nodeId");
3042
+ const body = {};
3043
+ for (const key of [
3044
+ "label",
3045
+ "title",
3046
+ "description",
3047
+ "shape",
3048
+ "kind",
3049
+ "colour_key",
3050
+ "image_url",
3051
+ "image_alt",
3052
+ "text_color",
3053
+ "bg_color",
3054
+ ]) {
3055
+ if (typeof args[key] === "string")
3056
+ body[key] = args[key];
3057
+ }
3058
+ for (const key of ["x", "y", "width", "height", "font_size", "order_index"]) {
3059
+ if (typeof args[key] === "number")
3060
+ body[key] = args[key];
3061
+ }
3062
+ if (typeof args.completed === "boolean")
3063
+ body.completed = args.completed;
3064
+ if (Object.keys(body).length === 0) {
3065
+ throw new Error("Provide at least one field to patch.");
3066
+ }
3067
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}/nodes/${nodeId}`, { method: "PATCH", body });
3068
+ return data;
3069
+ }
3070
+ async function deleteMindmapNode_(args) {
3071
+ const cfg = resolveConfig();
3072
+ const mindmapId = requireString(args, "mindmapId");
3073
+ const nodeId = requireString(args, "nodeId");
3074
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}/nodes/${nodeId}`, { method: "DELETE" });
3075
+ return data;
3076
+ }
3077
+ async function addMindmapEdge_(args) {
3078
+ const cfg = resolveConfig();
3079
+ const mindmapId = requireString(args, "mindmapId");
3080
+ const from_node_id = requireString(args, "from_node_id");
3081
+ const to_node_id = requireString(args, "to_node_id");
3082
+ const body = { from_node_id, to_node_id };
3083
+ if (typeof args.label === "string")
3084
+ body.label = args.label;
3085
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}/edges`, { method: "POST", body });
3086
+ return data;
3087
+ }
3088
+ async function deleteMindmapEdge_(args) {
3089
+ const cfg = resolveConfig();
3090
+ const mindmapId = requireString(args, "mindmapId");
3091
+ const edgeId = requireString(args, "edgeId");
3092
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}/edges/${edgeId}`, { method: "DELETE" });
3093
+ return data;
3094
+ }
3095
+ async function createMindmapGroup_(args) {
3096
+ const cfg = resolveConfig();
3097
+ const mindmapId = requireString(args, "mindmapId");
3098
+ const nodes = Array.isArray(args.nodes)
3099
+ ? args.nodes.filter((v) => typeof v === "string" && !!v)
3100
+ : [];
3101
+ if (nodes.length === 0) {
3102
+ throw new Error("`nodes` must be a non-empty array of node ids.");
3103
+ }
3104
+ const edges = Array.isArray(args.edges)
3105
+ ? args.edges.filter((v) => typeof v === "string" && !!v)
3106
+ : [];
3107
+ const body = { nodes };
3108
+ if (edges.length > 0)
3109
+ body.edges = edges;
3110
+ if (typeof args.name === "string")
3111
+ body.name = args.name;
3112
+ else if (typeof args.title === "string")
3113
+ body.name = args.title;
3114
+ if (typeof args.bg_color === "string")
3115
+ body.bg_color = args.bg_color;
3116
+ if (typeof args.outline_color === "string")
3117
+ body.outline_color = args.outline_color;
3118
+ if (typeof args.outline_width === "number")
3119
+ body.outline_width = args.outline_width;
3120
+ if (typeof args.corner_radius === "number")
3121
+ body.corner_radius = args.corner_radius;
3122
+ if (typeof args.padding === "number")
3123
+ body.padding = args.padding;
3124
+ if (typeof args.custom_css === "string")
3125
+ body.custom_css = args.custom_css;
3126
+ const { data } = await apiRequest(cfg, `/api/v1/mindmaps/${mindmapId}/groups`, { method: "POST", body });
3127
+ return data;
3128
+ }
3129
+ /* ─── Activity log ─────────────────────────────────────────────────── */
3130
+ async function listActivity_(args) {
641
3131
  const cfg = resolveConfig();
642
- const docId = requireString(args, "docId");
643
3132
  const query = {};
644
3133
  if (typeof args.limit === "number")
645
3134
  query.limit = args.limit;
646
- const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/revisions`, { query });
3135
+ if (typeof args.cursor === "string")
3136
+ query.cursor = args.cursor;
3137
+ if (typeof args.since === "string")
3138
+ query.since = args.since;
3139
+ if (typeof args.until === "string")
3140
+ query.until = args.until;
3141
+ if (typeof args.user === "string")
3142
+ query.user = args.user;
3143
+ if (typeof args.source === "string")
3144
+ query.source = args.source;
3145
+ if (typeof args.action === "string")
3146
+ query.action = args.action;
3147
+ if (typeof args.resource_type === "string")
3148
+ query.resource_type = args.resource_type;
3149
+ const { data } = await apiRequest(cfg, "/api/v1/activity", { query });
647
3150
  return data;
648
3151
  }
649
- async function restoreRevision(args) {
3152
+ async function getActivity_(args) {
650
3153
  const cfg = resolveConfig();
651
- const docId = requireString(args, "docId");
652
- const revisionId = requireString(args, "revisionId");
653
- const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/revisions/${revisionId}/restore`, {
3154
+ const entryId = requireString(args, "entryId");
3155
+ const { data } = await apiRequest(cfg, `/api/v1/activity/${entryId}`);
3156
+ return data;
3157
+ }
3158
+ /* ─── Mindmap export (PDF / PNG / SVG) ───────────────────────────── */
3159
+ async function exportMindmapTool(args) {
3160
+ const cfg = resolveConfig();
3161
+ const mindmapId = requireString(args, "mindmap_id");
3162
+ const formatRaw = typeof args.format === "string" ? args.format.toLowerCase() : "pdf";
3163
+ const format = (formatRaw === "svg" || formatRaw === "png" || formatRaw === "pdf" ? formatRaw : "pdf");
3164
+ const openFile = args.open !== false;
3165
+ const dpi = Math.max(1, Math.min(3, Number(args.dpi ?? 2)));
3166
+ const w = Math.max(320, Math.min(7680, Number(args.w ?? 1920)));
3167
+ const h = Math.max(240, Math.min(7680, Number(args.h ?? 1080)));
3168
+ // Shared out-path resolver.
3169
+ const { resolve: resolvePath, isAbsolute } = await import("node:path");
3170
+ const { writeFile } = await import("node:fs/promises");
3171
+ if (format === "svg") {
3172
+ const url = new URL(`/api/v1/mindmaps/${mindmapId}/export`, cfg.apiUrl);
3173
+ url.searchParams.set("format", "svg");
3174
+ const res = await fetch(url, {
3175
+ headers: {
3176
+ Authorization: `Bearer ${cfg.token}`,
3177
+ Accept: "image/svg+xml",
3178
+ "User-Agent": "atlas-mcp/0.1.0",
3179
+ },
3180
+ });
3181
+ if (!res.ok) {
3182
+ const text = await res.text().catch(() => "");
3183
+ throw new Error(`SVG export failed (${res.status}): ${text}`);
3184
+ }
3185
+ const bytes = Buffer.from(await res.arrayBuffer());
3186
+ const stub = `mindmap-${mindmapId.slice(0, 8)}`;
3187
+ const outPathRaw = typeof args.out_path === "string" && args.out_path.trim()
3188
+ ? args.out_path
3189
+ : `${stub}.svg`;
3190
+ const outPath = isAbsolute(outPathRaw) ? outPathRaw : resolvePath(process.cwd(), outPathRaw);
3191
+ await writeFile(outPath, bytes);
3192
+ if (openFile)
3193
+ openWithOs(outPath);
3194
+ return { out_path: outPath, format: "svg", bytes: bytes.length };
3195
+ }
3196
+ // PDF / PNG — mint a token, drive Playwright.
3197
+ const tokenUrl = new URL(`/api/v1/mindmaps/${mindmapId}/export-token`, cfg.apiUrl);
3198
+ const tokRes = await fetch(tokenUrl, {
654
3199
  method: "POST",
3200
+ headers: {
3201
+ Authorization: `Bearer ${cfg.token}`,
3202
+ Accept: "application/json",
3203
+ "User-Agent": "atlas-mcp/0.1.0",
3204
+ },
655
3205
  });
656
- return {
657
- docId: data.id,
658
- title: data.title,
659
- updated_at: data.updated_at,
660
- etag: data.etag,
661
- restored_from: data.restored_from,
662
- };
663
- }
664
- /* ─── Helpers ──────────────────────────────────────────────────────── */
665
- function requireString(args, key) {
666
- const v = args[key];
667
- if (typeof v !== "string" || !v)
668
- throw new Error(`\`${key}\` is required.`);
669
- return v;
3206
+ if (!tokRes.ok) {
3207
+ const text = await tokRes.text().catch(() => "");
3208
+ throw new Error(`Export-token request failed (${tokRes.status}): ${text}`);
3209
+ }
3210
+ const tok = (await tokRes.json());
3211
+ const { chromium } = await import("playwright-core");
3212
+ const browser = await chromium.launch({ headless: true });
3213
+ let outBuffer;
3214
+ try {
3215
+ const context = await browser.newContext({
3216
+ viewport: { width: w, height: h },
3217
+ deviceScaleFactor: dpi,
3218
+ locale: "en-AU",
3219
+ });
3220
+ await context.addCookies([
3221
+ {
3222
+ name: tok.cookieName,
3223
+ value: tok.token,
3224
+ url: new URL(tok.mindmapUrl).origin,
3225
+ httpOnly: true,
3226
+ secure: tok.mindmapUrl.startsWith("https:"),
3227
+ sameSite: "Lax",
3228
+ },
3229
+ ]);
3230
+ const page = await context.newPage();
3231
+ const url = new URL(tok.mindmapUrl);
3232
+ url.searchParams.set("w", String(w));
3233
+ url.searchParams.set("h", String(h));
3234
+ await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: 60_000 });
3235
+ await page.waitForSelector(".mindmap-export-ready", { timeout: 30_000 });
3236
+ if (format === "pdf") {
3237
+ outBuffer = Buffer.from(await page.pdf({
3238
+ width: `${w}px`,
3239
+ height: `${h}px`,
3240
+ printBackground: true,
3241
+ margin: { top: "0mm", right: "0mm", bottom: "0mm", left: "0mm" },
3242
+ }));
3243
+ }
3244
+ else {
3245
+ outBuffer = Buffer.from(await page.screenshot({
3246
+ type: "png",
3247
+ clip: { x: 0, y: 0, width: w, height: h },
3248
+ omitBackground: false,
3249
+ }));
3250
+ }
3251
+ }
3252
+ finally {
3253
+ try {
3254
+ await browser.close();
3255
+ }
3256
+ catch {
3257
+ /* non-fatal */
3258
+ }
3259
+ }
3260
+ const safeTitle = (tok.title || "mindmap")
3261
+ .replace(/[^\w\s.-]+/g, "")
3262
+ .replace(/\s+/g, "-")
3263
+ .slice(0, 60) || "mindmap";
3264
+ const outPathRaw = typeof args.out_path === "string" && args.out_path.trim()
3265
+ ? args.out_path
3266
+ : `${safeTitle}.${format}`;
3267
+ const outPath = isAbsolute(outPathRaw) ? outPathRaw : resolvePath(process.cwd(), outPathRaw);
3268
+ await writeFile(outPath, outBuffer);
3269
+ if (openFile)
3270
+ openWithOs(outPath);
3271
+ return { out_path: outPath, format, bytes: outBuffer.length };
670
3272
  }
671
- function isRecord(v) {
672
- return typeof v === "object" && v !== null;
3273
+ function openWithOs(path) {
3274
+ // Non-blocking OS "open" — ignores failures (file is already on disk).
3275
+ import("node:child_process").then(({ spawn }) => {
3276
+ try {
3277
+ if (process.platform === "win32") {
3278
+ spawn("cmd", ["/c", "start", "", path], { detached: true, stdio: "ignore" }).unref();
3279
+ }
3280
+ else if (process.platform === "darwin") {
3281
+ spawn("open", [path], { detached: true, stdio: "ignore" }).unref();
3282
+ }
3283
+ else {
3284
+ spawn("xdg-open", [path], { detached: true, stdio: "ignore" }).unref();
3285
+ }
3286
+ }
3287
+ catch {
3288
+ /* non-fatal */
3289
+ }
3290
+ });
673
3291
  }
674
3292
  /* ─── Boot ─────────────────────────────────────────────────────────── */
675
3293
  async function main() {