clokio-mcp 0.13.3 → 0.16.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/README.md CHANGED
@@ -9,9 +9,13 @@ to your Clokio workspace to read and manage tasks, projects, clients, employees
9
9
  and attendance in natural language.
10
10
 
11
11
  Every request is authenticated with **your own** Clokio API key (`clk_...`),
12
- sent as `X-API-Key`. The key carries its own scopes, so this server never
13
- re-implements authorization: it is a thin, stateless client, and a `403` / `422`
14
- from the API is surfaced verbatim.
12
+ sent in the **`X-API-Key`** header. The key carries its own scopes, so this
13
+ server never re-implements authorization: it is a thin, stateless client, and a
14
+ `403` / `422` from the API is surfaced verbatim.
15
+
16
+ > **Auth header:** the Clokio Public API accepts the key only in `X-API-Key`.
17
+ > An `Authorization: Bearer <key>` header is **not** recognised and returns
18
+ > `401`. If you call the API directly (not through this server), use `X-API-Key`.
15
19
 
16
20
  ## Install
17
21
 
@@ -59,7 +63,7 @@ acting. An explicit code on a tool call always takes precedence.
59
63
 
60
64
  Optional: set `CLOKIO_TOOLSETS` to register only the tool groups you need, e.g.
61
65
  `CLOKIO_TOOLSETS=tasks`. This cuts the per-session tool-list cost — a
62
- task-focused session drops from 48 tools to 26. Optional groups are `tasks`,
66
+ task-focused session drops from 50 tools to 31. Optional groups are `tasks`,
63
67
  `employees` and `attendance` (which includes leave and time entries); combine
64
68
  them with commas. The `projects` group (project/client lookups, task statuses
65
69
  and labels, and `whoami`) is always on, because the task tools resolve names
@@ -67,12 +71,12 @@ through it.
67
71
 
68
72
  ## Tools
69
73
 
70
- 48 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
74
+ 50 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
71
75
  are marked mutating (deletes are `destructive`).
72
76
 
73
77
  | Area | Tools |
74
78
  |------|-------|
75
- | **Tasks** | list (with `compact` / `sort` / `updated_since`), get (with `comments_limit`), create, update, delete, bulk update, set assignees, list/add/edit comments, list/upload/download attachments, activity, list/add/remove dependencies, set custom field |
79
+ | **Tasks** | list (with `compact` / `sort` / `updated_since`), get (with `comments_limit`), create, update, delete, bulk update, set assignees, list/add/edit comments, list attachments (with `is_latest` + `latest_only`), upload, download one (save to disk, or read PDF text / ZIP entries / images / text inline), download all to a folder, activity, list/add/remove dependencies, set custom field |
76
80
  | **Projects & clients** | list projects, project statuses, project custom fields, project contacts, get client, add client contact |
77
81
  | **Reference** | task statuses, task labels, locations, list webhooks (read-only), whoami |
78
82
  | **Employees** | list (name search), lookup by PIN, task-stats, inbox (mentions, new assignments, others' status changes), create, set status |
@@ -87,6 +91,9 @@ are marked mutating (deletes are `destructive`).
87
91
  issuer (see `clokio_whoami`), so you rarely set an author. Pass
88
92
  `created_by_employee_code` / `author_employee_code` only to credit someone
89
93
  else, or set `CLOKIO_DEFAULT_ACTOR` for a shared key.
94
+ - **Write Markdown.** Descriptions and comment bodies accept Markdown (paragraphs, lists,
95
+ `**bold**`, `` `code` ``, links, headings, fenced code); the server converts it to the HTML
96
+ Clokio stores. Text that already starts with an HTML tag is passed through untouched.
90
97
  - **Status slugs** come from `clokio_list_task_statuses` /
91
98
  `clokio_get_project_statuses`.
92
99
  - An API key sees **public custom fields only**.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Attachment handling: everything that turns the raw bytes of a Clokio
3
+ * attachment into something an agent can actually use - save it to disk, read a
4
+ * PDF's text, list a ZIP's entries, or hand an image to the model.
5
+ *
6
+ * The bytes themselves come from client.ts (requestRawBytes), which owns the
7
+ * credential and redirect handling. This module never fetches; it only decides
8
+ * what to DO with what it was given.
9
+ */
10
+ import { ClokioApiError, looksLikeText, requestRawBytes } from './client.js';
11
+ import { mcpContent } from './tools/helpers.js';
12
+ /**
13
+ * How much decoded text (PDF or text file) we return inline before truncating.
14
+ * Same reasoning as the text cap in requestRaw: a 5 MB PDF of logs would bury
15
+ * the conversation. The caller can always pass save_path for the whole file.
16
+ */
17
+ const TEXT_INLINE_LIMIT = 100_000;
18
+ /**
19
+ * How large an image we are willing to hand to the model as base64. Images ride
20
+ * in the context as tokens; a 20 MB screenshot is both unusable and ruinous.
21
+ * Over this, we describe it and point at save_path.
22
+ */
23
+ const IMAGE_INLINE_LIMIT = 4 * 1024 * 1024; // 4 MB
24
+ function extensionOf(fileName) {
25
+ return (fileName ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '';
26
+ }
27
+ async function sha256Hex(buffer) {
28
+ // Dynamic import, not require(): this is an ES module.
29
+ const { createHash } = await import('node:crypto');
30
+ return createHash('sha256').update(buffer).digest('hex');
31
+ }
32
+ const IMAGE_MIME = {
33
+ png: 'image/png',
34
+ jpg: 'image/jpeg',
35
+ jpeg: 'image/jpeg',
36
+ gif: 'image/gif',
37
+ webp: 'image/webp',
38
+ bmp: 'image/bmp',
39
+ };
40
+ function imageMimeFor(contentType, fileName) {
41
+ if (contentType.startsWith('image/'))
42
+ return contentType.split(';')[0].trim();
43
+ const ext = extensionOf(fileName);
44
+ return IMAGE_MIME[ext] ?? null;
45
+ }
46
+ /**
47
+ * Extract text from a PDF buffer, page by page, with pdfjs-dist (Mozilla's
48
+ * maintained PDF engine). Loaded lazily so a session that never reads a PDF
49
+ * pays nothing for it, and wrapped by the caller so a parse failure (encrypted
50
+ * or image-only PDF) degrades to a clear message rather than crashing the tool.
51
+ *
52
+ * The `legacy` build is used deliberately: it is the CommonJS/Node-targeted bundle
53
+ * that runs without a browser Worker. `pdf-parse` was tried first and REJECTED -
54
+ * version 1.1.x fails "bad XRef entry" on valid PDFs (reproduced against a
55
+ * reportlab PDF and a macOS system PDF), so it would have failed on real
56
+ * attachments in production while passing a naive smoke test.
57
+ */
58
+ async function extractPdfText(buffer) {
59
+ // @ts-ignore - the legacy build has no bundled types; its runtime shape is used.
60
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
61
+ const data = new Uint8Array(buffer);
62
+ const doc = await pdfjs.getDocument({ data, useSystemFonts: true, isEvalSupported: false }).promise;
63
+ // destroy() in finally: a throw from getPage/getTextContent on a malformed PDF
64
+ // is caught by the caller for the user message, but WITHOUT this the pdfjs
65
+ // document and its worker would leak for the life of the process, and a run of
66
+ // failing PDFs would pile them up.
67
+ try {
68
+ const parts = [];
69
+ for (let n = 1; n <= doc.numPages; n++) {
70
+ const page = await doc.getPage(n);
71
+ const content = await page.getTextContent();
72
+ parts.push(content.items.map((i) => (typeof i.str === 'string' ? i.str : '')).join(' '));
73
+ }
74
+ return { text: parts.join('\n\n'), pages: doc.numPages };
75
+ }
76
+ finally {
77
+ await doc.destroy();
78
+ }
79
+ }
80
+ function capText(text) {
81
+ if (text.length <= TEXT_INLINE_LIMIT)
82
+ return { body: text, truncated: false };
83
+ return {
84
+ body: text.slice(0, TEXT_INLINE_LIMIT) +
85
+ `\n\n[...truncated. ${(text.length - TEXT_INLINE_LIMIT).toLocaleString()} more characters not shown. ` +
86
+ 'Pass save_path to write the whole file to disk.]',
87
+ truncated: true,
88
+ };
89
+ }
90
+ /**
91
+ * List a ZIP's entries by reading its End Of Central Directory record and the
92
+ * Central Directory, with no third-party library and WITHOUT inflating any
93
+ * entry. This is a read of the archive's table of contents only - names, sizes
94
+ * and modification dates - which is exactly what the spec asks for.
95
+ *
96
+ * Format: PKZIP APPNOTE. EOCD signature 0x06054b50 sits near the end (it may be
97
+ * followed by up to 65,535 bytes of comment, so we scan backwards for it).
98
+ * Each central-directory header is signature 0x02014b50 followed by fixed
99
+ * fields, then variable name/extra/comment lengths.
100
+ */
101
+ export function listZipEntries(buffer) {
102
+ const EOCD_SIG = 0x06054b50;
103
+ const CEN_SIG = 0x02014b50;
104
+ const ZIP64_EOCD_LOCATOR_SIG = 0x07064b50;
105
+ // Find EOCD by scanning back from the end for its signature. This is how every
106
+ // ZIP reader locates it (the record has no fixed position - it may be followed
107
+ // by up to 64 KB of comment), so a signature embedded in a comment or in stored
108
+ // data could in principle false-match. We accept that: it is the standard
109
+ // approach, the listing is advisory (names/sizes, never extraction), and a
110
+ // misread yields a clear "not a readable ZIP" rather than a wrong file. 22
111
+ // bytes is the minimum EOCD size.
112
+ let eocd = -1;
113
+ const minEocd = 22;
114
+ const scanStart = Math.max(0, buffer.length - (minEocd + 0xffff));
115
+ for (let i = buffer.length - minEocd; i >= scanStart; i--) {
116
+ if (buffer.readUInt32LE(i) === EOCD_SIG) {
117
+ eocd = i;
118
+ break;
119
+ }
120
+ }
121
+ if (eocd < 0) {
122
+ throw new ClokioApiError(0, 'Not a readable ZIP (no end-of-central-directory record found).');
123
+ }
124
+ let entryCount = buffer.readUInt16LE(eocd + 10);
125
+ let cdOffset = buffer.readUInt32LE(eocd + 16);
126
+ // ZIP64: a 0xffff count or 0xffffffff offset means the real values live in the
127
+ // ZIP64 EOCD, located via the ZIP64 EOCD locator just before the EOCD.
128
+ if ((entryCount === 0xffff || cdOffset === 0xffffffff) && eocd - 20 >= 0) {
129
+ const locator = eocd - 20;
130
+ if (buffer.readUInt32LE(locator) === ZIP64_EOCD_LOCATOR_SIG) {
131
+ const z64 = Number(buffer.readBigUInt64LE(locator + 8));
132
+ if (buffer.readUInt32LE(z64) === 0x06064b50) {
133
+ entryCount = Number(buffer.readBigUInt64LE(z64 + 32));
134
+ cdOffset = Number(buffer.readBigUInt64LE(z64 + 48));
135
+ }
136
+ }
137
+ }
138
+ const entries = [];
139
+ let p = cdOffset;
140
+ for (let n = 0; n < entryCount && p + 46 <= buffer.length; n++) {
141
+ if (buffer.readUInt32LE(p) !== CEN_SIG)
142
+ break;
143
+ const compressedSize = buffer.readUInt32LE(p + 20);
144
+ const uncompressedSize = buffer.readUInt32LE(p + 24);
145
+ const nameLen = buffer.readUInt16LE(p + 28);
146
+ const extraLen = buffer.readUInt16LE(p + 30);
147
+ const commentLen = buffer.readUInt16LE(p + 32);
148
+ const dosTime = buffer.readUInt16LE(p + 12);
149
+ const dosDate = buffer.readUInt16LE(p + 14);
150
+ const name = buffer.toString('utf8', p + 46, p + 46 + nameLen);
151
+ entries.push({
152
+ name,
153
+ size: uncompressedSize,
154
+ compressed_size: compressedSize,
155
+ modified: dosDateToIso(dosDate, dosTime),
156
+ });
157
+ p += 46 + nameLen + extraLen + commentLen;
158
+ }
159
+ return entries;
160
+ }
161
+ /** Decode a DOS date+time pair (ZIP's native stamp) into an ISO-ish string. */
162
+ function dosDateToIso(dosDate, dosTime) {
163
+ if (dosDate === 0)
164
+ return null;
165
+ const day = dosDate & 0x1f;
166
+ const month = (dosDate >> 5) & 0x0f;
167
+ const year = ((dosDate >> 9) & 0x7f) + 1980;
168
+ const second = (dosTime & 0x1f) * 2;
169
+ const minute = (dosTime >> 5) & 0x3f;
170
+ const hour = (dosTime >> 11) & 0x1f;
171
+ const pad = (v) => String(v).padStart(2, '0');
172
+ return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
173
+ }
174
+ /**
175
+ * Write a buffer to an absolute-or-relative path, creating parent directories.
176
+ * Refuses to overwrite unless told to. Returns the resolved absolute path.
177
+ */
178
+ export async function saveBuffer(buffer, savePath, overwrite) {
179
+ const { mkdir, writeFile, access } = await import('node:fs/promises');
180
+ const { dirname, resolve } = await import('node:path');
181
+ const abs = resolve(savePath);
182
+ if (!overwrite) {
183
+ let exists = true;
184
+ try {
185
+ await access(abs);
186
+ }
187
+ catch {
188
+ exists = false;
189
+ }
190
+ if (exists) {
191
+ throw new ClokioApiError(0, `Refusing to overwrite an existing file at ${abs}. Pass overwrite: true to replace it.`);
192
+ }
193
+ }
194
+ await mkdir(dirname(abs), { recursive: true });
195
+ await writeFile(abs, buffer);
196
+ return abs;
197
+ }
198
+ /**
199
+ * Download one attachment and either save it, or return its content in the most
200
+ * usable form for its type:
201
+ * - save_path set -> write to disk, return {saved_to, sha256, ...}
202
+ * - text-like -> the decoded text (capped)
203
+ * - PDF -> extracted text + page count (capped)
204
+ * - ZIP -> the entry list
205
+ * - image -> the image itself, as MCP image content
206
+ * - anything else -> a type+size description, as today
207
+ */
208
+ export async function downloadAttachment(config, taskId, attachmentId, opts) {
209
+ const { buffer, contentType } = await requestRawBytes(config, `/tasks/${taskId}/attachments/${attachmentId}`);
210
+ const kb = (buffer.byteLength / 1024).toFixed(1);
211
+ if (opts.savePath) {
212
+ const abs = await saveBuffer(buffer, opts.savePath, opts.overwrite ?? false);
213
+ const saved = {
214
+ saved_to: abs,
215
+ file_name: opts.fileName,
216
+ mime_type: contentType,
217
+ file_size: buffer.byteLength,
218
+ sha256: await sha256Hex(buffer),
219
+ };
220
+ return saved;
221
+ }
222
+ const ext = extensionOf(opts.fileName);
223
+ // PDF -> text
224
+ if (ext === 'pdf' || contentType.includes('application/pdf')) {
225
+ try {
226
+ const { text, pages } = await extractPdfText(buffer);
227
+ const { body, truncated } = capText(text);
228
+ return {
229
+ kind: 'pdf',
230
+ pages,
231
+ truncated,
232
+ file_size: buffer.byteLength,
233
+ text: body,
234
+ };
235
+ }
236
+ catch (e) {
237
+ return {
238
+ kind: 'pdf',
239
+ error: `Could not extract text from this PDF: ${e.message}. ` +
240
+ 'It may be encrypted or image-only. Pass save_path to write the raw file to disk.',
241
+ file_size: buffer.byteLength,
242
+ };
243
+ }
244
+ }
245
+ // ZIP -> entry list
246
+ if (ext === 'zip' || contentType.includes('application/zip')) {
247
+ try {
248
+ const entries = listZipEntries(buffer);
249
+ return { kind: 'zip', entry_count: entries.length, entries };
250
+ }
251
+ catch (e) {
252
+ return {
253
+ kind: 'zip',
254
+ error: `Could not list this ZIP: ${e.message}. Pass save_path to write it to disk.`,
255
+ file_size: buffer.byteLength,
256
+ };
257
+ }
258
+ }
259
+ // Image -> hand it to the model
260
+ const imageMime = imageMimeFor(contentType, opts.fileName);
261
+ if (imageMime) {
262
+ if (buffer.byteLength > IMAGE_INLINE_LIMIT) {
263
+ return (`[image: ${imageMime}, ${kb} KB] - too large to return inline ` +
264
+ `(limit ${(IMAGE_INLINE_LIMIT / 1024 / 1024).toFixed(0)} MB). ` +
265
+ 'Pass save_path to write it to disk.');
266
+ }
267
+ const result = mcpContent([
268
+ {
269
+ type: 'image',
270
+ data: buffer.toString('base64'),
271
+ mimeType: imageMime,
272
+ },
273
+ ]);
274
+ return result;
275
+ }
276
+ // Text -> decoded text (capped)
277
+ if (looksLikeText(contentType, opts.fileName)) {
278
+ const { body, truncated } = capText(buffer.toString('utf8'));
279
+ if (!truncated)
280
+ return body;
281
+ return `[truncated: showing the first ${TEXT_INLINE_LIMIT.toLocaleString()} characters of ${kb} KB]\n\n${body}`;
282
+ }
283
+ // Anything else -> describe it.
284
+ return (`[binary file: ${contentType}, ${kb} KB]\n\n` +
285
+ 'The bytes are not included - they would be unreadable here and would cost a great deal of context. ' +
286
+ 'Pass save_path to write the file to disk, or ask the person to open it in Clokio.');
287
+ }
288
+ /**
289
+ * Coerce whatever the attachments endpoint returned into a plain array of rows,
290
+ * or null if it is not list-shaped.
291
+ *
292
+ * request() returns the bare `data` array for an un-paginated list TODAY, but it
293
+ * returns `{data, meta}` the moment the API attaches a meta envelope (it does
294
+ * for every paginated list). A bare `Array.isArray` check would then silently
295
+ * drop is_latest stamping and make the bulk tool report "no list" over a task
296
+ * that has attachments. Accept both shapes so the feature survives that change.
297
+ */
298
+ /**
299
+ * Turn an API-supplied file_name into a safe basename to write under a chosen
300
+ * directory. The file_name is UNTRUSTED on the write path (same posture as the
301
+ * redirect checks in client.ts): strip any directory part so `../`, an absolute
302
+ * path or a Windows drive letter can only ever land a file INSIDE the target
303
+ * directory. A name that was nothing but a path (yields '', '.' or '..') falls
304
+ * back to a per-id name so it is still saved, just not where it asked.
305
+ */
306
+ export function safeAttachmentName(rawName, id) {
307
+ // Split on BOTH separators: basename() on POSIX does not strip a Windows
308
+ // backslash, so "..\\..\\evil" would survive a posix basename().
309
+ const last = (rawName ?? '').split(/[\\/]/).pop() ?? '';
310
+ return last && last !== '.' && last !== '..' ? last : `attachment-${id}`;
311
+ }
312
+ export function asAttachmentArray(value) {
313
+ if (Array.isArray(value))
314
+ return value;
315
+ if (value &&
316
+ typeof value === 'object' &&
317
+ Array.isArray(value.data)) {
318
+ return value.data;
319
+ }
320
+ return null;
321
+ }
322
+ /**
323
+ * Stamp each attachment with is_latest: whether it is the newest upload bearing
324
+ * its file_name on this task. "Newest" is decided by created_at (falling back to
325
+ * uploaded_at, then id as a monotonic tiebreaker). A file_name with a single
326
+ * upload is trivially latest.
327
+ *
328
+ * This is computed client-side rather than asked of the API, deliberately: the
329
+ * API has no versioning concept, and the MCP is a thin wrapper that adds no
330
+ * server state. The field is ADDITIVE - existing readers that ignore it are
331
+ * unaffected.
332
+ */
333
+ export function stampIsLatest(rows) {
334
+ // Rank as a (timestamp, id) PAIR, compared lexicographically - never folded
335
+ // into one number. A millisecond epoch is ~1.7e12; multiplying by an id to
336
+ // make room overflows Number.MAX_SAFE_INTEGER (9e15), silently dropping the
337
+ // id and losing the tiebreak - a real bug the tie test caught. A row with no
338
+ // parseable stamp sorts below any that has one (-Infinity), then by id.
339
+ const nameOf = (r) => r.file_name ?? `__id_${r.id}`;
340
+ const stampMs = (r) => {
341
+ const s = r.created_at ?? r.uploaded_at;
342
+ const t = s ? Date.parse(s) : NaN;
343
+ return Number.isNaN(t) ? -Infinity : t;
344
+ };
345
+ // true if a ranks strictly higher than b (newer stamp, or equal stamp + higher id).
346
+ const higher = (a, b) => {
347
+ const ta = stampMs(a);
348
+ const tb = stampMs(b);
349
+ return ta !== tb ? ta > tb : a.id > b.id;
350
+ };
351
+ const winner = new Map();
352
+ for (const r of rows) {
353
+ const name = nameOf(r);
354
+ const current = winner.get(name);
355
+ if (current === undefined || higher(r, current))
356
+ winner.set(name, r);
357
+ }
358
+ return rows.map((r) => ({
359
+ ...r,
360
+ is_latest: winner.get(nameOf(r))?.id === r.id,
361
+ }));
362
+ }
package/dist/client.js CHANGED
@@ -57,6 +57,84 @@ export function seg(value) {
57
57
  * The description still answers the question the caller actually had - is
58
58
  * there a file here, what kind, how big.
59
59
  */
60
+ /**
61
+ * The raw bytes of a file endpoint, plus the content-type the API reported.
62
+ *
63
+ * This is the shared core: it owns the /api/v1 prefix guard, the by-hand
64
+ * redirect to signed storage with NO credential attached, and the redirect
65
+ * target scheme check - all the security-load-bearing parts of the download
66
+ * path. `requestRaw` renders these bytes as text/description for the model;
67
+ * the save-to-disk and typed-content tools consume the bytes directly.
68
+ */
69
+ export async function requestRawBytes(config, path) {
70
+ const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
71
+ const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
72
+ if (!url.pathname.startsWith(expectedPrefix)) {
73
+ throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1.');
74
+ }
75
+ let res;
76
+ try {
77
+ // `manual`, not 'error' and not 'follow' - see the long note below.
78
+ res = await fetch(url, {
79
+ headers: { 'X-API-Key': config.apiKey },
80
+ redirect: 'manual',
81
+ });
82
+ if (res.status >= 300 && res.status < 400) {
83
+ const location = res.headers.get('location');
84
+ if (!location) {
85
+ throw new ClokioApiError(0, 'Clokio redirected the download without a destination.');
86
+ }
87
+ const target = new URL(location, url);
88
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
89
+ throw new ClokioApiError(0, `Refusing to follow a download redirect to a ${target.protocol} URL.`);
90
+ }
91
+ // NO headers: the key must not cross to the storage origin.
92
+ res = await fetch(target, { redirect: 'error' });
93
+ }
94
+ }
95
+ catch (e) {
96
+ if (e instanceof ClokioApiError)
97
+ throw e;
98
+ throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
99
+ }
100
+ if (!res.ok) {
101
+ const text = await res.text();
102
+ let message = `Clokio API returned HTTP ${res.status}`;
103
+ try {
104
+ const parsed = JSON.parse(text);
105
+ if (parsed && typeof parsed.message === 'string')
106
+ message = parsed.message;
107
+ }
108
+ catch {
109
+ /* a non-JSON error body is not more informative than the status */
110
+ }
111
+ throw new ClokioApiError(res.status, message);
112
+ }
113
+ const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
114
+ const buffer = Buffer.from(await res.arrayBuffer());
115
+ return { buffer, contentType };
116
+ }
117
+ /**
118
+ * Decide whether an attachment is text, from BOTH the content-type and the file
119
+ * extension. Exported because the download tool needs the same verdict the
120
+ * renderer uses, to choose between text, PDF, image and binary handling.
121
+ *
122
+ * CONTENT-TYPE IS NOT ENOUGH. The API deliberately re-labels html, json, js,
123
+ * svg and xml attachments as application/octet-stream so a browser cannot
124
+ * render them from our origin - an XSS defence. Trusting the header alone would
125
+ * report a JSON report an agent attached as unreadable binary, so the file
126
+ * EXTENSION gets a say too.
127
+ */
128
+ export function looksLikeText(contentType, fileName) {
129
+ const extension = (fileName ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '';
130
+ const TEXT_EXTENSIONS = new Set([
131
+ 'txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'xml', 'yaml', 'yml',
132
+ 'html', 'htm', 'log', 'diff', 'patch', 'sql', 'js', 'ts', 'css', 'svg',
133
+ ]);
134
+ return (contentType.startsWith('text/') ||
135
+ /\b(json|xml|csv|yaml|javascript|markdown)\b/.test(contentType) ||
136
+ TEXT_EXTENSIONS.has(extension));
137
+ }
60
138
  export async function requestRaw(config, path, fileName) {
61
139
  const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
62
140
  const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
@@ -126,19 +204,7 @@ export async function requestRaw(config, path, fileName) {
126
204
  }
127
205
  const type = res.headers.get('content-type') ?? 'application/octet-stream';
128
206
  const buffer = Buffer.from(await res.arrayBuffer());
129
- // CONTENT-TYPE IS NOT ENOUGH. The API deliberately re-labels html, json,
130
- // js, svg and xml attachments as application/octet-stream so a browser
131
- // cannot render them from our origin - an XSS defence. Trusting the header
132
- // alone would report a JSON report an agent attached as unreadable binary,
133
- // so the file EXTENSION gets a say too.
134
- const extension = (fileName ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '';
135
- const TEXT_EXTENSIONS = new Set([
136
- 'txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'xml', 'yaml', 'yml',
137
- 'html', 'htm', 'log', 'diff', 'patch', 'sql', 'js', 'ts', 'css', 'svg',
138
- ]);
139
- const isText = type.startsWith('text/') ||
140
- /\b(json|xml|csv|yaml|javascript|markdown)\b/.test(type) ||
141
- TEXT_EXTENSIONS.has(extension);
207
+ const isText = looksLikeText(type, fileName);
142
208
  const kb = (buffer.byteLength / 1024).toFixed(1);
143
209
  if (isText) {
144
210
  // A CAP ON TEXT TOO. Attachments go up to 200 MB and .csv/.log are
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Markdown → HTML for the text an agent writes INTO Clokio.
3
+ *
4
+ * Clokio stores task descriptions and comments as sanitized HTML (the web
5
+ * editor is TipTap). A model naturally writes Markdown, and the first task
6
+ * created through this server from Claude arrived as one flat paragraph with
7
+ * literal `**` and backticks. Converting here keeps every tool's input
8
+ * natural for the model and correct for Clokio.
9
+ *
10
+ * Deliberately small: paragraphs, line breaks, headings, bullet and numbered
11
+ * lists, fenced code, blockquotes, bold, italic, inline code and links. Text
12
+ * that already starts with an HTML tag is passed through untouched, so a
13
+ * caller that sends HTML keeps working. Everything else is escaped, so a
14
+ * stray `<b>` in prose shows as text rather than becoming markup.
15
+ */
16
+ // `"` too, not just the tag characters: escaping runs BEFORE the link regex,
17
+ // and the generated <a href="..."> wraps the URL in double quotes - a
18
+ // quote the URL smuggles through would close the attribute and open a new
19
+ // one (`[x](https://e.com/"onmouseover="alert(1))` produced a live
20
+ // onmouseover, reproduced in review). Clokio's server sanitizer strips such
21
+ // attributes anyway, but this package is consumed outside Clokio's web
22
+ // surfaces too, and it must not emit injectable HTML in the first place.
23
+ const escapeHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
+ // The EXACT shape the backend's upload answers with (tasks/org-{n}/{n}/media/
25
+ // {file}) - anchored, no slash or dot-segment room. A bare startsWith
26
+ // allowlist let `/tasks/media/../../anything` through, and the browser
27
+ // normalizes dot segments BEFORE sending, so that <img> fired an arbitrary
28
+ // same-origin GET with the logged-in reader's session cookie (caught in
29
+ // review). Anything under /tasks/media/ that is not this shape stays text.
30
+ const MEDIA_PATH = /^\/tasks\/media\/tasks\/org-\d+\/\d+\/media\/[^/\s)]+$/;
31
+ const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
32
+ function inline(text) {
33
+ // The stash protects finished HTML from the LATER rules: inline code first
34
+ // (so its contents are never styled or linked), then each generated
35
+ // image/video/anchor tag - without that, the em rule rewrote a `*` inside
36
+ // a URL into <em> IN THE src ATTRIBUTE and broke the image (caught in
37
+ // review; alt text likewise stays literal rather than styled).
38
+ const stashed = [];
39
+ const stash = (html) => {
40
+ stashed.push(html);
41
+ return `\u0000${stashed.length - 1}\u0000`;
42
+ };
43
+ let s = text.replace(/`([^`\n]+)`/g, (_m, code) => stash(`<code>${escapeHtml(code)}</code>`));
44
+ s = escapeHtml(s);
45
+ // Images BEFORE links, or the link rule eats the [alt](url) part and leaves
46
+ // a stray "!". Besides https, the /tasks/media/ urls that
47
+ // clokio_upload_task_media returns are allowed - embedding them is that
48
+ // tool's whole point (task #5296). A video extension gets a <video> tag:
49
+ // an <img> pointing at an mp4 renders as a broken image. escapeHtml
50
+ // already ran, so a quote in the URL is &quot; and cannot close the
51
+ // attribute.
52
+ s = s.replace(/!\[([^\]\n]*)\]\(((?:https?:\/\/|\/tasks\/media\/)[^)\s]+)\)/g, (whole, alt, url) => {
53
+ if (url.startsWith('/tasks/media/') && !MEDIA_PATH.test(url))
54
+ return whole;
55
+ return stash(VIDEO_EXT.test(url)
56
+ ? `<video src="${url}" controls></video>`
57
+ : `<img src="${url}" alt="${alt}">`);
58
+ });
59
+ s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, (_m, label, url) => stash(`<a href="${url}">${label}</a>`));
60
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
61
+ s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, '$1<em>$2</em>');
62
+ return s.replace(/\u0000(\d+)\u0000/g, (_m, i) => stashed[Number(i)]);
63
+ }
64
+ export function looksLikeHtml(text) {
65
+ // `video` is in the list for the same reason `img` is: the media tool's
66
+ // HTML advice starts a body with exactly that tag, and leaving it out
67
+ // escaped the whole body to literal text (caught in review).
68
+ return /^\s*<(p|div|ul|ol|li|h[1-6]|strong|em|b|i|u|s|br|pre|code|blockquote|a|img|video|span|table)\b[^>]*>/i.test(text);
69
+ }
70
+ export function markdownToHtml(input) {
71
+ if (input == null)
72
+ return '';
73
+ const text = String(input);
74
+ if (!text.trim())
75
+ return '';
76
+ if (looksLikeHtml(text))
77
+ return text;
78
+ const out = [];
79
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
80
+ let para = [];
81
+ let list = null;
82
+ let quote = [];
83
+ const flushPara = () => {
84
+ if (para.length)
85
+ out.push(`<p>${para.map(inline).join('<br>')}</p>`);
86
+ para = [];
87
+ };
88
+ const flushList = () => {
89
+ if (list)
90
+ out.push(`<${list.tag}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.tag}>`);
91
+ list = null;
92
+ };
93
+ const flushQuote = () => {
94
+ if (quote.length)
95
+ out.push(`<blockquote><p>${quote.map(inline).join('<br>')}</p></blockquote>`);
96
+ quote = [];
97
+ };
98
+ const flushAll = () => {
99
+ flushPara();
100
+ flushList();
101
+ flushQuote();
102
+ };
103
+ for (let i = 0; i < lines.length; i++) {
104
+ const line = lines[i];
105
+ const trimmed = line.trim();
106
+ if (/^```/.test(trimmed)) {
107
+ flushAll();
108
+ const code = [];
109
+ i++;
110
+ while (i < lines.length && !/^```/.test(lines[i].trim()))
111
+ code.push(lines[i++]);
112
+ out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
113
+ continue;
114
+ }
115
+ if (!trimmed) {
116
+ flushAll();
117
+ continue;
118
+ }
119
+ const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
120
+ if (heading) {
121
+ flushAll();
122
+ out.push(`<h${heading[1].length}>${inline(heading[2])}</h${heading[1].length}>`);
123
+ continue;
124
+ }
125
+ const quoted = /^>\s?(.*)$/.exec(trimmed);
126
+ if (quoted) {
127
+ flushPara();
128
+ flushList();
129
+ quote.push(quoted[1]);
130
+ continue;
131
+ }
132
+ const bullet = /^[-*+]\s+(.+)$/.exec(trimmed);
133
+ const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
134
+ if (bullet || numbered) {
135
+ flushPara();
136
+ flushQuote();
137
+ const tag = bullet ? 'ul' : 'ol';
138
+ if (!list || list.tag !== tag) {
139
+ flushList();
140
+ list = { tag, items: [] };
141
+ }
142
+ list.items.push((bullet ?? numbered)[1]);
143
+ continue;
144
+ }
145
+ flushList();
146
+ flushQuote();
147
+ para.push(trimmed);
148
+ }
149
+ flushAll();
150
+ return out.join('');
151
+ }
@@ -1,10 +1,24 @@
1
1
  import { ClokioApiError } from '../client.js';
2
+ export function mcpContent(content, isError = false) {
3
+ return { __mcpContent: true, content, isError };
4
+ }
5
+ function isMcpContentResult(value) {
6
+ return (typeof value === 'object' &&
7
+ value !== null &&
8
+ value.__mcpContent === true &&
9
+ Array.isArray(value.content));
10
+ }
2
11
  /**
3
12
  * Register one Clokio tool. The handler returns any JSON-serialisable value,
4
13
  * rendered as pretty JSON text. ClokioApiError becomes an isError tool result
5
14
  * carrying the API's own status + message, so the model can correct its request
6
15
  * (a 422 naming the bad field, a 404, a 403 scope refusal).
7
16
  *
17
+ * A handler may instead return `mcpContent([...])` to emit raw MCP content
18
+ * blocks - an image the model can see, or several blocks together. That path is
19
+ * used by the attachment tools (an image comes back as an image, not a size
20
+ * description); every other tool returns a plain value and is rendered as text.
21
+ *
8
22
  * `mutates` marks a tool that changes data (create/update/delete): it sets the
9
23
  * MCP readOnlyHint/destructiveHint annotations so clients can surface a warning.
10
24
  */
@@ -12,6 +26,11 @@ export function registerTool(server, config, def) {
12
26
  const callback = async (args) => {
13
27
  try {
14
28
  const result = await def.handler(args, config);
29
+ // A handler may hand back pre-built MCP content (an image, multiple
30
+ // blocks). Pass it through verbatim rather than JSON-stringifying it.
31
+ if (isMcpContentResult(result)) {
32
+ return { content: result.content, ...(result.isError ? { isError: true } : {}) };
33
+ }
15
34
  return {
16
35
  content: [
17
36
  {
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { request, requestRaw, requestUpload } from '../client.js';
2
+ import { markdownToHtml } from '../markdown.js';
3
+ import { request, requestUpload } from '../client.js';
3
4
  import { registerTool } from './helpers.js';
5
+ import { asAttachmentArray, downloadAttachment, safeAttachmentName, stampIsLatest, } from '../attachments.js';
4
6
  /** See employees.ts - the same stable-identifier rule applies to task filters. */
5
7
  const employeeCode = z
6
8
  .string()
@@ -120,7 +122,7 @@ export function registerTaskTools(server, config) {
120
122
  schema: {
121
123
  project_id: z.number().int(),
122
124
  title: z.string().max(255),
123
- description: z.string().optional().describe('HTML or plain text'),
125
+ description: z.string().optional().describe('Markdown, HTML or plain text (Markdown is converted to HTML)'),
124
126
  priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
125
127
  status: z.string().optional().describe("Status slug valid for THIS project's board"),
126
128
  due_date: z.string().optional().describe('YYYY-MM-DD'),
@@ -140,7 +142,7 @@ export function registerTaskTools(server, config) {
140
142
  body: {
141
143
  project_id: args.project_id,
142
144
  title: args.title,
143
- description: args.description,
145
+ description: args.description === undefined ? undefined : markdownToHtml(args.description),
144
146
  priority: args.priority,
145
147
  status: args.status,
146
148
  due_date: args.due_date,
@@ -190,6 +192,8 @@ export function registerTaskTools(server, config) {
190
192
  if (args[key] !== undefined)
191
193
  body[key] = args[key];
192
194
  }
195
+ if (typeof body.description === 'string')
196
+ body.description = markdownToHtml(body.description);
193
197
  // Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
194
198
  // actor. The API otherwise falls back to the key's issuer on its own,
195
199
  // so this only overrides for a shared/service key. An explicit
@@ -233,7 +237,7 @@ export function registerTaskTools(server, config) {
233
237
  mutates: true,
234
238
  description: 'Add a comment to a task. author_employee_code is usually unnecessary: an unnamed comment is attributed ' +
235
239
  'to the key issuer automatically. Set it only to credit someone else. Use readable formatting ' +
236
- '(paragraphs, blank lines, bullet lists); markdown TABLES are not rendered and post as raw text. Write ' +
240
+ '(paragraphs, blank lines, bullet lists, **bold**, `code`); Markdown is converted to HTML, but TABLES are not rendered and post as raw text. Write ' +
237
241
  'in the language of the person you are answering. ' +
238
242
  'TO MENTION SOMEONE, write @ followed by their full name exactly as clokio_list_employees spells it ' +
239
243
  '(for example "@Kiran Bahadur") in the body text - no markup needed. It becomes a real mention and ' +
@@ -249,7 +253,7 @@ export function registerTaskTools(server, config) {
249
253
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments`, {
250
254
  method: 'POST',
251
255
  body: {
252
- body: args.body,
256
+ body: markdownToHtml(args.body),
253
257
  author_employee_code: args.author_employee_code ?? cfg.defaultActor,
254
258
  notify: args.notify,
255
259
  },
@@ -271,40 +275,135 @@ export function registerTaskTools(server, config) {
271
275
  },
272
276
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments/${args.comment_id}`, {
273
277
  method: 'PATCH',
274
- body: { body: args.body, author_employee_code: args.author_employee_code ?? cfg.defaultActor },
278
+ body: { body: markdownToHtml(args.body), author_employee_code: args.author_employee_code ?? cfg.defaultActor },
275
279
  }),
276
280
  });
277
281
  registerTool(server, config, {
278
282
  name: 'clokio_list_task_attachments',
279
283
  description: "A task's attachments: file name, size, mime type, who uploaded it, and the path to fetch the bytes. " +
280
284
  'A task whose real content is a screenshot or a spreadsheet reads as "(no description)" without this - ' +
281
- 'check here before concluding a task has no detail. Use clokio_download_task_attachment for the file.',
282
- schema: { id: z.number().int() },
283
- handler: (args, cfg) => request(cfg, `/tasks/${args.id}/attachments`),
285
+ 'check here before concluding a task has no detail. Each row carries is_latest: whether it is the newest ' +
286
+ 'upload with the same file name (the same name can be uploaded more than once; nothing else marks which ' +
287
+ 'is current). Pass latest_only: true to drop superseded duplicates. Use clokio_download_task_attachment ' +
288
+ 'for one file, or clokio_download_task_attachments to save them all at once.',
289
+ schema: {
290
+ id: z.number().int(),
291
+ latest_only: z
292
+ .boolean()
293
+ .optional()
294
+ .describe('Return only the newest upload of each file name, dropping superseded duplicates'),
295
+ },
296
+ handler: async (args, cfg) => {
297
+ const raw = await request(cfg, `/tasks/${args.id}/attachments`);
298
+ const rows = asAttachmentArray(raw);
299
+ if (!rows)
300
+ return raw; // unexpected shape - hand it back rather than hide it
301
+ const stamped = stampIsLatest(rows);
302
+ return args.latest_only ? stamped.filter((r) => r.is_latest) : stamped;
303
+ },
284
304
  });
285
305
  registerTool(server, config, {
286
306
  name: 'clokio_download_task_attachment',
287
- description: 'Download one attachment. Returns the FILE, not JSON: text comes back as text (truncated if very ' +
288
- 'large, and it says so), while binary files (images, PDFs, spreadsheets) are reported with their type ' +
289
- 'and size rather than dumped into the conversation. Get the attachment id - and file_name, which helps ' +
290
- 'this tool recognise text - from clokio_list_task_attachments.',
307
+ description: 'Download one attachment. With save_path, writes the file to disk (creating missing folders, refusing to ' +
308
+ 'overwrite unless overwrite: true) and returns {saved_to, sha256, mime_type, file_size}. Without ' +
309
+ 'save_path it returns the content in the most usable form: text files as text; a PDF as its extracted ' +
310
+ 'text plus page count; a ZIP as its entry list; an image as an image you can see; anything else as a ' +
311
+ 'type-and-size description. Large text/PDF is truncated with a note (use save_path for the whole file). ' +
312
+ 'Get the attachment id and file_name from clokio_list_task_attachments - file_name drives type detection.',
291
313
  schema: {
292
314
  id: z.number().int().describe('The task id'),
293
315
  attachment_id: z.number().int(),
294
316
  file_name: z
295
317
  .string()
296
318
  .optional()
297
- .describe('The file_name from clokio_list_task_attachments. Improves text detection'),
319
+ .describe('The file_name from clokio_list_task_attachments. Drives type detection (text/PDF/zip/image)'),
320
+ save_path: z
321
+ .string()
322
+ .optional()
323
+ .describe('Write the file here instead of returning its content; parent folders are created'),
324
+ overwrite: z
325
+ .boolean()
326
+ .optional()
327
+ .describe('Allow save_path to replace an existing file (default false)'),
328
+ },
329
+ handler: (args, cfg) => downloadAttachment(cfg, args.id, args.attachment_id, {
330
+ fileName: args.file_name,
331
+ savePath: args.save_path,
332
+ overwrite: args.overwrite,
333
+ }),
334
+ });
335
+ registerTool(server, config, {
336
+ name: 'clokio_download_task_attachments',
337
+ description: "Save ALL of a task's attachments to a folder in one call. Creates save_dir if missing, writes each file " +
338
+ 'under its own name, and returns the list of saved paths with sizes and sha256. Pass latest_only: true to ' +
339
+ 'skip superseded duplicates (older uploads of a name that was uploaded again). A file name that appears ' +
340
+ 'more than once is disambiguated with a numeric suffix so nothing is clobbered.',
341
+ schema: {
342
+ id: z.number().int().describe('The task id'),
343
+ save_dir: z.string().describe('Directory to write the files into; created if missing'),
344
+ latest_only: z
345
+ .boolean()
346
+ .optional()
347
+ .describe('Save only the newest upload of each file name'),
348
+ overwrite: z
349
+ .boolean()
350
+ .optional()
351
+ .describe('Allow replacing existing files in save_dir (default false)'),
352
+ },
353
+ handler: async (args, cfg) => {
354
+ const { join } = await import('node:path');
355
+ const rows = asAttachmentArray(await request(cfg, `/tasks/${args.id}/attachments`));
356
+ if (!rows) {
357
+ return { error: 'The attachments endpoint did not return a list.' };
358
+ }
359
+ const stamped = stampIsLatest(rows);
360
+ const selected = args.latest_only ? stamped.filter((r) => r.is_latest) : stamped;
361
+ const saved = [];
362
+ const failed = [];
363
+ const usedNames = new Set();
364
+ for (const row of selected) {
365
+ // file_name is API-supplied and UNTRUSTED on the write path;
366
+ // safeAttachmentName strips any directory part so it stays in save_dir.
367
+ const original = safeAttachmentName(row.file_name, row.id);
368
+ let name = original;
369
+ let counter = 2;
370
+ while (usedNames.has(name)) {
371
+ const dot = original.lastIndexOf('.');
372
+ name =
373
+ dot > 0
374
+ ? `${original.slice(0, dot)} (${counter})${original.slice(dot)}`
375
+ : `${original} (${counter})`;
376
+ counter++;
377
+ }
378
+ usedNames.add(name);
379
+ try {
380
+ const result = (await downloadAttachment(cfg, args.id, row.id, {
381
+ fileName: row.file_name,
382
+ savePath: join(args.save_dir, name),
383
+ overwrite: args.overwrite,
384
+ }));
385
+ saved.push(result);
386
+ }
387
+ catch (e) {
388
+ failed.push({
389
+ attachment_id: row.id,
390
+ file_name: row.file_name,
391
+ error: e.message,
392
+ });
393
+ }
394
+ }
395
+ return { saved_count: saved.length, saved, ...(failed.length ? { failed } : {}) };
298
396
  },
299
- handler: (args, cfg) => requestRaw(cfg, `/tasks/${args.id}/attachments/${args.attachment_id}`, args.file_name),
300
397
  });
301
398
  registerTool(server, config, {
302
399
  name: 'clokio_upload_task_attachment',
303
400
  mutates: true,
304
- description: 'Upload a LOCAL FILE to a task as an attachment - a screenshot, a log, a spreadsheet. Give the path to ' +
401
+ description: 'Upload a LOCAL FILE to a task as an attachment - a log, a spreadsheet, a PDF. Give the path to ' +
305
402
  'a file on this machine; it is read and sent. Allowed types include images, PDF, Office docs, txt/csv/' +
306
403
  'md/json, code diffs, zip and video, up to 200 MB. The uploader is the key issuer unless you set ' +
307
- 'uploaded_by_employee_code.',
404
+ 'uploaded_by_employee_code. NOTE: an attachment is a file in the task\'s file list - its download_path ' +
405
+ 'only loads WITH an API key, so it can NOT be shown inline in a comment. For an image or video the ' +
406
+ 'reader should SEE, use clokio_upload_task_media instead.',
308
407
  schema: {
309
408
  id: z.number().int().describe('The task id'),
310
409
  file_path: z.string().describe('Path to a file on THIS machine'),
@@ -316,6 +415,21 @@ export function registerTaskTools(server, config) {
316
415
  uploaded_by_employee_code: args.uploaded_by_employee_code ?? cfg.defaultActor,
317
416
  }),
318
417
  });
418
+ registerTool(server, config, {
419
+ name: 'clokio_upload_task_media',
420
+ mutates: true,
421
+ description: 'Upload an image or video the reader should SEE INLINE in a comment or the task description ' +
422
+ '(task #5296). Returns {url} - a /tasks/media/... path served to logged-in users with no API key. ' +
423
+ 'Embed it in the body you then post: Markdown ![description](URL) or HTML <img src="URL">. ' +
424
+ 'Media types only (jpg/png/gif/webp/heic/mp4/webm/mov), 50 MB; HEIC becomes JPEG. This does NOT add ' +
425
+ 'the file to the attachments list - for a downloadable file (zip, PDF, log), use ' +
426
+ 'clokio_upload_task_attachment instead.',
427
+ schema: {
428
+ id: z.number().int().describe('The task id'),
429
+ file_path: z.string().describe('Path to an image/video on THIS machine'),
430
+ },
431
+ handler: (args, cfg) => requestUpload(cfg, `/tasks/${args.id}/media`, args.file_path),
432
+ });
319
433
  registerTool(server, config, {
320
434
  name: 'clokio_get_task_activity',
321
435
  description: 'Get the activity log (audit trail) for a task.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.13.3",
3
+ "version": "0.16.0",
4
4
  "description": "Model Context Protocol (MCP) server for Clokio — manage tasks, projects, clients, employees and attendance from Claude and any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,10 +40,11 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@modelcontextprotocol/sdk": "^1.0.4",
43
+ "pdfjs-dist": "^4.10.38",
43
44
  "zod": "^3.23.8"
44
45
  },
45
46
  "devDependencies": {
46
- "@types/node": "^22.20.4",
47
+ "@types/node": "^22.20.5",
47
48
  "typescript": "^5.9.3"
48
49
  },
49
50
  "engines": {