@gethmy/mcp 2.19.0 → 2.20.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/cli.js CHANGED
@@ -3644,6 +3644,43 @@ async function putToSignedUrl(uploadUrl, bytes, contentType) {
3644
3644
  throw new Error(`Direct storage upload failed: ${res.status}${detail ? ` — ${detail}` : ""}`);
3645
3645
  }
3646
3646
  }
3647
+ async function attachFileToCard(client3, cardId, file) {
3648
+ const { filePath, base64Data, fileName, contentType } = file;
3649
+ if (filePath && base64Data) {
3650
+ throw new Error("Provide either filePath or base64Data, not both.");
3651
+ }
3652
+ if (filePath) {
3653
+ const bytes = await readFileForUpload(filePath, MAX_ATTACHMENT_SIZE, "attachment");
3654
+ const resolvedName = fileName || basename(filePath);
3655
+ const signed = await client3.requestCardAttachmentUploadUrl(cardId, {
3656
+ fileName: resolvedName,
3657
+ fileType: contentType,
3658
+ size: bytes.byteLength
3659
+ });
3660
+ await putToSignedUrl(signed.uploadUrl, bytes, contentType || signed.fileType || "application/octet-stream");
3661
+ return await client3.finalizeCardAttachment(cardId, {
3662
+ storagePath: signed.storagePath,
3663
+ fileName: resolvedName,
3664
+ fileType: contentType || signed.fileType,
3665
+ sha256: sha256Hex(bytes),
3666
+ size: bytes.byteLength
3667
+ });
3668
+ }
3669
+ if (base64Data) {
3670
+ if (!fileName) {
3671
+ throw new Error("fileName is required when using base64Data.");
3672
+ }
3673
+ if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
3674
+ throw new Error(`File is over the 5MB attachment limit. Use the harmony_request_upload_url + harmony_finalize_upload handshake for large files.`);
3675
+ }
3676
+ return await client3.uploadCardAttachment(cardId, {
3677
+ fileName,
3678
+ data: base64Data,
3679
+ fileType: contentType
3680
+ });
3681
+ }
3682
+ throw new Error("Provide either filePath or base64Data.");
3683
+ }
3647
3684
  var memorySessions = new Map;
3648
3685
  function parseLabelList(raw) {
3649
3686
  if (raw === undefined || raw === null)
@@ -3800,7 +3837,7 @@ function cleanupMemorySession(cardId) {
3800
3837
  }
3801
3838
  var TOOLS = {
3802
3839
  harmony_create_card: {
3803
- description: "Create a new card in a Kanban column",
3840
+ description: "Create a new card in a Kanban column. Optionally attach reference files " + "(e.g. a screenshot from the prompt) at creation time via `attachments` — " + "the card is created first, then each file is uploaded to it.",
3804
3841
  inputSchema: {
3805
3842
  type: "object",
3806
3843
  properties: {
@@ -3823,6 +3860,31 @@ var TOOLS = {
3823
3860
  planId: {
3824
3861
  type: "string",
3825
3862
  description: "Plan ID to link this card to (optional). Links the card to that plan via its plan_id."
3863
+ },
3864
+ attachments: {
3865
+ type: "array",
3866
+ description: "Optional reference files to attach to the new card (e.g. a screenshot from the prompt). " + "Max 5MB each; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV. Each file's bytes " + "come from `filePath` (absolute local path the server reads, preferred) or `base64Data` " + "(small-file fallback; requires fileName). NOTE: a pasted image only attaches if your " + "harness has written it to a local file you can pass as filePath — a model cannot re-emit " + "pasted image bytes into base64Data. Per-file failures never block card creation; they are " + "reported back in the result's `attachments` array so you can retry via harmony_upload.",
3867
+ items: {
3868
+ type: "object",
3869
+ properties: {
3870
+ filePath: {
3871
+ type: "string",
3872
+ description: "Absolute path to a local file the server can read. Mutually exclusive with base64Data."
3873
+ },
3874
+ base64Data: {
3875
+ type: "string",
3876
+ description: "Base64-encoded bytes (a `data:` URL prefix is accepted and stripped). Requires fileName. Mutually exclusive with filePath."
3877
+ },
3878
+ fileName: {
3879
+ type: "string",
3880
+ description: "File name including extension (required with base64Data; else defaults to the filePath basename)."
3881
+ },
3882
+ contentType: {
3883
+ type: "string",
3884
+ description: "Optional MIME type (inferred from the extension when omitted)."
3885
+ }
3886
+ }
3887
+ }
3826
3888
  }
3827
3889
  },
3828
3890
  required: ["title"]
@@ -5637,6 +5699,12 @@ async function handleToolCall(name, args, deps) {
5637
5699
  case "harmony_create_card": {
5638
5700
  const title = z.string().min(1).max(500).parse(args.title);
5639
5701
  const projectId = args.projectId || getProjectId();
5702
+ const attachments = args.attachments != null ? z.array(z.object({
5703
+ filePath: z.string().optional(),
5704
+ base64Data: z.string().optional(),
5705
+ fileName: z.string().optional(),
5706
+ contentType: z.string().optional()
5707
+ })).parse(args.attachments) : [];
5640
5708
  const result = await client3.createCard(projectId, {
5641
5709
  title,
5642
5710
  columnId: args.columnId,
@@ -5645,7 +5713,30 @@ async function handleToolCall(name, args, deps) {
5645
5713
  assigneeId: args.assigneeId,
5646
5714
  planId: args.planId
5647
5715
  });
5648
- return { success: true, ...result };
5716
+ if (attachments.length === 0) {
5717
+ return { success: true, ...result };
5718
+ }
5719
+ const cardId = result.card?.id;
5720
+ if (!cardId) {
5721
+ return {
5722
+ success: true,
5723
+ ...result,
5724
+ attachmentWarning: "Card created, but attachments were skipped: no card id was returned to upload against."
5725
+ };
5726
+ }
5727
+ const attachmentResults = await Promise.all(attachments.map(async (file) => {
5728
+ try {
5729
+ const uploaded = await attachFileToCard(client3, cardId, file);
5730
+ return { ok: true, attachment: uploaded.attachment };
5731
+ } catch (err) {
5732
+ return {
5733
+ ok: false,
5734
+ fileName: file.fileName ?? file.filePath ?? "(unnamed)",
5735
+ error: err instanceof Error ? err.message : String(err)
5736
+ };
5737
+ }
5738
+ }));
5739
+ return { success: true, ...result, attachments: attachmentResults };
5649
5740
  }
5650
5741
  case "harmony_update_card": {
5651
5742
  const cardId = z.string().uuid().parse(args.cardId);
@@ -5931,37 +6022,12 @@ ${list}
5931
6022
  const cardId2 = z.string().uuid().parse(args.cardId);
5932
6023
  const fileName = args.fileName != null ? z.string().parse(args.fileName) : undefined;
5933
6024
  const contentType = args.contentType != null ? z.string().parse(args.contentType) : undefined;
5934
- if (filePath) {
5935
- const bytes = await readFileForUpload(filePath, MAX_ATTACHMENT_SIZE, "attachment");
5936
- const resolvedName = fileName || basename(filePath);
5937
- const signed = await client3.requestCardAttachmentUploadUrl(cardId2, {
5938
- fileName: resolvedName,
5939
- fileType: contentType,
5940
- size: bytes.byteLength
5941
- });
5942
- await putToSignedUrl(signed.uploadUrl, bytes, contentType || signed.fileType || "application/octet-stream");
5943
- return await client3.finalizeCardAttachment(cardId2, {
5944
- storagePath: signed.storagePath,
5945
- fileName: resolvedName,
5946
- fileType: contentType || signed.fileType,
5947
- sha256: sha256Hex(bytes),
5948
- size: bytes.byteLength
5949
- });
5950
- }
5951
- if (base64Data) {
5952
- if (!fileName) {
5953
- throw new Error("fileName is required when using base64Data.");
5954
- }
5955
- if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
5956
- throw new Error(`File is over the 5MB attachment limit. Use the harmony_request_upload_url + harmony_finalize_upload handshake for large files.`);
5957
- }
5958
- return await client3.uploadCardAttachment(cardId2, {
5959
- fileName,
5960
- data: base64Data,
5961
- fileType: contentType
5962
- });
5963
- }
5964
- throw new Error("Provide either filePath or base64Data.");
6025
+ return await attachFileToCard(client3, cardId2, {
6026
+ filePath,
6027
+ base64Data,
6028
+ fileName,
6029
+ contentType
6030
+ });
5965
6031
  }
5966
6032
  const title = args.title != null ? z.string().parse(args.title) : undefined;
5967
6033
  const cardId = args.cardId != null ? z.string().uuid().parse(args.cardId) : undefined;
package/dist/index.js CHANGED
@@ -3639,6 +3639,43 @@ async function putToSignedUrl(uploadUrl, bytes, contentType) {
3639
3639
  throw new Error(`Direct storage upload failed: ${res.status}${detail ? ` — ${detail}` : ""}`);
3640
3640
  }
3641
3641
  }
3642
+ async function attachFileToCard(client3, cardId, file) {
3643
+ const { filePath, base64Data, fileName, contentType } = file;
3644
+ if (filePath && base64Data) {
3645
+ throw new Error("Provide either filePath or base64Data, not both.");
3646
+ }
3647
+ if (filePath) {
3648
+ const bytes = await readFileForUpload(filePath, MAX_ATTACHMENT_SIZE, "attachment");
3649
+ const resolvedName = fileName || basename(filePath);
3650
+ const signed = await client3.requestCardAttachmentUploadUrl(cardId, {
3651
+ fileName: resolvedName,
3652
+ fileType: contentType,
3653
+ size: bytes.byteLength
3654
+ });
3655
+ await putToSignedUrl(signed.uploadUrl, bytes, contentType || signed.fileType || "application/octet-stream");
3656
+ return await client3.finalizeCardAttachment(cardId, {
3657
+ storagePath: signed.storagePath,
3658
+ fileName: resolvedName,
3659
+ fileType: contentType || signed.fileType,
3660
+ sha256: sha256Hex(bytes),
3661
+ size: bytes.byteLength
3662
+ });
3663
+ }
3664
+ if (base64Data) {
3665
+ if (!fileName) {
3666
+ throw new Error("fileName is required when using base64Data.");
3667
+ }
3668
+ if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
3669
+ throw new Error(`File is over the 5MB attachment limit. Use the harmony_request_upload_url + harmony_finalize_upload handshake for large files.`);
3670
+ }
3671
+ return await client3.uploadCardAttachment(cardId, {
3672
+ fileName,
3673
+ data: base64Data,
3674
+ fileType: contentType
3675
+ });
3676
+ }
3677
+ throw new Error("Provide either filePath or base64Data.");
3678
+ }
3642
3679
  var memorySessions = new Map;
3643
3680
  function parseLabelList(raw) {
3644
3681
  if (raw === undefined || raw === null)
@@ -3795,7 +3832,7 @@ function cleanupMemorySession(cardId) {
3795
3832
  }
3796
3833
  var TOOLS = {
3797
3834
  harmony_create_card: {
3798
- description: "Create a new card in a Kanban column",
3835
+ description: "Create a new card in a Kanban column. Optionally attach reference files " + "(e.g. a screenshot from the prompt) at creation time via `attachments` — " + "the card is created first, then each file is uploaded to it.",
3799
3836
  inputSchema: {
3800
3837
  type: "object",
3801
3838
  properties: {
@@ -3818,6 +3855,31 @@ var TOOLS = {
3818
3855
  planId: {
3819
3856
  type: "string",
3820
3857
  description: "Plan ID to link this card to (optional). Links the card to that plan via its plan_id."
3858
+ },
3859
+ attachments: {
3860
+ type: "array",
3861
+ description: "Optional reference files to attach to the new card (e.g. a screenshot from the prompt). " + "Max 5MB each; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV. Each file's bytes " + "come from `filePath` (absolute local path the server reads, preferred) or `base64Data` " + "(small-file fallback; requires fileName). NOTE: a pasted image only attaches if your " + "harness has written it to a local file you can pass as filePath — a model cannot re-emit " + "pasted image bytes into base64Data. Per-file failures never block card creation; they are " + "reported back in the result's `attachments` array so you can retry via harmony_upload.",
3862
+ items: {
3863
+ type: "object",
3864
+ properties: {
3865
+ filePath: {
3866
+ type: "string",
3867
+ description: "Absolute path to a local file the server can read. Mutually exclusive with base64Data."
3868
+ },
3869
+ base64Data: {
3870
+ type: "string",
3871
+ description: "Base64-encoded bytes (a `data:` URL prefix is accepted and stripped). Requires fileName. Mutually exclusive with filePath."
3872
+ },
3873
+ fileName: {
3874
+ type: "string",
3875
+ description: "File name including extension (required with base64Data; else defaults to the filePath basename)."
3876
+ },
3877
+ contentType: {
3878
+ type: "string",
3879
+ description: "Optional MIME type (inferred from the extension when omitted)."
3880
+ }
3881
+ }
3882
+ }
3821
3883
  }
3822
3884
  },
3823
3885
  required: ["title"]
@@ -5632,6 +5694,12 @@ async function handleToolCall(name, args, deps) {
5632
5694
  case "harmony_create_card": {
5633
5695
  const title = z.string().min(1).max(500).parse(args.title);
5634
5696
  const projectId = args.projectId || getProjectId();
5697
+ const attachments = args.attachments != null ? z.array(z.object({
5698
+ filePath: z.string().optional(),
5699
+ base64Data: z.string().optional(),
5700
+ fileName: z.string().optional(),
5701
+ contentType: z.string().optional()
5702
+ })).parse(args.attachments) : [];
5635
5703
  const result = await client3.createCard(projectId, {
5636
5704
  title,
5637
5705
  columnId: args.columnId,
@@ -5640,7 +5708,30 @@ async function handleToolCall(name, args, deps) {
5640
5708
  assigneeId: args.assigneeId,
5641
5709
  planId: args.planId
5642
5710
  });
5643
- return { success: true, ...result };
5711
+ if (attachments.length === 0) {
5712
+ return { success: true, ...result };
5713
+ }
5714
+ const cardId = result.card?.id;
5715
+ if (!cardId) {
5716
+ return {
5717
+ success: true,
5718
+ ...result,
5719
+ attachmentWarning: "Card created, but attachments were skipped: no card id was returned to upload against."
5720
+ };
5721
+ }
5722
+ const attachmentResults = await Promise.all(attachments.map(async (file) => {
5723
+ try {
5724
+ const uploaded = await attachFileToCard(client3, cardId, file);
5725
+ return { ok: true, attachment: uploaded.attachment };
5726
+ } catch (err) {
5727
+ return {
5728
+ ok: false,
5729
+ fileName: file.fileName ?? file.filePath ?? "(unnamed)",
5730
+ error: err instanceof Error ? err.message : String(err)
5731
+ };
5732
+ }
5733
+ }));
5734
+ return { success: true, ...result, attachments: attachmentResults };
5644
5735
  }
5645
5736
  case "harmony_update_card": {
5646
5737
  const cardId = z.string().uuid().parse(args.cardId);
@@ -5926,37 +6017,12 @@ ${list}
5926
6017
  const cardId2 = z.string().uuid().parse(args.cardId);
5927
6018
  const fileName = args.fileName != null ? z.string().parse(args.fileName) : undefined;
5928
6019
  const contentType = args.contentType != null ? z.string().parse(args.contentType) : undefined;
5929
- if (filePath) {
5930
- const bytes = await readFileForUpload(filePath, MAX_ATTACHMENT_SIZE, "attachment");
5931
- const resolvedName = fileName || basename(filePath);
5932
- const signed = await client3.requestCardAttachmentUploadUrl(cardId2, {
5933
- fileName: resolvedName,
5934
- fileType: contentType,
5935
- size: bytes.byteLength
5936
- });
5937
- await putToSignedUrl(signed.uploadUrl, bytes, contentType || signed.fileType || "application/octet-stream");
5938
- return await client3.finalizeCardAttachment(cardId2, {
5939
- storagePath: signed.storagePath,
5940
- fileName: resolvedName,
5941
- fileType: contentType || signed.fileType,
5942
- sha256: sha256Hex(bytes),
5943
- size: bytes.byteLength
5944
- });
5945
- }
5946
- if (base64Data) {
5947
- if (!fileName) {
5948
- throw new Error("fileName is required when using base64Data.");
5949
- }
5950
- if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
5951
- throw new Error(`File is over the 5MB attachment limit. Use the harmony_request_upload_url + harmony_finalize_upload handshake for large files.`);
5952
- }
5953
- return await client3.uploadCardAttachment(cardId2, {
5954
- fileName,
5955
- data: base64Data,
5956
- fileType: contentType
5957
- });
5958
- }
5959
- throw new Error("Provide either filePath or base64Data.");
6020
+ return await attachFileToCard(client3, cardId2, {
6021
+ filePath,
6022
+ base64Data,
6023
+ fileName,
6024
+ contentType
6025
+ });
5960
6026
  }
5961
6027
  const title = args.title != null ? z.string().parse(args.title) : undefined;
5962
6028
  const cardId = args.cardId != null ? z.string().uuid().parse(args.cardId) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/server.ts CHANGED
@@ -221,6 +221,80 @@ async function putToSignedUrl(
221
221
  }
222
222
  }
223
223
 
224
+ /** One card-attachment input: bytes as a local `filePath` (preferred — read
225
+ * direct-to-storage) or a small `base64Data` blob (requires `fileName`). */
226
+ export interface CardAttachmentInput {
227
+ filePath?: string;
228
+ base64Data?: string;
229
+ fileName?: string;
230
+ contentType?: string;
231
+ }
232
+
233
+ /**
234
+ * Attach one file to an existing card, running the same direct-to-storage
235
+ * handshake as `harmony_upload` `{target:"card_attachment"}`. Extracted so
236
+ * `harmony_create_card` can attach reference files (e.g. a prompt screenshot)
237
+ * at creation time without duplicating the orchestration. Requires an existing
238
+ * `cardId` — attachment upload is inherently a second step after the card row
239
+ * exists, so the create path calls this only after `createCard` returns.
240
+ */
241
+ async function attachFileToCard(
242
+ client: HarmonyApiClient,
243
+ cardId: string,
244
+ file: CardAttachmentInput,
245
+ ) {
246
+ const { filePath, base64Data, fileName, contentType } = file;
247
+ if (filePath && base64Data) {
248
+ throw new Error("Provide either filePath or base64Data, not both.");
249
+ }
250
+
251
+ if (filePath) {
252
+ // Server can read the file → upload direct-to-storage via the handshake
253
+ // (no base64 through the model context or edge-fn body).
254
+ const bytes = await readFileForUpload(
255
+ filePath,
256
+ MAX_ATTACHMENT_SIZE,
257
+ "attachment",
258
+ );
259
+ const resolvedName = fileName || basename(filePath);
260
+ const signed = await client.requestCardAttachmentUploadUrl(cardId, {
261
+ fileName: resolvedName,
262
+ fileType: contentType,
263
+ size: bytes.byteLength,
264
+ });
265
+ await putToSignedUrl(
266
+ signed.uploadUrl,
267
+ bytes,
268
+ contentType || signed.fileType || "application/octet-stream",
269
+ );
270
+ return await client.finalizeCardAttachment(cardId, {
271
+ storagePath: signed.storagePath,
272
+ fileName: resolvedName,
273
+ fileType: contentType || signed.fileType,
274
+ sha256: sha256Hex(bytes),
275
+ size: bytes.byteLength,
276
+ });
277
+ }
278
+
279
+ if (base64Data) {
280
+ if (!fileName) {
281
+ throw new Error("fileName is required when using base64Data.");
282
+ }
283
+ if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
284
+ throw new Error(
285
+ `File is over the 5MB attachment limit. Use the harmony_request_upload_url + harmony_finalize_upload handshake for large files.`,
286
+ );
287
+ }
288
+ return await client.uploadCardAttachment(cardId, {
289
+ fileName,
290
+ data: base64Data,
291
+ fileType: contentType,
292
+ });
293
+ }
294
+
295
+ throw new Error("Provide either filePath or base64Data.");
296
+ }
297
+
224
298
  /**
225
299
  * Dependencies injected into tool handlers.
226
300
  * Allows the same handlers to be used by both stdio and remote (HTTP) transports.
@@ -524,7 +598,10 @@ function cleanupMemorySession(cardId: string): void {
524
598
  export const TOOLS = {
525
599
  // Card operations
526
600
  harmony_create_card: {
527
- description: "Create a new card in a Kanban column",
601
+ description:
602
+ "Create a new card in a Kanban column. Optionally attach reference files " +
603
+ "(e.g. a screenshot from the prompt) at creation time via `attachments` — " +
604
+ "the card is created first, then each file is uploaded to it.",
528
605
  inputSchema: {
529
606
  type: "object",
530
607
  properties: {
@@ -550,6 +627,42 @@ export const TOOLS = {
550
627
  description:
551
628
  "Plan ID to link this card to (optional). Links the card to that plan via its plan_id.",
552
629
  },
630
+ attachments: {
631
+ type: "array",
632
+ description:
633
+ "Optional reference files to attach to the new card (e.g. a screenshot from the prompt). " +
634
+ "Max 5MB each; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV. Each file's bytes " +
635
+ "come from `filePath` (absolute local path the server reads, preferred) or `base64Data` " +
636
+ "(small-file fallback; requires fileName). NOTE: a pasted image only attaches if your " +
637
+ "harness has written it to a local file you can pass as filePath — a model cannot re-emit " +
638
+ "pasted image bytes into base64Data. Per-file failures never block card creation; they are " +
639
+ "reported back in the result's `attachments` array so you can retry via harmony_upload.",
640
+ items: {
641
+ type: "object",
642
+ properties: {
643
+ filePath: {
644
+ type: "string",
645
+ description:
646
+ "Absolute path to a local file the server can read. Mutually exclusive with base64Data.",
647
+ },
648
+ base64Data: {
649
+ type: "string",
650
+ description:
651
+ "Base64-encoded bytes (a `data:` URL prefix is accepted and stripped). Requires fileName. Mutually exclusive with filePath.",
652
+ },
653
+ fileName: {
654
+ type: "string",
655
+ description:
656
+ "File name including extension (required with base64Data; else defaults to the filePath basename).",
657
+ },
658
+ contentType: {
659
+ type: "string",
660
+ description:
661
+ "Optional MIME type (inferred from the extension when omitted).",
662
+ },
663
+ },
664
+ },
665
+ },
553
666
  },
554
667
  required: ["title"],
555
668
  },
@@ -2681,6 +2794,19 @@ async function handleToolCall(
2681
2794
  case "harmony_create_card": {
2682
2795
  const title = z.string().min(1).max(500).parse(args.title);
2683
2796
  const projectId = (args.projectId as string) || getProjectId();
2797
+ const attachments =
2798
+ args.attachments != null
2799
+ ? z
2800
+ .array(
2801
+ z.object({
2802
+ filePath: z.string().optional(),
2803
+ base64Data: z.string().optional(),
2804
+ fileName: z.string().optional(),
2805
+ contentType: z.string().optional(),
2806
+ }),
2807
+ )
2808
+ .parse(args.attachments)
2809
+ : [];
2684
2810
  const result = await client.createCard(projectId, {
2685
2811
  title,
2686
2812
  columnId: args.columnId as string | undefined,
@@ -2689,7 +2815,40 @@ async function handleToolCall(
2689
2815
  assigneeId: args.assigneeId as string | undefined,
2690
2816
  planId: args.planId as string | undefined,
2691
2817
  });
2692
- return { success: true, ...result };
2818
+
2819
+ if (attachments.length === 0) {
2820
+ return { success: true, ...result };
2821
+ }
2822
+
2823
+ // Attach reference files (e.g. a prompt screenshot) to the freshly
2824
+ // created card. Attachment upload requires an existing cardId, so this
2825
+ // runs only after createCard returns. A bad attachment must never lose
2826
+ // the card — per-file failures are captured and reported alongside the
2827
+ // successes rather than throwing out the whole create.
2828
+ const cardId = (result.card as { id?: string } | null)?.id;
2829
+ if (!cardId) {
2830
+ return {
2831
+ success: true,
2832
+ ...result,
2833
+ attachmentWarning:
2834
+ "Card created, but attachments were skipped: no card id was returned to upload against.",
2835
+ };
2836
+ }
2837
+ const attachmentResults = await Promise.all(
2838
+ attachments.map(async (file) => {
2839
+ try {
2840
+ const uploaded = await attachFileToCard(client, cardId, file);
2841
+ return { ok: true as const, attachment: uploaded.attachment };
2842
+ } catch (err) {
2843
+ return {
2844
+ ok: false as const,
2845
+ fileName: file.fileName ?? file.filePath ?? "(unnamed)",
2846
+ error: err instanceof Error ? err.message : String(err),
2847
+ };
2848
+ }
2849
+ }),
2850
+ );
2851
+ return { success: true, ...result, attachments: attachmentResults };
2693
2852
  }
2694
2853
 
2695
2854
  case "harmony_update_card": {
@@ -3162,52 +3321,13 @@ async function handleToolCall(
3162
3321
  args.contentType != null
3163
3322
  ? z.string().parse(args.contentType)
3164
3323
  : undefined;
3165
-
3166
- if (filePath) {
3167
- // Server can read the file → upload direct-to-storage via the
3168
- // handshake (no base64 through the model context or edge-fn body).
3169
- const bytes = await readFileForUpload(
3170
- filePath,
3171
- MAX_ATTACHMENT_SIZE,
3172
- "attachment",
3173
- );
3174
- const resolvedName = fileName || basename(filePath);
3175
- const signed = await client.requestCardAttachmentUploadUrl(cardId, {
3176
- fileName: resolvedName,
3177
- fileType: contentType,
3178
- size: bytes.byteLength,
3179
- });
3180
- await putToSignedUrl(
3181
- signed.uploadUrl,
3182
- bytes,
3183
- contentType || signed.fileType || "application/octet-stream",
3184
- );
3185
- return await client.finalizeCardAttachment(cardId, {
3186
- storagePath: signed.storagePath,
3187
- fileName: resolvedName,
3188
- fileType: contentType || signed.fileType,
3189
- sha256: sha256Hex(bytes),
3190
- size: bytes.byteLength,
3191
- });
3192
- }
3193
-
3194
- if (base64Data) {
3195
- if (!fileName) {
3196
- throw new Error("fileName is required when using base64Data.");
3197
- }
3198
- if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
3199
- throw new Error(
3200
- `File is over the 5MB attachment limit. Use the harmony_request_upload_url + harmony_finalize_upload handshake for large files.`,
3201
- );
3202
- }
3203
- return await client.uploadCardAttachment(cardId, {
3204
- fileName,
3205
- data: base64Data,
3206
- fileType: contentType,
3207
- });
3208
- }
3209
-
3210
- throw new Error("Provide either filePath or base64Data.");
3324
+ // Same handshake as the create-card `attachments` path (shared helper).
3325
+ return await attachFileToCard(client, cardId, {
3326
+ filePath,
3327
+ base64Data,
3328
+ fileName,
3329
+ contentType,
3330
+ });
3211
3331
  }
3212
3332
 
3213
3333
  // target === "artifact"