clokio-mcp 0.16.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
@@ -76,7 +76,7 @@ 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 |
@@ -96,6 +96,15 @@ are marked mutating (deletes are `destructive`).
96
96
  Clokio stores. Text that already starts with an HTML tag is passed through untouched.
97
97
  - **Status slugs** come from `clokio_list_task_statuses` /
98
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.
99
108
  - An API key sees **public custom fields only**.
100
109
 
101
110
  ## Security
@@ -7,6 +7,122 @@ import { asAttachmentArray, downloadAttachment, safeAttachmentName, stampIsLates
7
7
  const employeeCode = z
8
8
  .string()
9
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.';
10
126
  /** Task-management tools: the core of the Clokio API surface. */
11
127
  export function registerTaskTools(server, config) {
12
128
  registerTool(server, config, {
@@ -24,6 +140,17 @@ export function registerTaskTools(server, config) {
24
140
  label: z.string().optional(),
25
141
  search: z.string().optional().describe('Free text over title and description'),
26
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.'),
27
154
  parent_task_id: z
28
155
  .number()
29
156
  .int()
@@ -79,6 +206,8 @@ export function registerTaskTools(server, config) {
79
206
  label: args.label,
80
207
  search: args.search,
81
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,
82
211
  parent_task_id: args.parent_task_id === null ? '' : args.parent_task_id,
83
212
  created_before: args.created_before,
84
213
  created_after: args.created_after,
@@ -118,7 +247,8 @@ export function registerTaskTools(server, config) {
118
247
  'You do NOT normally need created_by_employee_code: an unnamed task is attributed to the key\'s issuer ' +
119
248
  'automatically (whoami shows who that is). Set it only to credit someone OTHER than the key owner. ' +
120
249
  'Pass parent_task_id to create it as a SUBTASK of that task ' +
121
- '(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,
122
252
  schema: {
123
253
  project_id: z.number().int(),
124
254
  title: z.string().max(255),
@@ -136,31 +266,61 @@ export function registerTaskTools(server, config) {
136
266
  .optional()
137
267
  .describe('Label names. A name that does not exist yet is CREATED - check clokio_list_task_labels first'),
138
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;
139
311
  },
140
- handler: (args, cfg) => request(cfg, '/tasks', {
141
- method: 'POST',
142
- body: {
143
- project_id: args.project_id,
144
- title: args.title,
145
- description: args.description === undefined ? undefined : markdownToHtml(args.description),
146
- priority: args.priority,
147
- status: args.status,
148
- due_date: args.due_date,
149
- start_date: args.start_date,
150
- estimated_hours: args.estimated_hours,
151
- parent_task_id: args.parent_task_id,
152
- assignee_employee_codes: args.assignee_employee_codes,
153
- label_names: args.label_names,
154
- created_by_employee_code: args.created_by_employee_code ?? cfg.defaultActor,
155
- },
156
- }),
157
312
  });
158
313
  registerTool(server, config, {
159
314
  name: 'clokio_update_task',
160
315
  mutates: true,
161
316
  description: 'Update a task. ONLY the fields you pass are changed; omitted fields are left alone. Pass null to ' +
162
317
  'due_date / start_date / estimated_hours / description to CLEAR them. Set actor_employee_code so the ' +
163
- '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.',
164
324
  schema: {
165
325
  id: z.number().int(),
166
326
  title: z.string().max(255).optional(),
@@ -172,6 +332,14 @@ export function registerTaskTools(server, config) {
172
332
  estimated_hours: z.number().min(0).max(9999.99).nullable().optional(),
173
333
  project_id: z.number().int().optional().describe('Move the task to another project'),
174
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'),
175
343
  },
176
344
  handler: (args, cfg) => {
177
345
  // Only keys the caller actually supplied may go in the body: PATCH
@@ -194,6 +362,24 @@ export function registerTaskTools(server, config) {
194
362
  }
195
363
  if (typeof body.description === 'string')
196
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
+ }
197
383
  // Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
198
384
  // actor. The API otherwise falls back to the key's issuer on its own,
199
385
  // so this only overrides for a shared/service key. An explicit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.16.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": {