@gethmy/mcp 2.12.0 → 2.13.1
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 +357 -53
- package/dist/index.js +355 -51
- package/dist/lib/api-client.js +21 -0
- package/package.json +1 -1
- package/src/api-client.ts +101 -0
- package/src/server.ts +509 -72
package/src/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { basename } from "node:path";
|
|
3
4
|
import { syncFull, syncPull, syncPush } from "@harmony/memory";
|
|
@@ -67,6 +68,99 @@ import { lintTags, normalizeTags } from "./memory-tags.js";
|
|
|
67
68
|
import { onboardNewUser } from "./onboard.js";
|
|
68
69
|
import { stripSkillPreamble } from "./skills.js";
|
|
69
70
|
|
|
71
|
+
// --- Signed-upload handshake (artifacts & card attachments) ---
|
|
72
|
+
// Mirror the edge-fn caps so a too-large `filePath`/`base64Data` fails fast in
|
|
73
|
+
// the client instead of being read fully into memory and round-tripped only to
|
|
74
|
+
// be rejected server-side (closes the #345 "no size pre-check" finding). The
|
|
75
|
+
// finalize endpoint re-enforces these as the real security boundary.
|
|
76
|
+
const MAX_ARTIFACT_SIZE = 2 * 1024 * 1024; // 2 MB — keep in sync with harmony-api
|
|
77
|
+
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5 MB — keep in sync with harmony-api
|
|
78
|
+
|
|
79
|
+
/** Decoded byte length of a base64 string, tolerating a leading `data:` URL
|
|
80
|
+
* prefix (the edge fn strips it; we strip a copy only to measure). */
|
|
81
|
+
function base64ByteLength(base64: string): number {
|
|
82
|
+
const stripped = base64.replace(/^data:[^,]*,/, "");
|
|
83
|
+
return Buffer.from(stripped, "base64").byteLength;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sha256Hex(bytes: Buffer): string {
|
|
87
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read a local file for a direct-to-storage upload, rejecting empty and
|
|
91
|
+
* over-cap before any network round-trip. `kind` shapes the error copy. */
|
|
92
|
+
async function readFileForUpload(
|
|
93
|
+
filePath: string,
|
|
94
|
+
maxSize: number,
|
|
95
|
+
kind: "attachment" | "artifact",
|
|
96
|
+
): Promise<Buffer> {
|
|
97
|
+
const bytes = await readFile(filePath);
|
|
98
|
+
if (bytes.byteLength === 0) {
|
|
99
|
+
throw new Error(`File is empty: ${filePath}`);
|
|
100
|
+
}
|
|
101
|
+
if (bytes.byteLength > maxSize) {
|
|
102
|
+
const mb = Math.round(maxSize / (1024 * 1024));
|
|
103
|
+
throw new Error(
|
|
104
|
+
`File is ${bytes.byteLength} bytes, over the ${maxSize}-byte (${mb}MB) ${kind} limit.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return bytes;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Enforce the exactly-one-of-scope rule shared by the artifact upload,
|
|
111
|
+
* upload-url, and finalize tools. */
|
|
112
|
+
function requireExactlyOneScope(scope: {
|
|
113
|
+
cardId?: string;
|
|
114
|
+
planId?: string;
|
|
115
|
+
workspaceId?: string;
|
|
116
|
+
}): void {
|
|
117
|
+
if (
|
|
118
|
+
[scope.cardId, scope.planId, scope.workspaceId].filter(Boolean).length !== 1
|
|
119
|
+
) {
|
|
120
|
+
throw new Error("Provide exactly one of cardId, planId, or workspaceId.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** PUT raw bytes straight to a Supabase signed upload URL (the token rides in
|
|
125
|
+
* the URL query). Used by the stdio internal handshake; the hosted server can't
|
|
126
|
+
* read local disk, so there the agent drives this PUT itself via the two-step
|
|
127
|
+
* tools. */
|
|
128
|
+
const SIGNED_UPLOAD_TIMEOUT_MS = 30_000;
|
|
129
|
+
|
|
130
|
+
async function putToSignedUrl(
|
|
131
|
+
uploadUrl: string,
|
|
132
|
+
bytes: Uint8Array,
|
|
133
|
+
contentType: string,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
let res: Response;
|
|
136
|
+
try {
|
|
137
|
+
res = await fetch(uploadUrl, {
|
|
138
|
+
method: "PUT",
|
|
139
|
+
headers: { "Content-Type": contentType },
|
|
140
|
+
// Cast around the TS 5.7 lib regression where the now-generic
|
|
141
|
+
// `Uint8Array<ArrayBufferLike>` no longer matches `BodyInit`'s
|
|
142
|
+
// `ArrayBuffer`-pinned `BufferSource`. The bytes are a valid fetch body.
|
|
143
|
+
body: bytes as unknown as BodyInit,
|
|
144
|
+
// Bound the upload so a stalled storage PUT can't hang the call
|
|
145
|
+
// indefinitely (there is no watchdog at this layer).
|
|
146
|
+
signal: AbortSignal.timeout(SIGNED_UPLOAD_TIMEOUT_MS),
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Direct storage upload timed out after ${SIGNED_UPLOAD_TIMEOUT_MS}ms.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const detail = await res.text().catch(() => "");
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Direct storage upload failed: ${res.status}${detail ? ` — ${detail}` : ""}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
70
164
|
/**
|
|
71
165
|
* Dependencies injected into tool handlers.
|
|
72
166
|
* Allows the same handlers to be used by both stdio and remote (HTTP) transports.
|
|
@@ -150,6 +244,86 @@ function parseLabelList(raw: unknown): string[] | undefined {
|
|
|
150
244
|
return undefined;
|
|
151
245
|
}
|
|
152
246
|
|
|
247
|
+
const UUID_RE =
|
|
248
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Coerce + validate a required string argument from a (possibly lenient) MCP
|
|
252
|
+
* caller, throwing a clear, field-attributed error on failure.
|
|
253
|
+
*
|
|
254
|
+
* The agent-session tools previously parsed these with a bare
|
|
255
|
+
* `z.string().parse(args.foo)`. When the field was missing or arrived as a
|
|
256
|
+
* non-string, Zod surfaced an opaque root-level blob —
|
|
257
|
+
* `[{"expected":"string","received":"undefined","path":[],...}]` — that names
|
|
258
|
+
* neither the offending field nor the received value. To an agent that reads
|
|
259
|
+
* as "the tool rejects every argument shape with an `expected string` error"
|
|
260
|
+
* (card #530). Instead we name the field, report what we got, and (matching
|
|
261
|
+
* the `parseLabelList` philosophy for quirky clients) accept the
|
|
262
|
+
* number/boolean values some MCP clients emit by stringifying scalars.
|
|
263
|
+
*/
|
|
264
|
+
function requireStringArg(
|
|
265
|
+
raw: unknown,
|
|
266
|
+
field: string,
|
|
267
|
+
opts: { max?: number } = {},
|
|
268
|
+
): string {
|
|
269
|
+
let value: string;
|
|
270
|
+
if (typeof raw === "string") value = raw.trim();
|
|
271
|
+
else if (typeof raw === "number" || typeof raw === "boolean")
|
|
272
|
+
value = String(raw);
|
|
273
|
+
else
|
|
274
|
+
throw new Error(
|
|
275
|
+
`${field} is required and must be a string (received ${
|
|
276
|
+
raw === undefined || raw === null ? "nothing" : typeof raw
|
|
277
|
+
}).`,
|
|
278
|
+
);
|
|
279
|
+
if (value.length === 0)
|
|
280
|
+
throw new Error(`${field} is required and must not be empty.`);
|
|
281
|
+
if (opts.max && value.length > opts.max)
|
|
282
|
+
throw new Error(
|
|
283
|
+
`${field} must be at most ${opts.max} characters (received ${value.length}).`,
|
|
284
|
+
);
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Coerce + validate a required UUID argument with a field-named error. */
|
|
289
|
+
function requireUuidArg(raw: unknown, field: string): string {
|
|
290
|
+
const value = requireStringArg(raw, field);
|
|
291
|
+
if (!UUID_RE.test(value))
|
|
292
|
+
throw new Error(`${field} must be a valid UUID (received "${value}").`);
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Coerce an optional 0–100 percentage. Accepts numeric strings (e.g. "50")
|
|
298
|
+
* because lenient MCP clients stringify scalar arguments; returns `undefined`
|
|
299
|
+
* when absent and throws a clear, field-named error when out of range.
|
|
300
|
+
*/
|
|
301
|
+
function optionalPercentArg(raw: unknown, field: string): number | undefined {
|
|
302
|
+
if (raw === undefined || raw === null) return undefined;
|
|
303
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
304
|
+
if (!Number.isFinite(n))
|
|
305
|
+
throw new Error(`${field} must be a number between 0 and 100.`);
|
|
306
|
+
if (n < 0 || n > 100)
|
|
307
|
+
throw new Error(`${field} must be between 0 and 100 (received ${n}).`);
|
|
308
|
+
return n;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Coerce an optional non-negative number argument (e.g.
|
|
313
|
+
* `estimatedMinutesRemaining`). Accepts numeric strings; returns `undefined`
|
|
314
|
+
* when absent so the field is omitted from the API payload entirely.
|
|
315
|
+
*/
|
|
316
|
+
function optionalNonNegativeNumberArg(
|
|
317
|
+
raw: unknown,
|
|
318
|
+
field: string,
|
|
319
|
+
): number | undefined {
|
|
320
|
+
if (raw === undefined || raw === null) return undefined;
|
|
321
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
322
|
+
if (!Number.isFinite(n) || n < 0)
|
|
323
|
+
throw new Error(`${field} must be a non-negative number.`);
|
|
324
|
+
return n;
|
|
325
|
+
}
|
|
326
|
+
|
|
153
327
|
function initMemorySession(
|
|
154
328
|
cardId: string,
|
|
155
329
|
agentIdentifier: string,
|
|
@@ -665,7 +839,7 @@ export const TOOLS = {
|
|
|
665
839
|
},
|
|
666
840
|
harmony_upload_card_attachment: {
|
|
667
841
|
description:
|
|
668
|
-
"Upload a file attachment to a card (e.g. a pasted screenshot or a document). Provide the file either as `filePath` (a local path the MCP server can read — works in local/stdio mode) or as `base64Data` (raw base64 bytes — works everywhere, including remote mode). Max 5MB. Allowed: PNG, JPEG, GIF, WebP, HEIC/HEIF, PDF, DOC/DOCX, XLS/XLSX, TXT, CSV. Returns the stored attachment with a short-lived signed URL.",
|
|
842
|
+
"Upload a file attachment to a card (e.g. a pasted screenshot or a document). Provide the file either as `filePath` (a local path the MCP server can read — works in local/stdio mode) or as `base64Data` (raw base64 bytes — works everywhere, including remote mode). With `filePath` the server uploads direct-to-storage (no base64 through the model context); `base64Data` is the small-file fallback. Max 5MB. Allowed: PNG, JPEG, GIF, WebP, HEIC/HEIF, PDF, DOC/DOCX, XLS/XLSX, TXT, CSV. Returns the stored attachment with a short-lived signed URL. For large files on the hosted MCP server (where only base64Data works), prefer the two-step harmony_request_card_attachment_upload_url + harmony_finalize_card_attachment handshake, which keeps bytes out of the model context.",
|
|
669
843
|
inputSchema: {
|
|
670
844
|
type: "object",
|
|
671
845
|
properties: {
|
|
@@ -694,6 +868,65 @@ export const TOOLS = {
|
|
|
694
868
|
required: ["cardId"],
|
|
695
869
|
},
|
|
696
870
|
},
|
|
871
|
+
harmony_request_card_attachment_upload_url: {
|
|
872
|
+
description:
|
|
873
|
+
"Step 1 of the agent-driven card-attachment upload — use this for large files or when running against the hosted MCP server (which cannot read your local disk). Mints a one-shot signed Supabase Storage upload URL for a server-chosen path under the card. Provide cardId, fileName (with extension), the byte size, and optionally fileType. Returns { uploadUrl, token, storagePath, fileType }. Next: PUT the raw bytes straight to uploadUrl (e.g. `curl -X PUT --data-binary @file.png '<uploadUrl>'`) — no bytes pass through the model context — then call harmony_finalize_card_attachment with the returned storagePath. Max 5MB; allowed: PNG, JPEG, GIF, WebP, HEIC/HEIF, PDF, DOC/DOCX, XLS/XLSX, TXT, CSV.",
|
|
874
|
+
inputSchema: {
|
|
875
|
+
type: "object",
|
|
876
|
+
properties: {
|
|
877
|
+
cardId: { type: "string", description: "Card UUID" },
|
|
878
|
+
fileName: {
|
|
879
|
+
type: "string",
|
|
880
|
+
description: "File name including extension (e.g. 'screenshot.png').",
|
|
881
|
+
},
|
|
882
|
+
fileType: {
|
|
883
|
+
type: "string",
|
|
884
|
+
description:
|
|
885
|
+
"Optional MIME type (e.g. 'image/png'). Inferred from the file extension when omitted.",
|
|
886
|
+
},
|
|
887
|
+
size: {
|
|
888
|
+
type: "number",
|
|
889
|
+
description: "File size in bytes (rejected early if over 5MB).",
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
required: ["cardId", "fileName", "size"],
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
harmony_finalize_card_attachment: {
|
|
896
|
+
description:
|
|
897
|
+
"Step 2 of the agent-driven card-attachment upload. After PUTting the bytes to the signed uploadUrl from harmony_request_card_attachment_upload_url, call this with the returned storagePath to validate and register the attachment. The server re-downloads the object and enforces size, an allowlisted content-type (magic-byte sniff, never the declared type), and — when you pass sha256 — an integrity check, deleting the object and failing on any mismatch. Returns the stored attachment with a short-lived signed URL.",
|
|
898
|
+
inputSchema: {
|
|
899
|
+
type: "object",
|
|
900
|
+
properties: {
|
|
901
|
+
cardId: { type: "string", description: "Card UUID" },
|
|
902
|
+
storagePath: {
|
|
903
|
+
type: "string",
|
|
904
|
+
description:
|
|
905
|
+
"The storagePath returned by harmony_request_card_attachment_upload_url.",
|
|
906
|
+
},
|
|
907
|
+
fileName: {
|
|
908
|
+
type: "string",
|
|
909
|
+
description: "File name including extension (e.g. 'screenshot.png').",
|
|
910
|
+
},
|
|
911
|
+
fileType: {
|
|
912
|
+
type: "string",
|
|
913
|
+
description:
|
|
914
|
+
"Optional MIME type; inferred from the extension when omitted.",
|
|
915
|
+
},
|
|
916
|
+
sha256: {
|
|
917
|
+
type: "string",
|
|
918
|
+
description:
|
|
919
|
+
"Optional hex SHA-256 of the uploaded bytes; verified against the stored object.",
|
|
920
|
+
},
|
|
921
|
+
size: {
|
|
922
|
+
type: "number",
|
|
923
|
+
description:
|
|
924
|
+
"Optional byte size (advisory; re-validated server-side).",
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
required: ["cardId", "storagePath", "fileName"],
|
|
928
|
+
},
|
|
929
|
+
},
|
|
697
930
|
harmony_classify_card: {
|
|
698
931
|
description:
|
|
699
932
|
"Classify a card with the LLM classifier: sets `intent` (plan/think/implement/review), `complexity_score` (0-10), and `model_tier` (simple/advanced/research), stamps `classified_at`, and applies the canonical type label (feature/bug/idea). Use this right after creating a card (e.g. in the `hmy-new` flow) so it's classified in-flow instead of waiting for it to surface on the web board. Idempotent — safe to re-run. Never touches the user-owned `model_override`.",
|
|
@@ -707,7 +940,7 @@ export const TOOLS = {
|
|
|
707
940
|
},
|
|
708
941
|
harmony_upload_artifact: {
|
|
709
942
|
description:
|
|
710
|
-
"Host a self-contained HTML document (e.g. a visual design draft or diagram) in Harmony and link it to a card, a plan, or a workspace. The file is stored privately and rendered in-app inside a sandboxed cross-origin iframe. Provide exactly one of cardId, planId, or workspaceId. Supply the HTML as `filePath` (a local path the MCP server can read) or `base64Data
|
|
943
|
+
"Host a self-contained HTML document (e.g. a visual design draft or diagram) in Harmony and link it to a card, a plan, or a workspace. The file is stored privately and rendered in-app inside a sandboxed cross-origin iframe. Provide exactly one of cardId, planId, or workspaceId. Supply the HTML as `filePath` (a local path the MCP server can read — uploaded direct-to-storage, no base64 through the model context) or `base64Data` (the small-file fallback). Only text/html, max 2MB. Returns the stored artifact with a short-lived signed URL; call harmony_share_artifact to mint a public link. For large files on the hosted MCP server (where only base64Data works), prefer the two-step harmony_request_artifact_upload_url + harmony_finalize_artifact handshake, which keeps bytes out of the model context.",
|
|
711
944
|
inputSchema: {
|
|
712
945
|
type: "object",
|
|
713
946
|
properties: {
|
|
@@ -736,6 +969,69 @@ export const TOOLS = {
|
|
|
736
969
|
required: [],
|
|
737
970
|
},
|
|
738
971
|
},
|
|
972
|
+
harmony_request_artifact_upload_url: {
|
|
973
|
+
description:
|
|
974
|
+
"Step 1 of the agent-driven artifact upload — use this for large files or when running against the hosted MCP server (which cannot read your local disk), instead of streaming base64 through the model context. Mints a one-shot signed Supabase Storage upload URL for a server-chosen path. Provide exactly one of cardId/planId/workspaceId, optionally a title and the byte size. Returns { uploadUrl, token, storagePath }. Next: PUT the raw HTML bytes straight to uploadUrl (e.g. `curl -X PUT --data-binary @doc.html '<uploadUrl>'`), then call harmony_finalize_artifact with the returned storagePath. Only text/html, max 2MB.",
|
|
975
|
+
inputSchema: {
|
|
976
|
+
type: "object",
|
|
977
|
+
properties: {
|
|
978
|
+
title: {
|
|
979
|
+
type: "string",
|
|
980
|
+
description:
|
|
981
|
+
"Display title (defaults to the file basename at finalize).",
|
|
982
|
+
},
|
|
983
|
+
cardId: { type: "string", description: "Link to this card (UUID)." },
|
|
984
|
+
planId: { type: "string", description: "Link to this plan (UUID)." },
|
|
985
|
+
workspaceId: {
|
|
986
|
+
type: "string",
|
|
987
|
+
description:
|
|
988
|
+
"Attach to this workspace as a standalone artifact (UUID).",
|
|
989
|
+
},
|
|
990
|
+
contentType: {
|
|
991
|
+
type: "string",
|
|
992
|
+
description: "MIME type; only 'text/html' is accepted (the default).",
|
|
993
|
+
},
|
|
994
|
+
size: {
|
|
995
|
+
type: "number",
|
|
996
|
+
description: "File size in bytes (rejected early if over 2MB).",
|
|
997
|
+
},
|
|
998
|
+
},
|
|
999
|
+
required: [],
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
harmony_finalize_artifact: {
|
|
1003
|
+
description:
|
|
1004
|
+
"Step 2 of the agent-driven artifact upload. After PUTting the HTML bytes to the signed uploadUrl from harmony_request_artifact_upload_url, call this with the returned storagePath to validate and register the artifact. The server re-downloads the object and enforces size, the text/html content-type (magic-byte sniff), and — when you pass sha256 — an integrity check, deleting the object and failing on any mismatch. Provide the same one of cardId/planId/workspaceId used for the upload URL. Returns the stored artifact with a short-lived signed URL; call harmony_share_artifact to mint a public link.",
|
|
1005
|
+
inputSchema: {
|
|
1006
|
+
type: "object",
|
|
1007
|
+
properties: {
|
|
1008
|
+
storagePath: {
|
|
1009
|
+
type: "string",
|
|
1010
|
+
description:
|
|
1011
|
+
"The storagePath returned by harmony_request_artifact_upload_url.",
|
|
1012
|
+
},
|
|
1013
|
+
title: { type: "string", description: "Display title." },
|
|
1014
|
+
cardId: { type: "string", description: "Link to this card (UUID)." },
|
|
1015
|
+
planId: { type: "string", description: "Link to this plan (UUID)." },
|
|
1016
|
+
workspaceId: {
|
|
1017
|
+
type: "string",
|
|
1018
|
+
description:
|
|
1019
|
+
"Attach to this workspace as a standalone artifact (UUID).",
|
|
1020
|
+
},
|
|
1021
|
+
sha256: {
|
|
1022
|
+
type: "string",
|
|
1023
|
+
description:
|
|
1024
|
+
"Optional hex SHA-256 of the uploaded bytes; verified against the stored object.",
|
|
1025
|
+
},
|
|
1026
|
+
size: {
|
|
1027
|
+
type: "number",
|
|
1028
|
+
description:
|
|
1029
|
+
"Optional byte size (advisory; re-validated server-side).",
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
required: ["storagePath"],
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
739
1035
|
harmony_share_artifact: {
|
|
740
1036
|
description:
|
|
741
1037
|
"Create a public, unauthenticated share link for a hosted artifact. Anyone with the link can view the rendered HTML without a Harmony account. Returns the share token and the full public URL.",
|
|
@@ -2153,8 +2449,6 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
|
|
|
2153
2449
|
// Auto-session pre-hook: track activity on card-related tools
|
|
2154
2450
|
const toolArgs = args || {};
|
|
2155
2451
|
const cardIdArg = toolArgs.cardId as string | undefined;
|
|
2156
|
-
const UUID_RE =
|
|
2157
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2158
2452
|
if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
|
|
2159
2453
|
const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
|
|
2160
2454
|
// Resolve MCP client identity per request from the in-scope Server. The
|
|
@@ -2690,7 +2984,7 @@ async function handleToolCall(
|
|
|
2690
2984
|
args.filePath != null ? z.string().parse(args.filePath) : undefined;
|
|
2691
2985
|
const base64Data =
|
|
2692
2986
|
args.base64Data != null ? z.string().parse(args.base64Data) : undefined;
|
|
2693
|
-
|
|
2987
|
+
const fileName =
|
|
2694
2988
|
args.fileName != null ? z.string().parse(args.fileName) : undefined;
|
|
2695
2989
|
const contentType =
|
|
2696
2990
|
args.contentType != null
|
|
@@ -2701,29 +2995,51 @@ async function handleToolCall(
|
|
|
2701
2995
|
throw new Error("Provide either filePath or base64Data, not both.");
|
|
2702
2996
|
}
|
|
2703
2997
|
|
|
2704
|
-
let data: string;
|
|
2705
2998
|
if (filePath) {
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2999
|
+
// Server can read the file → upload direct-to-storage via the handshake
|
|
3000
|
+
// (no base64 through the model context or the edge-fn JSON body).
|
|
3001
|
+
const bytes = await readFileForUpload(
|
|
3002
|
+
filePath,
|
|
3003
|
+
MAX_ATTACHMENT_SIZE,
|
|
3004
|
+
"attachment",
|
|
3005
|
+
);
|
|
3006
|
+
const resolvedName = fileName || basename(filePath);
|
|
3007
|
+
const signed = await client.requestCardAttachmentUploadUrl(cardId, {
|
|
3008
|
+
fileName: resolvedName,
|
|
3009
|
+
fileType: contentType,
|
|
3010
|
+
size: bytes.byteLength,
|
|
3011
|
+
});
|
|
3012
|
+
await putToSignedUrl(
|
|
3013
|
+
signed.uploadUrl,
|
|
3014
|
+
bytes,
|
|
3015
|
+
contentType || signed.fileType || "application/octet-stream",
|
|
3016
|
+
);
|
|
3017
|
+
return await client.finalizeCardAttachment(cardId, {
|
|
3018
|
+
storagePath: signed.storagePath,
|
|
3019
|
+
fileName: resolvedName,
|
|
3020
|
+
fileType: contentType || signed.fileType,
|
|
3021
|
+
sha256: sha256Hex(bytes),
|
|
3022
|
+
size: bytes.byteLength,
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
if (base64Data) {
|
|
2713
3027
|
if (!fileName) {
|
|
2714
3028
|
throw new Error("fileName is required when using base64Data.");
|
|
2715
3029
|
}
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
3030
|
+
if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
|
|
3031
|
+
throw new Error(
|
|
3032
|
+
`File is over the 5MB attachment limit. Use the harmony_request_card_attachment_upload_url + harmony_finalize_card_attachment handshake for large files.`,
|
|
3033
|
+
);
|
|
3034
|
+
}
|
|
3035
|
+
return await client.uploadCardAttachment(cardId, {
|
|
3036
|
+
fileName,
|
|
3037
|
+
data: base64Data,
|
|
3038
|
+
fileType: contentType,
|
|
3039
|
+
});
|
|
2719
3040
|
}
|
|
2720
3041
|
|
|
2721
|
-
|
|
2722
|
-
fileName: fileName as string,
|
|
2723
|
-
data,
|
|
2724
|
-
fileType: contentType,
|
|
2725
|
-
});
|
|
2726
|
-
return result;
|
|
3042
|
+
throw new Error("Provide either filePath or base64Data.");
|
|
2727
3043
|
}
|
|
2728
3044
|
|
|
2729
3045
|
case "harmony_upload_artifact": {
|
|
@@ -2737,12 +3053,7 @@ async function handleToolCall(
|
|
|
2737
3053
|
args.workspaceId != null
|
|
2738
3054
|
? z.string().uuid().parse(args.workspaceId)
|
|
2739
3055
|
: undefined;
|
|
2740
|
-
|
|
2741
|
-
if (anchors.length !== 1) {
|
|
2742
|
-
throw new Error(
|
|
2743
|
-
"Provide exactly one of cardId, planId, or workspaceId.",
|
|
2744
|
-
);
|
|
2745
|
-
}
|
|
3056
|
+
requireExactlyOneScope({ cardId, planId, workspaceId });
|
|
2746
3057
|
|
|
2747
3058
|
const filePath =
|
|
2748
3059
|
args.filePath != null ? z.string().parse(args.filePath) : undefined;
|
|
@@ -2752,29 +3063,149 @@ async function handleToolCall(
|
|
|
2752
3063
|
throw new Error("Provide either filePath or base64Data, not both.");
|
|
2753
3064
|
}
|
|
2754
3065
|
|
|
2755
|
-
let data: string;
|
|
2756
|
-
let inferredTitle = title;
|
|
2757
3066
|
if (filePath) {
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
3067
|
+
// Server can read the file → upload direct-to-storage via the handshake
|
|
3068
|
+
// (no base64 through the model context or the edge-fn JSON body).
|
|
3069
|
+
const bytes = await readFileForUpload(
|
|
3070
|
+
filePath,
|
|
3071
|
+
MAX_ARTIFACT_SIZE,
|
|
3072
|
+
"artifact",
|
|
3073
|
+
);
|
|
3074
|
+
const resolvedTitle = title || basename(filePath);
|
|
3075
|
+
const signed = await client.requestArtifactUploadUrl({
|
|
3076
|
+
title: resolvedTitle,
|
|
3077
|
+
cardId,
|
|
3078
|
+
planId,
|
|
3079
|
+
workspaceId,
|
|
3080
|
+
contentType: "text/html",
|
|
3081
|
+
size: bytes.byteLength,
|
|
3082
|
+
});
|
|
3083
|
+
await putToSignedUrl(signed.uploadUrl, bytes, "text/html");
|
|
3084
|
+
return await client.finalizeArtifact({
|
|
3085
|
+
storagePath: signed.storagePath,
|
|
3086
|
+
sha256: sha256Hex(bytes),
|
|
3087
|
+
size: bytes.byteLength,
|
|
3088
|
+
title: resolvedTitle,
|
|
3089
|
+
cardId,
|
|
3090
|
+
planId,
|
|
3091
|
+
workspaceId,
|
|
3092
|
+
});
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
if (base64Data) {
|
|
3096
|
+
if (base64ByteLength(base64Data) > MAX_ARTIFACT_SIZE) {
|
|
3097
|
+
throw new Error(
|
|
3098
|
+
`Artifact is over the 2MB limit. Use the harmony_request_artifact_upload_url + harmony_finalize_artifact handshake for large files.`,
|
|
3099
|
+
);
|
|
2761
3100
|
}
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3101
|
+
return await client.uploadArtifact({
|
|
3102
|
+
title,
|
|
3103
|
+
cardId,
|
|
3104
|
+
planId,
|
|
3105
|
+
workspaceId,
|
|
3106
|
+
data: base64Data,
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
throw new Error("Provide either filePath or base64Data.");
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
case "harmony_request_artifact_upload_url": {
|
|
3114
|
+
const title =
|
|
3115
|
+
args.title != null ? z.string().parse(args.title) : undefined;
|
|
3116
|
+
const cardId =
|
|
3117
|
+
args.cardId != null ? z.string().uuid().parse(args.cardId) : undefined;
|
|
3118
|
+
const planId =
|
|
3119
|
+
args.planId != null ? z.string().uuid().parse(args.planId) : undefined;
|
|
3120
|
+
const workspaceId =
|
|
3121
|
+
args.workspaceId != null
|
|
3122
|
+
? z.string().uuid().parse(args.workspaceId)
|
|
3123
|
+
: undefined;
|
|
3124
|
+
requireExactlyOneScope({ cardId, planId, workspaceId });
|
|
3125
|
+
const contentType =
|
|
3126
|
+
args.contentType != null
|
|
3127
|
+
? z.string().parse(args.contentType)
|
|
3128
|
+
: undefined;
|
|
3129
|
+
const size =
|
|
3130
|
+
args.size != null ? z.number().positive().parse(args.size) : undefined;
|
|
3131
|
+
if (size != null && size > MAX_ARTIFACT_SIZE) {
|
|
3132
|
+
throw new Error(
|
|
3133
|
+
`Declared size ${size} bytes is over the ${MAX_ARTIFACT_SIZE}-byte (2MB) artifact limit.`,
|
|
3134
|
+
);
|
|
2768
3135
|
}
|
|
3136
|
+
return await client.requestArtifactUploadUrl({
|
|
3137
|
+
title,
|
|
3138
|
+
cardId,
|
|
3139
|
+
planId,
|
|
3140
|
+
workspaceId,
|
|
3141
|
+
contentType,
|
|
3142
|
+
size,
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
2769
3145
|
|
|
2770
|
-
|
|
2771
|
-
|
|
3146
|
+
case "harmony_finalize_artifact": {
|
|
3147
|
+
const storagePath = z.string().parse(args.storagePath);
|
|
3148
|
+
const title =
|
|
3149
|
+
args.title != null ? z.string().parse(args.title) : undefined;
|
|
3150
|
+
const cardId =
|
|
3151
|
+
args.cardId != null ? z.string().uuid().parse(args.cardId) : undefined;
|
|
3152
|
+
const planId =
|
|
3153
|
+
args.planId != null ? z.string().uuid().parse(args.planId) : undefined;
|
|
3154
|
+
const workspaceId =
|
|
3155
|
+
args.workspaceId != null
|
|
3156
|
+
? z.string().uuid().parse(args.workspaceId)
|
|
3157
|
+
: undefined;
|
|
3158
|
+
requireExactlyOneScope({ cardId, planId, workspaceId });
|
|
3159
|
+
const sha256 =
|
|
3160
|
+
args.sha256 != null ? z.string().parse(args.sha256) : undefined;
|
|
3161
|
+
const size =
|
|
3162
|
+
args.size != null ? z.number().positive().parse(args.size) : undefined;
|
|
3163
|
+
return await client.finalizeArtifact({
|
|
3164
|
+
storagePath,
|
|
3165
|
+
sha256,
|
|
3166
|
+
size,
|
|
3167
|
+
title,
|
|
2772
3168
|
cardId,
|
|
2773
3169
|
planId,
|
|
2774
3170
|
workspaceId,
|
|
2775
|
-
data,
|
|
2776
3171
|
});
|
|
2777
|
-
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
case "harmony_request_card_attachment_upload_url": {
|
|
3175
|
+
const cardId = z.string().uuid().parse(args.cardId);
|
|
3176
|
+
const fileName = z.string().parse(args.fileName);
|
|
3177
|
+
const fileType =
|
|
3178
|
+
args.fileType != null ? z.string().parse(args.fileType) : undefined;
|
|
3179
|
+
const size = z.number().positive().parse(args.size);
|
|
3180
|
+
if (size > MAX_ATTACHMENT_SIZE) {
|
|
3181
|
+
throw new Error(
|
|
3182
|
+
`Declared size ${size} bytes is over the ${MAX_ATTACHMENT_SIZE}-byte (5MB) attachment limit.`,
|
|
3183
|
+
);
|
|
3184
|
+
}
|
|
3185
|
+
return await client.requestCardAttachmentUploadUrl(cardId, {
|
|
3186
|
+
fileName,
|
|
3187
|
+
fileType,
|
|
3188
|
+
size,
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
case "harmony_finalize_card_attachment": {
|
|
3193
|
+
const cardId = z.string().uuid().parse(args.cardId);
|
|
3194
|
+
const storagePath = z.string().parse(args.storagePath);
|
|
3195
|
+
const fileName = z.string().parse(args.fileName);
|
|
3196
|
+
const fileType =
|
|
3197
|
+
args.fileType != null ? z.string().parse(args.fileType) : undefined;
|
|
3198
|
+
const sha256 =
|
|
3199
|
+
args.sha256 != null ? z.string().parse(args.sha256) : undefined;
|
|
3200
|
+
const size =
|
|
3201
|
+
args.size != null ? z.number().positive().parse(args.size) : undefined;
|
|
3202
|
+
return await client.finalizeCardAttachment(cardId, {
|
|
3203
|
+
storagePath,
|
|
3204
|
+
fileName,
|
|
3205
|
+
fileType,
|
|
3206
|
+
sha256,
|
|
3207
|
+
size,
|
|
3208
|
+
});
|
|
2778
3209
|
}
|
|
2779
3210
|
|
|
2780
3211
|
case "harmony_share_artifact": {
|
|
@@ -3003,13 +3434,15 @@ async function handleToolCall(
|
|
|
3003
3434
|
|
|
3004
3435
|
// Agent context operations
|
|
3005
3436
|
case "harmony_start_agent_session": {
|
|
3006
|
-
const cardId =
|
|
3007
|
-
const agentIdentifier =
|
|
3008
|
-
.
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
const agentName =
|
|
3437
|
+
const cardId = requireUuidArg(args.cardId, "cardId");
|
|
3438
|
+
const agentIdentifier = requireStringArg(
|
|
3439
|
+
args.agentIdentifier,
|
|
3440
|
+
"agentIdentifier",
|
|
3441
|
+
{ max: 100 },
|
|
3442
|
+
);
|
|
3443
|
+
const agentName = requireStringArg(args.agentName, "agentName", {
|
|
3444
|
+
max: 100,
|
|
3445
|
+
});
|
|
3013
3446
|
const moveToColumn = args.moveToColumn as string | undefined;
|
|
3014
3447
|
const addLabels = parseLabelList(args.addLabels);
|
|
3015
3448
|
|
|
@@ -3096,9 +3529,10 @@ async function handleToolCall(
|
|
|
3096
3529
|
agentName,
|
|
3097
3530
|
status: "working",
|
|
3098
3531
|
currentTask: args.currentTask as string | undefined,
|
|
3099
|
-
estimatedMinutesRemaining:
|
|
3100
|
-
|
|
3101
|
-
|
|
3532
|
+
estimatedMinutesRemaining: optionalNonNegativeNumberArg(
|
|
3533
|
+
args.estimatedMinutesRemaining,
|
|
3534
|
+
"estimatedMinutesRemaining",
|
|
3535
|
+
),
|
|
3102
3536
|
steerable:
|
|
3103
3537
|
args.steerable === true || args.steerable === "true"
|
|
3104
3538
|
? true
|
|
@@ -3129,17 +3563,19 @@ async function handleToolCall(
|
|
|
3129
3563
|
}
|
|
3130
3564
|
|
|
3131
3565
|
case "harmony_update_agent_progress": {
|
|
3132
|
-
const cardId =
|
|
3133
|
-
const agentIdentifier =
|
|
3134
|
-
.
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
const agentName =
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3566
|
+
const cardId = requireUuidArg(args.cardId, "cardId");
|
|
3567
|
+
const agentIdentifier = requireStringArg(
|
|
3568
|
+
args.agentIdentifier,
|
|
3569
|
+
"agentIdentifier",
|
|
3570
|
+
{ max: 100 },
|
|
3571
|
+
);
|
|
3572
|
+
const agentName = requireStringArg(args.agentName, "agentName", {
|
|
3573
|
+
max: 100,
|
|
3574
|
+
});
|
|
3575
|
+
const progressPercent = optionalPercentArg(
|
|
3576
|
+
args.progressPercent,
|
|
3577
|
+
"progressPercent",
|
|
3578
|
+
);
|
|
3143
3579
|
// Convert actions parameter to recentActions format and merge with memory actions
|
|
3144
3580
|
const callerActions = args.actions as
|
|
3145
3581
|
| { description: string }[]
|
|
@@ -3182,9 +3618,10 @@ async function handleToolCall(
|
|
|
3182
3618
|
progressPercent,
|
|
3183
3619
|
currentTask: args.currentTask as string | undefined,
|
|
3184
3620
|
blockers: args.blockers as string[] | undefined,
|
|
3185
|
-
estimatedMinutesRemaining:
|
|
3186
|
-
|
|
3187
|
-
|
|
3621
|
+
estimatedMinutesRemaining: optionalNonNegativeNumberArg(
|
|
3622
|
+
args.estimatedMinutesRemaining,
|
|
3623
|
+
"estimatedMinutesRemaining",
|
|
3624
|
+
),
|
|
3188
3625
|
...(mergedRecentActions && { recentActions: mergedRecentActions }),
|
|
3189
3626
|
...(runActivity.length > 0 && { runActivity }),
|
|
3190
3627
|
});
|
|
@@ -3194,14 +3631,14 @@ async function handleToolCall(
|
|
|
3194
3631
|
}
|
|
3195
3632
|
|
|
3196
3633
|
case "harmony_end_agent_session": {
|
|
3197
|
-
const cardId =
|
|
3634
|
+
const cardId = requireUuidArg(args.cardId, "cardId");
|
|
3198
3635
|
const moveToColumn = args.moveToColumn as string | undefined;
|
|
3199
3636
|
const sessionStatus =
|
|
3200
3637
|
(args.status as "completed" | "paused") || "completed";
|
|
3201
|
-
const endProgressPercent =
|
|
3202
|
-
args.progressPercent
|
|
3203
|
-
|
|
3204
|
-
|
|
3638
|
+
const endProgressPercent = optionalPercentArg(
|
|
3639
|
+
args.progressPercent,
|
|
3640
|
+
"progressPercent",
|
|
3641
|
+
);
|
|
3205
3642
|
|
|
3206
3643
|
// Final flush of any pending memory actions before ending the session
|
|
3207
3644
|
await flushMemoryActions(client, cardId);
|