clokio-mcp 0.25.0 → 0.26.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
@@ -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 120 tools to 33. Optional groups are `tasks`,
68
+ task-focused session drops from 121 tools to 34. 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,12 +76,12 @@ through it.
76
76
 
77
77
  ## Tools
78
78
 
79
- 120 tools across seven areas. Read-only tools carry the MCP `readOnlyHint`; writes
79
+ 121 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 |
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; labels: replace, add, remove), delete, bulk update (status, priority, due date, 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
85
  | **Projects & clients** | list projects, project statuses, project custom fields, 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 |
@@ -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
 
@@ -7,6 +7,22 @@ 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
+ * One custom field value as the API takes it: text / number / date string,
12
+ * true / false for a checkbox, an option's value OR label for a dropdown, a
13
+ * list of them for a multi-select, null to clear. The server validates it
14
+ * against the field's type (backend task #5874 part 2).
15
+ */
16
+ const customFieldValue = z
17
+ .union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.null()])
18
+ .describe('The value; a dropdown option by its value or its label; null clears');
19
+ /** field_key -> value, at least one and at most 50 fields (the server's cap). */
20
+ const customFieldsMap = z
21
+ .record(z.string().min(1).max(50), customFieldValue)
22
+ .refine((m) => Object.keys(m).length >= 1 && Object.keys(m).length <= 50, {
23
+ message: 'custom_fields must name 1-50 fields',
24
+ })
25
+ .describe('Object of field_key -> value, e.g. {"stage": "In build", "client_po": "PO-1"}');
10
26
  /**
11
27
  * One assignee (Clokio Public API, spec 2026-10-16). A STRING is an
12
28
  * employee_code of the key's OWN organisation. On a board shared with a
@@ -178,6 +194,45 @@ const RECURRENCE_MODEL_NOTE = 'RECURRING TASKS: pass `recurrence` to make this t
178
194
  'the template (title, description, assignees, labels, priority, estimate) early on each matching day, ' +
179
195
  'due that day, independent of whether earlier copies were closed. A multi-period cadence ' +
180
196
  '(every: N > 1) counts whole periods from the task\'s DUE DATE, so set one.';
197
+ /**
198
+ * A label name as the API takes it: the task_labels column is 50 characters
199
+ * (the backend validates max:50 on every label field since 0.26.0's backend,
200
+ * task #5874).
201
+ */
202
+ const labelName = z.string().min(1).max(50);
203
+ /** One names field on update / bulk: up to 50 names (the API's cap). */
204
+ const labelNames = z.array(labelName).max(50).optional();
205
+ /** How labels work on clokio_update_task, said once. */
206
+ const LABELS_ON_UPDATE_NOTE = 'LABELS: `label_names` REPLACES the task\'s whole label set (an empty list removes every label). ' +
207
+ '`add_label_names` / `remove_label_names` change it instead, keeping the rest. Do not combine ' +
208
+ '`label_names` with either of them, and do not put one label in both (spellings the organisation treats as ' +
209
+ 'one label count; the server refuses it). An empty list is not a change. A name that does not exist in ' +
210
+ 'your organisation is CREATED (as on clokio_create_task; check clokio_list_task_labels first to reuse ' +
211
+ 'an existing spelling), names match case-insensitively, a remove never creates, and adding a label the ' +
212
+ 'task already carries or removing one it does not carry changes nothing. Up to 50 names per field, ' +
213
+ '50 characters each. Each change is written to the task\'s activity log, credited like any other edit.';
214
+ /** A diff list that names something; an empty list is not a change (the API ignores it too). */
215
+ export function hasNames(list) {
216
+ return Array.isArray(list) && list.length > 0;
217
+ }
218
+ /**
219
+ * Refuse before any request what the API would refuse anyway: replace with a
220
+ * non-empty diff (update only), and the same name (trimmed, case-folded) in
221
+ * both diff lists, which the API refuses before any lookup. Accent variants
222
+ * of an existing label ("Café" / "Cafe") are judged by the API on the
223
+ * resolved labels, in its own sentence.
224
+ */
225
+ export function refuseLabelConflicts(args, allowReplace) {
226
+ if (allowReplace && args.label_names !== undefined && (hasNames(args.add_label_names) || hasNames(args.remove_label_names))) {
227
+ throw new Error('Pass either label_names (replaces every label) or add_label_names / remove_label_names (a change), not both.');
228
+ }
229
+ const fold = (n) => n.trim().toLowerCase();
230
+ const adding = new Set((args.add_label_names ?? []).map(fold));
231
+ const both = [...new Set((args.remove_label_names ?? []).filter((n) => adding.has(fold(n))).map((n) => n.trim()))];
232
+ if (both.length > 0) {
233
+ throw new Error(`These labels are in both add_label_names and remove_label_names: ${both.join(', ')}. Send each label in one of them.`);
234
+ }
235
+ }
181
236
  /** Task-management tools: the core of the Clokio API surface. */
182
237
  /** #5: what clokio_set_task_assignees answers - the id, the link and the people, never the whole task. */
183
238
  export function compactAssignees(taskId, t, baseUrl = 'https://app.clokio.io') {
@@ -391,7 +446,7 @@ export function registerTaskTools(server, config) {
391
446
  .optional()
392
447
  .describe('Each a code of your organisation, or {employee_code, organization_id} for a client\'s person on a shared board'),
393
448
  label_names: z
394
- .array(z.string().max(100))
449
+ .array(labelName)
395
450
  .max(20)
396
451
  .optional()
397
452
  .describe('Label names. A name that does not exist yet is CREATED - check clokio_list_task_labels first'),
@@ -450,7 +505,12 @@ export function registerTaskTools(server, config) {
450
505
  '\n\nManage an existing series here: `recurrence` (an object) sets or retunes the rule and switches ' +
451
506
  'the series ON; `recurrence: null` STOPS it (future copies stop; already-spawned copies are ordinary ' +
452
507
  'tasks and stay). To PAUSE while keeping the rule stored, pass `recurrence_active: false`, and ' +
453
- '`recurrence_active: true` to resume.',
508
+ '`recurrence_active: true` to resume.\n\n' +
509
+ LABELS_ON_UPDATE_NOTE +
510
+ '\n\n' +
511
+ '`custom_fields` (object field_key -> value) sets several custom fields in the same call, with the ' +
512
+ 'rules of clokio_set_task_custom_field (option labels accepted, null clears). Every field is checked ' +
513
+ 'before anything is written: one bad field refuses the whole update and nothing changes.',
454
514
  schema: {
455
515
  id: z.number().int(),
456
516
  title: z.string().max(255).optional(),
@@ -470,6 +530,10 @@ export function registerTaskTools(server, config) {
470
530
  .boolean()
471
531
  .optional()
472
532
  .describe('Pause (false) / resume (true) the series without touching its stored rule'),
533
+ 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'),
534
+ add_label_names: labelNames.describe('Labels to ADD, keeping the others. A missing name is CREATED; one the task already carries changes nothing'),
535
+ 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)'),
536
+ custom_fields: customFieldsMap.optional(),
473
537
  },
474
538
  handler: (args, cfg) => {
475
539
  // Only keys the caller actually supplied may go in the body: PATCH
@@ -486,10 +550,18 @@ export function registerTaskTools(server, config) {
486
550
  'estimated_hours',
487
551
  'project_id',
488
552
  'actor_employee_code',
553
+ 'label_names',
554
+ 'custom_fields',
489
555
  ]) {
490
556
  if (args[key] !== undefined)
491
557
  body[key] = args[key];
492
558
  }
559
+ refuseLabelConflicts(args, true);
560
+ // An empty add/remove list is not a change: it is not sent.
561
+ if (hasNames(args.add_label_names))
562
+ body.add_label_names = args.add_label_names;
563
+ if (hasNames(args.remove_label_names))
564
+ body.remove_label_names = args.remove_label_names;
493
565
  if (typeof body.description === 'string')
494
566
  body.description = markdownToHtml(body.description);
495
567
  // Series management maps onto the API's two columns. `recurrence`
@@ -510,6 +582,12 @@ export function registerTaskTools(server, config) {
510
582
  else if (args.recurrence_active !== undefined) {
511
583
  body.is_recurring = args.recurrence_active;
512
584
  }
585
+ // Nothing to change (only empty add/remove lists, or no field at all):
586
+ // refused here, as the bulk tool refuses, instead of a request that
587
+ // changes nothing.
588
+ if (Object.keys(body).filter((k) => k !== 'actor_employee_code').length === 0) {
589
+ throw new Error('Nothing to change: give at least one field (an empty add_label_names / remove_label_names list is not a change).');
590
+ }
513
591
  // Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
514
592
  // actor. The API otherwise falls back to the key's issuer on its own,
515
593
  // so this only overrides for a shared/service key. An explicit
@@ -858,34 +936,84 @@ export function registerTaskTools(server, config) {
858
936
  registerTool(server, config, {
859
937
  name: 'clokio_set_task_custom_field',
860
938
  mutates: true,
861
- description: 'Set one custom field value on a task, identified by field_key (never field_id). An API key sees ' +
862
- 'PUBLIC fields only.',
939
+ description: 'Set one custom field value on a task, identified by field_key (never field_id); list a project\'s ' +
940
+ 'keys with clokio_get_project_custom_fields. An API key sees PUBLIC fields only. A dropdown or ' +
941
+ 'multi-select value may be the option\'s stored value OR its label as shown on screen (value first, ' +
942
+ 'then the exact label, then the label ignoring case and surrounding spaces). A label two options ' +
943
+ 'share is refused asking for the value; an unknown one is refused listing the options. null clears ' +
944
+ 'the value. To set several fields on one task use custom_fields on clokio_update_task; on many tasks, ' +
945
+ 'clokio_bulk_set_task_custom_field.',
863
946
  schema: {
864
947
  id: z.number().int(),
865
948
  field_key: z.string(),
866
- value: z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]),
949
+ value: customFieldValue,
950
+ actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
951
+ },
952
+ handler: (args, cfg) => {
953
+ const body = { field_key: args.field_key, value: args.value };
954
+ const actor = args.actor_employee_code ?? cfg.defaultActor;
955
+ if (actor)
956
+ body.actor_employee_code = actor;
957
+ return request(cfg, `/tasks/${args.id}/custom-fields`, { method: 'PATCH', body });
958
+ },
959
+ });
960
+ registerTool(server, config, {
961
+ name: 'clokio_bulk_set_task_custom_field',
962
+ mutates: true,
963
+ description: 'Set the same custom field value(s) on up to 200 tasks at once: custom_fields is an object of ' +
964
+ 'field_key -> value (one or more fields; option labels accepted as on clokio_set_task_custom_field; ' +
965
+ 'null clears). Fields are defined PER PROJECT, so each task is judged against its own project. ' +
966
+ 'Partial success is normal: the result is {updated, not_found, field_not_on_project, invalid_value, ' +
967
+ 'moved, errors}. not_found = ids not in your org; field_not_on_project = the task\'s project has no such ' +
968
+ 'field (or only a private one); invalid_value = a value refused for that project\'s field; moved = ' +
969
+ 'the task moved to another project while the request ran, nothing written: retry those ids; errors ' +
970
+ 'says why per task id and field key. Per task it is all or nothing. Attribution follows the key ' +
971
+ 'issuer unless actor_employee_code is set.',
972
+ schema: {
973
+ ids: z.array(z.number().int()).min(1).max(200),
974
+ custom_fields: customFieldsMap,
975
+ actor_employee_code: employeeCode.optional().describe('Who to credit; omit to use the key issuer'),
976
+ },
977
+ handler: (args, cfg) => {
978
+ const body = { ids: args.ids, custom_fields: args.custom_fields };
979
+ const actor = args.actor_employee_code ?? cfg.defaultActor;
980
+ if (actor)
981
+ body.actor_employee_code = actor;
982
+ return request(cfg, '/tasks/bulk/custom-fields', { method: 'PATCH', body });
867
983
  },
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
984
  });
873
985
  registerTool(server, config, {
874
986
  name: 'clokio_bulk_update_tasks',
875
987
  mutates: true,
876
- description: 'Apply ONE change (any of status, priority, due_date) to up to 200 tasks at once - for cleaning up a ' +
877
- 'board without a call per task. Partial success is normal: the result is {updated, not_found, ' +
878
- 'invalid_status}. not_found lists ids that do not exist in your org (skipped, not fatal); ' +
879
- 'invalid_status lists tasks whose board does not have the given status slug. Give at least one of ' +
880
- 'status/priority/due_date. Attribution follows the key issuer unless actor_employee_code is set.',
988
+ description: 'Apply ONE change (any of status, priority, due_date, add_label_names, remove_label_names) to up to 200 ' +
989
+ 'tasks at once - for cleaning up a board without a call per task. Partial success is normal: the result ' +
990
+ 'is {updated, not_found, invalid_status}. not_found lists ids that do not exist in your org (skipped, not ' +
991
+ 'fatal); invalid_status lists tasks whose board does not have the given status slug (such a task is ' +
992
+ 'skipped whole, its labels included). Give at least one of status/priority/due_date/add_label_names/' +
993
+ '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 ' +
994
+ 'your organisation), remove_label_names removes (never creates); a label a task already carries, or one ' +
995
+ 'it does not carry, changes nothing for that task. To REPLACE a task\'s whole label set use ' +
996
+ 'clokio_update_task with label_names. With labels, the result also has labels_failed: ids whose label change ' +
997
+ 'could not be written after their other changes were saved (normally empty; send the labels again). ' +
998
+ 'Attribution follows the key issuer unless actor_employee_code is set.',
881
999
  schema: {
882
1000
  ids: z.array(z.number().int()).min(1).max(200),
883
1001
  status: z.string().optional().describe('Status slug - validated per task against its own board'),
884
1002
  priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
885
1003
  due_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
886
1004
  actor_employee_code: employeeCode.optional().describe('Who to credit; omit to use the key issuer'),
1005
+ add_label_names: labelNames.describe('Labels to ADD to every task. A missing name is CREATED once in your organisation'),
1006
+ remove_label_names: labelNames.describe('Labels to REMOVE from every task. An unknown name changes nothing (never created)'),
887
1007
  },
888
1008
  handler: (args, cfg) => {
1009
+ if (args.status === undefined &&
1010
+ args.priority === undefined &&
1011
+ args.due_date === undefined &&
1012
+ !hasNames(args.add_label_names) &&
1013
+ !hasNames(args.remove_label_names)) {
1014
+ throw new Error('Give at least one of: status, priority, due_date, add_label_names, remove_label_names.');
1015
+ }
1016
+ refuseLabelConflicts(args, false);
889
1017
  const body = { ids: args.ids };
890
1018
  if (args.status !== undefined)
891
1019
  body.status = args.status;
@@ -893,6 +1021,10 @@ export function registerTaskTools(server, config) {
893
1021
  body.priority = args.priority;
894
1022
  if (args.due_date !== undefined)
895
1023
  body.due_date = args.due_date;
1024
+ if (hasNames(args.add_label_names))
1025
+ body.add_label_names = args.add_label_names;
1026
+ if (hasNames(args.remove_label_names))
1027
+ body.remove_label_names = args.remove_label_names;
896
1028
  body.actor_employee_code = args.actor_employee_code ?? cfg.defaultActor;
897
1029
  return request(cfg, '/tasks/bulk', { method: 'PATCH', body });
898
1030
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.25.0",
3
+ "version": "0.26.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": {