clokio-mcp 0.16.0 → 0.17.1
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 +10 -1
- package/dist/tools/tasks.js +205 -19
- package/package.json +1 -1
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
|
package/dist/tools/tasks.js
CHANGED
|
@@ -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