@kezlahd/atlas-mcp 0.2.1 → 0.3.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
@@ -48,7 +48,7 @@ const TOOLS = [
48
48
  },
49
49
  {
50
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).",
51
+ description: "Create a new doc. For a structured doc, prefer atlas_create_doc_from_preset — it stamps a cover + relevant section skeleton. For a hand-rolled doc, pass template_content: { cover: { subtitle }, sections: [...] }; a cover is strongly recommended as the first element. Returns { id, etag, ... }; pipe into atlas_update_section / atlas_build_moodboard / atlas_add_products_to_doc / atlas_add_section / atlas_update_cover etc. to fill sections one at a time (safer than another full-template PUT, which fights the web editor's autosave).",
52
52
  inputSchema: {
53
53
  type: "object",
54
54
  properties: {
@@ -173,7 +173,7 @@ const TOOLS = [
173
173
  },
174
174
  {
175
175
  name: "atlas_update_section",
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.",
176
+ description: "Edit ONE existing section by id — this tool cannot create sections. Use atlas_add_section, atlas_update_template, or a dedicated add-tool (atlas_build_moodboard, atlas_add_products_to_doc, …) to create. Fields: `data` REPLACES the section's data object (per-key merge is ambiguous for arrays); `style` merges over existing style keys; `hidden: true/false` toggles visibility (hidden sections stay in the doc but are omitted from the printed output — use this instead of deleting a section the user might want back). On concurrent edit the tool auto-retries once (re-GET → re-apply → re-PATCH); if the second attempt also races, bubble the error so the user can decide whether to force.",
177
177
  inputSchema: {
178
178
  type: "object",
179
179
  properties: {
@@ -213,7 +213,7 @@ const TOOLS = [
213
213
  },
214
214
  {
215
215
  name: "atlas_describe_section_type",
216
- description: "Look up what fields a section type takes. Use this before atlas_update_section so you send the right shape. If called with no `type`, returns the full catalogue of known section types.",
216
+ description: "Look up a section type's shape BEFORE calling atlas_update_section / atlas_add_section. Returns { type, purpose, data_shape, common_fields, hidden_supported: true }. Every section accepts a top-level `hidden: boolean`. Image/asset fields (moodboard images[].url, cover bgImageUrl, cover presentedBy.logoUrl, people-link photoUrl) require a SIGNED URL, not a storage key — pass storage keys through atlas_get_file_url first. Call with no `type` for the full catalogue of known types.",
217
217
  inputSchema: {
218
218
  type: "object",
219
219
  properties: {
@@ -241,7 +241,7 @@ const TOOLS = [
241
241
  },
242
242
  {
243
243
  name: "atlas_upload_file",
244
- description: "Upload a local file (path on the user's machine) to the workspace bucket. Three-step signed-URL flow. Returns the storage path + a 1-hour signed URL. Extension whitelist enforced (images/video/audio/pdf/txt/md/json/csv); executables/archives refused.",
244
+ description: "Upload a local file (path on the user's machine) to the workspace bucket. Three-step signed-URL flow. Returns the storage path AND a long-lived (1-year) signed URL — drop the signed URL straight into section fields (moodboard images[].url, cover bgImageUrl, people-link photoUrl, …) that need a URL rather than a storage key. Extension whitelist enforced (images/video/audio/pdf/txt/md/json/csv); executables/archives refused.",
245
245
  inputSchema: {
246
246
  type: "object",
247
247
  properties: {
@@ -967,7 +967,7 @@ const TOOLS = [
967
967
  },
968
968
  {
969
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'.",
970
+ description: "Build a moodboard section wired to real images. Uploads any local paths through the signed-URL flow, re-uses already-uploaded storage keys, SIGNS every storage key into a 1-year signed URL before storing it on the section (raw storage keys render as 404s), and appends (or inserts at `position`) a moodboard section with the shape { title, rows, cols, aspect, spacing, radius, images: [{ id, alt?, url (signed) } | null] }. images.length === rows*cols; empty slots are null. Default 3×3 square. 20 MB per-file limit. For colour extraction, follow with atlas_extract_palette.",
971
971
  inputSchema: {
972
972
  type: "object",
973
973
  properties: {
@@ -997,7 +997,7 @@ const TOOLS = [
997
997
  },
998
998
  {
999
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[] }.",
1000
+ description: "Append a palette section wired to a moodboard. The moodboard must already contain at least one image (storage keys are re-signed server-side for extraction). Writes { title, sourceId, rows, cols, numColors: rows*cols, extractedColours: [{ hex, weight }] } and inserts the palette right below the source moodboard. Returns { palette_section_id, colours }. If called twice on the same moodboard the existing palette section is patched in place.",
1001
1001
  inputSchema: {
1002
1002
  type: "object",
1003
1003
  properties: {
@@ -1116,6 +1116,96 @@ const TOOLS = [
1116
1116
  required: ["doc_id", "shot_ids"],
1117
1117
  },
1118
1118
  },
1119
+ {
1120
+ name: "atlas_add_calendar_event_to_doc",
1121
+ description: "Schedule a shoot AND wire its id into a calendar-event section in one call. Creates the event via /api/v1/events, then either appends the new event id to section_id's `eventIds` (must be a calendar-event section) or creates a new calendar-event section with it. Use when a doc needs to reference a shoot that doesn't exist yet — calendar-event sections only embed already-scheduled shoots, atlas_create_event on its own won't surface in the doc.",
1122
+ inputSchema: {
1123
+ type: "object",
1124
+ properties: {
1125
+ doc_id: { type: "string" },
1126
+ section_id: {
1127
+ type: "string",
1128
+ description: "Append to this existing calendar-event section. Omit to create a new section at the end of the doc.",
1129
+ },
1130
+ title: {
1131
+ type: "string",
1132
+ description: "Section title when creating a new section (default 'Schedule').",
1133
+ },
1134
+ layout: {
1135
+ type: "string",
1136
+ enum: ["card", "table", "banner"],
1137
+ description: "Section layout when creating. Default 'card'.",
1138
+ },
1139
+ event: {
1140
+ type: "object",
1141
+ description: "Shoot payload — same shape as atlas_create_event. `title` + `starts_at` required.",
1142
+ properties: {
1143
+ title: { type: "string" },
1144
+ starts_at: { type: "string", description: "ISO 8601 start." },
1145
+ ends_at: { type: "string", description: "ISO 8601 end." },
1146
+ all_day: { type: "boolean" },
1147
+ location: { type: "string" },
1148
+ shoot_type: { type: "string" },
1149
+ colour_key: {
1150
+ type: "string",
1151
+ enum: ["rose", "amber", "sky", "violet", "lime", "stone"],
1152
+ },
1153
+ colour_hex: { type: "string" },
1154
+ status: {
1155
+ type: "string",
1156
+ enum: ["planned", "confirmed", "wrapped", "cancelled"],
1157
+ },
1158
+ notes: { type: "string" },
1159
+ description: { type: "string" },
1160
+ },
1161
+ required: ["title", "starts_at"],
1162
+ },
1163
+ },
1164
+ required: ["doc_id", "event"],
1165
+ },
1166
+ },
1167
+ {
1168
+ name: "atlas_add_section",
1169
+ description: "Generic 'create section' for any type that doesn't have a dedicated add-tool (text, notes, table, code-block, calendar-event, people-link, toc, …). Uses the scoped append endpoint — doesn't fight the web editor's autosave the way a full-template PUT does. Call atlas_describe_section_type first to learn the data shape for your target type.",
1170
+ inputSchema: {
1171
+ type: "object",
1172
+ properties: {
1173
+ doc_id: { type: "string" },
1174
+ type: {
1175
+ type: "string",
1176
+ description: "Section type — e.g. 'text', 'notes', 'table', 'code-block', 'calendar-event', 'people-link', 'toc'. See atlas_describe_section_type for the full list.",
1177
+ },
1178
+ data: {
1179
+ type: "object",
1180
+ description: "section.data payload matching the type's shape. Use atlas_describe_section_type for field names.",
1181
+ },
1182
+ position: {
1183
+ type: "number",
1184
+ description: "Insert at this section index. Omit to append.",
1185
+ },
1186
+ hidden: {
1187
+ type: "boolean",
1188
+ description: "Mark the new section hidden (dimmed in editor, omitted from printed output). Default false.",
1189
+ },
1190
+ },
1191
+ required: ["doc_id", "type", "data"],
1192
+ },
1193
+ },
1194
+ {
1195
+ name: "atlas_update_cover",
1196
+ description: "Scoped merge-update of the doc cover (title, subtitle, bg image, presentedBy lockup, …). Cover lives on template_content.cover — not in sections[] — so atlas_update_section can't touch it. The server shallow-merges the fields you send, so passing `{ cover: { subtitle: 'x' } }` just updates subtitle. Image fields (bgImageUrl, presentedBy.logoUrl) require a SIGNED URL — pass storage keys through atlas_get_file_url first.",
1197
+ inputSchema: {
1198
+ type: "object",
1199
+ properties: {
1200
+ doc_id: { type: "string" },
1201
+ cover: {
1202
+ type: "object",
1203
+ description: "Partial cover payload — any subset of { subtitle, date, bgImageUrl, titlePosition, accentColor, presentedBy, ... }.",
1204
+ },
1205
+ },
1206
+ required: ["doc_id", "cover"],
1207
+ },
1208
+ },
1119
1209
  {
1120
1210
  name: "atlas_upload_workspace_logo",
1121
1211
  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`).",
@@ -1437,7 +1527,7 @@ const TOOLS = [
1437
1527
  },
1438
1528
  ];
1439
1529
  /* ─── Server setup ─────────────────────────────────────────────────── */
1440
- const server = new Server({ name: "atlas-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
1530
+ const server = new Server({ name: "atlas-mcp", version: "0.3.0" }, { capabilities: { tools: {} } });
1441
1531
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1442
1532
  tools: TOOLS,
1443
1533
  }));
@@ -1590,6 +1680,12 @@ async function dispatch(name, args) {
1590
1680
  return addGearToDoc(args);
1591
1681
  case "atlas_add_shots_to_doc":
1592
1682
  return addShotsToDoc(args);
1683
+ case "atlas_add_calendar_event_to_doc":
1684
+ return addCalendarEventToDoc(args);
1685
+ case "atlas_add_section":
1686
+ return addSection(args);
1687
+ case "atlas_update_cover":
1688
+ return updateCover(args);
1593
1689
  case "read_achievements":
1594
1690
  return readAchievements();
1595
1691
  case "atlas_list_mindmaps":
@@ -1669,36 +1765,54 @@ async function updateSection(args) {
1669
1765
  if (newData === undefined && newStyle === undefined && hidden === undefined) {
1670
1766
  throw new Error("Provide at least one of: data, style, hidden.");
1671
1767
  }
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.`);
1768
+ const body = {
1769
+ ...(newData !== undefined ? { data: newData } : {}),
1770
+ ...(newStyle !== undefined ? { style: newStyle } : {}),
1771
+ ...(hidden !== undefined ? { hidden } : {}),
1772
+ };
1773
+ // Retry on precondition failure. The web editor's autosave bumps
1774
+ // updated_at every few seconds, so a GET → PATCH window that spans
1775
+ // one autosave will 412 even when nothing conflicts. The user's
1776
+ // patch intent is the same shape regardless of what the editor
1777
+ // wrote, so re-applying it against the fresh state is safe.
1778
+ const MAX_ATTEMPTS = 2;
1779
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1780
+ const current = await getTemplate({ docId });
1781
+ try {
1782
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template/sections/${sectionId}`, {
1783
+ method: "PATCH",
1784
+ body,
1785
+ ifMatch: current.etag,
1786
+ });
1787
+ return {
1788
+ updated_section: sectionId,
1789
+ section: data.section,
1790
+ etag: data.etag,
1791
+ updated_at: data.updated_at,
1792
+ ...(attempt > 1 ? { retried: true } : {}),
1793
+ };
1696
1794
  }
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.");
1795
+ catch (e) {
1796
+ if (e instanceof ApiError && e.status === 404) {
1797
+ throw new Error(`Section '${sectionId}' not found in doc '${docId}'. Call atlas_list_sections to see current section ids.`);
1798
+ }
1799
+ // Server now returns 412 for If-Match failures (RFC 7232); legacy
1800
+ // deployments may still return 409 with the same message, so
1801
+ // treat both as precondition failures.
1802
+ if (e instanceof ApiError &&
1803
+ (e.status === 412 || e.status === 409) &&
1804
+ attempt < MAX_ATTEMPTS) {
1805
+ process.stderr.write(`[atlas-mcp] updateSection ${sectionId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
1806
+ continue;
1807
+ }
1808
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
1809
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor (or hand off) and retry, or ask the user to make this edit themselves.`);
1810
+ }
1811
+ throw e;
1699
1812
  }
1700
- throw e;
1701
1813
  }
1814
+ // Unreachable — the loop always either returns or throws.
1815
+ throw new Error("updateSection: unreachable retry fallthrough");
1702
1816
  }
1703
1817
  async function createDoc(args) {
1704
1818
  const cfg = resolveConfig();
@@ -1785,27 +1899,41 @@ async function updateTemplate(args) {
1785
1899
  throw new Error("`template_content` must be an object matching TemplateContent shape.");
1786
1900
  }
1787
1901
  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,
1795
- });
1796
- return {
1797
- docId: data.id,
1798
- title: data.title,
1799
- etag: data.etag,
1800
- updated_at: data.updated_at,
1801
- };
1802
- }
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.");
1902
+ // Get-then-put with one retry on concurrent-edit race. The caller
1903
+ // passed us an explicit template_content, so unlike patchTemplateSections
1904
+ // we can't re-apply "the user's intent against fresh state" — the
1905
+ // intent IS the full template. We just re-check the etag and retry.
1906
+ const MAX_ATTEMPTS = 2;
1907
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1908
+ const current = await getTemplate({ docId });
1909
+ try {
1910
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
1911
+ method: "PUT",
1912
+ body: { template_content: templateContent, ...(title ? { title } : {}) },
1913
+ ifMatch: current.etag,
1914
+ });
1915
+ return {
1916
+ docId: data.id,
1917
+ title: data.title,
1918
+ etag: data.etag,
1919
+ updated_at: data.updated_at,
1920
+ ...(attempt > 1 ? { retried: true } : {}),
1921
+ };
1922
+ }
1923
+ catch (e) {
1924
+ if (e instanceof ApiError &&
1925
+ (e.status === 412 || e.status === 409) &&
1926
+ attempt < MAX_ATTEMPTS) {
1927
+ process.stderr.write(`[atlas-mcp] updateTemplate ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
1928
+ continue;
1929
+ }
1930
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
1931
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor and retry, or ask the user to make this edit themselves.`);
1932
+ }
1933
+ throw e;
1806
1934
  }
1807
- throw e;
1808
1935
  }
1936
+ throw new Error("updateTemplate: unreachable retry fallthrough");
1809
1937
  }
1810
1938
  async function describeSectionType(args) {
1811
1939
  const type = args.type;
@@ -1871,8 +1999,22 @@ async function uploadFile(args) {
1871
1999
  method: "POST",
1872
2000
  body: { path: signRes.path, alt },
1873
2001
  });
2002
+ // Step 4: mint a long-lived signed URL so the caller can drop it
2003
+ // straight into a section field (moodboard images[].url, cover
2004
+ // bgImageUrl, people-link photoUrl, …) that needs a URL rather than a
2005
+ // storage key.
2006
+ let signedUrl = null;
2007
+ try {
2008
+ const { data: signed } = await apiRequest(cfg, "/api/v1/files/sign", { method: "POST", body: { path: signRes.path } });
2009
+ signedUrl = signed.signed_url;
2010
+ }
2011
+ catch {
2012
+ // Non-fatal — the file is uploaded + registered, caller can mint a
2013
+ // signed URL later with atlas_get_file_url.
2014
+ }
1874
2015
  return {
1875
2016
  path: signRes.path,
2017
+ signed_url: signedUrl,
1876
2018
  size: info.size,
1877
2019
  content_type: contentType,
1878
2020
  metadata: reg.file,
@@ -2436,7 +2578,7 @@ async function uploadWorkspaceLogo(args) {
2436
2578
  headers: {
2437
2579
  Authorization: `Bearer ${cfg.token}`,
2438
2580
  Accept: "application/json",
2439
- "User-Agent": "atlas-mcp/0.1.0",
2581
+ "User-Agent": "atlas-mcp/0.3.0",
2440
2582
  },
2441
2583
  body: form,
2442
2584
  });
@@ -2469,7 +2611,7 @@ async function exportDocTool(args) {
2469
2611
  headers: {
2470
2612
  Authorization: `Bearer ${cfg.token}`,
2471
2613
  Accept: "text/plain",
2472
- "User-Agent": "atlas-mcp/0.1.0",
2614
+ "User-Agent": "atlas-mcp/0.3.0",
2473
2615
  },
2474
2616
  });
2475
2617
  if (!res.ok || !res.body) {
@@ -2544,6 +2686,14 @@ async function exportDocTool(args) {
2544
2686
  return { out_path: outPath, pages: done.pages, bytes: bytes.length };
2545
2687
  }
2546
2688
  /* ─── Moodboard, palette, products, locations, gear, shots helpers ─── */
2689
+ /** Mint a 1-year signed URL for a storage key. Mirrors the browser
2690
+ * upload path (lib/doc-image-upload.ts), which signs for the same
2691
+ * duration so a doc's images keep rendering across normal lifetimes.
2692
+ * Returns the signed URL (or throws if the API rejects). */
2693
+ async function signStorageKey(cfg, path) {
2694
+ const { data } = await apiRequest(cfg, "/api/v1/files/sign", { method: "POST", body: { path } });
2695
+ return data.signed_url;
2696
+ }
2547
2697
  async function uploadLocalImage(cfg, localPath) {
2548
2698
  const { readFile, stat } = await import("node:fs/promises");
2549
2699
  const { basename, extname, isAbsolute, resolve: resolvePath } = await import("node:path");
@@ -2596,31 +2746,46 @@ function looksLikeStorageKey(input) {
2596
2746
  function randId(prefix) {
2597
2747
  return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
2598
2748
  }
2599
- /** Shared get-template → mutate sections → put-template flow. */
2749
+ /** Shared get-template → mutate sections → put-template flow.
2750
+ *
2751
+ * Retries once on precondition failure (412 — or legacy 409 — from a
2752
+ * concurrent web-editor autosave). The mutate callback is re-run against
2753
+ * the freshly-fetched state so append/insert operations land on top of
2754
+ * whatever the autosave wrote. */
2600
2755
  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.");
2756
+ const MAX_ATTEMPTS = 2;
2757
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
2758
+ const { data: current } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`);
2759
+ const existingSections = Array.isArray(current.template_content?.sections)
2760
+ ? current.template_content.sections
2761
+ : [];
2762
+ const { sections: nextSections, message } = mutate([...existingSections]);
2763
+ const nextContent = {
2764
+ ...(current.template_content ?? { sections: [] }),
2765
+ sections: nextSections,
2766
+ };
2767
+ try {
2768
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
2769
+ method: "PUT",
2770
+ body: { template_content: nextContent, ...(message ? { message } : {}) },
2771
+ ifMatch: current.etag,
2772
+ });
2773
+ return { etag: data.etag, updated_at: data.updated_at };
2774
+ }
2775
+ catch (e) {
2776
+ if (e instanceof ApiError &&
2777
+ (e.status === 412 || e.status === 409) &&
2778
+ attempt < MAX_ATTEMPTS) {
2779
+ process.stderr.write(`[atlas-mcp] patchTemplateSections ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
2780
+ continue;
2781
+ }
2782
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
2783
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor and retry.`);
2784
+ }
2785
+ throw e;
2621
2786
  }
2622
- throw e;
2623
2787
  }
2788
+ throw new Error("patchTemplateSections: unreachable retry fallthrough");
2624
2789
  }
2625
2790
  async function buildMoodboard(args) {
2626
2791
  const cfg = resolveConfig();
@@ -2635,24 +2800,41 @@ async function buildMoodboard(args) {
2635
2800
  const spacing = typeof args.spacing === "number" ? args.spacing : 8;
2636
2801
  const radius = typeof args.radius === "number" ? args.radius : 8;
2637
2802
  const position = typeof args.position === "number" ? Math.round(args.position) : null;
2803
+ // For every image — whether we just uploaded it or the caller handed
2804
+ // us a pre-existing storage key — mint a 1-year signed URL. Storing
2805
+ // raw storage keys here would render as 404s (storage objects are
2806
+ // private; the renderer uses `<img src>` with no server rewrite).
2638
2807
  let uploadedCount = 0;
2639
- const storageKeys = [];
2808
+ const resolved = [];
2640
2809
  for (const p of paths) {
2641
2810
  if (typeof p !== "string" || !p)
2642
2811
  continue;
2812
+ let key;
2643
2813
  if (looksLikeStorageKey(p)) {
2644
- storageKeys.push(p);
2814
+ key = p;
2645
2815
  }
2646
2816
  else {
2647
- const key = await uploadLocalImage(cfg, p);
2648
- storageKeys.push(key);
2817
+ key = await uploadLocalImage(cfg, p);
2649
2818
  uploadedCount++;
2650
2819
  }
2820
+ const signedUrl = await signStorageKey(cfg, key);
2821
+ resolved.push({ key, signedUrl });
2651
2822
  }
2652
2823
  const total = rows * cols;
2653
2824
  const images = [];
2654
2825
  for (let i = 0; i < total; i++) {
2655
- images.push(storageKeys[i] ? { url: storageKeys[i] } : null);
2826
+ const r = resolved[i];
2827
+ if (r) {
2828
+ images.push({
2829
+ id: randId("img"),
2830
+ url: r.signedUrl,
2831
+ // No per-image alt input yet — tool caller sets alts via the
2832
+ // web editor or atlas_set_file_alt.
2833
+ });
2834
+ }
2835
+ else {
2836
+ images.push(null);
2837
+ }
2656
2838
  }
2657
2839
  const sectionId = randId("sec");
2658
2840
  const result = await patchTemplateSections(cfg, docId, (sections) => {
@@ -2937,6 +3119,171 @@ async function addShotsToDoc(args) {
2937
3119
  });
2938
3120
  return { section_id: targetId, added: rows.length, missing, etag: res.etag };
2939
3121
  }
3122
+ /** Schedule a shoot AND wire its id into a calendar-event section in one
3123
+ * call. Mirrors addProductsToDoc's shape: POST to /api/v1/events, then
3124
+ * patchTemplateSections to either append the new id to an existing
3125
+ * calendar-event section or create a fresh one. */
3126
+ async function addCalendarEventToDoc(args) {
3127
+ const cfg = resolveConfig();
3128
+ const docId = requireString(args, "doc_id");
3129
+ const sectionId = typeof args.section_id === "string" ? args.section_id : null;
3130
+ const title = typeof args.title === "string" ? args.title : "Schedule";
3131
+ const layout = typeof args.layout === "string" && ["card", "table", "banner"].includes(args.layout)
3132
+ ? args.layout
3133
+ : "card";
3134
+ if (!isRecord(args.event)) {
3135
+ throw new Error("`event` must be an object with at least { title, starts_at }.");
3136
+ }
3137
+ const eventBody = args.event;
3138
+ if (typeof eventBody.title !== "string" || !eventBody.title.trim()) {
3139
+ throw new Error("`event.title` is required.");
3140
+ }
3141
+ if (typeof eventBody.starts_at !== "string" || !eventBody.starts_at.trim()) {
3142
+ throw new Error("`event.starts_at` is required (ISO 8601).");
3143
+ }
3144
+ // Step 1: create the shoot.
3145
+ const { data: eventRes } = await apiRequest(cfg, "/api/v1/events", { method: "POST", body: eventBody });
3146
+ const eventId = eventRes.event?.id;
3147
+ if (!eventId) {
3148
+ throw new Error("Event creation returned no id — cannot wire into section.");
3149
+ }
3150
+ // Step 2: wire the id into a calendar-event section (append to
3151
+ // existing, or create a new one).
3152
+ let targetId = sectionId;
3153
+ const res = await patchTemplateSections(cfg, docId, (sections) => {
3154
+ if (targetId) {
3155
+ const idx = sections.findIndex((s) => s.id === targetId);
3156
+ if (idx === -1)
3157
+ throw new Error(`Section '${targetId}' not found`);
3158
+ if (sections[idx].type !== "calendar-event") {
3159
+ throw new Error(`Section '${targetId}' is '${sections[idx].type}', not 'calendar-event'`);
3160
+ }
3161
+ const existing = (sections[idx].data ?? {});
3162
+ const prev = Array.isArray(existing.eventIds)
3163
+ ? existing.eventIds.filter((v) => typeof v === "string")
3164
+ : [];
3165
+ sections[idx] = {
3166
+ ...sections[idx],
3167
+ data: {
3168
+ ...(sections[idx].data ?? {}),
3169
+ eventIds: [...prev, eventId],
3170
+ },
3171
+ };
3172
+ }
3173
+ else {
3174
+ targetId = randId("sec");
3175
+ sections.push({
3176
+ id: targetId,
3177
+ type: "calendar-event",
3178
+ data: {
3179
+ title,
3180
+ eventIds: [eventId],
3181
+ layout,
3182
+ showAttendees: true,
3183
+ showLocation: true,
3184
+ showDescription: true,
3185
+ },
3186
+ });
3187
+ }
3188
+ return { sections, message: "Add calendar event to doc" };
3189
+ });
3190
+ return {
3191
+ section_id: targetId,
3192
+ event_id: eventId,
3193
+ etag: res.etag,
3194
+ };
3195
+ }
3196
+ /** Append (or insert at `position`) a new section of any supported type.
3197
+ * Uses the scoped append endpoint so the write doesn't fight the web
3198
+ * editor's autosave. Retries once on precondition failure. */
3199
+ async function addSection(args) {
3200
+ const cfg = resolveConfig();
3201
+ const docId = requireString(args, "doc_id");
3202
+ const type = requireString(args, "type");
3203
+ if (!isRecord(args.data)) {
3204
+ throw new Error("`data` must be an object matching the target section type's shape.");
3205
+ }
3206
+ const data = args.data;
3207
+ const position = typeof args.position === "number" ? Math.round(args.position) : undefined;
3208
+ const hidden = typeof args.hidden === "boolean" ? args.hidden : undefined;
3209
+ // Fetch etag so If-Match survives a concurrent autosave.
3210
+ const MAX_ATTEMPTS = 2;
3211
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
3212
+ const current = await getTemplate({ docId });
3213
+ try {
3214
+ const { data: res } = await apiRequest(cfg, `/api/v1/docs/${docId}/template/sections`, {
3215
+ method: "POST",
3216
+ body: {
3217
+ type,
3218
+ data,
3219
+ ...(hidden !== undefined ? { hidden } : {}),
3220
+ ...(position !== undefined ? { position } : {}),
3221
+ },
3222
+ ifMatch: current.etag,
3223
+ });
3224
+ return {
3225
+ section: res.section,
3226
+ section_id: res.section.id,
3227
+ etag: res.etag,
3228
+ updated_at: res.updated_at,
3229
+ ...(attempt > 1 ? { retried: true } : {}),
3230
+ };
3231
+ }
3232
+ catch (e) {
3233
+ if (e instanceof ApiError &&
3234
+ (e.status === 412 || e.status === 409) &&
3235
+ attempt < MAX_ATTEMPTS) {
3236
+ process.stderr.write(`[atlas-mcp] addSection ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
3237
+ continue;
3238
+ }
3239
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
3240
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor and retry.`);
3241
+ }
3242
+ throw e;
3243
+ }
3244
+ }
3245
+ throw new Error("addSection: unreachable retry fallthrough");
3246
+ }
3247
+ /** Scoped merge-update of template_content.cover. Uses the dedicated
3248
+ * cover PATCH endpoint — doesn't touch sections[] or docSettings. */
3249
+ async function updateCover(args) {
3250
+ const cfg = resolveConfig();
3251
+ const docId = requireString(args, "doc_id");
3252
+ if (!isRecord(args.cover)) {
3253
+ throw new Error("`cover` must be an object of fields to merge onto template_content.cover.");
3254
+ }
3255
+ const cover = args.cover;
3256
+ const MAX_ATTEMPTS = 2;
3257
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
3258
+ const current = await getTemplate({ docId });
3259
+ try {
3260
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template/cover`, {
3261
+ method: "PATCH",
3262
+ body: { cover },
3263
+ ifMatch: current.etag,
3264
+ });
3265
+ return {
3266
+ cover: data.cover,
3267
+ etag: data.etag,
3268
+ updated_at: data.updated_at,
3269
+ ...(attempt > 1 ? { retried: true } : {}),
3270
+ };
3271
+ }
3272
+ catch (e) {
3273
+ if (e instanceof ApiError &&
3274
+ (e.status === 412 || e.status === 409) &&
3275
+ attempt < MAX_ATTEMPTS) {
3276
+ process.stderr.write(`[atlas-mcp] updateCover ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
3277
+ continue;
3278
+ }
3279
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
3280
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor and retry.`);
3281
+ }
3282
+ throw e;
3283
+ }
3284
+ }
3285
+ throw new Error("updateCover: unreachable retry fallthrough");
3286
+ }
2940
3287
  /* ─── Mindmap tools ────────────────────────────────────────────────── */
2941
3288
  async function listMindmaps_(args) {
2942
3289
  const cfg = resolveConfig();
@@ -3175,7 +3522,7 @@ async function exportMindmapTool(args) {
3175
3522
  headers: {
3176
3523
  Authorization: `Bearer ${cfg.token}`,
3177
3524
  Accept: "image/svg+xml",
3178
- "User-Agent": "atlas-mcp/0.1.0",
3525
+ "User-Agent": "atlas-mcp/0.3.0",
3179
3526
  },
3180
3527
  });
3181
3528
  if (!res.ok) {
@@ -3200,7 +3547,7 @@ async function exportMindmapTool(args) {
3200
3547
  headers: {
3201
3548
  Authorization: `Bearer ${cfg.token}`,
3202
3549
  Accept: "application/json",
3203
- "User-Agent": "atlas-mcp/0.1.0",
3550
+ "User-Agent": "atlas-mcp/0.3.0",
3204
3551
  },
3205
3552
  });
3206
3553
  if (!tokRes.ok) {