clokio-mcp 0.14.0 → 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 +5 -2
- package/dist/markdown.js +151 -0
- package/dist/tools/tasks.js +27 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ acting. An explicit code on a tool call always takes precedence.
|
|
|
63
63
|
|
|
64
64
|
Optional: set `CLOKIO_TOOLSETS` to register only the tool groups you need, e.g.
|
|
65
65
|
`CLOKIO_TOOLSETS=tasks`. This cuts the per-session tool-list cost — a
|
|
66
|
-
task-focused session drops from
|
|
66
|
+
task-focused session drops from 50 tools to 31. Optional groups are `tasks`,
|
|
67
67
|
`employees` and `attendance` (which includes leave and time entries); combine
|
|
68
68
|
them with commas. The `projects` group (project/client lookups, task statuses
|
|
69
69
|
and labels, and `whoami`) is always on, because the task tools resolve names
|
|
@@ -71,7 +71,7 @@ through it.
|
|
|
71
71
|
|
|
72
72
|
## Tools
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
50 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
|
|
75
75
|
are marked mutating (deletes are `destructive`).
|
|
76
76
|
|
|
77
77
|
| Area | Tools |
|
|
@@ -91,6 +91,9 @@ are marked mutating (deletes are `destructive`).
|
|
|
91
91
|
issuer (see `clokio_whoami`), so you rarely set an author. Pass
|
|
92
92
|
`created_by_employee_code` / `author_employee_code` only to credit someone
|
|
93
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.
|
|
94
97
|
- **Status slugs** come from `clokio_list_task_statuses` /
|
|
95
98
|
`clokio_get_project_statuses`.
|
|
96
99
|
- An API key sees **public custom fields only**.
|
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/tasks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { markdownToHtml } from '../markdown.js';
|
|
2
3
|
import { request, requestUpload } from '../client.js';
|
|
3
4
|
import { registerTool } from './helpers.js';
|
|
4
5
|
import { asAttachmentArray, downloadAttachment, safeAttachmentName, stampIsLatest, } from '../attachments.js';
|
|
@@ -121,7 +122,7 @@ export function registerTaskTools(server, config) {
|
|
|
121
122
|
schema: {
|
|
122
123
|
project_id: z.number().int(),
|
|
123
124
|
title: z.string().max(255),
|
|
124
|
-
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)'),
|
|
125
126
|
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
|
126
127
|
status: z.string().optional().describe("Status slug valid for THIS project's board"),
|
|
127
128
|
due_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
@@ -141,7 +142,7 @@ export function registerTaskTools(server, config) {
|
|
|
141
142
|
body: {
|
|
142
143
|
project_id: args.project_id,
|
|
143
144
|
title: args.title,
|
|
144
|
-
description: args.description,
|
|
145
|
+
description: args.description === undefined ? undefined : markdownToHtml(args.description),
|
|
145
146
|
priority: args.priority,
|
|
146
147
|
status: args.status,
|
|
147
148
|
due_date: args.due_date,
|
|
@@ -191,6 +192,8 @@ export function registerTaskTools(server, config) {
|
|
|
191
192
|
if (args[key] !== undefined)
|
|
192
193
|
body[key] = args[key];
|
|
193
194
|
}
|
|
195
|
+
if (typeof body.description === 'string')
|
|
196
|
+
body.description = markdownToHtml(body.description);
|
|
194
197
|
// Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
|
|
195
198
|
// actor. The API otherwise falls back to the key's issuer on its own,
|
|
196
199
|
// so this only overrides for a shared/service key. An explicit
|
|
@@ -234,7 +237,7 @@ export function registerTaskTools(server, config) {
|
|
|
234
237
|
mutates: true,
|
|
235
238
|
description: 'Add a comment to a task. author_employee_code is usually unnecessary: an unnamed comment is attributed ' +
|
|
236
239
|
'to the key issuer automatically. Set it only to credit someone else. Use readable formatting ' +
|
|
237
|
-
'(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 ' +
|
|
238
241
|
'in the language of the person you are answering. ' +
|
|
239
242
|
'TO MENTION SOMEONE, write @ followed by their full name exactly as clokio_list_employees spells it ' +
|
|
240
243
|
'(for example "@Kiran Bahadur") in the body text - no markup needed. It becomes a real mention and ' +
|
|
@@ -250,7 +253,7 @@ export function registerTaskTools(server, config) {
|
|
|
250
253
|
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments`, {
|
|
251
254
|
method: 'POST',
|
|
252
255
|
body: {
|
|
253
|
-
body: args.body,
|
|
256
|
+
body: markdownToHtml(args.body),
|
|
254
257
|
author_employee_code: args.author_employee_code ?? cfg.defaultActor,
|
|
255
258
|
notify: args.notify,
|
|
256
259
|
},
|
|
@@ -272,7 +275,7 @@ export function registerTaskTools(server, config) {
|
|
|
272
275
|
},
|
|
273
276
|
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments/${args.comment_id}`, {
|
|
274
277
|
method: 'PATCH',
|
|
275
|
-
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 },
|
|
276
279
|
}),
|
|
277
280
|
});
|
|
278
281
|
registerTool(server, config, {
|
|
@@ -395,10 +398,12 @@ export function registerTaskTools(server, config) {
|
|
|
395
398
|
registerTool(server, config, {
|
|
396
399
|
name: 'clokio_upload_task_attachment',
|
|
397
400
|
mutates: true,
|
|
398
|
-
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 ' +
|
|
399
402
|
'a file on this machine; it is read and sent. Allowed types include images, PDF, Office docs, txt/csv/' +
|
|
400
403
|
'md/json, code diffs, zip and video, up to 200 MB. The uploader is the key issuer unless you set ' +
|
|
401
|
-
'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.',
|
|
402
407
|
schema: {
|
|
403
408
|
id: z.number().int().describe('The task id'),
|
|
404
409
|
file_path: z.string().describe('Path to a file on THIS machine'),
|
|
@@ -410,6 +415,21 @@ export function registerTaskTools(server, config) {
|
|
|
410
415
|
uploaded_by_employee_code: args.uploaded_by_employee_code ?? cfg.defaultActor,
|
|
411
416
|
}),
|
|
412
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
|
+
});
|
|
413
433
|
registerTool(server, config, {
|
|
414
434
|
name: 'clokio_get_task_activity',
|
|
415
435
|
description: 'Get the activity log (audit trail) for a task.',
|
package/package.json
CHANGED