@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/api.js +1 -1
- package/dist/index.js +431 -84
- package/dist/index.js.map +1 -1
- package/dist/section-catalog.js +25 -14
- package/dist/section-catalog.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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: "
|
|
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.
|
|
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
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
-
|
|
1698
|
-
|
|
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
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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.
|
|
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.
|
|
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
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
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
|
|
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
|
-
|
|
2814
|
+
key = p;
|
|
2645
2815
|
}
|
|
2646
2816
|
else {
|
|
2647
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
3550
|
+
"User-Agent": "atlas-mcp/0.3.0",
|
|
3204
3551
|
},
|
|
3205
3552
|
});
|
|
3206
3553
|
if (!tokRes.ok) {
|