clokio-mcp 0.25.0 → 0.27.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 +6 -5
- package/dist/tools/projects.js +155 -0
- package/dist/tools/tasks.js +384 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ acting. An explicit code on a tool call always takes precedence.
|
|
|
65
65
|
|
|
66
66
|
Optional: set `CLOKIO_TOOLSETS` to register only the tool groups you need, e.g.
|
|
67
67
|
`CLOKIO_TOOLSETS=tasks`. This cuts the per-session tool-list cost - a
|
|
68
|
-
task-focused session drops from
|
|
68
|
+
task-focused session drops from 127 tools to 40. Optional groups are `tasks`,
|
|
69
69
|
`employees`, `attendance` (which includes leave, time entries and logging time), `docs` and
|
|
70
70
|
`tickets`;
|
|
71
71
|
combine them with commas. If your key cannot open documents (no acts-as
|
|
@@ -76,13 +76,13 @@ through it.
|
|
|
76
76
|
|
|
77
77
|
## Tools
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
127 tools across seven areas. Read-only tools carry the MCP `readOnlyHint`; writes
|
|
80
80
|
are marked mutating (deletes are `destructive`).
|
|
81
81
|
|
|
82
82
|
| Area | Tools |
|
|
83
83
|
|------|-------|
|
|
84
|
-
| **Tasks** | list (with `compact` / `sort` / `updated_since` / `recurring`), get (with `comments_limit`; names the ticket a task was opened from), create, update (incl. recurring series: set a rule, pause/resume, stop), delete, bulk update, set assignees (a shared board's client people as `{employee_code, organization_id}`), list a board's people, 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, read images pasted into descriptions and comments, activity (with organisations, paged), list/add/remove dependencies, set custom field |
|
|
85
|
-
| **Projects & clients** | list projects, project statuses, project custom fields, project contacts, get client, add client contact |
|
|
84
|
+
| **Tasks** | list (with `compact` / `sort` / `updated_since` / `updated_before` / `recurring`, no due date, no assignee, a custom field value, title-only search), a project's task summary (counts by status / priority / label / assignee, overdue and stale lists, per option of a release dropdown: `clokio_get_project_task_summary`), a project's activity log (`clokio_list_project_activity`), get (with `comments_limit`; names the ticket a task was opened from), create, update (incl. recurring series: set a rule, pause/resume, stop; labels: replace, add, remove; parent task; assignees in the same call), delete, bulk update (status, priority, due date, estimate, add / remove labels), set assignees (a shared board's client people as `{employee_code, organization_id}`), list a board's people, 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, read images pasted into descriptions and comments, activity (with organisations, paged), list/add/remove dependencies, set custom field (an option by value or label), set custom fields on many tasks (`clokio_bulk_set_task_custom_field`, partial success); `clokio_update_task` takes `custom_fields` too |
|
|
85
|
+
| **Projects & clients** | list projects, project statuses (and create / edit them), project custom fields (and create / edit them, e.g. a release dropdown), project contacts, get client, add client contact |
|
|
86
86
|
| **Reference** | task statuses, task labels, locations, list webhooks (read-only), whoami |
|
|
87
87
|
| **Employees** | list (name search), lookup by PIN, task-stats, inbox (mentions, new assignments, others' status changes; with the tickets toolset, also the acting employee's open tickets), create, set status |
|
|
88
88
|
| **Attendance** | daily, range, per-employee, monthly summary, clock in/out, break start/end, time entries (local times in the person's zone; edit / delete your own), leaves (with `days`, half days counted 0.5), days of leave taken per type and year (`clokio_get_leave_days_taken`; the old name `clokio_get_leave_balances` still works; Clokio tracks no quota or remaining balance; leave and time-entry lists take `employee_code` and at most 92 days per call), **log time on a task**, start / stop / read the timer (see [Logging time](#logging-time)) |
|
|
@@ -117,7 +117,8 @@ are marked mutating (deletes are `destructive`).
|
|
|
117
117
|
`recurrence.armed` on a task says whether the series is live, a copy names
|
|
118
118
|
its template as `recurrence.spawned_from`, and `series_of` on the list tool
|
|
119
119
|
returns every occurrence of one series.
|
|
120
|
-
- An API key sees **public custom fields only**.
|
|
120
|
+
- An API key sees **public custom fields only**. A dropdown or multi-select
|
|
121
|
+
value may be the option's value or its label as shown on screen.
|
|
121
122
|
|
|
122
123
|
## Logging time
|
|
123
124
|
|
package/dist/tools/projects.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { request } from '../client.js';
|
|
3
3
|
import { registerTool } from './helpers.js';
|
|
4
4
|
import { VERSION } from '../version.js';
|
|
5
|
+
import { FIELD_KEY_PATTERN } from './tasks.js';
|
|
5
6
|
/** Projects, clients, and the board-configuration + reference lookups. */
|
|
6
7
|
export function registerProjectTools(server, config) {
|
|
7
8
|
registerTool(server, config, {
|
|
@@ -29,6 +30,160 @@ export function registerProjectTools(server, config) {
|
|
|
29
30
|
schema: { project_id: z.number().int() },
|
|
30
31
|
handler: (args, cfg) => request(cfg, `/projects/${args.project_id}/custom-fields`),
|
|
31
32
|
});
|
|
33
|
+
// ── Board configuration writes (0.27.0, backend #5874 part 4) ──
|
|
34
|
+
// projects:write. Every rule is the web editor's own copy on the server;
|
|
35
|
+
// nothing is judged here beyond the shapes. No delete in this version.
|
|
36
|
+
const fieldOptions = z
|
|
37
|
+
.array(z.object({
|
|
38
|
+
value: z.string().min(1).max(100).describe('The stored value tasks hold; keep it stable'),
|
|
39
|
+
label: z.string().max(100).optional().describe('What people see; defaults to the value'),
|
|
40
|
+
color: z
|
|
41
|
+
.string()
|
|
42
|
+
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be #rrggbb')
|
|
43
|
+
.optional(),
|
|
44
|
+
}))
|
|
45
|
+
.min(1)
|
|
46
|
+
.max(200);
|
|
47
|
+
const fieldType = z.enum(['text', 'number', 'date', 'dropdown', 'checkbox', 'multi_select', 'url', 'email']);
|
|
48
|
+
const actorCode = z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code: letters, digits, _ or -')
|
|
51
|
+
.optional()
|
|
52
|
+
.describe('Checked by the server (an unknown code is refused); a configuration edit writes no activity row');
|
|
53
|
+
const statusColor = z
|
|
54
|
+
.string()
|
|
55
|
+
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be #rrggbb')
|
|
56
|
+
.nullable()
|
|
57
|
+
.optional();
|
|
58
|
+
registerTool(server, config, {
|
|
59
|
+
name: 'clokio_create_project_custom_field',
|
|
60
|
+
mutates: true,
|
|
61
|
+
description: 'Create a custom field on a project (the board\'s columns). type is one of text, number, date, dropdown, ' +
|
|
62
|
+
'checkbox, multi_select, url, email. A dropdown or multi_select NEEDS options ({value, label?, color?}; ' +
|
|
63
|
+
'the label defaults to the value); every other type takes none. The field key is generated from the ' +
|
|
64
|
+
'name and never changes (it is what clokio_set_task_custom_field and the list filters take). An API ' +
|
|
65
|
+
'key creates PUBLIC fields only. A release or sprint is a dropdown field: create it once, add an ' +
|
|
66
|
+
'option per release with clokio_update_project_custom_field, set it on tasks with ' +
|
|
67
|
+
'clokio_bulk_set_task_custom_field, read it with clokio_get_project_task_summary group_by_custom_field. ' +
|
|
68
|
+
'Needs the projects:write scope.',
|
|
69
|
+
schema: {
|
|
70
|
+
project_id: z.number().int(),
|
|
71
|
+
name: z.string().min(1).max(50),
|
|
72
|
+
type: fieldType,
|
|
73
|
+
options: fieldOptions.optional(),
|
|
74
|
+
is_required: z.boolean().optional(),
|
|
75
|
+
actor_employee_code: actorCode,
|
|
76
|
+
},
|
|
77
|
+
handler: (args, cfg) => {
|
|
78
|
+
const optioned = args.type === 'dropdown' || args.type === 'multi_select';
|
|
79
|
+
if (optioned && args.options === undefined) {
|
|
80
|
+
throw new Error(`A ${args.type} field needs options: give at least one {value, label}.`);
|
|
81
|
+
}
|
|
82
|
+
if (!optioned && args.options !== undefined) {
|
|
83
|
+
throw new Error(`A ${args.type} field takes no options; only dropdown and multi_select do.`);
|
|
84
|
+
}
|
|
85
|
+
const body = { name: args.name, type: args.type };
|
|
86
|
+
if (args.options !== undefined)
|
|
87
|
+
body.options = args.options;
|
|
88
|
+
if (args.is_required !== undefined)
|
|
89
|
+
body.is_required = args.is_required;
|
|
90
|
+
const actor = args.actor_employee_code ?? cfg.defaultActor;
|
|
91
|
+
if (actor)
|
|
92
|
+
body.actor_employee_code = actor;
|
|
93
|
+
return request(cfg, `/projects/${args.project_id}/custom-fields`, { method: 'POST', body });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
registerTool(server, config, {
|
|
97
|
+
name: 'clokio_update_project_custom_field',
|
|
98
|
+
mutates: true,
|
|
99
|
+
description: 'Edit a project custom field by its field_key: rename it (the key stays), change its type, flip ' +
|
|
100
|
+
'is_required, or replace its options with the WHOLE new list. To add a release, read the field with ' +
|
|
101
|
+
'clokio_get_project_custom_fields and send every option plus the new one; renaming a label or ' +
|
|
102
|
+
'reordering is free (tasks store the value). Removing an option tasks still hold is refused naming ' +
|
|
103
|
+
'it; so is a type change that would leave stored answers unreadable. A private field answers not ' +
|
|
104
|
+
'found. Needs the projects:write scope.',
|
|
105
|
+
schema: {
|
|
106
|
+
project_id: z.number().int(),
|
|
107
|
+
field_key: z.string().regex(FIELD_KEY_PATTERN, 'field_key: letters, digits, _ or -'),
|
|
108
|
+
name: z.string().min(1).max(50).optional(),
|
|
109
|
+
type: fieldType.optional(),
|
|
110
|
+
options: fieldOptions.optional(),
|
|
111
|
+
is_required: z.boolean().optional(),
|
|
112
|
+
actor_employee_code: actorCode,
|
|
113
|
+
},
|
|
114
|
+
handler: (args, cfg) => {
|
|
115
|
+
const body = {};
|
|
116
|
+
for (const key of ['name', 'type', 'options', 'is_required']) {
|
|
117
|
+
if (args[key] !== undefined)
|
|
118
|
+
body[key] = args[key];
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(body).length === 0) {
|
|
121
|
+
throw new Error('Nothing to change: give at least one of name, type, options, is_required.');
|
|
122
|
+
}
|
|
123
|
+
const actor = args.actor_employee_code ?? cfg.defaultActor;
|
|
124
|
+
if (actor)
|
|
125
|
+
body.actor_employee_code = actor;
|
|
126
|
+
return request(cfg, `/projects/${args.project_id}/custom-fields/${args.field_key}`, { method: 'PATCH', body });
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
registerTool(server, config, {
|
|
130
|
+
name: 'clokio_create_project_status',
|
|
131
|
+
mutates: true,
|
|
132
|
+
description: 'Add a status (a board column) to a project. The name must be new in the project (compared ignoring ' +
|
|
133
|
+
'case); the slug is generated from it and never changes. A new status is always ACTIVE: a project has ' +
|
|
134
|
+
'exactly one completed status, which always stays the last column. position (0-based) places it. A ' +
|
|
135
|
+
'project still using the organisation\'s statuses gets its own copy first, so no other project ' +
|
|
136
|
+
'changes. Needs the projects:write scope.',
|
|
137
|
+
schema: {
|
|
138
|
+
project_id: z.number().int(),
|
|
139
|
+
name: z.string().min(1).max(50),
|
|
140
|
+
color: statusColor.describe('#rrggbb'),
|
|
141
|
+
position: z.number().int().min(0).max(200).optional().describe('0-based place on the board'),
|
|
142
|
+
actor_employee_code: actorCode,
|
|
143
|
+
},
|
|
144
|
+
handler: (args, cfg) => {
|
|
145
|
+
const body = { name: args.name };
|
|
146
|
+
if (args.color !== undefined)
|
|
147
|
+
body.color = args.color;
|
|
148
|
+
if (args.position !== undefined)
|
|
149
|
+
body.position = args.position;
|
|
150
|
+
const actor = args.actor_employee_code ?? cfg.defaultActor;
|
|
151
|
+
if (actor)
|
|
152
|
+
body.actor_employee_code = actor;
|
|
153
|
+
return request(cfg, `/projects/${args.project_id}/statuses`, { method: 'POST', body });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
registerTool(server, config, {
|
|
157
|
+
name: 'clokio_update_project_status',
|
|
158
|
+
mutates: true,
|
|
159
|
+
description: 'Edit a project status by its slug (clokio_get_project_statuses): rename (the slug stays), recolour, ' +
|
|
160
|
+
'move (position, 0-based; the completed status stays last) or change category. A status cannot become ' +
|
|
161
|
+
'a second completed one, and the completed one cannot become active. Statuses cannot be deleted ' +
|
|
162
|
+
'through the API. Needs the projects:write scope.',
|
|
163
|
+
schema: {
|
|
164
|
+
project_id: z.number().int(),
|
|
165
|
+
slug: z.string().regex(/^[A-Za-z0-9_-]{1,100}$/, 'slug: letters, digits, _ or -'),
|
|
166
|
+
name: z.string().min(1).max(50).optional(),
|
|
167
|
+
color: statusColor.describe('#rrggbb, or null for the default colour'),
|
|
168
|
+
category: z.enum(['active', 'done']).optional(),
|
|
169
|
+
position: z.number().int().min(0).max(200).optional().describe('0-based place on the board'),
|
|
170
|
+
actor_employee_code: actorCode,
|
|
171
|
+
},
|
|
172
|
+
handler: (args, cfg) => {
|
|
173
|
+
const body = {};
|
|
174
|
+
for (const key of ['name', 'color', 'category', 'position']) {
|
|
175
|
+
if (args[key] !== undefined)
|
|
176
|
+
body[key] = args[key];
|
|
177
|
+
}
|
|
178
|
+
if (Object.keys(body).length === 0) {
|
|
179
|
+
throw new Error('Nothing to change: give at least one of name, color, category, position.');
|
|
180
|
+
}
|
|
181
|
+
const actor = args.actor_employee_code ?? cfg.defaultActor;
|
|
182
|
+
if (actor)
|
|
183
|
+
body.actor_employee_code = actor;
|
|
184
|
+
return request(cfg, `/projects/${args.project_id}/statuses/${args.slug}`, { method: 'PATCH', body });
|
|
185
|
+
},
|
|
186
|
+
});
|
|
32
187
|
registerTool(server, config, {
|
|
33
188
|
name: 'clokio_get_project_contacts',
|
|
34
189
|
description: "The contacts for a project's client.",
|
package/dist/tools/tasks.js
CHANGED
|
@@ -7,6 +7,32 @@ import { asAttachmentArray, downloadAttachment, downloadTaskMedia, inlineMediaRo
|
|
|
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
|
+
* A custom field's field_key, by the backend's own rule: the route
|
|
12
|
+
* `->where('fieldKey', '[A-Za-z0-9_-]{1,50}')` and the 50-char `key` column
|
|
13
|
+
* (keys are generated with Str::slug). Every field_key input uses it, so a
|
|
14
|
+
* key such as `a]b` or `x&y` never reaches a query string or a path.
|
|
15
|
+
*/
|
|
16
|
+
export const FIELD_KEY_PATTERN = /^[A-Za-z0-9_-]{1,50}$/;
|
|
17
|
+
const fieldKey = z.string().regex(FIELD_KEY_PATTERN, 'field_key must be 1-50 chars of letters, digits, _ or -');
|
|
18
|
+
/** THE estimated_hours ceiling of every Public API write (create, update, bulk): the backend's ESTIMATED_HOURS_MAX. */
|
|
19
|
+
export const ESTIMATED_HOURS_MAX = 9999.99;
|
|
20
|
+
/**
|
|
21
|
+
* One custom field value as the API takes it: text / number / date string,
|
|
22
|
+
* true / false for a checkbox, an option's value OR label for a dropdown, a
|
|
23
|
+
* list of them for a multi-select, null to clear. The server validates it
|
|
24
|
+
* against the field's type (backend task #5874 part 2).
|
|
25
|
+
*/
|
|
26
|
+
const customFieldValue = z
|
|
27
|
+
.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.null()])
|
|
28
|
+
.describe('The value; a dropdown option by its value or its label; null clears');
|
|
29
|
+
/** field_key -> value, at least one and at most 50 fields (the server's cap). */
|
|
30
|
+
const customFieldsMap = z
|
|
31
|
+
.record(fieldKey, customFieldValue)
|
|
32
|
+
.refine((m) => Object.keys(m).length >= 1 && Object.keys(m).length <= 50, {
|
|
33
|
+
message: 'custom_fields must name 1-50 fields',
|
|
34
|
+
})
|
|
35
|
+
.describe('Object of field_key -> value, e.g. {"stage": "In build", "client_po": "PO-1"}');
|
|
10
36
|
/**
|
|
11
37
|
* One assignee (Clokio Public API, spec 2026-10-16). A STRING is an
|
|
12
38
|
* employee_code of the key's OWN organisation. On a board shared with a
|
|
@@ -178,6 +204,45 @@ const RECURRENCE_MODEL_NOTE = 'RECURRING TASKS: pass `recurrence` to make this t
|
|
|
178
204
|
'the template (title, description, assignees, labels, priority, estimate) early on each matching day, ' +
|
|
179
205
|
'due that day, independent of whether earlier copies were closed. A multi-period cadence ' +
|
|
180
206
|
'(every: N > 1) counts whole periods from the task\'s DUE DATE, so set one.';
|
|
207
|
+
/**
|
|
208
|
+
* A label name as the API takes it: the task_labels column is 50 characters
|
|
209
|
+
* (the backend validates max:50 on every label field since 0.26.0's backend,
|
|
210
|
+
* task #5874).
|
|
211
|
+
*/
|
|
212
|
+
const labelName = z.string().min(1).max(50);
|
|
213
|
+
/** One names field on update / bulk: up to 50 names (the API's cap). */
|
|
214
|
+
const labelNames = z.array(labelName).max(50).optional();
|
|
215
|
+
/** How labels work on clokio_update_task, said once. */
|
|
216
|
+
const LABELS_ON_UPDATE_NOTE = 'LABELS: `label_names` REPLACES the task\'s whole label set (an empty list removes every label). ' +
|
|
217
|
+
'`add_label_names` / `remove_label_names` change it instead, keeping the rest. Do not combine ' +
|
|
218
|
+
'`label_names` with either of them, and do not put one label in both (spellings the organisation treats as ' +
|
|
219
|
+
'one label count; the server refuses it). An empty list is not a change. A name that does not exist in ' +
|
|
220
|
+
'your organisation is CREATED (as on clokio_create_task; check clokio_list_task_labels first to reuse ' +
|
|
221
|
+
'an existing spelling), names match case-insensitively, a remove never creates, and adding a label the ' +
|
|
222
|
+
'task already carries or removing one it does not carry changes nothing. Up to 50 names per field, ' +
|
|
223
|
+
'50 characters each. Each change is written to the task\'s activity log, credited like any other edit.';
|
|
224
|
+
/** A diff list that names something; an empty list is not a change (the API ignores it too). */
|
|
225
|
+
export function hasNames(list) {
|
|
226
|
+
return Array.isArray(list) && list.length > 0;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Refuse before any request what the API would refuse anyway: replace with a
|
|
230
|
+
* non-empty diff (update only), and the same name (trimmed, case-folded) in
|
|
231
|
+
* both diff lists, which the API refuses before any lookup. Accent variants
|
|
232
|
+
* of an existing label ("Café" / "Cafe") are judged by the API on the
|
|
233
|
+
* resolved labels, in its own sentence.
|
|
234
|
+
*/
|
|
235
|
+
export function refuseLabelConflicts(args, allowReplace) {
|
|
236
|
+
if (allowReplace && args.label_names !== undefined && (hasNames(args.add_label_names) || hasNames(args.remove_label_names))) {
|
|
237
|
+
throw new Error('Pass either label_names (replaces every label) or add_label_names / remove_label_names (a change), not both.');
|
|
238
|
+
}
|
|
239
|
+
const fold = (n) => n.trim().toLowerCase();
|
|
240
|
+
const adding = new Set((args.add_label_names ?? []).map(fold));
|
|
241
|
+
const both = [...new Set((args.remove_label_names ?? []).filter((n) => adding.has(fold(n))).map((n) => n.trim()))];
|
|
242
|
+
if (both.length > 0) {
|
|
243
|
+
throw new Error(`These labels are in both add_label_names and remove_label_names: ${both.join(', ')}. Send each label in one of them.`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
181
246
|
/** Task-management tools: the core of the Clokio API surface. */
|
|
182
247
|
/** #5: what clokio_set_task_assignees answers - the id, the link and the people, never the whole task. */
|
|
183
248
|
export function compactAssignees(taskId, t, baseUrl = 'https://app.clokio.io') {
|
|
@@ -224,6 +289,32 @@ export function renderActivity(res) {
|
|
|
224
289
|
`Page ${meta.current_page ?? '-'} of ${meta.last_page ?? '-'} (total ${meta.total ?? '-'}); pass page to continue.`,
|
|
225
290
|
].join('\n');
|
|
226
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* 0.27.0 (backend #5874 part 4): a project's activity feed, one change per
|
|
294
|
+
* line like renderActivity, each naming its task, then the keyset cursor to
|
|
295
|
+
* continue with. Values are flattened the same way, so a value cannot print
|
|
296
|
+
* something that looks like further lines.
|
|
297
|
+
*/
|
|
298
|
+
export function renderProjectActivity(res) {
|
|
299
|
+
const rows = Array.isArray(res) ? res : (res?.data ?? []);
|
|
300
|
+
const meta = res?.meta ?? {};
|
|
301
|
+
const flat = (v) => String(v).replace(/\s+/g, ' ').trim();
|
|
302
|
+
const value = (v) => {
|
|
303
|
+
if (v === null || v === undefined)
|
|
304
|
+
return '-';
|
|
305
|
+
const f = flat(typeof v === 'object' ? JSON.stringify(v) : v);
|
|
306
|
+
return f === '' ? '-' : f;
|
|
307
|
+
};
|
|
308
|
+
const task = (t) => t ? `task #${flat(t.id)}${t.title ? ` "${flat(t.title)}"` : ''}${t.deleted ? ' (deleted)' : ''}` : 'task -';
|
|
309
|
+
const line = (a) => `[${flat(a.created_at)}] ${task(a.task)} - ${flat(activityActor(a.employee))}: ${flat(a.action)}${a.field ? ` ${flat(a.field)}` : ''}` +
|
|
310
|
+
(a.old_value != null || a.new_value != null ? ` (${value(a.old_value)} -> ${value(a.new_value)})` : '');
|
|
311
|
+
return [
|
|
312
|
+
...(rows.length ? rows.map(line) : ['No activity.']),
|
|
313
|
+
meta.next_cursor
|
|
314
|
+
? `More: pass cursor="${flat(meta.next_cursor)}" (with the same filters) for the next page.`
|
|
315
|
+
: 'End of the log for these filters.',
|
|
316
|
+
].join('\n');
|
|
317
|
+
}
|
|
227
318
|
/**
|
|
228
319
|
* #4 (0.23.0): the ticket a task was opened from, as `linked_ticket:
|
|
229
320
|
* "ticket #<id> <url>"` first in the answer, replacing the raw `ticket`
|
|
@@ -239,6 +330,39 @@ export function withLinkedTicket(task) {
|
|
|
239
330
|
}
|
|
240
331
|
return rest;
|
|
241
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* The list filters of backend task #5874 part 3, turned into GET /tasks query
|
|
335
|
+
* keys: `custom_field` (an object) becomes custom_field[<field_key>]=<value>,
|
|
336
|
+
* a boolean checkbox value 1 / 0. The contradictions the server refuses with a
|
|
337
|
+
* 422 are refused here first, in the same words, so no request is spent on
|
|
338
|
+
* them.
|
|
339
|
+
*/
|
|
340
|
+
export function listFilterQuery(args) {
|
|
341
|
+
if (args.no_due_date === true && (args.due_before !== undefined || args.due_after !== undefined)) {
|
|
342
|
+
throw new Error('no_due_date=true cannot be combined with due_before or due_after: a task with no due date is never due before or after anything. Drop one of them.');
|
|
343
|
+
}
|
|
344
|
+
if (args.no_assignee === true && args.assignee_employee_code !== undefined) {
|
|
345
|
+
throw new Error('no_assignee=true cannot be combined with assignee_employee_code: a task with no assignee is held by nobody. Drop one of them.');
|
|
346
|
+
}
|
|
347
|
+
const out = {
|
|
348
|
+
no_due_date: args.no_due_date === undefined ? undefined : args.no_due_date ? 1 : 0,
|
|
349
|
+
no_assignee: args.no_assignee === undefined ? undefined : args.no_assignee ? 1 : 0,
|
|
350
|
+
search_in: args.search_in,
|
|
351
|
+
updated_before: args.updated_before,
|
|
352
|
+
};
|
|
353
|
+
if (args.custom_field !== undefined) {
|
|
354
|
+
if (args.project_id === undefined) {
|
|
355
|
+
throw new Error('custom_field filters need project_id: custom fields belong to one project. Add project_id; clokio_get_project_custom_fields lists its field keys.');
|
|
356
|
+
}
|
|
357
|
+
for (const [key, value] of Object.entries(args.custom_field)) {
|
|
358
|
+
if (!FIELD_KEY_PATTERN.test(key)) {
|
|
359
|
+
throw new Error(`custom_field key "${key}" is not a field_key: 1-50 chars of letters, digits, _ or -.`);
|
|
360
|
+
}
|
|
361
|
+
out[`custom_field[${key}]`] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
242
366
|
export function registerTaskTools(server, config) {
|
|
243
367
|
registerTool(server, config, {
|
|
244
368
|
name: 'clokio_list_tasks',
|
|
@@ -246,14 +370,44 @@ export function registerTaskTools(server, config) {
|
|
|
246
370
|
'your own code. An UNKNOWN query parameter is a 422 naming it, so use exactly these names. Paginate ' +
|
|
247
371
|
'with page, or crawl with cursor (preferred past one page). Every task carries a task_url.\n\n' +
|
|
248
372
|
'The response is {data, meta}. meta carries total, per_page, current_page and next_cursor - read ' +
|
|
249
|
-
'next_cursor and pass it back as `cursor` for the following page; it is null on the last one.'
|
|
373
|
+
'next_cursor and pass it back as `cursor` for the following page; it is null on the last one. ' +
|
|
374
|
+
'For counts rather than rows (how many open tasks per status, label or person, what is overdue or ' +
|
|
375
|
+
'stale on one board), use clokio_get_project_task_summary instead of paging.',
|
|
250
376
|
schema: {
|
|
251
377
|
project_id: z.number().int().optional(),
|
|
252
378
|
status: z.string().optional().describe('Status slug, e.g. "in_progress"'),
|
|
253
379
|
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
|
254
380
|
assignee_employee_code: employeeCode.optional().describe('employee_code, e.g. "00080"'),
|
|
255
381
|
label: z.string().optional(),
|
|
256
|
-
search: z.string().optional().describe('Free text over title and description'),
|
|
382
|
+
search: z.string().optional().describe('Free text over title and description (see search_in)'),
|
|
383
|
+
search_in: z
|
|
384
|
+
.enum(['title', 'all'])
|
|
385
|
+
.optional()
|
|
386
|
+
.describe('Where `search` looks: "title" only, or "all" = title and description (the default)'),
|
|
387
|
+
no_due_date: z
|
|
388
|
+
.boolean()
|
|
389
|
+
.optional()
|
|
390
|
+
.describe('true = only tasks with NO due date; false = only tasks that have one; omit for no filter. ' +
|
|
391
|
+
'Cannot be combined with due_before / due_after when true.'),
|
|
392
|
+
no_assignee: z
|
|
393
|
+
.boolean()
|
|
394
|
+
.optional()
|
|
395
|
+
.describe('true = only tasks nobody is assigned to; false = only tasks somebody holds; omit for no filter. ' +
|
|
396
|
+
'A client person on a shared board, or a departed employee, still counts as holding a task. ' +
|
|
397
|
+
'Cannot be combined with assignee_employee_code when true.'),
|
|
398
|
+
custom_field: z
|
|
399
|
+
.record(fieldKey, z.union([z.string().min(1).max(500), z.number(), z.boolean()]))
|
|
400
|
+
.refine((m) => Object.keys(m).length >= 1 && Object.keys(m).length <= 20, {
|
|
401
|
+
message: 'custom_field must name 1-20 fields',
|
|
402
|
+
})
|
|
403
|
+
.optional()
|
|
404
|
+
.describe('Match custom field values: an object of field_key -> value, e.g. {"stage": "In build", "escalated": true}. ' +
|
|
405
|
+
'Several fields are ANDed. REQUIRES project_id (fields belong to one project); the keys are the ' +
|
|
406
|
+
'field_key values clokio_get_project_custom_fields lists, and a key that is not a public field of ' +
|
|
407
|
+
'that project is refused naming it. Per type: dropdown = the option value or its label (a label ' +
|
|
408
|
+
'two options share is refused asking for the value); multi-select = the task\'s list CONTAINS that ' +
|
|
409
|
+
'option; checkbox = true (ticked) or false (not ticked, an unanswered checkbox included); text, ' +
|
|
410
|
+
'url and email = exact match, ignoring case; number and date (YYYY-MM-DD) = exact.'),
|
|
257
411
|
open: z.boolean().optional().describe('true = only tasks whose status is not a "done" one'),
|
|
258
412
|
recurring: z
|
|
259
413
|
.boolean()
|
|
@@ -283,6 +437,11 @@ export function registerTaskTools(server, config) {
|
|
|
283
437
|
.string()
|
|
284
438
|
.optional()
|
|
285
439
|
.describe('ISO-8601. Incremental sync: only tasks changed at or after this instant'),
|
|
440
|
+
updated_before: z
|
|
441
|
+
.string()
|
|
442
|
+
.optional()
|
|
443
|
+
.describe('YYYY-MM-DD or ISO-8601, organization timezone. Only tasks last updated BEFORE this (exclusive); ' +
|
|
444
|
+
'the other half of updated_since'),
|
|
286
445
|
include_comments: z.boolean().optional(),
|
|
287
446
|
per_page: z.number().int().min(1).max(100).optional(),
|
|
288
447
|
page: z.number().int().optional(),
|
|
@@ -316,6 +475,7 @@ export function registerTaskTools(server, config) {
|
|
|
316
475
|
},
|
|
317
476
|
handler: (args, cfg) => request(cfg, '/tasks', {
|
|
318
477
|
query: {
|
|
478
|
+
...listFilterQuery(args),
|
|
319
479
|
project_id: args.project_id,
|
|
320
480
|
status: args.status,
|
|
321
481
|
priority: args.priority,
|
|
@@ -340,6 +500,83 @@ export function registerTaskTools(server, config) {
|
|
|
340
500
|
},
|
|
341
501
|
}),
|
|
342
502
|
});
|
|
503
|
+
registerTool(server, config, {
|
|
504
|
+
name: 'clokio_get_project_task_summary',
|
|
505
|
+
description: "One project's task numbers in one call, counted by the server: tasks by status (the project's own " +
|
|
506
|
+
'statuses, each with is_open), by priority (low / medium / high / urgent / none), by label name and by ' +
|
|
507
|
+
'assignee, plus unassigned and no_due_date counts; and two short lists of compact task rows: overdue ' +
|
|
508
|
+
"(open tasks due before today in the organisation's time zone, oldest due first) and stale (open tasks " +
|
|
509
|
+
'not updated for stale_days, least recently updated first), each at most 50 rows with a total. ' +
|
|
510
|
+
'OPEN tasks only unless include_closed is true. Subtasks count as tasks of their own unless top_level ' +
|
|
511
|
+
'is true. A task held by several people is counted for EACH of them in by_assignee, so those numbers ' +
|
|
512
|
+
'can sum above total. The answer carries a definitions block naming the rule behind every number, and ' +
|
|
513
|
+
'echoes stale_days. Use this for "how is project X doing" questions; use clokio_list_tasks for the rows ' +
|
|
514
|
+
'themselves. Needs tasks:read.',
|
|
515
|
+
schema: {
|
|
516
|
+
project_id: z.number().int(),
|
|
517
|
+
include_closed: z
|
|
518
|
+
.boolean()
|
|
519
|
+
.optional()
|
|
520
|
+
.describe('true also counts closed tasks (status category done). overdue and stale stay open tasks'),
|
|
521
|
+
top_level: z
|
|
522
|
+
.boolean()
|
|
523
|
+
.optional()
|
|
524
|
+
.describe('true counts top-level tasks only (subtasks excluded), overdue and stale included'),
|
|
525
|
+
stale_days: z
|
|
526
|
+
.number()
|
|
527
|
+
.int()
|
|
528
|
+
.min(1)
|
|
529
|
+
.max(365)
|
|
530
|
+
.optional()
|
|
531
|
+
.describe('Days without an update (updated_at) that make an open task stale. Default 14, range 1-365'),
|
|
532
|
+
group_by_custom_field: fieldKey
|
|
533
|
+
.optional()
|
|
534
|
+
.describe('The field_key of a PUBLIC dropdown or multi-select field of this project (clokio_get_project_custom_fields). ' +
|
|
535
|
+
'Adds by_custom_field: tasks per option (value and label) with open / closed / total, plus none for tasks ' +
|
|
536
|
+
'with no answer; a multi-select task counts under each option it holds. This answers "what is in each ' +
|
|
537
|
+
'release / sprint". Any other field type, a private field or an unknown key is refused (422).'),
|
|
538
|
+
},
|
|
539
|
+
handler: (args, cfg) => request(cfg, `/projects/${args.project_id}/task-summary`, {
|
|
540
|
+
query: {
|
|
541
|
+
include_closed: args.include_closed === undefined ? undefined : args.include_closed ? 1 : 0,
|
|
542
|
+
top_level: args.top_level === undefined ? undefined : args.top_level ? 1 : 0,
|
|
543
|
+
stale_days: args.stale_days,
|
|
544
|
+
group_by_custom_field: args.group_by_custom_field,
|
|
545
|
+
},
|
|
546
|
+
}),
|
|
547
|
+
});
|
|
548
|
+
registerTool(server, config, {
|
|
549
|
+
name: 'clokio_list_project_activity',
|
|
550
|
+
description: "One project's activity log, newest first: every change on every task of the board (deleted tasks " +
|
|
551
|
+
'included), one line per change naming the task, who acted (name, code, organisation) and what changed. ' +
|
|
552
|
+
"since / until narrow it (a plain date or time is read in the organisation's time zone; until is " +
|
|
553
|
+
'exclusive), action limits it to some actions (e.g. ["status_changed", "assigned"]). Pages of per_page ' +
|
|
554
|
+
'(1-100, default 25); the answer ends with the cursor to pass back for the next page, with the same ' +
|
|
555
|
+
"filters. Only PUBLIC custom fields' history appears, and internal comments never do. Use " +
|
|
556
|
+
'clokio_get_task_activity for one task. Needs tasks:read.',
|
|
557
|
+
schema: {
|
|
558
|
+
project_id: z.number().int(),
|
|
559
|
+
since: z.string().optional().describe('YYYY-MM-DD or ISO-8601: rows at or after this instant'),
|
|
560
|
+
until: z.string().optional().describe('YYYY-MM-DD or ISO-8601: rows before this instant (exclusive)'),
|
|
561
|
+
action: z
|
|
562
|
+
.array(z.string().min(1).max(60))
|
|
563
|
+
.min(1)
|
|
564
|
+
.max(30)
|
|
565
|
+
.optional()
|
|
566
|
+
.describe('Only these actions, e.g. ["status_changed", "assigned", "custom_field_changed"]'),
|
|
567
|
+
cursor: z.string().max(500).optional().describe('The cursor the previous page ended with'),
|
|
568
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
569
|
+
},
|
|
570
|
+
handler: async (args, cfg) => renderProjectActivity(await request(cfg, `/projects/${args.project_id}/activities`, {
|
|
571
|
+
query: {
|
|
572
|
+
since: args.since,
|
|
573
|
+
until: args.until,
|
|
574
|
+
action: args.action,
|
|
575
|
+
cursor: args.cursor,
|
|
576
|
+
per_page: args.per_page,
|
|
577
|
+
},
|
|
578
|
+
})),
|
|
579
|
+
});
|
|
343
580
|
registerTool(server, config, {
|
|
344
581
|
name: 'clokio_get_task',
|
|
345
582
|
description: 'Get one task by id, with its full detail (assignees, labels, status, custom fields, task_url) and ' +
|
|
@@ -383,7 +620,7 @@ export function registerTaskTools(server, config) {
|
|
|
383
620
|
status: z.string().optional().describe("Status slug valid for THIS project's board"),
|
|
384
621
|
due_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
385
622
|
start_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
386
|
-
estimated_hours: z.number().min(0).max(
|
|
623
|
+
estimated_hours: z.number().min(0).max(ESTIMATED_HOURS_MAX).optional(),
|
|
387
624
|
parent_task_id: z.number().int().optional().describe('Makes this a subtask of that task'),
|
|
388
625
|
assignee_employee_codes: z
|
|
389
626
|
.array(assigneeReference)
|
|
@@ -391,7 +628,7 @@ export function registerTaskTools(server, config) {
|
|
|
391
628
|
.optional()
|
|
392
629
|
.describe('Each a code of your organisation, or {employee_code, organization_id} for a client\'s person on a shared board'),
|
|
393
630
|
label_names: z
|
|
394
|
-
.array(
|
|
631
|
+
.array(labelName)
|
|
395
632
|
.max(20)
|
|
396
633
|
.optional()
|
|
397
634
|
.describe('Label names. A name that does not exist yet is CREATED - check clokio_list_task_labels first'),
|
|
@@ -450,7 +687,19 @@ export function registerTaskTools(server, config) {
|
|
|
450
687
|
'\n\nManage an existing series here: `recurrence` (an object) sets or retunes the rule and switches ' +
|
|
451
688
|
'the series ON; `recurrence: null` STOPS it (future copies stop; already-spawned copies are ordinary ' +
|
|
452
689
|
'tasks and stay). To PAUSE while keeping the rule stored, pass `recurrence_active: false`, and ' +
|
|
453
|
-
'`recurrence_active: true` to resume
|
|
690
|
+
'`recurrence_active: true` to resume.\n\n' +
|
|
691
|
+
LABELS_ON_UPDATE_NOTE +
|
|
692
|
+
'\n\n' +
|
|
693
|
+
'`custom_fields` (object field_key -> value) sets several custom fields in the same call, with the ' +
|
|
694
|
+
'rules of clokio_set_task_custom_field (option labels accepted, null clears). Every field is checked ' +
|
|
695
|
+
'before anything is written: one bad field refuses the whole update and nothing changes.\n\n' +
|
|
696
|
+
'`parent_task_id` makes the task a subtask of another task on the SAME project (null makes it ' +
|
|
697
|
+
'top-level again). Subtasks go one level deep: a subtask cannot be a parent, a task that has subtasks ' +
|
|
698
|
+
'cannot become one, and a task cannot be its own parent.\n\n' +
|
|
699
|
+
'`assignee_employee_codes` (with `assignee_mode`: replace by default, add, remove) changes the ' +
|
|
700
|
+
'assignees in the same call, with exactly the rules of clokio_set_task_assignees (a client\'s person ' +
|
|
701
|
+
'as {employee_code, organization_id}; assigning makes the person a watcher). A refused person refuses ' +
|
|
702
|
+
'the WHOLE update: nothing in the call is written.',
|
|
454
703
|
schema: {
|
|
455
704
|
id: z.number().int(),
|
|
456
705
|
title: z.string().max(255).optional(),
|
|
@@ -459,8 +708,15 @@ export function registerTaskTools(server, config) {
|
|
|
459
708
|
status: z.string().optional().describe("Status slug valid for this task's board"),
|
|
460
709
|
due_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
|
|
461
710
|
start_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
|
|
462
|
-
estimated_hours: z.number().min(0).max(
|
|
463
|
-
project_id: z
|
|
711
|
+
estimated_hours: z.number().min(0).max(ESTIMATED_HOURS_MAX).nullable().optional(),
|
|
712
|
+
project_id: z
|
|
713
|
+
.number()
|
|
714
|
+
.int()
|
|
715
|
+
.optional()
|
|
716
|
+
.describe('Move the task to another project. Its subtasks move with it, and a status the new board lacks becomes ' +
|
|
717
|
+
'that board\'s default. A SUBTASK moves only when the same call sends parent_task_id: null (it ' +
|
|
718
|
+
'becomes a top-level task there) or the id of a task on the new project (it moves under it); ' +
|
|
719
|
+
'otherwise the server refuses it.'),
|
|
464
720
|
actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
|
|
465
721
|
recurrence: recurrenceInput
|
|
466
722
|
.nullable()
|
|
@@ -470,6 +726,27 @@ export function registerTaskTools(server, config) {
|
|
|
470
726
|
.boolean()
|
|
471
727
|
.optional()
|
|
472
728
|
.describe('Pause (false) / resume (true) the series without touching its stored rule'),
|
|
729
|
+
label_names: labelNames.describe('REPLACES every label on the task with exactly these names ([] removes them all). A missing name is CREATED. Cannot be combined with add_label_names / remove_label_names'),
|
|
730
|
+
add_label_names: labelNames.describe('Labels to ADD, keeping the others. A missing name is CREATED; one the task already carries changes nothing'),
|
|
731
|
+
remove_label_names: labelNames.describe('Labels to REMOVE, keeping the others. A name the task does not carry, or that does not exist, changes nothing (never created)'),
|
|
732
|
+
custom_fields: customFieldsMap.optional(),
|
|
733
|
+
parent_task_id: z
|
|
734
|
+
.number()
|
|
735
|
+
.int()
|
|
736
|
+
.min(1)
|
|
737
|
+
.nullable()
|
|
738
|
+
.optional()
|
|
739
|
+
.describe('Hang the task under this task (same project, one level deep), or null to make it top-level'),
|
|
740
|
+
assignee_employee_codes: z
|
|
741
|
+
.array(assigneeReference)
|
|
742
|
+
.min(1)
|
|
743
|
+
.max(50)
|
|
744
|
+
.optional()
|
|
745
|
+
.describe('Assignees, as on clokio_set_task_assignees: a code of your organisation, or {employee_code, organization_id} for a client\'s person'),
|
|
746
|
+
assignee_mode: z
|
|
747
|
+
.enum(['replace', 'add', 'remove'])
|
|
748
|
+
.optional()
|
|
749
|
+
.describe('How assignee_employee_codes applies: replace (default) overwrites, add appends, remove takes off'),
|
|
473
750
|
},
|
|
474
751
|
handler: (args, cfg) => {
|
|
475
752
|
// Only keys the caller actually supplied may go in the body: PATCH
|
|
@@ -486,10 +763,24 @@ export function registerTaskTools(server, config) {
|
|
|
486
763
|
'estimated_hours',
|
|
487
764
|
'project_id',
|
|
488
765
|
'actor_employee_code',
|
|
766
|
+
'label_names',
|
|
767
|
+
'custom_fields',
|
|
768
|
+
'parent_task_id',
|
|
769
|
+
'assignee_employee_codes',
|
|
770
|
+
'assignee_mode',
|
|
489
771
|
]) {
|
|
490
772
|
if (args[key] !== undefined)
|
|
491
773
|
body[key] = args[key];
|
|
492
774
|
}
|
|
775
|
+
if (args.assignee_mode !== undefined && args.assignee_employee_codes === undefined) {
|
|
776
|
+
throw new Error('assignee_mode needs assignee_employee_codes.');
|
|
777
|
+
}
|
|
778
|
+
refuseLabelConflicts(args, true);
|
|
779
|
+
// An empty add/remove list is not a change: it is not sent.
|
|
780
|
+
if (hasNames(args.add_label_names))
|
|
781
|
+
body.add_label_names = args.add_label_names;
|
|
782
|
+
if (hasNames(args.remove_label_names))
|
|
783
|
+
body.remove_label_names = args.remove_label_names;
|
|
493
784
|
if (typeof body.description === 'string')
|
|
494
785
|
body.description = markdownToHtml(body.description);
|
|
495
786
|
// Series management maps onto the API's two columns. `recurrence`
|
|
@@ -510,6 +801,12 @@ export function registerTaskTools(server, config) {
|
|
|
510
801
|
else if (args.recurrence_active !== undefined) {
|
|
511
802
|
body.is_recurring = args.recurrence_active;
|
|
512
803
|
}
|
|
804
|
+
// Nothing to change (only empty add/remove lists, or no field at all):
|
|
805
|
+
// refused here, as the bulk tool refuses, instead of a request that
|
|
806
|
+
// changes nothing.
|
|
807
|
+
if (Object.keys(body).filter((k) => k !== 'actor_employee_code').length === 0) {
|
|
808
|
+
throw new Error('Nothing to change: give at least one field (an empty add_label_names / remove_label_names list is not a change).');
|
|
809
|
+
}
|
|
513
810
|
// Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
|
|
514
811
|
// actor. The API otherwise falls back to the key's issuer on its own,
|
|
515
812
|
// so this only overrides for a shared/service key. An explicit
|
|
@@ -544,11 +841,15 @@ export function registerTaskTools(server, config) {
|
|
|
544
841
|
.max(50)
|
|
545
842
|
.describe('Each a code of your organisation, or {employee_code, organization_id} for a client\'s person on a shared board'),
|
|
546
843
|
mode: z.enum(['add', 'replace', 'remove']).describe('Required'),
|
|
844
|
+
actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
|
|
845
|
+
},
|
|
846
|
+
handler: async (args, cfg) => {
|
|
847
|
+
const body = { employee_codes: args.employee_codes, mode: args.mode };
|
|
848
|
+
const actor = args.actor_employee_code ?? cfg.defaultActor;
|
|
849
|
+
if (actor)
|
|
850
|
+
body.actor_employee_code = actor;
|
|
851
|
+
return compactAssignees(args.id, await request(cfg, `/tasks/${args.id}/assignees`, { method: 'PATCH', body }), cfg.baseUrl);
|
|
547
852
|
},
|
|
548
|
-
handler: async (args, cfg) => compactAssignees(args.id, await request(cfg, `/tasks/${args.id}/assignees`, {
|
|
549
|
-
method: 'PATCH',
|
|
550
|
-
body: { employee_codes: args.employee_codes, mode: args.mode },
|
|
551
|
-
}), cfg.baseUrl),
|
|
552
853
|
});
|
|
553
854
|
registerTool(server, config, {
|
|
554
855
|
name: 'clokio_list_project_people',
|
|
@@ -858,34 +1159,87 @@ export function registerTaskTools(server, config) {
|
|
|
858
1159
|
registerTool(server, config, {
|
|
859
1160
|
name: 'clokio_set_task_custom_field',
|
|
860
1161
|
mutates: true,
|
|
861
|
-
description: 'Set one custom field value on a task, identified by field_key (never field_id)
|
|
862
|
-
'PUBLIC fields only.'
|
|
1162
|
+
description: 'Set one custom field value on a task, identified by field_key (never field_id); list a project\'s ' +
|
|
1163
|
+
'keys with clokio_get_project_custom_fields. An API key sees PUBLIC fields only. A dropdown or ' +
|
|
1164
|
+
'multi-select value may be the option\'s stored value OR its label as shown on screen (value first, ' +
|
|
1165
|
+
'then the exact label, then the label ignoring case and surrounding spaces). A label two options ' +
|
|
1166
|
+
'share is refused asking for the value; an unknown one is refused listing the options. null clears ' +
|
|
1167
|
+
'the value. To set several fields on one task use custom_fields on clokio_update_task; on many tasks, ' +
|
|
1168
|
+
'clokio_bulk_set_task_custom_field.',
|
|
863
1169
|
schema: {
|
|
864
1170
|
id: z.number().int(),
|
|
865
|
-
field_key:
|
|
866
|
-
value:
|
|
1171
|
+
field_key: fieldKey,
|
|
1172
|
+
value: customFieldValue,
|
|
1173
|
+
actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
|
|
1174
|
+
},
|
|
1175
|
+
handler: (args, cfg) => {
|
|
1176
|
+
const body = { field_key: args.field_key, value: args.value };
|
|
1177
|
+
const actor = args.actor_employee_code ?? cfg.defaultActor;
|
|
1178
|
+
if (actor)
|
|
1179
|
+
body.actor_employee_code = actor;
|
|
1180
|
+
return request(cfg, `/tasks/${args.id}/custom-fields`, { method: 'PATCH', body });
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
1183
|
+
registerTool(server, config, {
|
|
1184
|
+
name: 'clokio_bulk_set_task_custom_field',
|
|
1185
|
+
mutates: true,
|
|
1186
|
+
description: 'Set the same custom field value(s) on up to 200 tasks at once: custom_fields is an object of ' +
|
|
1187
|
+
'field_key -> value (one or more fields; option labels accepted as on clokio_set_task_custom_field; ' +
|
|
1188
|
+
'null clears). Fields are defined PER PROJECT, so each task is judged against its own project. ' +
|
|
1189
|
+
'Partial success is normal: the result is {updated, not_found, field_not_on_project, invalid_value, ' +
|
|
1190
|
+
'moved, errors}. not_found = ids not in your org; field_not_on_project = the task\'s project has no such ' +
|
|
1191
|
+
'field (or only a private one); invalid_value = a value refused for that project\'s field; moved = ' +
|
|
1192
|
+
'the task moved to another project while the request ran, nothing written: retry those ids; errors ' +
|
|
1193
|
+
'says why per task id and field key. Per task it is all or nothing. Attribution follows the key ' +
|
|
1194
|
+
'issuer unless actor_employee_code is set.',
|
|
1195
|
+
schema: {
|
|
1196
|
+
ids: z.array(z.number().int()).min(1).max(200),
|
|
1197
|
+
custom_fields: customFieldsMap,
|
|
1198
|
+
actor_employee_code: employeeCode.optional().describe('Who to credit; omit to use the key issuer'),
|
|
1199
|
+
},
|
|
1200
|
+
handler: (args, cfg) => {
|
|
1201
|
+
const body = { ids: args.ids, custom_fields: args.custom_fields };
|
|
1202
|
+
const actor = args.actor_employee_code ?? cfg.defaultActor;
|
|
1203
|
+
if (actor)
|
|
1204
|
+
body.actor_employee_code = actor;
|
|
1205
|
+
return request(cfg, '/tasks/bulk/custom-fields', { method: 'PATCH', body });
|
|
867
1206
|
},
|
|
868
|
-
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/custom-fields`, {
|
|
869
|
-
method: 'PATCH',
|
|
870
|
-
body: { field_key: args.field_key, value: args.value },
|
|
871
|
-
}),
|
|
872
1207
|
});
|
|
873
1208
|
registerTool(server, config, {
|
|
874
1209
|
name: 'clokio_bulk_update_tasks',
|
|
875
1210
|
mutates: true,
|
|
876
|
-
description: 'Apply ONE change (any of status, priority, due_date) to up to 200
|
|
877
|
-
'board without a call per task. Partial success is normal: the result
|
|
878
|
-
'invalid_status}. not_found lists ids that do not exist in your org (skipped, not
|
|
879
|
-
'invalid_status lists tasks whose board does not have the given status slug
|
|
880
|
-
'
|
|
1211
|
+
description: 'Apply ONE change (any of status, priority, due_date, estimated_hours, add_label_names, remove_label_names) to up to 200 ' +
|
|
1212
|
+
'tasks at once - for cleaning up a board without a call per task. Partial success is normal: the result ' +
|
|
1213
|
+
'is {updated, not_found, invalid_status}. not_found lists ids that do not exist in your org (skipped, not ' +
|
|
1214
|
+
'fatal); invalid_status lists tasks whose board does not have the given status slug (such a task is ' +
|
|
1215
|
+
'skipped whole, its labels included). estimated_hours (0-9999.99, null clears) sets one estimate on every ' +
|
|
1216
|
+
'task; a task already holding it changes nothing. Give at least one of status/priority/due_date/estimated_hours/add_label_names/' +
|
|
1217
|
+
'remove_label_names (an empty list does not count). Labels are a change only: add_label_names adds (a missing name is CREATED once in ' +
|
|
1218
|
+
'your organisation), remove_label_names removes (never creates); a label a task already carries, or one ' +
|
|
1219
|
+
'it does not carry, changes nothing for that task. To REPLACE a task\'s whole label set use ' +
|
|
1220
|
+
'clokio_update_task with label_names. With labels, the result also has labels_failed: ids whose label change ' +
|
|
1221
|
+
'could not be written after their other changes were saved (normally empty; send the labels again). ' +
|
|
1222
|
+
'Attribution follows the key issuer unless actor_employee_code is set.',
|
|
881
1223
|
schema: {
|
|
882
1224
|
ids: z.array(z.number().int()).min(1).max(200),
|
|
883
1225
|
status: z.string().optional().describe('Status slug - validated per task against its own board'),
|
|
884
1226
|
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
|
885
1227
|
due_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
|
|
1228
|
+
estimated_hours: z.number().min(0).max(ESTIMATED_HOURS_MAX).nullable().optional().describe('Hours, 0-9999.99, set on every task; null clears'),
|
|
886
1229
|
actor_employee_code: employeeCode.optional().describe('Who to credit; omit to use the key issuer'),
|
|
1230
|
+
add_label_names: labelNames.describe('Labels to ADD to every task. A missing name is CREATED once in your organisation'),
|
|
1231
|
+
remove_label_names: labelNames.describe('Labels to REMOVE from every task. An unknown name changes nothing (never created)'),
|
|
887
1232
|
},
|
|
888
1233
|
handler: (args, cfg) => {
|
|
1234
|
+
if (args.status === undefined &&
|
|
1235
|
+
args.priority === undefined &&
|
|
1236
|
+
args.due_date === undefined &&
|
|
1237
|
+
args.estimated_hours === undefined &&
|
|
1238
|
+
!hasNames(args.add_label_names) &&
|
|
1239
|
+
!hasNames(args.remove_label_names)) {
|
|
1240
|
+
throw new Error('Give at least one of: status, priority, due_date, estimated_hours, add_label_names, remove_label_names.');
|
|
1241
|
+
}
|
|
1242
|
+
refuseLabelConflicts(args, false);
|
|
889
1243
|
const body = { ids: args.ids };
|
|
890
1244
|
if (args.status !== undefined)
|
|
891
1245
|
body.status = args.status;
|
|
@@ -893,6 +1247,12 @@ export function registerTaskTools(server, config) {
|
|
|
893
1247
|
body.priority = args.priority;
|
|
894
1248
|
if (args.due_date !== undefined)
|
|
895
1249
|
body.due_date = args.due_date;
|
|
1250
|
+
if (args.estimated_hours !== undefined)
|
|
1251
|
+
body.estimated_hours = args.estimated_hours;
|
|
1252
|
+
if (hasNames(args.add_label_names))
|
|
1253
|
+
body.add_label_names = args.add_label_names;
|
|
1254
|
+
if (hasNames(args.remove_label_names))
|
|
1255
|
+
body.remove_label_names = args.remove_label_names;
|
|
896
1256
|
body.actor_employee_code = args.actor_employee_code ?? cfg.defaultActor;
|
|
897
1257
|
return request(cfg, '/tasks/bulk', { method: 'PATCH', body });
|
|
898
1258
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clokio-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Model Context Protocol (MCP) server for Clokio - manage tasks, projects, clients, employees, attendance and documents from Claude and any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|