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 +13 -6
- package/dist/attachments.js +362 -0
- package/dist/client.js +79 -13
- package/dist/markdown.js +151 -0
- package/dist/tools/helpers.js +19 -0
- package/dist/tools/tasks.js +131 -17
- package/package.json +3 -2
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
|
|
13
|
-
re-implements authorization: it is a thin, stateless client, and a
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/markdown.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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 " 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
|
+
}
|
package/dist/tools/helpers.js
CHANGED
|
@@ -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
|
{
|
package/dist/tools/tasks.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
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);
|
|
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.
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
288
|
-
'
|
|
289
|
-
'
|
|
290
|
-
'
|
|
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.
|
|
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
|
|
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  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.
|
|
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.
|
|
47
|
+
"@types/node": "^22.20.5",
|
|
47
48
|
"typescript": "^5.9.3"
|
|
48
49
|
},
|
|
49
50
|
"engines": {
|