@kezlahd/atlas-mcp 0.2.1 → 0.4.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 (drops / shoot plans / campaigns / lookbooks / any of the catalogue in atlas_list_doc_presets), prefer atlas_create_doc_from_preset — pass the brief as the cover subtitle and then read the preset's `agent_guidance` for the next-step fill order. For a BESPOKE one-off doc, use this tool and build sections manually: 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: {
@@ -80,7 +80,7 @@ const TOOLS = [
80
80
  },
81
81
  {
82
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.",
83
+ description: "List every doc preset available in Atlas (shoot-day, deliverable, planning, narrative-first templates). Returns the full catalogue with each preset's id, label, blurb, category, estimated fill time, default title, ordered section list, and (where present) an `agent_guidance` runbook explaining how to fill the skeleton. Design principle to carry across every doc: docs follow a NARRATIVE — start with cover + story, then supporting evidence (moodboards, palettes), then concrete deliverables (products, shots, dates, people), then logistics (notes, call sheets). Pick the preset whose section order already matches that arc rather than reshuffling after creation.",
84
84
  inputSchema: {
85
85
  type: "object",
86
86
  properties: {},
@@ -88,7 +88,7 @@ const TOOLS = [
88
88
  },
89
89
  {
90
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).",
91
+ description: "Create a new doc from a preset. Choose a preset based on the doc's purpose (list via atlas_list_doc_presets — each entry includes a one-line blurb). After creation, the doc has EMPTY sections in the right narrative order; fill each with the appropriate tool: atlas_update_cover for the cover subtitle + date, atlas_build_moodboard for moodboard images, atlas_extract_palette for the palette (points at a moodboard section), atlas_add_products_to_doc for product rows, atlas_add_shots_to_doc for the shot list, atlas_add_calendar_event_to_doc for dates, atlas_update_section for text/notes/table bodies. Each preset's `agent_guidance` field (fetched via atlas_list_doc_presets) tells you how to fill it — read it before filling. Fill the cover subtitle + section copy from the conversation context you already have (brand voice, brief, references); don't ask the user for anything already in the conversation. Title defaults to the preset's defaultTitle; override to taste. Returns the created doc record (same shape as atlas_doc_create).",
92
92
  inputSchema: {
93
93
  type: "object",
94
94
  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`).",
@@ -1404,6 +1494,62 @@ const TOOLS = [
1404
1494
  required: ["entryId"],
1405
1495
  },
1406
1496
  },
1497
+ {
1498
+ name: "atlas_list_pending_bugs",
1499
+ description: "List pending (or otherwise unresolved) MCP bug reports in the current Atlas workspace. Requires bugs:read. Call this BEFORE atlas_report_bug to avoid filing duplicates — if a similar bug already exists in pending/triaged/in_progress state, DON'T file a new one. Returns { items, next_cursor } with each item carrying title, description, mcp_tool, severity, status, created_at. If a similar bug exists, note it in your reply to the user rather than filing again (there is no comment surface yet — just skip).",
1500
+ inputSchema: {
1501
+ type: "object",
1502
+ properties: {
1503
+ mcp_tool: {
1504
+ type: "string",
1505
+ description: "Optional — filter to bugs filed against this exact MCP tool name (e.g. 'atlas_build_moodboard').",
1506
+ },
1507
+ status: {
1508
+ type: "string",
1509
+ description: "Optional CSV filter. Defaults to 'pending,triaged,in_progress' (unresolved). Pass 'fixed,closed,duplicate' to see resolved bugs.",
1510
+ },
1511
+ limit: {
1512
+ type: "number",
1513
+ description: "Max rows to return (default 50, max 500).",
1514
+ },
1515
+ cursor: {
1516
+ type: "string",
1517
+ description: "Opaque next_cursor from a previous response. Omit for the first page.",
1518
+ },
1519
+ },
1520
+ },
1521
+ },
1522
+ {
1523
+ name: "atlas_report_bug",
1524
+ description: "File a bug report against the Atlas MCP. Call this whenever an MCP tool returns an unexpected error, a false success, or silently produces broken output. The developer (Kieran) reads these to prioritise fixes. REQUIREMENT: Check atlas_list_pending_bugs FIRST to avoid duplicates — if a similar bug already exists in pending/triaged/in_progress state, DON'T file a new one (just mention the existing bug id in your reply). Requires bugs:write. Returns the created row (id, title, status, created_at…). Severity defaults to 'medium'; use 'critical' only for data-loss / corruption / repro-every-time issues. Keep `description` concrete: what you called, with what args, what you got back, what you expected.",
1525
+ inputSchema: {
1526
+ type: "object",
1527
+ properties: {
1528
+ title: {
1529
+ type: "string",
1530
+ description: "Short title (3–160 chars). Lead with the broken tool / symptom.",
1531
+ },
1532
+ description: {
1533
+ type: "string",
1534
+ description: "What happened (10–8000 chars). Include the tool name, arguments passed, the error / wrong output, and what you expected. Precise > verbose.",
1535
+ },
1536
+ mcp_tool: {
1537
+ type: "string",
1538
+ description: "The MCP tool that misbehaved (e.g. 'atlas_build_moodboard'). Omit for cross-cutting bugs that aren't tied to one tool.",
1539
+ },
1540
+ reproduction: {
1541
+ type: "string",
1542
+ description: "Optional repro steps or the minimum repro args payload. Helps triage faster.",
1543
+ },
1544
+ severity: {
1545
+ type: "string",
1546
+ enum: ["low", "medium", "high", "critical"],
1547
+ description: "Default 'medium'. 'critical' = data loss / corruption / repros every time; 'high' = blocks a real workflow; 'low' = cosmetic / nice-to-have.",
1548
+ },
1549
+ },
1550
+ required: ["title", "description"],
1551
+ },
1552
+ },
1407
1553
  {
1408
1554
  name: "export_mindmap",
1409
1555
  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.",
@@ -1437,7 +1583,7 @@ const TOOLS = [
1437
1583
  },
1438
1584
  ];
1439
1585
  /* ─── Server setup ─────────────────────────────────────────────────── */
1440
- const server = new Server({ name: "atlas-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
1586
+ const server = new Server({ name: "atlas-mcp", version: "0.4.0" }, { capabilities: { tools: {} } });
1441
1587
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1442
1588
  tools: TOOLS,
1443
1589
  }));
@@ -1590,6 +1736,12 @@ async function dispatch(name, args) {
1590
1736
  return addGearToDoc(args);
1591
1737
  case "atlas_add_shots_to_doc":
1592
1738
  return addShotsToDoc(args);
1739
+ case "atlas_add_calendar_event_to_doc":
1740
+ return addCalendarEventToDoc(args);
1741
+ case "atlas_add_section":
1742
+ return addSection(args);
1743
+ case "atlas_update_cover":
1744
+ return updateCover(args);
1593
1745
  case "read_achievements":
1594
1746
  return readAchievements();
1595
1747
  case "atlas_list_mindmaps":
@@ -1618,6 +1770,10 @@ async function dispatch(name, args) {
1618
1770
  return listActivity_(args);
1619
1771
  case "atlas_get_activity":
1620
1772
  return getActivity_(args);
1773
+ case "atlas_list_pending_bugs":
1774
+ return listPendingBugs_(args);
1775
+ case "atlas_report_bug":
1776
+ return reportBug_(args);
1621
1777
  case "export_mindmap":
1622
1778
  return exportMindmapTool(args);
1623
1779
  default:
@@ -1669,36 +1825,54 @@ async function updateSection(args) {
1669
1825
  if (newData === undefined && newStyle === undefined && hidden === undefined) {
1670
1826
  throw new Error("Provide at least one of: data, style, hidden.");
1671
1827
  }
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.`);
1828
+ const body = {
1829
+ ...(newData !== undefined ? { data: newData } : {}),
1830
+ ...(newStyle !== undefined ? { style: newStyle } : {}),
1831
+ ...(hidden !== undefined ? { hidden } : {}),
1832
+ };
1833
+ // Retry on precondition failure. The web editor's autosave bumps
1834
+ // updated_at every few seconds, so a GET → PATCH window that spans
1835
+ // one autosave will 412 even when nothing conflicts. The user's
1836
+ // patch intent is the same shape regardless of what the editor
1837
+ // wrote, so re-applying it against the fresh state is safe.
1838
+ const MAX_ATTEMPTS = 2;
1839
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1840
+ const current = await getTemplate({ docId });
1841
+ try {
1842
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template/sections/${sectionId}`, {
1843
+ method: "PATCH",
1844
+ body,
1845
+ ifMatch: current.etag,
1846
+ });
1847
+ return {
1848
+ updated_section: sectionId,
1849
+ section: data.section,
1850
+ etag: data.etag,
1851
+ updated_at: data.updated_at,
1852
+ ...(attempt > 1 ? { retried: true } : {}),
1853
+ };
1696
1854
  }
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.");
1855
+ catch (e) {
1856
+ if (e instanceof ApiError && e.status === 404) {
1857
+ throw new Error(`Section '${sectionId}' not found in doc '${docId}'. Call atlas_list_sections to see current section ids.`);
1858
+ }
1859
+ // Server now returns 412 for If-Match failures (RFC 7232); legacy
1860
+ // deployments may still return 409 with the same message, so
1861
+ // treat both as precondition failures.
1862
+ if (e instanceof ApiError &&
1863
+ (e.status === 412 || e.status === 409) &&
1864
+ attempt < MAX_ATTEMPTS) {
1865
+ process.stderr.write(`[atlas-mcp] updateSection ${sectionId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
1866
+ continue;
1867
+ }
1868
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
1869
+ 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.`);
1870
+ }
1871
+ throw e;
1699
1872
  }
1700
- throw e;
1701
1873
  }
1874
+ // Unreachable — the loop always either returns or throws.
1875
+ throw new Error("updateSection: unreachable retry fallthrough");
1702
1876
  }
1703
1877
  async function createDoc(args) {
1704
1878
  const cfg = resolveConfig();
@@ -1785,27 +1959,41 @@ async function updateTemplate(args) {
1785
1959
  throw new Error("`template_content` must be an object matching TemplateContent shape.");
1786
1960
  }
1787
1961
  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.");
1962
+ // Get-then-put with one retry on concurrent-edit race. The caller
1963
+ // passed us an explicit template_content, so unlike patchTemplateSections
1964
+ // we can't re-apply "the user's intent against fresh state" — the
1965
+ // intent IS the full template. We just re-check the etag and retry.
1966
+ const MAX_ATTEMPTS = 2;
1967
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1968
+ const current = await getTemplate({ docId });
1969
+ try {
1970
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
1971
+ method: "PUT",
1972
+ body: { template_content: templateContent, ...(title ? { title } : {}) },
1973
+ ifMatch: current.etag,
1974
+ });
1975
+ return {
1976
+ docId: data.id,
1977
+ title: data.title,
1978
+ etag: data.etag,
1979
+ updated_at: data.updated_at,
1980
+ ...(attempt > 1 ? { retried: true } : {}),
1981
+ };
1982
+ }
1983
+ catch (e) {
1984
+ if (e instanceof ApiError &&
1985
+ (e.status === 412 || e.status === 409) &&
1986
+ attempt < MAX_ATTEMPTS) {
1987
+ process.stderr.write(`[atlas-mcp] updateTemplate ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
1988
+ continue;
1989
+ }
1990
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
1991
+ 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.`);
1992
+ }
1993
+ throw e;
1806
1994
  }
1807
- throw e;
1808
1995
  }
1996
+ throw new Error("updateTemplate: unreachable retry fallthrough");
1809
1997
  }
1810
1998
  async function describeSectionType(args) {
1811
1999
  const type = args.type;
@@ -1871,8 +2059,22 @@ async function uploadFile(args) {
1871
2059
  method: "POST",
1872
2060
  body: { path: signRes.path, alt },
1873
2061
  });
2062
+ // Step 4: mint a long-lived signed URL so the caller can drop it
2063
+ // straight into a section field (moodboard images[].url, cover
2064
+ // bgImageUrl, people-link photoUrl, …) that needs a URL rather than a
2065
+ // storage key.
2066
+ let signedUrl = null;
2067
+ try {
2068
+ const { data: signed } = await apiRequest(cfg, "/api/v1/files/sign", { method: "POST", body: { path: signRes.path } });
2069
+ signedUrl = signed.signed_url;
2070
+ }
2071
+ catch {
2072
+ // Non-fatal — the file is uploaded + registered, caller can mint a
2073
+ // signed URL later with atlas_get_file_url.
2074
+ }
1874
2075
  return {
1875
2076
  path: signRes.path,
2077
+ signed_url: signedUrl,
1876
2078
  size: info.size,
1877
2079
  content_type: contentType,
1878
2080
  metadata: reg.file,
@@ -2436,7 +2638,7 @@ async function uploadWorkspaceLogo(args) {
2436
2638
  headers: {
2437
2639
  Authorization: `Bearer ${cfg.token}`,
2438
2640
  Accept: "application/json",
2439
- "User-Agent": "atlas-mcp/0.1.0",
2641
+ "User-Agent": "atlas-mcp/0.4.0",
2440
2642
  },
2441
2643
  body: form,
2442
2644
  });
@@ -2469,7 +2671,7 @@ async function exportDocTool(args) {
2469
2671
  headers: {
2470
2672
  Authorization: `Bearer ${cfg.token}`,
2471
2673
  Accept: "text/plain",
2472
- "User-Agent": "atlas-mcp/0.1.0",
2674
+ "User-Agent": "atlas-mcp/0.4.0",
2473
2675
  },
2474
2676
  });
2475
2677
  if (!res.ok || !res.body) {
@@ -2544,6 +2746,14 @@ async function exportDocTool(args) {
2544
2746
  return { out_path: outPath, pages: done.pages, bytes: bytes.length };
2545
2747
  }
2546
2748
  /* ─── Moodboard, palette, products, locations, gear, shots helpers ─── */
2749
+ /** Mint a 1-year signed URL for a storage key. Mirrors the browser
2750
+ * upload path (lib/doc-image-upload.ts), which signs for the same
2751
+ * duration so a doc's images keep rendering across normal lifetimes.
2752
+ * Returns the signed URL (or throws if the API rejects). */
2753
+ async function signStorageKey(cfg, path) {
2754
+ const { data } = await apiRequest(cfg, "/api/v1/files/sign", { method: "POST", body: { path } });
2755
+ return data.signed_url;
2756
+ }
2547
2757
  async function uploadLocalImage(cfg, localPath) {
2548
2758
  const { readFile, stat } = await import("node:fs/promises");
2549
2759
  const { basename, extname, isAbsolute, resolve: resolvePath } = await import("node:path");
@@ -2596,31 +2806,46 @@ function looksLikeStorageKey(input) {
2596
2806
  function randId(prefix) {
2597
2807
  return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
2598
2808
  }
2599
- /** Shared get-template → mutate sections → put-template flow. */
2809
+ /** Shared get-template → mutate sections → put-template flow.
2810
+ *
2811
+ * Retries once on precondition failure (412 — or legacy 409 — from a
2812
+ * concurrent web-editor autosave). The mutate callback is re-run against
2813
+ * the freshly-fetched state so append/insert operations land on top of
2814
+ * whatever the autosave wrote. */
2600
2815
  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.");
2816
+ const MAX_ATTEMPTS = 2;
2817
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
2818
+ const { data: current } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`);
2819
+ const existingSections = Array.isArray(current.template_content?.sections)
2820
+ ? current.template_content.sections
2821
+ : [];
2822
+ const { sections: nextSections, message } = mutate([...existingSections]);
2823
+ const nextContent = {
2824
+ ...(current.template_content ?? { sections: [] }),
2825
+ sections: nextSections,
2826
+ };
2827
+ try {
2828
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template`, {
2829
+ method: "PUT",
2830
+ body: { template_content: nextContent, ...(message ? { message } : {}) },
2831
+ ifMatch: current.etag,
2832
+ });
2833
+ return { etag: data.etag, updated_at: data.updated_at };
2834
+ }
2835
+ catch (e) {
2836
+ if (e instanceof ApiError &&
2837
+ (e.status === 412 || e.status === 409) &&
2838
+ attempt < MAX_ATTEMPTS) {
2839
+ process.stderr.write(`[atlas-mcp] patchTemplateSections ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
2840
+ continue;
2841
+ }
2842
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
2843
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor and retry.`);
2844
+ }
2845
+ throw e;
2621
2846
  }
2622
- throw e;
2623
2847
  }
2848
+ throw new Error("patchTemplateSections: unreachable retry fallthrough");
2624
2849
  }
2625
2850
  async function buildMoodboard(args) {
2626
2851
  const cfg = resolveConfig();
@@ -2635,24 +2860,41 @@ async function buildMoodboard(args) {
2635
2860
  const spacing = typeof args.spacing === "number" ? args.spacing : 8;
2636
2861
  const radius = typeof args.radius === "number" ? args.radius : 8;
2637
2862
  const position = typeof args.position === "number" ? Math.round(args.position) : null;
2863
+ // For every image — whether we just uploaded it or the caller handed
2864
+ // us a pre-existing storage key — mint a 1-year signed URL. Storing
2865
+ // raw storage keys here would render as 404s (storage objects are
2866
+ // private; the renderer uses `<img src>` with no server rewrite).
2638
2867
  let uploadedCount = 0;
2639
- const storageKeys = [];
2868
+ const resolved = [];
2640
2869
  for (const p of paths) {
2641
2870
  if (typeof p !== "string" || !p)
2642
2871
  continue;
2872
+ let key;
2643
2873
  if (looksLikeStorageKey(p)) {
2644
- storageKeys.push(p);
2874
+ key = p;
2645
2875
  }
2646
2876
  else {
2647
- const key = await uploadLocalImage(cfg, p);
2648
- storageKeys.push(key);
2877
+ key = await uploadLocalImage(cfg, p);
2649
2878
  uploadedCount++;
2650
2879
  }
2880
+ const signedUrl = await signStorageKey(cfg, key);
2881
+ resolved.push({ key, signedUrl });
2651
2882
  }
2652
2883
  const total = rows * cols;
2653
2884
  const images = [];
2654
2885
  for (let i = 0; i < total; i++) {
2655
- images.push(storageKeys[i] ? { url: storageKeys[i] } : null);
2886
+ const r = resolved[i];
2887
+ if (r) {
2888
+ images.push({
2889
+ id: randId("img"),
2890
+ url: r.signedUrl,
2891
+ // No per-image alt input yet — tool caller sets alts via the
2892
+ // web editor or atlas_set_file_alt.
2893
+ });
2894
+ }
2895
+ else {
2896
+ images.push(null);
2897
+ }
2656
2898
  }
2657
2899
  const sectionId = randId("sec");
2658
2900
  const result = await patchTemplateSections(cfg, docId, (sections) => {
@@ -2937,6 +3179,171 @@ async function addShotsToDoc(args) {
2937
3179
  });
2938
3180
  return { section_id: targetId, added: rows.length, missing, etag: res.etag };
2939
3181
  }
3182
+ /** Schedule a shoot AND wire its id into a calendar-event section in one
3183
+ * call. Mirrors addProductsToDoc's shape: POST to /api/v1/events, then
3184
+ * patchTemplateSections to either append the new id to an existing
3185
+ * calendar-event section or create a fresh one. */
3186
+ async function addCalendarEventToDoc(args) {
3187
+ const cfg = resolveConfig();
3188
+ const docId = requireString(args, "doc_id");
3189
+ const sectionId = typeof args.section_id === "string" ? args.section_id : null;
3190
+ const title = typeof args.title === "string" ? args.title : "Schedule";
3191
+ const layout = typeof args.layout === "string" && ["card", "table", "banner"].includes(args.layout)
3192
+ ? args.layout
3193
+ : "card";
3194
+ if (!isRecord(args.event)) {
3195
+ throw new Error("`event` must be an object with at least { title, starts_at }.");
3196
+ }
3197
+ const eventBody = args.event;
3198
+ if (typeof eventBody.title !== "string" || !eventBody.title.trim()) {
3199
+ throw new Error("`event.title` is required.");
3200
+ }
3201
+ if (typeof eventBody.starts_at !== "string" || !eventBody.starts_at.trim()) {
3202
+ throw new Error("`event.starts_at` is required (ISO 8601).");
3203
+ }
3204
+ // Step 1: create the shoot.
3205
+ const { data: eventRes } = await apiRequest(cfg, "/api/v1/events", { method: "POST", body: eventBody });
3206
+ const eventId = eventRes.event?.id;
3207
+ if (!eventId) {
3208
+ throw new Error("Event creation returned no id — cannot wire into section.");
3209
+ }
3210
+ // Step 2: wire the id into a calendar-event section (append to
3211
+ // existing, or create a new one).
3212
+ let targetId = sectionId;
3213
+ const res = await patchTemplateSections(cfg, docId, (sections) => {
3214
+ if (targetId) {
3215
+ const idx = sections.findIndex((s) => s.id === targetId);
3216
+ if (idx === -1)
3217
+ throw new Error(`Section '${targetId}' not found`);
3218
+ if (sections[idx].type !== "calendar-event") {
3219
+ throw new Error(`Section '${targetId}' is '${sections[idx].type}', not 'calendar-event'`);
3220
+ }
3221
+ const existing = (sections[idx].data ?? {});
3222
+ const prev = Array.isArray(existing.eventIds)
3223
+ ? existing.eventIds.filter((v) => typeof v === "string")
3224
+ : [];
3225
+ sections[idx] = {
3226
+ ...sections[idx],
3227
+ data: {
3228
+ ...(sections[idx].data ?? {}),
3229
+ eventIds: [...prev, eventId],
3230
+ },
3231
+ };
3232
+ }
3233
+ else {
3234
+ targetId = randId("sec");
3235
+ sections.push({
3236
+ id: targetId,
3237
+ type: "calendar-event",
3238
+ data: {
3239
+ title,
3240
+ eventIds: [eventId],
3241
+ layout,
3242
+ showAttendees: true,
3243
+ showLocation: true,
3244
+ showDescription: true,
3245
+ },
3246
+ });
3247
+ }
3248
+ return { sections, message: "Add calendar event to doc" };
3249
+ });
3250
+ return {
3251
+ section_id: targetId,
3252
+ event_id: eventId,
3253
+ etag: res.etag,
3254
+ };
3255
+ }
3256
+ /** Append (or insert at `position`) a new section of any supported type.
3257
+ * Uses the scoped append endpoint so the write doesn't fight the web
3258
+ * editor's autosave. Retries once on precondition failure. */
3259
+ async function addSection(args) {
3260
+ const cfg = resolveConfig();
3261
+ const docId = requireString(args, "doc_id");
3262
+ const type = requireString(args, "type");
3263
+ if (!isRecord(args.data)) {
3264
+ throw new Error("`data` must be an object matching the target section type's shape.");
3265
+ }
3266
+ const data = args.data;
3267
+ const position = typeof args.position === "number" ? Math.round(args.position) : undefined;
3268
+ const hidden = typeof args.hidden === "boolean" ? args.hidden : undefined;
3269
+ // Fetch etag so If-Match survives a concurrent autosave.
3270
+ const MAX_ATTEMPTS = 2;
3271
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
3272
+ const current = await getTemplate({ docId });
3273
+ try {
3274
+ const { data: res } = await apiRequest(cfg, `/api/v1/docs/${docId}/template/sections`, {
3275
+ method: "POST",
3276
+ body: {
3277
+ type,
3278
+ data,
3279
+ ...(hidden !== undefined ? { hidden } : {}),
3280
+ ...(position !== undefined ? { position } : {}),
3281
+ },
3282
+ ifMatch: current.etag,
3283
+ });
3284
+ return {
3285
+ section: res.section,
3286
+ section_id: res.section.id,
3287
+ etag: res.etag,
3288
+ updated_at: res.updated_at,
3289
+ ...(attempt > 1 ? { retried: true } : {}),
3290
+ };
3291
+ }
3292
+ catch (e) {
3293
+ if (e instanceof ApiError &&
3294
+ (e.status === 412 || e.status === 409) &&
3295
+ attempt < MAX_ATTEMPTS) {
3296
+ process.stderr.write(`[atlas-mcp] addSection ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
3297
+ continue;
3298
+ }
3299
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
3300
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor and retry.`);
3301
+ }
3302
+ throw e;
3303
+ }
3304
+ }
3305
+ throw new Error("addSection: unreachable retry fallthrough");
3306
+ }
3307
+ /** Scoped merge-update of template_content.cover. Uses the dedicated
3308
+ * cover PATCH endpoint — doesn't touch sections[] or docSettings. */
3309
+ async function updateCover(args) {
3310
+ const cfg = resolveConfig();
3311
+ const docId = requireString(args, "doc_id");
3312
+ if (!isRecord(args.cover)) {
3313
+ throw new Error("`cover` must be an object of fields to merge onto template_content.cover.");
3314
+ }
3315
+ const cover = args.cover;
3316
+ const MAX_ATTEMPTS = 2;
3317
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
3318
+ const current = await getTemplate({ docId });
3319
+ try {
3320
+ const { data } = await apiRequest(cfg, `/api/v1/docs/${docId}/template/cover`, {
3321
+ method: "PATCH",
3322
+ body: { cover },
3323
+ ifMatch: current.etag,
3324
+ });
3325
+ return {
3326
+ cover: data.cover,
3327
+ etag: data.etag,
3328
+ updated_at: data.updated_at,
3329
+ ...(attempt > 1 ? { retried: true } : {}),
3330
+ };
3331
+ }
3332
+ catch (e) {
3333
+ if (e instanceof ApiError &&
3334
+ (e.status === 412 || e.status === 409) &&
3335
+ attempt < MAX_ATTEMPTS) {
3336
+ process.stderr.write(`[atlas-mcp] updateCover ${docId}: ${e.status} on attempt ${attempt}, re-fetching + retrying\n`);
3337
+ continue;
3338
+ }
3339
+ if (e instanceof ApiError && (e.status === 412 || e.status === 409)) {
3340
+ throw new Error(`Doc kept racing after ${MAX_ATTEMPTS} attempts — someone else is actively editing. Pause the web editor and retry.`);
3341
+ }
3342
+ throw e;
3343
+ }
3344
+ }
3345
+ throw new Error("updateCover: unreachable retry fallthrough");
3346
+ }
2940
3347
  /* ─── Mindmap tools ────────────────────────────────────────────────── */
2941
3348
  async function listMindmaps_(args) {
2942
3349
  const cfg = resolveConfig();
@@ -3155,6 +3562,48 @@ async function getActivity_(args) {
3155
3562
  const { data } = await apiRequest(cfg, `/api/v1/activity/${entryId}`);
3156
3563
  return data;
3157
3564
  }
3565
+ /* ─── Bug reports ──────────────────────────────────────────────────── */
3566
+ /** List pending (or otherwise unresolved) bugs so an agent can dedupe
3567
+ * before filing a new one. Default filter: pending + triaged +
3568
+ * in_progress — the three "we haven't fixed this yet" statuses. */
3569
+ async function listPendingBugs_(args) {
3570
+ const cfg = resolveConfig();
3571
+ const query = {};
3572
+ if (typeof args.limit === "number")
3573
+ query.limit = args.limit;
3574
+ if (typeof args.cursor === "string")
3575
+ query.cursor = args.cursor;
3576
+ if (typeof args.mcp_tool === "string" && args.mcp_tool) {
3577
+ query.mcp_tool = args.mcp_tool;
3578
+ }
3579
+ query.status =
3580
+ typeof args.status === "string" && args.status.trim()
3581
+ ? args.status
3582
+ : "pending,triaged,in_progress";
3583
+ const { data } = await apiRequest(cfg, "/api/v1/bugs", { query });
3584
+ return data;
3585
+ }
3586
+ async function reportBug_(args) {
3587
+ const cfg = resolveConfig();
3588
+ const body = {
3589
+ title: requireString(args, "title"),
3590
+ description: requireString(args, "description"),
3591
+ };
3592
+ if (typeof args.mcp_tool === "string" && args.mcp_tool) {
3593
+ body.mcp_tool = args.mcp_tool;
3594
+ }
3595
+ if (typeof args.reproduction === "string" && args.reproduction) {
3596
+ body.reproduction = args.reproduction;
3597
+ }
3598
+ if (typeof args.severity === "string" && args.severity) {
3599
+ body.severity = args.severity;
3600
+ }
3601
+ const { data } = await apiRequest(cfg, "/api/v1/bugs", {
3602
+ method: "POST",
3603
+ body,
3604
+ });
3605
+ return data;
3606
+ }
3158
3607
  /* ─── Mindmap export (PDF / PNG / SVG) ───────────────────────────── */
3159
3608
  async function exportMindmapTool(args) {
3160
3609
  const cfg = resolveConfig();
@@ -3175,7 +3624,7 @@ async function exportMindmapTool(args) {
3175
3624
  headers: {
3176
3625
  Authorization: `Bearer ${cfg.token}`,
3177
3626
  Accept: "image/svg+xml",
3178
- "User-Agent": "atlas-mcp/0.1.0",
3627
+ "User-Agent": "atlas-mcp/0.4.0",
3179
3628
  },
3180
3629
  });
3181
3630
  if (!res.ok) {
@@ -3200,7 +3649,7 @@ async function exportMindmapTool(args) {
3200
3649
  headers: {
3201
3650
  Authorization: `Bearer ${cfg.token}`,
3202
3651
  Accept: "application/json",
3203
- "User-Agent": "atlas-mcp/0.1.0",
3652
+ "User-Agent": "atlas-mcp/0.4.0",
3204
3653
  },
3205
3654
  });
3206
3655
  if (!tokRes.ok) {