@gethmy/mcp 2.11.2 → 2.13.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/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.
@@ -665,7 +759,7 @@ export const TOOLS = {
665
759
  },
666
760
  harmony_upload_card_attachment: {
667
761
  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.",
762
+ "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
763
  inputSchema: {
670
764
  type: "object",
671
765
  properties: {
@@ -694,6 +788,65 @@ export const TOOLS = {
694
788
  required: ["cardId"],
695
789
  },
696
790
  },
791
+ harmony_request_card_attachment_upload_url: {
792
+ description:
793
+ "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.",
794
+ inputSchema: {
795
+ type: "object",
796
+ properties: {
797
+ cardId: { type: "string", description: "Card UUID" },
798
+ fileName: {
799
+ type: "string",
800
+ description: "File name including extension (e.g. 'screenshot.png').",
801
+ },
802
+ fileType: {
803
+ type: "string",
804
+ description:
805
+ "Optional MIME type (e.g. 'image/png'). Inferred from the file extension when omitted.",
806
+ },
807
+ size: {
808
+ type: "number",
809
+ description: "File size in bytes (rejected early if over 5MB).",
810
+ },
811
+ },
812
+ required: ["cardId", "fileName", "size"],
813
+ },
814
+ },
815
+ harmony_finalize_card_attachment: {
816
+ description:
817
+ "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.",
818
+ inputSchema: {
819
+ type: "object",
820
+ properties: {
821
+ cardId: { type: "string", description: "Card UUID" },
822
+ storagePath: {
823
+ type: "string",
824
+ description:
825
+ "The storagePath returned by harmony_request_card_attachment_upload_url.",
826
+ },
827
+ fileName: {
828
+ type: "string",
829
+ description: "File name including extension (e.g. 'screenshot.png').",
830
+ },
831
+ fileType: {
832
+ type: "string",
833
+ description:
834
+ "Optional MIME type; inferred from the extension when omitted.",
835
+ },
836
+ sha256: {
837
+ type: "string",
838
+ description:
839
+ "Optional hex SHA-256 of the uploaded bytes; verified against the stored object.",
840
+ },
841
+ size: {
842
+ type: "number",
843
+ description:
844
+ "Optional byte size (advisory; re-validated server-side).",
845
+ },
846
+ },
847
+ required: ["cardId", "storagePath", "fileName"],
848
+ },
849
+ },
697
850
  harmony_classify_card: {
698
851
  description:
699
852
  "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`.",
@@ -705,6 +858,116 @@ export const TOOLS = {
705
858
  required: ["cardId"],
706
859
  },
707
860
  },
861
+ harmony_upload_artifact: {
862
+ description:
863
+ "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.",
864
+ inputSchema: {
865
+ type: "object",
866
+ properties: {
867
+ title: {
868
+ type: "string",
869
+ description: "Display title (defaults to the file basename).",
870
+ },
871
+ cardId: { type: "string", description: "Link to this card (UUID)." },
872
+ planId: { type: "string", description: "Link to this plan (UUID)." },
873
+ workspaceId: {
874
+ type: "string",
875
+ description:
876
+ "Attach to this workspace as a standalone artifact (UUID).",
877
+ },
878
+ filePath: {
879
+ type: "string",
880
+ description:
881
+ "Absolute path to a local .html file the MCP server process can read. Mutually exclusive with base64Data.",
882
+ },
883
+ base64Data: {
884
+ type: "string",
885
+ description:
886
+ "Base64-encoded HTML bytes (a `data:` URL prefix is accepted and stripped). Mutually exclusive with filePath.",
887
+ },
888
+ },
889
+ required: [],
890
+ },
891
+ },
892
+ harmony_request_artifact_upload_url: {
893
+ description:
894
+ "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.",
895
+ inputSchema: {
896
+ type: "object",
897
+ properties: {
898
+ title: {
899
+ type: "string",
900
+ description:
901
+ "Display title (defaults to the file basename at finalize).",
902
+ },
903
+ cardId: { type: "string", description: "Link to this card (UUID)." },
904
+ planId: { type: "string", description: "Link to this plan (UUID)." },
905
+ workspaceId: {
906
+ type: "string",
907
+ description:
908
+ "Attach to this workspace as a standalone artifact (UUID).",
909
+ },
910
+ contentType: {
911
+ type: "string",
912
+ description: "MIME type; only 'text/html' is accepted (the default).",
913
+ },
914
+ size: {
915
+ type: "number",
916
+ description: "File size in bytes (rejected early if over 2MB).",
917
+ },
918
+ },
919
+ required: [],
920
+ },
921
+ },
922
+ harmony_finalize_artifact: {
923
+ description:
924
+ "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.",
925
+ inputSchema: {
926
+ type: "object",
927
+ properties: {
928
+ storagePath: {
929
+ type: "string",
930
+ description:
931
+ "The storagePath returned by harmony_request_artifact_upload_url.",
932
+ },
933
+ title: { type: "string", description: "Display title." },
934
+ cardId: { type: "string", description: "Link to this card (UUID)." },
935
+ planId: { type: "string", description: "Link to this plan (UUID)." },
936
+ workspaceId: {
937
+ type: "string",
938
+ description:
939
+ "Attach to this workspace as a standalone artifact (UUID).",
940
+ },
941
+ sha256: {
942
+ type: "string",
943
+ description:
944
+ "Optional hex SHA-256 of the uploaded bytes; verified against the stored object.",
945
+ },
946
+ size: {
947
+ type: "number",
948
+ description:
949
+ "Optional byte size (advisory; re-validated server-side).",
950
+ },
951
+ },
952
+ required: ["storagePath"],
953
+ },
954
+ },
955
+ harmony_share_artifact: {
956
+ description:
957
+ "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.",
958
+ inputSchema: {
959
+ type: "object",
960
+ properties: {
961
+ artifactId: { type: "string", description: "Artifact UUID" },
962
+ expiresInDays: {
963
+ type: "number",
964
+ description:
965
+ "Optional expiry in days. Omit for a link that never expires.",
966
+ },
967
+ },
968
+ required: ["artifactId"],
969
+ },
970
+ },
708
971
  harmony_get_card_external_links: {
709
972
  description:
710
973
  "Get external URL references attached to a card (links to docs, gists, dashboards, etc.).",
@@ -1754,6 +2017,126 @@ export const TOOLS = {
1754
2017
  },
1755
2018
  },
1756
2019
 
2020
+ // ============ PLAYBOOK TOOLS (Method/Loop layer) ============
2021
+
2022
+ harmony_list_playbook: {
2023
+ description:
2024
+ "List a workspace's playbooks (reusable process definitions). Returns each playbook's name, version, steps_version, and state. Read-only.",
2025
+ inputSchema: {
2026
+ type: "object",
2027
+ properties: {
2028
+ workspaceId: {
2029
+ type: "string",
2030
+ description: "Workspace ID (optional if context set)",
2031
+ },
2032
+ },
2033
+ required: [],
2034
+ },
2035
+ },
2036
+
2037
+ harmony_get_playbook: {
2038
+ description:
2039
+ "Get one playbook by ID, including its steps/stages definition and its recent runs. Read-only.",
2040
+ inputSchema: {
2041
+ type: "object",
2042
+ properties: {
2043
+ playbookId: { type: "string", description: "Playbook ID (UUID)" },
2044
+ },
2045
+ required: ["playbookId"],
2046
+ },
2047
+ },
2048
+
2049
+ harmony_run_playbook: {
2050
+ description:
2051
+ "Run a playbook server-side and return the finalized run. Only legacy automation playbooks (steps_version 1) are runnable; stage playbooks (steps_version 2) are rejected. The server drives every step to completion.",
2052
+ inputSchema: {
2053
+ type: "object",
2054
+ properties: {
2055
+ playbookId: {
2056
+ type: "string",
2057
+ description: "Playbook ID to run (UUID)",
2058
+ },
2059
+ },
2060
+ required: ["playbookId"],
2061
+ },
2062
+ },
2063
+
2064
+ harmony_create_playbook: {
2065
+ description:
2066
+ "Create a new playbook in a workspace. Default steps_version 1 is a legacy automation macro (an array of tool steps); steps_version 2 is the Method stage model (an array of stage objects).",
2067
+ inputSchema: {
2068
+ type: "object",
2069
+ properties: {
2070
+ workspaceId: {
2071
+ type: "string",
2072
+ description: "Workspace ID (optional if context set)",
2073
+ },
2074
+ name: { type: "string", description: "Playbook name" },
2075
+ description: { type: "string", description: "Playbook description" },
2076
+ stepsVersion: {
2077
+ type: "number",
2078
+ enum: [1, 2],
2079
+ description:
2080
+ "1 = automation macro (tool steps), 2 = Method stage model (stage objects). Default 1.",
2081
+ },
2082
+ steps: {
2083
+ type: "array",
2084
+ description:
2085
+ "Steps (steps_version 1: tool-step objects) or stages (steps_version 2: stage objects).",
2086
+ items: { type: "object" },
2087
+ },
2088
+ },
2089
+ required: ["name"],
2090
+ },
2091
+ },
2092
+
2093
+ harmony_update_playbook: {
2094
+ description:
2095
+ "Update a playbook's name, description, steps/stages, enabled flag, or lifecycle state ('active'|'deprecated').",
2096
+ inputSchema: {
2097
+ type: "object",
2098
+ properties: {
2099
+ playbookId: {
2100
+ type: "string",
2101
+ description: "Playbook ID to update (UUID)",
2102
+ },
2103
+ name: { type: "string", description: "New name" },
2104
+ description: { type: "string", description: "New description" },
2105
+ steps: {
2106
+ type: "array",
2107
+ description: "New steps (v1) or stages (v2) array.",
2108
+ items: { type: "object" },
2109
+ },
2110
+ enabled: {
2111
+ type: "boolean",
2112
+ description: "Enable/disable the playbook",
2113
+ },
2114
+ state: {
2115
+ type: "string",
2116
+ enum: ["active", "deprecated"],
2117
+ description: "Lifecycle state",
2118
+ },
2119
+ },
2120
+ required: ["playbookId"],
2121
+ },
2122
+ },
2123
+
2124
+ harmony_save_card_as_playbook: {
2125
+ description:
2126
+ "Save an existing card as a new steps_version 1 (automation) playbook, seeding one create-card step from the card. Returns the created playbook.",
2127
+ inputSchema: {
2128
+ type: "object",
2129
+ properties: {
2130
+ cardId: { type: "string", description: "Card ID to template (UUID)" },
2131
+ name: {
2132
+ type: "string",
2133
+ description: "Name for the new playbook (defaults to the card title)",
2134
+ },
2135
+ },
2136
+ required: ["cardId"],
2137
+ },
2138
+ },
2139
+
1757
2140
  // ============ ONBOARDING TOOLS ============
1758
2141
  harmony_signup: {
1759
2142
  description:
@@ -2523,7 +2906,7 @@ async function handleToolCall(
2523
2906
  args.filePath != null ? z.string().parse(args.filePath) : undefined;
2524
2907
  const base64Data =
2525
2908
  args.base64Data != null ? z.string().parse(args.base64Data) : undefined;
2526
- let fileName =
2909
+ const fileName =
2527
2910
  args.fileName != null ? z.string().parse(args.fileName) : undefined;
2528
2911
  const contentType =
2529
2912
  args.contentType != null
@@ -2534,28 +2917,232 @@ async function handleToolCall(
2534
2917
  throw new Error("Provide either filePath or base64Data, not both.");
2535
2918
  }
2536
2919
 
2537
- let data: string;
2538
2920
  if (filePath) {
2539
- const bytes = await readFile(filePath);
2540
- if (bytes.byteLength === 0) {
2541
- throw new Error(`File is empty: ${filePath}`);
2542
- }
2543
- data = bytes.toString("base64");
2544
- fileName = fileName || basename(filePath);
2545
- } else if (base64Data) {
2921
+ // Server can read the file → upload direct-to-storage via the handshake
2922
+ // (no base64 through the model context or the edge-fn JSON body).
2923
+ const bytes = await readFileForUpload(
2924
+ filePath,
2925
+ MAX_ATTACHMENT_SIZE,
2926
+ "attachment",
2927
+ );
2928
+ const resolvedName = fileName || basename(filePath);
2929
+ const signed = await client.requestCardAttachmentUploadUrl(cardId, {
2930
+ fileName: resolvedName,
2931
+ fileType: contentType,
2932
+ size: bytes.byteLength,
2933
+ });
2934
+ await putToSignedUrl(
2935
+ signed.uploadUrl,
2936
+ bytes,
2937
+ contentType || signed.fileType || "application/octet-stream",
2938
+ );
2939
+ return await client.finalizeCardAttachment(cardId, {
2940
+ storagePath: signed.storagePath,
2941
+ fileName: resolvedName,
2942
+ fileType: contentType || signed.fileType,
2943
+ sha256: sha256Hex(bytes),
2944
+ size: bytes.byteLength,
2945
+ });
2946
+ }
2947
+
2948
+ if (base64Data) {
2546
2949
  if (!fileName) {
2547
2950
  throw new Error("fileName is required when using base64Data.");
2548
2951
  }
2549
- data = base64Data;
2550
- } else {
2551
- throw new Error("Provide either filePath or base64Data.");
2952
+ if (base64ByteLength(base64Data) > MAX_ATTACHMENT_SIZE) {
2953
+ throw new Error(
2954
+ `File is over the 5MB attachment limit. Use the harmony_request_card_attachment_upload_url + harmony_finalize_card_attachment handshake for large files.`,
2955
+ );
2956
+ }
2957
+ return await client.uploadCardAttachment(cardId, {
2958
+ fileName,
2959
+ data: base64Data,
2960
+ fileType: contentType,
2961
+ });
2962
+ }
2963
+
2964
+ throw new Error("Provide either filePath or base64Data.");
2965
+ }
2966
+
2967
+ case "harmony_upload_artifact": {
2968
+ const title =
2969
+ args.title != null ? z.string().parse(args.title) : undefined;
2970
+ const cardId =
2971
+ args.cardId != null ? z.string().uuid().parse(args.cardId) : undefined;
2972
+ const planId =
2973
+ args.planId != null ? z.string().uuid().parse(args.planId) : undefined;
2974
+ const workspaceId =
2975
+ args.workspaceId != null
2976
+ ? z.string().uuid().parse(args.workspaceId)
2977
+ : undefined;
2978
+ requireExactlyOneScope({ cardId, planId, workspaceId });
2979
+
2980
+ const filePath =
2981
+ args.filePath != null ? z.string().parse(args.filePath) : undefined;
2982
+ const base64Data =
2983
+ args.base64Data != null ? z.string().parse(args.base64Data) : undefined;
2984
+ if (filePath && base64Data) {
2985
+ throw new Error("Provide either filePath or base64Data, not both.");
2552
2986
  }
2553
2987
 
2554
- const result = await client.uploadCardAttachment(cardId, {
2555
- fileName: fileName as string,
2556
- data,
2557
- fileType: contentType,
2988
+ if (filePath) {
2989
+ // Server can read the file → upload direct-to-storage via the handshake
2990
+ // (no base64 through the model context or the edge-fn JSON body).
2991
+ const bytes = await readFileForUpload(
2992
+ filePath,
2993
+ MAX_ARTIFACT_SIZE,
2994
+ "artifact",
2995
+ );
2996
+ const resolvedTitle = title || basename(filePath);
2997
+ const signed = await client.requestArtifactUploadUrl({
2998
+ title: resolvedTitle,
2999
+ cardId,
3000
+ planId,
3001
+ workspaceId,
3002
+ contentType: "text/html",
3003
+ size: bytes.byteLength,
3004
+ });
3005
+ await putToSignedUrl(signed.uploadUrl, bytes, "text/html");
3006
+ return await client.finalizeArtifact({
3007
+ storagePath: signed.storagePath,
3008
+ sha256: sha256Hex(bytes),
3009
+ size: bytes.byteLength,
3010
+ title: resolvedTitle,
3011
+ cardId,
3012
+ planId,
3013
+ workspaceId,
3014
+ });
3015
+ }
3016
+
3017
+ if (base64Data) {
3018
+ if (base64ByteLength(base64Data) > MAX_ARTIFACT_SIZE) {
3019
+ throw new Error(
3020
+ `Artifact is over the 2MB limit. Use the harmony_request_artifact_upload_url + harmony_finalize_artifact handshake for large files.`,
3021
+ );
3022
+ }
3023
+ return await client.uploadArtifact({
3024
+ title,
3025
+ cardId,
3026
+ planId,
3027
+ workspaceId,
3028
+ data: base64Data,
3029
+ });
3030
+ }
3031
+
3032
+ throw new Error("Provide either filePath or base64Data.");
3033
+ }
3034
+
3035
+ case "harmony_request_artifact_upload_url": {
3036
+ const title =
3037
+ args.title != null ? z.string().parse(args.title) : undefined;
3038
+ const cardId =
3039
+ args.cardId != null ? z.string().uuid().parse(args.cardId) : undefined;
3040
+ const planId =
3041
+ args.planId != null ? z.string().uuid().parse(args.planId) : undefined;
3042
+ const workspaceId =
3043
+ args.workspaceId != null
3044
+ ? z.string().uuid().parse(args.workspaceId)
3045
+ : undefined;
3046
+ requireExactlyOneScope({ cardId, planId, workspaceId });
3047
+ const contentType =
3048
+ args.contentType != null
3049
+ ? z.string().parse(args.contentType)
3050
+ : undefined;
3051
+ const size =
3052
+ args.size != null ? z.number().positive().parse(args.size) : undefined;
3053
+ if (size != null && size > MAX_ARTIFACT_SIZE) {
3054
+ throw new Error(
3055
+ `Declared size ${size} bytes is over the ${MAX_ARTIFACT_SIZE}-byte (2MB) artifact limit.`,
3056
+ );
3057
+ }
3058
+ return await client.requestArtifactUploadUrl({
3059
+ title,
3060
+ cardId,
3061
+ planId,
3062
+ workspaceId,
3063
+ contentType,
3064
+ size,
3065
+ });
3066
+ }
3067
+
3068
+ case "harmony_finalize_artifact": {
3069
+ const storagePath = z.string().parse(args.storagePath);
3070
+ const title =
3071
+ args.title != null ? z.string().parse(args.title) : undefined;
3072
+ const cardId =
3073
+ args.cardId != null ? z.string().uuid().parse(args.cardId) : undefined;
3074
+ const planId =
3075
+ args.planId != null ? z.string().uuid().parse(args.planId) : undefined;
3076
+ const workspaceId =
3077
+ args.workspaceId != null
3078
+ ? z.string().uuid().parse(args.workspaceId)
3079
+ : undefined;
3080
+ requireExactlyOneScope({ cardId, planId, workspaceId });
3081
+ const sha256 =
3082
+ args.sha256 != null ? z.string().parse(args.sha256) : undefined;
3083
+ const size =
3084
+ args.size != null ? z.number().positive().parse(args.size) : undefined;
3085
+ return await client.finalizeArtifact({
3086
+ storagePath,
3087
+ sha256,
3088
+ size,
3089
+ title,
3090
+ cardId,
3091
+ planId,
3092
+ workspaceId,
3093
+ });
3094
+ }
3095
+
3096
+ case "harmony_request_card_attachment_upload_url": {
3097
+ const cardId = z.string().uuid().parse(args.cardId);
3098
+ const fileName = z.string().parse(args.fileName);
3099
+ const fileType =
3100
+ args.fileType != null ? z.string().parse(args.fileType) : undefined;
3101
+ const size = z.number().positive().parse(args.size);
3102
+ if (size > MAX_ATTACHMENT_SIZE) {
3103
+ throw new Error(
3104
+ `Declared size ${size} bytes is over the ${MAX_ATTACHMENT_SIZE}-byte (5MB) attachment limit.`,
3105
+ );
3106
+ }
3107
+ return await client.requestCardAttachmentUploadUrl(cardId, {
3108
+ fileName,
3109
+ fileType,
3110
+ size,
2558
3111
  });
3112
+ }
3113
+
3114
+ case "harmony_finalize_card_attachment": {
3115
+ const cardId = z.string().uuid().parse(args.cardId);
3116
+ const storagePath = z.string().parse(args.storagePath);
3117
+ const fileName = z.string().parse(args.fileName);
3118
+ const fileType =
3119
+ args.fileType != null ? z.string().parse(args.fileType) : undefined;
3120
+ const sha256 =
3121
+ args.sha256 != null ? z.string().parse(args.sha256) : undefined;
3122
+ const size =
3123
+ args.size != null ? z.number().positive().parse(args.size) : undefined;
3124
+ return await client.finalizeCardAttachment(cardId, {
3125
+ storagePath,
3126
+ fileName,
3127
+ fileType,
3128
+ sha256,
3129
+ size,
3130
+ });
3131
+ }
3132
+
3133
+ case "harmony_share_artifact": {
3134
+ const artifactId = z.string().uuid().parse(args.artifactId);
3135
+ const expiresInDays =
3136
+ args.expiresInDays != null
3137
+ ? z.number().positive().parse(args.expiresInDays)
3138
+ : undefined;
3139
+ const expiresAt = expiresInDays
3140
+ ? new Date(Date.now() + expiresInDays * 86_400_000).toISOString()
3141
+ : undefined;
3142
+ const result = await client.createArtifactShareLink(
3143
+ artifactId,
3144
+ expiresAt,
3145
+ );
2559
3146
  return result;
2560
3147
  }
2561
3148
 
@@ -4028,6 +4615,68 @@ async function handleToolCall(
4028
4615
  };
4029
4616
  }
4030
4617
 
4618
+ // ============ PLAYBOOK TOOLS (Method/Loop layer) ============
4619
+
4620
+ case "harmony_list_playbook": {
4621
+ const workspaceId = (args.workspaceId as string) || getWorkspaceId();
4622
+ const result = await client.listPlaybooks(workspaceId);
4623
+ return {
4624
+ success: true,
4625
+ playbooks: result.playbooks,
4626
+ count: (result.playbooks as unknown[]).length,
4627
+ };
4628
+ }
4629
+
4630
+ case "harmony_get_playbook": {
4631
+ const playbookId = z.string().uuid().parse(args.playbookId);
4632
+ const result = await client.getPlaybook(playbookId);
4633
+ return { success: true, playbook: result.playbook, runs: result.runs };
4634
+ }
4635
+
4636
+ case "harmony_run_playbook": {
4637
+ const playbookId = z.string().uuid().parse(args.playbookId);
4638
+ const result = await client.runPlaybook(playbookId);
4639
+ return { success: true, run: result.run };
4640
+ }
4641
+
4642
+ case "harmony_create_playbook": {
4643
+ const workspaceId = (args.workspaceId as string) || getWorkspaceId();
4644
+ const name = z.string().min(1).max(200).parse(args.name);
4645
+ const stepsVersion =
4646
+ args.stepsVersion !== undefined
4647
+ ? z.union([z.literal(1), z.literal(2)]).parse(args.stepsVersion)
4648
+ : undefined;
4649
+ const result = await client.createPlaybook({
4650
+ workspaceId,
4651
+ name,
4652
+ description: args.description as string | undefined,
4653
+ steps: args.steps,
4654
+ stepsVersion,
4655
+ });
4656
+ return { success: true, playbook: result.playbook };
4657
+ }
4658
+
4659
+ case "harmony_update_playbook": {
4660
+ const playbookId = z.string().uuid().parse(args.playbookId);
4661
+ const result = await client.updatePlaybook(playbookId, {
4662
+ name: args.name as string | undefined,
4663
+ description: args.description as string | undefined,
4664
+ steps: args.steps,
4665
+ enabled: args.enabled as boolean | undefined,
4666
+ state: args.state as string | undefined,
4667
+ });
4668
+ return { success: true, playbook: result.playbook };
4669
+ }
4670
+
4671
+ case "harmony_save_card_as_playbook": {
4672
+ const cardId = z.string().uuid().parse(args.cardId);
4673
+ const result = await client.savePlaybookFromCard({
4674
+ cardId,
4675
+ name: args.name as string | undefined,
4676
+ });
4677
+ return { success: true, playbook: result.playbook };
4678
+ }
4679
+
4031
4680
  // ============ ONBOARDING TOOLS ============
4032
4681
  case "harmony_signup": {
4033
4682
  const email = z.string().email().max(254).parse(args.email);