clokio-mcp 0.13.2 → 0.14.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
 
@@ -72,10 +76,10 @@ 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
- | **Employees** | list (name search), lookup by PIN, task-stats, inbox, create, set status |
82
+ | **Employees** | list (name search), lookup by PIN, task-stats, inbox (mentions, new assignments, others' status changes), create, set status |
79
83
  | **Attendance** | daily, range, per-employee, monthly summary, clock in/out, break start/end, time entries, leaves, leave balances |
80
84
 
81
85
  ### Conventions
@@ -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
@@ -73,16 +73,25 @@ export function registerEmployeeTools(server, config) {
73
73
  });
74
74
  registerTool(server, config, {
75
75
  name: 'clokio_get_inbox',
76
- description: 'What needs one person\'s attention: mentions_waiting (comments that @-mention them and that they have ' +
77
- 'NOT replied to yet) and newly_assigned (tasks assigned to them in the window). This is how you find ' +
78
- 'that someone asked a question, without scanning the board. `since` defaults to 14 days (max 90). ' +
79
- 'To answer for the KEY OWNER, pass their own employee_code (from clokio_whoami).',
76
+ description: 'What needs one person\'s attention, in three lists: mentions_waiting (comments that @-mention them and ' +
77
+ 'that they have NOT replied to yet; mentions on closed tasks are excluded), newly_assigned (tasks ' +
78
+ 'assigned to them in the window), and status_changes (status moves on tasks they hold or created, made ' +
79
+ 'by SOMEONE ELSE - their own moves and task-creation rows are excluded). This is how you find that ' +
80
+ 'someone asked a question or moved your work, without scanning the board. `since` defaults to 14 days ' +
81
+ '(max 90). status_changes is capped by `limit` (default 100, max 500); the response sets ' +
82
+ 'status_changes_truncated=true when more exist past the cap. To answer for the KEY OWNER, pass their ' +
83
+ 'own employee_code (from clokio_whoami).',
80
84
  schema: {
81
85
  employee_code: employeeCode,
82
86
  since: z.string().optional().describe('ISO-8601 or YYYY-MM-DD. Only items after this. Default 14 days ago'),
87
+ limit: z
88
+ .number()
89
+ .int()
90
+ .optional()
91
+ .describe('Max status_changes to return (default 100, max 500). status_changes_truncated flags overflow'),
83
92
  },
84
93
  handler: (args, cfg) => request(cfg, `/employees/${seg(args.employee_code)}/inbox`, {
85
- query: { since: args.since },
94
+ query: { since: args.since, limit: args.limit },
86
95
  }),
87
96
  });
88
97
  registerTool(server, config, {
@@ -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,7 @@
1
1
  import { z } from 'zod';
2
- import { request, requestRaw, requestUpload } from '../client.js';
2
+ import { request, requestUpload } from '../client.js';
3
3
  import { registerTool } from './helpers.js';
4
+ import { asAttachmentArray, downloadAttachment, safeAttachmentName, stampIsLatest, } from '../attachments.js';
4
5
  /** See employees.ts - the same stable-identifier rule applies to task filters. */
5
6
  const employeeCode = z
6
7
  .string()
@@ -278,25 +279,118 @@ export function registerTaskTools(server, config) {
278
279
  name: 'clokio_list_task_attachments',
279
280
  description: "A task's attachments: file name, size, mime type, who uploaded it, and the path to fetch the bytes. " +
280
281
  '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`),
282
+ 'check here before concluding a task has no detail. Each row carries is_latest: whether it is the newest ' +
283
+ 'upload with the same file name (the same name can be uploaded more than once; nothing else marks which ' +
284
+ 'is current). Pass latest_only: true to drop superseded duplicates. Use clokio_download_task_attachment ' +
285
+ 'for one file, or clokio_download_task_attachments to save them all at once.',
286
+ schema: {
287
+ id: z.number().int(),
288
+ latest_only: z
289
+ .boolean()
290
+ .optional()
291
+ .describe('Return only the newest upload of each file name, dropping superseded duplicates'),
292
+ },
293
+ handler: async (args, cfg) => {
294
+ const raw = await request(cfg, `/tasks/${args.id}/attachments`);
295
+ const rows = asAttachmentArray(raw);
296
+ if (!rows)
297
+ return raw; // unexpected shape - hand it back rather than hide it
298
+ const stamped = stampIsLatest(rows);
299
+ return args.latest_only ? stamped.filter((r) => r.is_latest) : stamped;
300
+ },
284
301
  });
285
302
  registerTool(server, config, {
286
303
  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.',
304
+ description: 'Download one attachment. With save_path, writes the file to disk (creating missing folders, refusing to ' +
305
+ 'overwrite unless overwrite: true) and returns {saved_to, sha256, mime_type, file_size}. Without ' +
306
+ 'save_path it returns the content in the most usable form: text files as text; a PDF as its extracted ' +
307
+ 'text plus page count; a ZIP as its entry list; an image as an image you can see; anything else as a ' +
308
+ 'type-and-size description. Large text/PDF is truncated with a note (use save_path for the whole file). ' +
309
+ 'Get the attachment id and file_name from clokio_list_task_attachments - file_name drives type detection.',
291
310
  schema: {
292
311
  id: z.number().int().describe('The task id'),
293
312
  attachment_id: z.number().int(),
294
313
  file_name: z
295
314
  .string()
296
315
  .optional()
297
- .describe('The file_name from clokio_list_task_attachments. Improves text detection'),
316
+ .describe('The file_name from clokio_list_task_attachments. Drives type detection (text/PDF/zip/image)'),
317
+ save_path: z
318
+ .string()
319
+ .optional()
320
+ .describe('Write the file here instead of returning its content; parent folders are created'),
321
+ overwrite: z
322
+ .boolean()
323
+ .optional()
324
+ .describe('Allow save_path to replace an existing file (default false)'),
325
+ },
326
+ handler: (args, cfg) => downloadAttachment(cfg, args.id, args.attachment_id, {
327
+ fileName: args.file_name,
328
+ savePath: args.save_path,
329
+ overwrite: args.overwrite,
330
+ }),
331
+ });
332
+ registerTool(server, config, {
333
+ name: 'clokio_download_task_attachments',
334
+ description: "Save ALL of a task's attachments to a folder in one call. Creates save_dir if missing, writes each file " +
335
+ 'under its own name, and returns the list of saved paths with sizes and sha256. Pass latest_only: true to ' +
336
+ 'skip superseded duplicates (older uploads of a name that was uploaded again). A file name that appears ' +
337
+ 'more than once is disambiguated with a numeric suffix so nothing is clobbered.',
338
+ schema: {
339
+ id: z.number().int().describe('The task id'),
340
+ save_dir: z.string().describe('Directory to write the files into; created if missing'),
341
+ latest_only: z
342
+ .boolean()
343
+ .optional()
344
+ .describe('Save only the newest upload of each file name'),
345
+ overwrite: z
346
+ .boolean()
347
+ .optional()
348
+ .describe('Allow replacing existing files in save_dir (default false)'),
349
+ },
350
+ handler: async (args, cfg) => {
351
+ const { join } = await import('node:path');
352
+ const rows = asAttachmentArray(await request(cfg, `/tasks/${args.id}/attachments`));
353
+ if (!rows) {
354
+ return { error: 'The attachments endpoint did not return a list.' };
355
+ }
356
+ const stamped = stampIsLatest(rows);
357
+ const selected = args.latest_only ? stamped.filter((r) => r.is_latest) : stamped;
358
+ const saved = [];
359
+ const failed = [];
360
+ const usedNames = new Set();
361
+ for (const row of selected) {
362
+ // file_name is API-supplied and UNTRUSTED on the write path;
363
+ // safeAttachmentName strips any directory part so it stays in save_dir.
364
+ const original = safeAttachmentName(row.file_name, row.id);
365
+ let name = original;
366
+ let counter = 2;
367
+ while (usedNames.has(name)) {
368
+ const dot = original.lastIndexOf('.');
369
+ name =
370
+ dot > 0
371
+ ? `${original.slice(0, dot)} (${counter})${original.slice(dot)}`
372
+ : `${original} (${counter})`;
373
+ counter++;
374
+ }
375
+ usedNames.add(name);
376
+ try {
377
+ const result = (await downloadAttachment(cfg, args.id, row.id, {
378
+ fileName: row.file_name,
379
+ savePath: join(args.save_dir, name),
380
+ overwrite: args.overwrite,
381
+ }));
382
+ saved.push(result);
383
+ }
384
+ catch (e) {
385
+ failed.push({
386
+ attachment_id: row.id,
387
+ file_name: row.file_name,
388
+ error: e.message,
389
+ });
390
+ }
391
+ }
392
+ return { saved_count: saved.length, saved, ...(failed.length ? { failed } : {}) };
298
393
  },
299
- handler: (args, cfg) => requestRaw(cfg, `/tasks/${args.id}/attachments/${args.attachment_id}`, args.file_name),
300
394
  });
301
395
  registerTool(server, config, {
302
396
  name: 'clokio_upload_task_attachment',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.13.2",
3
+ "version": "0.14.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": {