clokio-mcp 0.14.0 → 0.17.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,12 +71,12 @@ 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 |
78
78
  |------|-------|
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 |
79
+ | **Tasks** | list (with `compact` / `sort` / `updated_since` / `recurring`), get (with `comments_limit`), create, update (incl. recurring series: set a rule, pause/resume, stop), 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 |
80
80
  | **Projects & clients** | list projects, project statuses, project custom fields, project contacts, get client, add client contact |
81
81
  | **Reference** | task statuses, task labels, locations, list webhooks (read-only), whoami |
82
82
  | **Employees** | list (name search), lookup by PIN, task-stats, inbox (mentions, new assignments, others' status changes), create, set status |
@@ -91,8 +91,20 @@ 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`.
99
+ - **Recurring tasks are templates.** Pass `recurrence` on create/update
100
+ (`{frequency, every?, days_of_week?, day_of_month?, anchor?}`) and the task
101
+ becomes the series template; **moving it to a done status starts the
102
+ schedule**. A fresh copy (same content, assignees, labels, estimate) is
103
+ created early on each matching day, due that day, independent of earlier
104
+ copies. Pause/resume with `recurrence_active`, stop with `recurrence: null`;
105
+ `recurrence.armed` on a task says whether the series is live, a copy names
106
+ its template as `recurrence.spawned_from`, and `series_of` on the list tool
107
+ returns every occurrence of one series.
96
108
  - An API key sees **public custom fields only**.
97
109
 
98
110
  ## Security
@@ -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';
@@ -6,6 +7,122 @@ import { asAttachmentArray, downloadAttachment, safeAttachmentName, stampIsLates
6
7
  const employeeCode = z
7
8
  .string()
8
9
  .regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -');
10
+ /**
11
+ * Recurrence, as a structured object the agent fills, serialized here into
12
+ * the rule string the Clokio scheduler executes (`daily`, `weekly:sun,wed`,
13
+ * `monthly:15`, `quarterly:MM-DD`, `custom:N:unit[:detail]`, ...).
14
+ *
15
+ * The server model is a TEMPLATE: the task itself is the series. Once the
16
+ * template sits in a done status (completing it ARMS the series), the
17
+ * scheduler creates a fresh copy - same title, description, assignees,
18
+ * labels, priority, estimate - early on each matching day, due that day,
19
+ * regardless of whether earlier copies were closed. The template stays done
20
+ * forever; the copies are the actual work items.
21
+ */
22
+ const recurrenceInput = z.object({
23
+ frequency: z.enum(['daily', 'weekly', 'monthly', 'quarterly', 'semiannual', 'yearly']),
24
+ every: z
25
+ .number()
26
+ .int()
27
+ .min(1)
28
+ .max(999)
29
+ .optional()
30
+ .describe('Every N periods (default 1). "Every 2 weeks" = frequency weekly + every 2.'),
31
+ days_of_week: z
32
+ .array(z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']))
33
+ .min(1)
34
+ .optional()
35
+ .describe('weekly: which weekday(s). REQUIRED for weekly.'),
36
+ day_of_month: z
37
+ .number()
38
+ .int()
39
+ .min(1)
40
+ .max(31)
41
+ .optional()
42
+ .describe('monthly: which day of the month. REQUIRED for monthly.'),
43
+ anchor: z
44
+ .string()
45
+ .regex(/^\d{2}-\d{2}$/, 'anchor must be MM-DD')
46
+ .optional()
47
+ .describe('quarterly / semiannual / yearly: the MM-DD date the cycle starts from. REQUIRED for those.'),
48
+ });
49
+ /** Canonical weekday order for serialized rules - see recurrenceToRule(). */
50
+ const WEEKDAY_ORDER = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
51
+ /**
52
+ * A field that names a day the chosen frequency does not use is REFUSED, not
53
+ * ignored: {frequency: 'daily', days_of_week: ['mon']} reads like "every
54
+ * Monday", and silently dropping the days spawns SEVEN copies a week.
55
+ */
56
+ function refuseIrrelevant(r, allowed) {
57
+ for (const field of ['days_of_week', 'day_of_month', 'anchor']) {
58
+ if (r[field] !== undefined && !allowed.includes(field)) {
59
+ throw new Error(`${field} does not apply to frequency "${r.frequency}" and would be silently ignored - remove it, or pick the frequency it belongs to.`);
60
+ }
61
+ }
62
+ }
63
+ /** Serialize the structured object into the scheduler's rule grammar. */
64
+ export function recurrenceToRule(r) {
65
+ const every = r.every ?? 1;
66
+ switch (r.frequency) {
67
+ case 'daily':
68
+ refuseIrrelevant(r, []);
69
+ return every === 1 ? 'daily' : `custom:${every}:day`;
70
+ case 'weekly': {
71
+ refuseIrrelevant(r, ['days_of_week']);
72
+ if (!r.days_of_week?.length) {
73
+ // A day-less weekly is the exact rule shape behind a production
74
+ // outage - the server refuses it too, but failing here names the fix.
75
+ throw new Error('weekly recurrence requires days_of_week (e.g. ["sun"]).');
76
+ }
77
+ // Canonical: dedupe + fixed weekday order, so the same intent always
78
+ // stores the same rule string ("did the rule change?" comparisons and
79
+ // health reports read these back).
80
+ const unique = [...new Set(r.days_of_week)];
81
+ const days = WEEKDAY_ORDER.filter((d) => unique.includes(d)).join(',');
82
+ if (every > 1 && unique.length > 1) {
83
+ // The scheduler's custom:N:week rule spawns ONE occurrence per cycle
84
+ // (the day list only seeds which day), so "every 2 weeks on Mon and
85
+ // Thu" would silently become "every 2 weeks on Mon". Refuse rather
86
+ // than surprise - weekly (every: 1) does cover multiple days. The
87
+ // server refuses the same shape (ValidRecurrenceRule).
88
+ throw new Error('every > 1 with multiple days_of_week is not supported by the scheduler (it would spawn only on the earliest day). Use one day, or every: 1 for all of them.');
89
+ }
90
+ return every === 1 ? `weekly:${days}` : `custom:${every}:week:${days}`;
91
+ }
92
+ case 'monthly': {
93
+ refuseIrrelevant(r, ['day_of_month']);
94
+ if (!r.day_of_month) {
95
+ throw new Error('monthly recurrence requires day_of_month (1-31).');
96
+ }
97
+ return every === 1 ? `monthly:${r.day_of_month}` : `custom:${every}:month:${r.day_of_month}`;
98
+ }
99
+ case 'quarterly':
100
+ case 'semiannual':
101
+ case 'yearly': {
102
+ refuseIrrelevant(r, ['anchor']);
103
+ if (!r.anchor) {
104
+ throw new Error(`${r.frequency} recurrence requires anchor (MM-DD, the cycle's first date).`);
105
+ }
106
+ // The MM-DD shape is schema-checked; the RANGE is checked here so
107
+ // "13-45" fails fast with the field named, not as a generic server 422.
108
+ const [mo, dy] = r.anchor.split('-').map(Number);
109
+ if (mo < 1 || mo > 12 || dy < 1 || dy > 31) {
110
+ throw new Error(`anchor "${r.anchor}" is not a real date - MM must be 01-12 and DD 01-31.`);
111
+ }
112
+ if (every !== 1) {
113
+ throw new Error(`${r.frequency} does not take "every" - its cadence is fixed. For other cadences use frequency monthly/weekly with every.`);
114
+ }
115
+ return `${r.frequency}:${r.anchor}`;
116
+ }
117
+ }
118
+ }
119
+ /** The one description of the template model, shared by both task tools. */
120
+ const RECURRENCE_MODEL_NOTE = 'RECURRING TASKS: pass `recurrence` to make this task a repeating series. The task becomes the series ' +
121
+ 'TEMPLATE - move it to a done status to START the schedule (an open template is configured but not ' +
122
+ 'spawning; the resource reports this as recurrence.armed). The scheduler then creates a fresh copy of ' +
123
+ 'the template (title, description, assignees, labels, priority, estimate) early on each matching day, ' +
124
+ 'due that day, independent of whether earlier copies were closed. A multi-period cadence ' +
125
+ '(every: N > 1) counts whole periods from the task\'s DUE DATE, so set one.';
9
126
  /** Task-management tools: the core of the Clokio API surface. */
10
127
  export function registerTaskTools(server, config) {
11
128
  registerTool(server, config, {
@@ -23,6 +140,17 @@ export function registerTaskTools(server, config) {
23
140
  label: z.string().optional(),
24
141
  search: z.string().optional().describe('Free text over title and description'),
25
142
  open: z.boolean().optional().describe('true = only tasks whose status is not a "done" one'),
143
+ recurring: z
144
+ .boolean()
145
+ .optional()
146
+ .describe('true = only the recurring SERIES TEMPLATES (tasks carrying a recurrence rule); false = only ' +
147
+ 'ordinary tasks, which includes the copies a template spawns. Omit for no filter.'),
148
+ series_of: z
149
+ .number()
150
+ .int()
151
+ .optional()
152
+ .describe('A series template\'s task id: returns every occurrence that template spawned ("did the Sunday ' +
153
+ 'check actually happen every week?"). A copy also names its template as recurrence.spawned_from.'),
26
154
  parent_task_id: z
27
155
  .number()
28
156
  .int()
@@ -78,6 +206,8 @@ export function registerTaskTools(server, config) {
78
206
  label: args.label,
79
207
  search: args.search,
80
208
  open: args.open === undefined ? undefined : args.open ? 1 : 0,
209
+ recurring: args.recurring === undefined ? undefined : args.recurring ? 1 : 0,
210
+ series_of: args.series_of,
81
211
  parent_task_id: args.parent_task_id === null ? '' : args.parent_task_id,
82
212
  created_before: args.created_before,
83
213
  created_after: args.created_after,
@@ -117,11 +247,12 @@ export function registerTaskTools(server, config) {
117
247
  'You do NOT normally need created_by_employee_code: an unnamed task is attributed to the key\'s issuer ' +
118
248
  'automatically (whoami shows who that is). Set it only to credit someone OTHER than the key owner. ' +
119
249
  'Pass parent_task_id to create it as a SUBTASK of that task ' +
120
- '(same project, and the parent must not itself be a subtask - one level deep only).',
250
+ '(same project, and the parent must not itself be a subtask - one level deep only).\n\n' +
251
+ RECURRENCE_MODEL_NOTE,
121
252
  schema: {
122
253
  project_id: z.number().int(),
123
254
  title: z.string().max(255),
124
- description: z.string().optional().describe('HTML or plain text'),
255
+ description: z.string().optional().describe('Markdown, HTML or plain text (Markdown is converted to HTML)'),
125
256
  priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
126
257
  status: z.string().optional().describe("Status slug valid for THIS project's board"),
127
258
  due_date: z.string().optional().describe('YYYY-MM-DD'),
@@ -135,31 +266,61 @@ export function registerTaskTools(server, config) {
135
266
  .optional()
136
267
  .describe('Label names. A name that does not exist yet is CREATED - check clokio_list_task_labels first'),
137
268
  created_by_employee_code: employeeCode.optional().describe('Credit someone OTHER than the key owner; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
269
+ recurrence: recurrenceInput
270
+ .optional()
271
+ .describe('Make this task a recurring series (see the tool description for how the template model works)'),
272
+ },
273
+ handler: async (args, cfg) => {
274
+ const result = (await request(cfg, '/tasks', {
275
+ method: 'POST',
276
+ body: {
277
+ project_id: args.project_id,
278
+ title: args.title,
279
+ description: args.description === undefined ? undefined : markdownToHtml(args.description),
280
+ priority: args.priority,
281
+ status: args.status,
282
+ due_date: args.due_date,
283
+ start_date: args.start_date,
284
+ estimated_hours: args.estimated_hours,
285
+ parent_task_id: args.parent_task_id,
286
+ assignee_employee_codes: args.assignee_employee_codes,
287
+ label_names: args.label_names,
288
+ created_by_employee_code: args.created_by_employee_code ?? cfg.defaultActor,
289
+ ...(args.recurrence
290
+ ? { is_recurring: true, recurrence_rule: recurrenceToRule(args.recurrence) }
291
+ : {}),
292
+ },
293
+ }));
294
+ // A Clokio older than the recurrence fields IGNORES unknown body keys
295
+ // on create (Laravel validate() drops what it has no rule for), so the
296
+ // task would be born NON-recurring with a 201 - and the agent would
297
+ // report a weekly series that never spawns. The echoed resource is the
298
+ // truth: if we asked for recurrence and it is not reflected, say so
299
+ // loudly instead of letting the silence stand.
300
+ if (args.recurrence && result && typeof result === 'object') {
301
+ const data = result.data;
302
+ const echoed = data?.recurrence;
303
+ if (echoed?.is_recurring !== true) {
304
+ result.warning =
305
+ 'The task WAS created, but the server IGNORED the recurrence fields - this Clokio version ' +
306
+ 'predates recurring-series support on the API. The task will NOT repeat. Update the server, ' +
307
+ 'then set the recurrence again with clokio_update_task.';
308
+ }
309
+ }
310
+ return result;
138
311
  },
139
- handler: (args, cfg) => request(cfg, '/tasks', {
140
- method: 'POST',
141
- body: {
142
- project_id: args.project_id,
143
- title: args.title,
144
- description: args.description,
145
- priority: args.priority,
146
- status: args.status,
147
- due_date: args.due_date,
148
- start_date: args.start_date,
149
- estimated_hours: args.estimated_hours,
150
- parent_task_id: args.parent_task_id,
151
- assignee_employee_codes: args.assignee_employee_codes,
152
- label_names: args.label_names,
153
- created_by_employee_code: args.created_by_employee_code ?? cfg.defaultActor,
154
- },
155
- }),
156
312
  });
157
313
  registerTool(server, config, {
158
314
  name: 'clokio_update_task',
159
315
  mutates: true,
160
316
  description: 'Update a task. ONLY the fields you pass are changed; omitted fields are left alone. Pass null to ' +
161
317
  'due_date / start_date / estimated_hours / description to CLEAR them. Set actor_employee_code so the ' +
162
- 'activity log names the person rather than the API key.',
318
+ 'activity log names the person rather than the API key.\n\n' +
319
+ RECURRENCE_MODEL_NOTE +
320
+ '\n\nManage an existing series here: `recurrence` (an object) sets or retunes the rule and switches ' +
321
+ 'the series ON; `recurrence: null` STOPS it (future copies stop; already-spawned copies are ordinary ' +
322
+ 'tasks and stay). To PAUSE while keeping the rule stored, pass `recurrence_active: false`, and ' +
323
+ '`recurrence_active: true` to resume.',
163
324
  schema: {
164
325
  id: z.number().int(),
165
326
  title: z.string().max(255).optional(),
@@ -171,6 +332,14 @@ export function registerTaskTools(server, config) {
171
332
  estimated_hours: z.number().min(0).max(9999.99).nullable().optional(),
172
333
  project_id: z.number().int().optional().describe('Move the task to another project'),
173
334
  actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
335
+ recurrence: recurrenceInput
336
+ .nullable()
337
+ .optional()
338
+ .describe('Set/retune the series rule (and switch it on), or null to STOP the series'),
339
+ recurrence_active: z
340
+ .boolean()
341
+ .optional()
342
+ .describe('Pause (false) / resume (true) the series without touching its stored rule'),
174
343
  },
175
344
  handler: (args, cfg) => {
176
345
  // Only keys the caller actually supplied may go in the body: PATCH
@@ -191,6 +360,26 @@ export function registerTaskTools(server, config) {
191
360
  if (args[key] !== undefined)
192
361
  body[key] = args[key];
193
362
  }
363
+ if (typeof body.description === 'string')
364
+ body.description = markdownToHtml(body.description);
365
+ // Series management maps onto the API's two columns. `recurrence`
366
+ // carries the whole intent (rule + on/off), so combining it with the
367
+ // bare pause/resume switch is ambiguous and refused rather than
368
+ // silently picking a winner.
369
+ if (args.recurrence !== undefined && args.recurrence_active !== undefined) {
370
+ throw new Error('Pass either recurrence or recurrence_active, not both.');
371
+ }
372
+ if (args.recurrence === null) {
373
+ body.is_recurring = false;
374
+ body.recurrence_rule = null;
375
+ }
376
+ else if (args.recurrence !== undefined) {
377
+ body.is_recurring = true;
378
+ body.recurrence_rule = recurrenceToRule(args.recurrence);
379
+ }
380
+ else if (args.recurrence_active !== undefined) {
381
+ body.is_recurring = args.recurrence_active;
382
+ }
194
383
  // Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
195
384
  // actor. The API otherwise falls back to the key's issuer on its own,
196
385
  // so this only overrides for a shared/service key. An explicit
@@ -234,7 +423,7 @@ export function registerTaskTools(server, config) {
234
423
  mutates: true,
235
424
  description: 'Add a comment to a task. author_employee_code is usually unnecessary: an unnamed comment is attributed ' +
236
425
  '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 ' +
426
+ '(paragraphs, blank lines, bullet lists, **bold**, `code`); Markdown is converted to HTML, but TABLES are not rendered and post as raw text. Write ' +
238
427
  'in the language of the person you are answering. ' +
239
428
  'TO MENTION SOMEONE, write @ followed by their full name exactly as clokio_list_employees spells it ' +
240
429
  '(for example "@Kiran Bahadur") in the body text - no markup needed. It becomes a real mention and ' +
@@ -250,7 +439,7 @@ export function registerTaskTools(server, config) {
250
439
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments`, {
251
440
  method: 'POST',
252
441
  body: {
253
- body: args.body,
442
+ body: markdownToHtml(args.body),
254
443
  author_employee_code: args.author_employee_code ?? cfg.defaultActor,
255
444
  notify: args.notify,
256
445
  },
@@ -272,7 +461,7 @@ export function registerTaskTools(server, config) {
272
461
  },
273
462
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments/${args.comment_id}`, {
274
463
  method: 'PATCH',
275
- body: { body: args.body, author_employee_code: args.author_employee_code ?? cfg.defaultActor },
464
+ body: { body: markdownToHtml(args.body), author_employee_code: args.author_employee_code ?? cfg.defaultActor },
276
465
  }),
277
466
  });
278
467
  registerTool(server, config, {
@@ -395,10 +584,12 @@ export function registerTaskTools(server, config) {
395
584
  registerTool(server, config, {
396
585
  name: 'clokio_upload_task_attachment',
397
586
  mutates: true,
398
- description: 'Upload a LOCAL FILE to a task as an attachment - a screenshot, a log, a spreadsheet. Give the path to ' +
587
+ description: 'Upload a LOCAL FILE to a task as an attachment - a log, a spreadsheet, a PDF. Give the path to ' +
399
588
  'a file on this machine; it is read and sent. Allowed types include images, PDF, Office docs, txt/csv/' +
400
589
  '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.',
590
+ 'uploaded_by_employee_code. NOTE: an attachment is a file in the task\'s file list - its download_path ' +
591
+ 'only loads WITH an API key, so it can NOT be shown inline in a comment. For an image or video the ' +
592
+ 'reader should SEE, use clokio_upload_task_media instead.',
402
593
  schema: {
403
594
  id: z.number().int().describe('The task id'),
404
595
  file_path: z.string().describe('Path to a file on THIS machine'),
@@ -410,6 +601,21 @@ export function registerTaskTools(server, config) {
410
601
  uploaded_by_employee_code: args.uploaded_by_employee_code ?? cfg.defaultActor,
411
602
  }),
412
603
  });
604
+ registerTool(server, config, {
605
+ name: 'clokio_upload_task_media',
606
+ mutates: true,
607
+ description: 'Upload an image or video the reader should SEE INLINE in a comment or the task description ' +
608
+ '(task #5296). Returns {url} - a /tasks/media/... path served to logged-in users with no API key. ' +
609
+ 'Embed it in the body you then post: Markdown ![description](URL) or HTML <img src="URL">. ' +
610
+ 'Media types only (jpg/png/gif/webp/heic/mp4/webm/mov), 50 MB; HEIC becomes JPEG. This does NOT add ' +
611
+ 'the file to the attachments list - for a downloadable file (zip, PDF, log), use ' +
612
+ 'clokio_upload_task_attachment instead.',
613
+ schema: {
614
+ id: z.number().int().describe('The task id'),
615
+ file_path: z.string().describe('Path to an image/video on THIS machine'),
616
+ },
617
+ handler: (args, cfg) => requestUpload(cfg, `/tasks/${args.id}/media`, args.file_path),
618
+ });
413
619
  registerTool(server, config, {
414
620
  name: 'clokio_get_task_activity',
415
621
  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.17.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": {