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 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 48 tools to 26. Optional groups are `tasks`,
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
- 48 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
74
+ 50 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
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**.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Markdown → HTML for the text an agent writes INTO Clokio.
3
+ *
4
+ * Clokio stores task descriptions and comments as sanitized HTML (the web
5
+ * editor is TipTap). A model naturally writes Markdown, and the first task
6
+ * created through this server from Claude arrived as one flat paragraph with
7
+ * literal `**` and backticks. Converting here keeps every tool's input
8
+ * natural for the model and correct for Clokio.
9
+ *
10
+ * Deliberately small: paragraphs, line breaks, headings, bullet and numbered
11
+ * lists, fenced code, blockquotes, bold, italic, inline code and links. Text
12
+ * that already starts with an HTML tag is passed through untouched, so a
13
+ * caller that sends HTML keeps working. Everything else is escaped, so a
14
+ * stray `<b>` in prose shows as text rather than becoming markup.
15
+ */
16
+ // `"` too, not just the tag characters: escaping runs BEFORE the link regex,
17
+ // and the generated <a href="..."> wraps the URL in double quotes - a
18
+ // quote the URL smuggles through would close the attribute and open a new
19
+ // one (`[x](https://e.com/"onmouseover="alert(1))` produced a live
20
+ // onmouseover, reproduced in review). Clokio's server sanitizer strips such
21
+ // attributes anyway, but this package is consumed outside Clokio's web
22
+ // surfaces too, and it must not emit injectable HTML in the first place.
23
+ const escapeHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
+ // The EXACT shape the backend's upload answers with (tasks/org-{n}/{n}/media/
25
+ // {file}) - anchored, no slash or dot-segment room. A bare startsWith
26
+ // allowlist let `/tasks/media/../../anything` through, and the browser
27
+ // normalizes dot segments BEFORE sending, so that <img> fired an arbitrary
28
+ // same-origin GET with the logged-in reader's session cookie (caught in
29
+ // review). Anything under /tasks/media/ that is not this shape stays text.
30
+ const MEDIA_PATH = /^\/tasks\/media\/tasks\/org-\d+\/\d+\/media\/[^/\s)]+$/;
31
+ const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
32
+ function inline(text) {
33
+ // The stash protects finished HTML from the LATER rules: inline code first
34
+ // (so its contents are never styled or linked), then each generated
35
+ // image/video/anchor tag - without that, the em rule rewrote a `*` inside
36
+ // a URL into <em> IN THE src ATTRIBUTE and broke the image (caught in
37
+ // review; alt text likewise stays literal rather than styled).
38
+ const stashed = [];
39
+ const stash = (html) => {
40
+ stashed.push(html);
41
+ return `\u0000${stashed.length - 1}\u0000`;
42
+ };
43
+ let s = text.replace(/`([^`\n]+)`/g, (_m, code) => stash(`<code>${escapeHtml(code)}</code>`));
44
+ s = escapeHtml(s);
45
+ // Images BEFORE links, or the link rule eats the [alt](url) part and leaves
46
+ // a stray "!". Besides https, the /tasks/media/ urls that
47
+ // clokio_upload_task_media returns are allowed - embedding them is that
48
+ // tool's whole point (task #5296). A video extension gets a <video> tag:
49
+ // an <img> pointing at an mp4 renders as a broken image. escapeHtml
50
+ // already ran, so a quote in the URL is &quot; and cannot close the
51
+ // attribute.
52
+ s = s.replace(/!\[([^\]\n]*)\]\(((?:https?:\/\/|\/tasks\/media\/)[^)\s]+)\)/g, (whole, alt, url) => {
53
+ if (url.startsWith('/tasks/media/') && !MEDIA_PATH.test(url))
54
+ return whole;
55
+ return stash(VIDEO_EXT.test(url)
56
+ ? `<video src="${url}" controls></video>`
57
+ : `<img src="${url}" alt="${alt}">`);
58
+ });
59
+ s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, (_m, label, url) => stash(`<a href="${url}">${label}</a>`));
60
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
61
+ s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, '$1<em>$2</em>');
62
+ return s.replace(/\u0000(\d+)\u0000/g, (_m, i) => stashed[Number(i)]);
63
+ }
64
+ export function looksLikeHtml(text) {
65
+ // `video` is in the list for the same reason `img` is: the media tool's
66
+ // HTML advice starts a body with exactly that tag, and leaving it out
67
+ // escaped the whole body to literal text (caught in review).
68
+ return /^\s*<(p|div|ul|ol|li|h[1-6]|strong|em|b|i|u|s|br|pre|code|blockquote|a|img|video|span|table)\b[^>]*>/i.test(text);
69
+ }
70
+ export function markdownToHtml(input) {
71
+ if (input == null)
72
+ return '';
73
+ const text = String(input);
74
+ if (!text.trim())
75
+ return '';
76
+ if (looksLikeHtml(text))
77
+ return text;
78
+ const out = [];
79
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
80
+ let para = [];
81
+ let list = null;
82
+ let quote = [];
83
+ const flushPara = () => {
84
+ if (para.length)
85
+ out.push(`<p>${para.map(inline).join('<br>')}</p>`);
86
+ para = [];
87
+ };
88
+ const flushList = () => {
89
+ if (list)
90
+ out.push(`<${list.tag}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.tag}>`);
91
+ list = null;
92
+ };
93
+ const flushQuote = () => {
94
+ if (quote.length)
95
+ out.push(`<blockquote><p>${quote.map(inline).join('<br>')}</p></blockquote>`);
96
+ quote = [];
97
+ };
98
+ const flushAll = () => {
99
+ flushPara();
100
+ flushList();
101
+ flushQuote();
102
+ };
103
+ for (let i = 0; i < lines.length; i++) {
104
+ const line = lines[i];
105
+ const trimmed = line.trim();
106
+ if (/^```/.test(trimmed)) {
107
+ flushAll();
108
+ const code = [];
109
+ i++;
110
+ while (i < lines.length && !/^```/.test(lines[i].trim()))
111
+ code.push(lines[i++]);
112
+ out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
113
+ continue;
114
+ }
115
+ if (!trimmed) {
116
+ flushAll();
117
+ continue;
118
+ }
119
+ const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
120
+ if (heading) {
121
+ flushAll();
122
+ out.push(`<h${heading[1].length}>${inline(heading[2])}</h${heading[1].length}>`);
123
+ continue;
124
+ }
125
+ const quoted = /^>\s?(.*)$/.exec(trimmed);
126
+ if (quoted) {
127
+ flushPara();
128
+ flushList();
129
+ quote.push(quoted[1]);
130
+ continue;
131
+ }
132
+ const bullet = /^[-*+]\s+(.+)$/.exec(trimmed);
133
+ const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
134
+ if (bullet || numbered) {
135
+ flushPara();
136
+ flushQuote();
137
+ const tag = bullet ? 'ul' : 'ol';
138
+ if (!list || list.tag !== tag) {
139
+ flushList();
140
+ list = { tag, items: [] };
141
+ }
142
+ list.items.push((bullet ?? numbered)[1]);
143
+ continue;
144
+ }
145
+ flushList();
146
+ flushQuote();
147
+ para.push(trimmed);
148
+ }
149
+ flushAll();
150
+ return out.join('');
151
+ }
@@ -1,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); markdown TABLES are not rendered and post as raw text. Write ' +
240
+ '(paragraphs, blank lines, bullet lists, **bold**, `code`); Markdown is converted to HTML, but TABLES are not rendered and post as raw text. Write ' +
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 screenshot, a log, a spreadsheet. Give the path to ' +
401
+ description: 'Upload a LOCAL FILE to a task as an attachment - a log, a spreadsheet, a PDF. Give the path to ' +
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 ![description](URL) or HTML <img src="URL">. ' +
424
+ 'Media types only (jpg/png/gif/webp/heic/mp4/webm/mov), 50 MB; HEIC becomes JPEG. This does NOT add ' +
425
+ 'the file to the attachments list - for a downloadable file (zip, PDF, log), use ' +
426
+ 'clokio_upload_task_attachment instead.',
427
+ schema: {
428
+ id: z.number().int().describe('The task id'),
429
+ file_path: z.string().describe('Path to an image/video on THIS machine'),
430
+ },
431
+ handler: (args, cfg) => requestUpload(cfg, `/tasks/${args.id}/media`, args.file_path),
432
+ });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.14.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": {