@hauptsache.net/clickup-mcp 1.6.2 → 1.7.2

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.
@@ -0,0 +1,112 @@
1
+ import { Buffer } from "buffer";
2
+ /**
3
+ * Attachment object as returned by POST /api/v2/task/{task_id}/attachment.
4
+ * ClickUp only renders an image inside a comment when the fragment carries this
5
+ * whole object - a bare URL string renders as an empty placeholder tile.
6
+ */
7
+ export interface ClickUpUploadedAttachment {
8
+ id: string;
9
+ name: string;
10
+ title?: string;
11
+ extension?: string;
12
+ url: string;
13
+ thumbnail_small?: string;
14
+ thumbnail_medium?: string;
15
+ thumbnail_large?: string;
16
+ width?: number;
17
+ height?: number;
18
+ [key: string]: any;
19
+ }
20
+ /** Image bytes ready to be uploaded */
21
+ interface ResolvedBytes {
22
+ kind: "bytes";
23
+ bytes: Buffer;
24
+ mimeType: string;
25
+ suggestedName: string;
26
+ }
27
+ /** Already an attachment on ClickUp's CDN - reuse it instead of uploading again */
28
+ interface ResolvedExisting {
29
+ kind: "existing";
30
+ url: string;
31
+ }
32
+ export type ResolvedImageSource = ResolvedBytes | ResolvedExisting;
33
+ /**
34
+ * ClickUp serves attachments from *.clickup-attachments.com. Such a URL is
35
+ * already uploaded, so it can be embedded directly.
36
+ */
37
+ export declare function isClickUpAttachmentUrl(url: string): boolean;
38
+ /**
39
+ * Turn the `src` of a markdown image into something uploadable.
40
+ *
41
+ * Supported sources, in this order:
42
+ * - a ClickUp attachment URL -> reused as-is, no upload
43
+ * - a base64 data URI -> decoded
44
+ * - any other http(s) URL -> downloaded
45
+ * - anything else -> read from the local filesystem
46
+ *
47
+ * The local path case is the interesting one: this server runs next to the agent,
48
+ * so a screenshot can be referenced by path instead of being inlined as base64,
49
+ * which would otherwise cost a multiple of the file size in tokens.
50
+ */
51
+ export declare function resolveImageSource(src: string, baseDir?: string): Promise<ResolvedImageSource>;
52
+ /**
53
+ * Derive the upload filename from the markdown alt text.
54
+ *
55
+ * ClickUp shows the *attachment filename* underneath an image, not the fragment
56
+ * text - so naming the upload after the caption is what makes a readable caption
57
+ * appear in the ticket.
58
+ */
59
+ export declare function captionToFilename(caption: string, fallbackName: string): string;
60
+ /**
61
+ * Upload a single image to a task and return the full attachment object.
62
+ */
63
+ export declare function uploadTaskAttachment(taskId: string, filename: string, bytes: Buffer, mimeType: string): Promise<ClickUpUploadedAttachment>;
64
+ /** A markdown image whose source resolved to uploadable bytes or an existing attachment */
65
+ export interface ResolvedMarkdownImage {
66
+ /** The original `src` as written in the markdown */
67
+ src: string;
68
+ alt: string;
69
+ resolved: ResolvedImageSource;
70
+ }
71
+ /** One markdown image reference that could not be used, and why */
72
+ export interface ImageFailure {
73
+ src: string;
74
+ error: string;
75
+ }
76
+ /**
77
+ * Phase 1 of attaching images: resolve every source without writing anything.
78
+ *
79
+ * Reads local files, downloads http(s) URLs, decodes data URIs and validates
80
+ * magic bytes and size. Failures are collected instead of thrown so the caller
81
+ * can report every broken reference at once - and abort before anything is
82
+ * posted to ClickUp. Identical sources are resolved once.
83
+ */
84
+ export declare function resolveMarkdownImages(images: {
85
+ src: string;
86
+ alt: string;
87
+ }[], baseDir?: string): Promise<{
88
+ resolved: ResolvedMarkdownImage[];
89
+ failures: ImageFailure[];
90
+ }>;
91
+ /** A successfully uploaded (or reused) attachment for one markdown source */
92
+ export interface UploadedMarkdownImage {
93
+ src: string;
94
+ attachment: ClickUpUploadedAttachment;
95
+ }
96
+ /**
97
+ * Phase 2: upload the resolved images to the task.
98
+ *
99
+ * Uploads run sequentially: a typical comment has a handful of screenshots, and
100
+ * N uploads plus one write call stays well inside ClickUp's 100 calls/minute.
101
+ * Stops at the first upload error - an API failure is unlikely to heal mid-batch,
102
+ * and everything uploaded so far is returned so the caller can tell a retry to
103
+ * reference those CDN URLs directly instead of uploading again.
104
+ */
105
+ export declare function uploadResolvedImages(taskId: string, images: ResolvedMarkdownImage[]): Promise<{
106
+ uploaded: UploadedMarkdownImage[];
107
+ failure: ImageFailure | null;
108
+ }>;
109
+ /** Map from markdown `src` to the attachment that should be embedded for it. */
110
+ export declare function toAttachmentMap(uploaded: UploadedMarkdownImage[]): Map<string, ClickUpUploadedAttachment>;
111
+ export {};
112
+ //# sourceMappingURL=attachments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../../src/shared/attachments.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAQhC;;;;GAIG;AACH,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,uCAAuC;AACvC,UAAU,aAAa;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,mFAAmF;AACnF,UAAU,gBAAgB;IACxB,IAAI,EAAE,UAAU,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,gBAAgB,CAAC;AAoCnE;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAM3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,MAAsB,GAC9B,OAAO,CAAC,mBAAmB,CAAC,CA2C9B;AAcD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAS/E;AAmCD;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,yBAAyB,CAAC,CAwBpC;AAED,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACpC,oDAAoD;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,mBAAmB,CAAC;CAC/B;AAED,mEAAmE;AACnE,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EAAE,EACtC,OAAO,GAAE,MAAsB,GAC9B,OAAO,CAAC;IAAE,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAAC,QAAQ,EAAE,YAAY,EAAE,CAAA;CAAE,CAAC,CAqB1E;AAED,6EAA6E;AAC7E,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,yBAAyB,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,qBAAqB,EAAE,GAC9B,OAAO,CAAC;IAAE,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAAA;CAAE,CAAC,CAwC9E;AAED,gFAAgF;AAChF,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,qBAAqB,EAAE,GAChC,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAMxC"}
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isClickUpAttachmentUrl = isClickUpAttachmentUrl;
4
+ exports.resolveImageSource = resolveImageSource;
5
+ exports.captionToFilename = captionToFilename;
6
+ exports.uploadTaskAttachment = uploadTaskAttachment;
7
+ exports.resolveMarkdownImages = resolveMarkdownImages;
8
+ exports.uploadResolvedImages = uploadResolvedImages;
9
+ exports.toAttachmentMap = toAttachmentMap;
10
+ const buffer_1 = require("buffer");
11
+ const crypto_1 = require("crypto");
12
+ const promises_1 = require("fs/promises");
13
+ const path_1 = require("path");
14
+ const config_1 = require("./config");
15
+ const data_uri_1 = require("./data-uri");
16
+ const image_processing_1 = require("./image-processing");
17
+ const MIME_EXTENSIONS = {
18
+ "image/png": ".png",
19
+ "image/jpeg": ".jpg",
20
+ "image/gif": ".gif",
21
+ "image/webp": ".webp",
22
+ };
23
+ /**
24
+ * Detect the mime type from magic bytes and reject anything that is not an image
25
+ * we know how to display. This is both a correctness guard (ClickUp needs a real
26
+ * image to render a preview) and a safety guard: it stops arbitrary local files
27
+ * from being pushed into a ticket just because a path was mentioned in markdown.
28
+ */
29
+ function assertSupportedImage(bytes, source) {
30
+ // Copy into a fresh view - Buffer instances share a pooled ArrayBuffer, so
31
+ // passing bytes.buffer directly would hand over unrelated neighbouring data.
32
+ const detected = (0, image_processing_1.detectMimeTypeFromBuffer)(new Uint8Array(bytes).buffer);
33
+ if (!detected) {
34
+ throw new Error(`${source} is not a supported image (expected PNG, JPEG, GIF or WebP based on its content)`);
35
+ }
36
+ return detected;
37
+ }
38
+ function assertWithinSizeLimit(byteLength, source) {
39
+ const limit = config_1.CONFIG.maxUploadSizeMB * 1024 * 1024;
40
+ if (byteLength > limit) {
41
+ throw new Error(`${source} is ${(byteLength / 1024 / 1024).toFixed(1)} MB which exceeds the ${config_1.CONFIG.maxUploadSizeMB} MB upload limit (raise MAX_UPLOAD_SIZE_MB to allow it)`);
42
+ }
43
+ }
44
+ /**
45
+ * ClickUp serves attachments from *.clickup-attachments.com. Such a URL is
46
+ * already uploaded, so it can be embedded directly.
47
+ */
48
+ function isClickUpAttachmentUrl(url) {
49
+ try {
50
+ return new URL(url).hostname.endsWith(".clickup-attachments.com");
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Turn the `src` of a markdown image into something uploadable.
58
+ *
59
+ * Supported sources, in this order:
60
+ * - a ClickUp attachment URL -> reused as-is, no upload
61
+ * - a base64 data URI -> decoded
62
+ * - any other http(s) URL -> downloaded
63
+ * - anything else -> read from the local filesystem
64
+ *
65
+ * The local path case is the interesting one: this server runs next to the agent,
66
+ * so a screenshot can be referenced by path instead of being inlined as base64,
67
+ * which would otherwise cost a multiple of the file size in tokens.
68
+ */
69
+ async function resolveImageSource(src, baseDir = process.cwd()) {
70
+ if (isClickUpAttachmentUrl(src)) {
71
+ return { kind: "existing", url: src };
72
+ }
73
+ const dataUri = (0, data_uri_1.parseDataUri)(src);
74
+ if (dataUri) {
75
+ const bytes = buffer_1.Buffer.from(dataUri.base64Data, "base64");
76
+ assertWithinSizeLimit(bytes.byteLength, "The inline image");
77
+ const mimeType = assertSupportedImage(bytes, "The inline image");
78
+ return {
79
+ kind: "bytes",
80
+ bytes,
81
+ mimeType,
82
+ suggestedName: `image${MIME_EXTENSIONS[mimeType] ?? ".png"}`,
83
+ };
84
+ }
85
+ if (/^https?:\/\//i.test(src)) {
86
+ const response = await fetch(src);
87
+ if (!response.ok) {
88
+ throw new Error(`Could not download ${src}: ${response.status} ${response.statusText}`);
89
+ }
90
+ const bytes = buffer_1.Buffer.from(await response.arrayBuffer());
91
+ assertWithinSizeLimit(bytes.byteLength, src);
92
+ const mimeType = assertSupportedImage(bytes, src);
93
+ const urlName = (0, path_1.basename)(new URL(src).pathname) || `image${MIME_EXTENSIONS[mimeType] ?? ".png"}`;
94
+ return { kind: "bytes", bytes, mimeType, suggestedName: decodeURIComponent(urlName) };
95
+ }
96
+ const filePath = (0, path_1.isAbsolute)(src) ? src : (0, path_1.resolve)(baseDir, decodeFilePath(src));
97
+ let bytes;
98
+ try {
99
+ bytes = await (0, promises_1.readFile)(filePath);
100
+ }
101
+ catch (error) {
102
+ if (error?.code === "ENOENT") {
103
+ throw new Error(`No such file: ${filePath}`);
104
+ }
105
+ throw new Error(`Could not read ${filePath}: ${error?.message || "unknown error"}`);
106
+ }
107
+ assertWithinSizeLimit(bytes.byteLength, filePath);
108
+ const mimeType = assertSupportedImage(bytes, filePath);
109
+ return { kind: "bytes", bytes, mimeType, suggestedName: (0, path_1.basename)(filePath) };
110
+ }
111
+ /**
112
+ * Markdown writers tend to percent-encode spaces in paths (`my%20shot.png`).
113
+ * Decode them, but leave the path alone if it is not valid encoding.
114
+ */
115
+ function decodeFilePath(src) {
116
+ try {
117
+ return decodeURIComponent(src);
118
+ }
119
+ catch {
120
+ return src;
121
+ }
122
+ }
123
+ /**
124
+ * Derive the upload filename from the markdown alt text.
125
+ *
126
+ * ClickUp shows the *attachment filename* underneath an image, not the fragment
127
+ * text - so naming the upload after the caption is what makes a readable caption
128
+ * appear in the ticket.
129
+ */
130
+ function captionToFilename(caption, fallbackName) {
131
+ const extension = (0, path_1.extname)(fallbackName) || ".png";
132
+ const cleaned = caption
133
+ .replace(/[/\\:*?"<>|]/g, " ")
134
+ .replace(/\s+/g, " ")
135
+ .trim()
136
+ .slice(0, 120)
137
+ .trim();
138
+ return cleaned ? `${cleaned}${extension}` : fallbackName;
139
+ }
140
+ /**
141
+ * Build the multipart/form-data body by hand.
142
+ *
143
+ * Deliberately not using `FormData`: the global FormData is only understood by the
144
+ * fetch implementation it ships with, so swapping fetch (as the tests do) silently
145
+ * turns the body into the string "[object FormData]". A hand-built buffer behaves
146
+ * identically everywhere and keeps the wire format assertable.
147
+ */
148
+ function buildMultipartBody(filename, bytes, mimeType) {
149
+ const boundary = `----clickupmcp${(0, crypto_1.randomUUID)().replace(/-/g, "")}`;
150
+ // Quotes and newlines would break out of the header - strip them.
151
+ const safeName = filename.replace(/["\r\n]/g, "");
152
+ const head = buffer_1.Buffer.from(`--${boundary}\r\n` +
153
+ `Content-Disposition: form-data; name="attachment"; filename="${safeName}"\r\n` +
154
+ `Content-Type: ${mimeType}\r\n\r\n`, "utf8");
155
+ const tail = buffer_1.Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
156
+ // Hand over a bare ArrayBuffer: Buffer and Uint8Array both trip up the BodyInit
157
+ // typing here. Copying into a fresh Uint8Array also detaches from Buffer's shared
158
+ // pool, so the ArrayBuffer contains exactly our bytes and nothing else.
159
+ const combined = new Uint8Array(buffer_1.Buffer.concat([head, bytes, tail]));
160
+ return {
161
+ body: combined.buffer,
162
+ contentType: `multipart/form-data; boundary=${boundary}`,
163
+ };
164
+ }
165
+ /**
166
+ * Upload a single image to a task and return the full attachment object.
167
+ */
168
+ async function uploadTaskAttachment(taskId, filename, bytes, mimeType) {
169
+ const { body, contentType } = buildMultipartBody(filename, bytes, mimeType);
170
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/attachment`, {
171
+ method: "POST",
172
+ headers: {
173
+ Authorization: config_1.CONFIG.apiKey,
174
+ "Content-Type": contentType,
175
+ },
176
+ body,
177
+ });
178
+ if (!response.ok) {
179
+ const errorText = await response.text().catch(() => "");
180
+ throw new Error(`Upload of "${filename}" failed: ${response.status} ${response.statusText}${errorText ? ` - ${errorText.slice(0, 300)}` : ""}`);
181
+ }
182
+ const attachment = (await response.json());
183
+ if (!attachment?.url) {
184
+ throw new Error(`Upload of "${filename}" returned no URL: ${JSON.stringify(attachment)}`);
185
+ }
186
+ return attachment;
187
+ }
188
+ /**
189
+ * Phase 1 of attaching images: resolve every source without writing anything.
190
+ *
191
+ * Reads local files, downloads http(s) URLs, decodes data URIs and validates
192
+ * magic bytes and size. Failures are collected instead of thrown so the caller
193
+ * can report every broken reference at once - and abort before anything is
194
+ * posted to ClickUp. Identical sources are resolved once.
195
+ */
196
+ async function resolveMarkdownImages(images, baseDir = process.cwd()) {
197
+ const resolved = [];
198
+ const failures = [];
199
+ const seen = new Set();
200
+ for (const { src, alt } of images) {
201
+ if (seen.has(src)) {
202
+ continue;
203
+ }
204
+ seen.add(src);
205
+ try {
206
+ resolved.push({ src, alt, resolved: await resolveImageSource(src, baseDir) });
207
+ }
208
+ catch (error) {
209
+ const message = error instanceof Error ? error.message : "unknown error";
210
+ console.error(`Cannot use image "${src}": ${message}`);
211
+ failures.push({ src, error: message });
212
+ }
213
+ }
214
+ return { resolved, failures };
215
+ }
216
+ /**
217
+ * Phase 2: upload the resolved images to the task.
218
+ *
219
+ * Uploads run sequentially: a typical comment has a handful of screenshots, and
220
+ * N uploads plus one write call stays well inside ClickUp's 100 calls/minute.
221
+ * Stops at the first upload error - an API failure is unlikely to heal mid-batch,
222
+ * and everything uploaded so far is returned so the caller can tell a retry to
223
+ * reference those CDN URLs directly instead of uploading again.
224
+ */
225
+ async function uploadResolvedImages(taskId, images) {
226
+ const uploaded = [];
227
+ for (const { src, alt, resolved } of images) {
228
+ try {
229
+ if (resolved.kind === "existing") {
230
+ // Already on ClickUp's CDN - synthesise the minimal attachment shape so
231
+ // the fragment builder has something to work with.
232
+ const name = decodeURIComponent((0, path_1.basename)(new URL(resolved.url).pathname));
233
+ uploaded.push({
234
+ src,
235
+ attachment: {
236
+ id: name,
237
+ name,
238
+ title: alt || name,
239
+ extension: (0, path_1.extname)(name).replace(/^\./, "") || undefined,
240
+ url: resolved.url,
241
+ thumbnail_small: resolved.url,
242
+ thumbnail_medium: resolved.url,
243
+ thumbnail_large: resolved.url,
244
+ },
245
+ });
246
+ }
247
+ else {
248
+ const filename = captionToFilename(alt, resolved.suggestedName);
249
+ const attachment = await uploadTaskAttachment(taskId, filename, resolved.bytes, resolved.mimeType);
250
+ uploaded.push({ src, attachment });
251
+ }
252
+ }
253
+ catch (error) {
254
+ const message = error instanceof Error ? error.message : "unknown error";
255
+ console.error(`Failed to attach image "${src}": ${message}`);
256
+ return { uploaded, failure: { src, error: message } };
257
+ }
258
+ }
259
+ return { uploaded, failure: null };
260
+ }
261
+ /** Map from markdown `src` to the attachment that should be embedded for it. */
262
+ function toAttachmentMap(uploaded) {
263
+ const map = new Map();
264
+ for (const { src, attachment } of uploaded) {
265
+ map.set(src, attachment);
266
+ }
267
+ return map;
268
+ }
@@ -5,6 +5,8 @@ export declare const CONFIG: {
5
5
  teamId: string;
6
6
  maxImages: number;
7
7
  maxResponseSizeMB: number;
8
+ maxUploadSizeMB: number;
9
+ commentEditWindowHours: number;
8
10
  primaryLanguageHint: string | undefined;
9
11
  mode: McpMode;
10
12
  };
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;AAUxD,eAAO,MAAM,MAAM;;;;;;;CAOlB,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;AA8CxD,eAAO,MAAM,MAAM;;;;;;;;;CAqBlB,CAAC"}
@@ -48,11 +48,55 @@ if (rawMode === 'read-minimal' || rawMode === 'read') {
48
48
  else if (rawMode && rawMode !== 'write') {
49
49
  console.error(`Invalid CLICKUP_MCP_MODE "${rawMode}". Using default "write". Valid options: read-minimal, read, write`);
50
50
  }
51
+ /**
52
+ * Numeric settings are exposed as optional MCPB user_config fields, so their env
53
+ * values can arrive blank or - if the host does not substitute an unset optional
54
+ * field - as the literal `${user_config.x}` placeholder. Both mean "not configured"
55
+ * and must fall back to the default rather than fail or turn into NaN.
56
+ */
57
+ function readOptionalEnv(name) {
58
+ const raw = process.env[name]?.trim();
59
+ if (!raw || /^\$\{.*}$/.test(raw)) {
60
+ return undefined;
61
+ }
62
+ return raw;
63
+ }
64
+ /**
65
+ * A typo must not silently change a limit: `parseFloat("Infinity")` would disable
66
+ * the comment age check altogether and `parseFloat("abc")` would yield NaN, which
67
+ * compares false against every size and so lifts the upload limit instead of
68
+ * enforcing it. Fail at startup instead.
69
+ */
70
+ function parseNumericEnv(name, fallback, { min, expectation }) {
71
+ const raw = readOptionalEnv(name);
72
+ if (raw === undefined) {
73
+ return fallback;
74
+ }
75
+ const value = Number(raw);
76
+ if (!Number.isFinite(value) || value < min) {
77
+ throw new Error(`Invalid ${name} "${raw}". ${expectation}`);
78
+ }
79
+ return value;
80
+ }
51
81
  exports.CONFIG = {
52
82
  apiKey: process.env.CLICKUP_API_KEY,
53
83
  teamId: process.env.CLICKUP_TEAM_ID,
54
84
  maxImages: process.env.MAX_IMAGES ? parseInt(process.env.MAX_IMAGES) : 4,
55
85
  maxResponseSizeMB: process.env.MAX_RESPONSE_SIZE_MB ? parseFloat(process.env.MAX_RESPONSE_SIZE_MB) : 1,
86
+ // Upper bound for a single image uploaded to ClickUp. Unlike maxResponseSizeMB this is
87
+ // not about context window budget - it only guards against accidentally pushing huge
88
+ // files into a ticket.
89
+ maxUploadSizeMB: parseNumericEnv("MAX_UPLOAD_SIZE_MB", 10, {
90
+ min: 1,
91
+ expectation: "Expected a positive number of megabytes.",
92
+ }),
93
+ // How long after creation a comment may still be edited. ClickUp has no way to tell
94
+ // "written by this MCP" apart from "written by the token owner in the UI", so this
95
+ // window is the actual guard against rewriting history. 0 disables editing entirely.
96
+ commentEditWindowHours: parseNumericEnv("CLICKUP_COMMENT_EDIT_WINDOW_HOURS", 24, {
97
+ min: 0,
98
+ expectation: "Expected a non-negative number of hours (0 disables editComment).",
99
+ }),
56
100
  primaryLanguageHint: detectedLanguageHint, // Store the cleaned code directly
57
101
  mode: mcpMode,
58
102
  };
@@ -1,4 +1,9 @@
1
1
  import { ContentBlock, ImageMetadataBlock } from "./types";
2
+ /**
3
+ * Detect MIME type from image binary data using magic bytes (file signatures)
4
+ * Returns null if the format is not recognized
5
+ */
6
+ export declare function detectMimeTypeFromBuffer(buffer: ArrayBuffer): string | null;
2
7
  /**
3
8
  * Downloads images from image_metadata blocks and applies smart size/count limiting
4
9
  * Prioritizes keeping the most recent images (assumes content is ordered with newest items last)
@@ -1 +1 @@
1
- {"version":3,"file":"image-processing.d.ts","sourceRoot":"","sources":["../../src/shared/image-processing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAE,kBAAkB,EAAC,MAAM,SAAS,CAAC;AAmDzD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,CAAC,YAAY,GAAG,kBAAkB,CAAC,EAAE,EAAE,SAAS,GAAE,MAAyB,EAAE,SAAS,GAAE,MAAiC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA0BhM"}
1
+ {"version":3,"file":"image-processing.d.ts","sourceRoot":"","sources":["../../src/shared/image-processing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAE,kBAAkB,EAAC,MAAM,SAAS,CAAC;AAKzD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA0B3E;AAgBD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,CAAC,YAAY,GAAG,kBAAkB,CAAC,EAAE,EAAE,SAAS,GAAE,MAAyB,EAAE,SAAS,GAAE,MAAiC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA0BhM"}
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectMimeTypeFromBuffer = detectMimeTypeFromBuffer;
3
4
  exports.downloadImages = downloadImages;
4
5
  const config_1 = require("./config");
5
6
  const data_uri_1 = require("./data-uri");
@@ -1 +1 @@
1
- {"version":3,"file":"task-write-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAcpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAsZtE"}
1
+ {"version":3,"file":"task-write-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+IpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAumBtE"}