clokio-mcp 0.5.3 → 0.13.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
@@ -24,7 +24,7 @@ permissions and the audit trail stay per-user.
24
24
  ```bash
25
25
  claude mcp add clokio \
26
26
  --env CLOKIO_API_KEY=clk_your_key_here \
27
- -- npx -y clokio-mcp
27
+ -- npx -y clokio-mcp@latest
28
28
  ```
29
29
 
30
30
  ### Claude Desktop
@@ -36,7 +36,7 @@ Add to `claude_desktop_config.json`:
36
36
  "mcpServers": {
37
37
  "clokio": {
38
38
  "command": "npx",
39
- "args": ["-y", "clokio-mcp"],
39
+ "args": ["-y", "clokio-mcp@latest"],
40
40
  "env": { "CLOKIO_API_KEY": "clk_your_key_here" }
41
41
  }
42
42
  }
@@ -45,32 +45,48 @@ Add to `claude_desktop_config.json`:
45
45
 
46
46
  ### Cursor / Cline / Zed / any MCP client
47
47
 
48
- Use the same command — `npx -y clokio-mcp` with `CLOKIO_API_KEY` in the env.
48
+ Use the same command — `npx -y clokio-mcp@latest` with `CLOKIO_API_KEY` in the env.
49
49
  MCP is an open standard.
50
50
 
51
51
  Optional: set `CLOKIO_BASE_URL` to point at a different Clokio installation
52
52
  (defaults to `https://app.clokio.io`).
53
53
 
54
+ Optional: set `CLOKIO_DEFAULT_ACTOR` to an `employee_code` (e.g. `00055`) to
55
+ attribute tasks, comments and updates that name no author. You usually do not
56
+ need it — an unnamed write is already credited to the person who issued the API
57
+ key. Use it only for a **shared or service key** whose issuer is not the person
58
+ acting. An explicit code on a tool call always takes precedence.
59
+
60
+ Optional: set `CLOKIO_TOOLSETS` to register only the tool groups you need, e.g.
61
+ `CLOKIO_TOOLSETS=tasks`. This cuts the per-session tool-list cost — a
62
+ task-focused session drops from 48 tools to 26. Optional groups are `tasks`,
63
+ `employees` and `attendance` (which includes leave and time entries); combine
64
+ them with commas. The `projects` group (project/client lookups, task statuses
65
+ and labels, and `whoami`) is always on, because the task tools resolve names
66
+ through it.
67
+
54
68
  ## Tools
55
69
 
56
- 36 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
70
+ 48 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
57
71
  are marked mutating (deletes are `destructive`).
58
72
 
59
73
  | Area | Tools |
60
74
  |------|-------|
61
- | **Tasks** | list, get, create, update, delete, set assignees, list/add comments, activity, list/add dependencies, set custom field |
75
+ | **Tasks** | list (with `compact` / `sort` / `updated_since`), get (with `comments_limit`), create, update, delete, bulk update, set assignees, list/add/edit comments, list/upload/download attachments, activity, list/add/remove dependencies, set custom field |
62
76
  | **Projects & clients** | list projects, project statuses, project custom fields, project contacts, get client, add client contact |
63
- | **Reference** | task statuses, task labels, locations |
64
- | **Employees** | list, lookup, task-stats, create, set status |
65
- | **Attendance** | daily, range, per-employee, monthly summary, clock in/out, break start/end, time entries, balances |
77
+ | **Reference** | task statuses, task labels, locations, list webhooks (read-only), whoami |
78
+ | **Employees** | list (name search), lookup by PIN, task-stats, inbox, create, set status |
79
+ | **Attendance** | daily, range, per-employee, monthly summary, clock in/out, break start/end, time entries, leaves, leave balances |
66
80
 
67
81
  ### Conventions
68
82
 
69
83
  - **People are identified by `employee_code`** (e.g. `00080`), not internal ids.
70
- Use `clokio_lookup_employee` to resolve a name to a code.
71
- - **Attribute writes to a person** — pass `creator_employee_code` when creating a
72
- task, `author_employee_code` when commenting, or the entry shows as the API key
73
- owner ("External System").
84
+ Use `clokio_list_employees` (with `search`) to resolve a name to a code, or
85
+ `clokio_lookup_employee_by_pin` for a kiosk PIN.
86
+ - **Attribution is automatic.** An unnamed write is credited to the key's
87
+ issuer (see `clokio_whoami`), so you rarely set an author. Pass
88
+ `created_by_employee_code` / `author_employee_code` only to credit someone
89
+ else, or set `CLOKIO_DEFAULT_ACTOR` for a shared key.
74
90
  - **Status slugs** come from `clokio_list_task_statuses` /
75
91
  `clokio_get_project_statuses`.
76
92
  - An API key sees **public custom fields only**.
package/dist/client.js CHANGED
@@ -13,7 +13,13 @@ export function loadConfig() {
13
13
  'CLOKIO_API_KEY environment variable.');
14
14
  }
15
15
  const baseUrl = (process.env.CLOKIO_BASE_URL?.trim() || 'https://app.clokio.io').replace(/\/+$/, '');
16
- return { baseUrl, apiKey };
16
+ const defaultActor = process.env.CLOKIO_DEFAULT_ACTOR?.trim() || undefined;
17
+ // Validate the same way the API constrains an employee_code, so a
18
+ // misconfigured value fails at startup rather than silently on every write.
19
+ if (defaultActor && !/^[A-Za-z0-9_-]{1,50}$/.test(defaultActor)) {
20
+ throw new Error('CLOKIO_DEFAULT_ACTOR must be an employee_code (1-50 chars of letters, digits, _ or -).');
21
+ }
22
+ return { baseUrl, apiKey, defaultActor };
17
23
  }
18
24
  /**
19
25
  * Encode ONE path segment supplied by a tool argument.
@@ -152,6 +158,74 @@ export async function requestRaw(config, path, fileName) {
152
158
  'The bytes are not included - they would be unreadable here and would cost a great deal of context. ' +
153
159
  'Ask the person to open the attachment in Clokio, or fetch this path yourself if you can handle binary.');
154
160
  }
161
+ /**
162
+ * Upload a LOCAL FILE to a Clokio endpoint as multipart/form-data.
163
+ *
164
+ * The one write on this API that is not JSON. The tool gives a file PATH the
165
+ * model can already reach (it is running in a session with a filesystem), this
166
+ * reads it and sends it under the `file` field. Extra text fields (e.g.
167
+ * uploaded_by_employee_code) ride along.
168
+ *
169
+ * The same /api/v1 prefix guard and redirect: 'error' as request(), so the
170
+ * upload path is not a hole in either defence, and the key is sent as a header
171
+ * exactly as elsewhere - multipart does not change where the credential goes.
172
+ */
173
+ export async function requestUpload(config, path, filePath, fields = {}) {
174
+ const { readFile } = await import('node:fs/promises');
175
+ const { basename } = await import('node:path');
176
+ const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
177
+ const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
178
+ if (!url.pathname.startsWith(expectedPrefix)) {
179
+ throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1.');
180
+ }
181
+ let bytes;
182
+ try {
183
+ bytes = await readFile(filePath);
184
+ }
185
+ catch (e) {
186
+ throw new ClokioApiError(0, `Could not read the file at ${filePath}: ${e.message}`);
187
+ }
188
+ const form = new FormData();
189
+ // A fresh Uint8Array view is a valid BlobPart; a Node Buffer is not, under
190
+ // strict lib types.
191
+ form.append('file', new Blob([new Uint8Array(bytes)]), basename(filePath));
192
+ for (const [key, value] of Object.entries(fields)) {
193
+ if (value !== undefined)
194
+ form.append(key, value);
195
+ }
196
+ let res;
197
+ try {
198
+ // Content-Type is set BY fetch from the FormData (with the boundary); do
199
+ // not set it by hand or the boundary is lost.
200
+ res = await fetch(url, {
201
+ method: 'POST',
202
+ headers: { 'X-API-Key': config.apiKey, Accept: 'application/json' },
203
+ body: form,
204
+ redirect: 'error',
205
+ });
206
+ }
207
+ catch (e) {
208
+ throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
209
+ }
210
+ const text = await res.text();
211
+ let parsed;
212
+ try {
213
+ parsed = text ? JSON.parse(text) : undefined;
214
+ }
215
+ catch {
216
+ parsed = text;
217
+ }
218
+ if (!res.ok) {
219
+ const message = parsed && typeof parsed === 'object' && 'message' in parsed && typeof parsed.message === 'string'
220
+ ? parsed.message
221
+ : `Clokio API returned HTTP ${res.status}`;
222
+ throw new ClokioApiError(res.status, message, parsed);
223
+ }
224
+ if (parsed && typeof parsed === 'object' && 'data' in parsed) {
225
+ return parsed.data;
226
+ }
227
+ return parsed;
228
+ }
155
229
  /** A Clokio API error, carrying the HTTP status and the server's message. */
156
230
  export class ClokioApiError extends Error {
157
231
  status;
@@ -234,9 +308,24 @@ export async function request(config, path, options = {}) {
234
308
  : `Clokio API returned HTTP ${res.status}`);
235
309
  throw new ClokioApiError(res.status, message, parsed);
236
310
  }
237
- // Standard envelope: {status, message, data}. Unwrap `data` when present.
311
+ // Standard envelope: {status, message, data, meta?}. Unwrap `data`, but
312
+ // KEEP `meta` when the API sent one.
313
+ //
314
+ // Unwrapping both was a real defect: list endpoints return
315
+ // meta.next_cursor, meta.total and meta.last_page, and this threw all of it
316
+ // away. The tool descriptions told the model to page with a cursor while
317
+ // the value it needed had already been discarded - so a caller could not
318
+ // tell a full first page from the whole result set, and every crawl past
319
+ // page one was impossible. Reported 2026-09-26.
320
+ //
321
+ // `data` is returned bare when there is no meta, so single-object reads
322
+ // (get_task, whoami) keep the shape they have always had.
238
323
  if (parsed && typeof parsed === 'object' && 'data' in parsed) {
239
- return parsed.data;
324
+ const envelope = parsed;
325
+ if (envelope.meta !== undefined && envelope.meta !== null) {
326
+ return { data: envelope.data, meta: envelope.meta };
327
+ }
328
+ return envelope.data;
240
329
  }
241
330
  return parsed;
242
331
  }
package/dist/index.js CHANGED
@@ -10,31 +10,66 @@
10
10
  * Or register with: claude mcp add clokio -- npx -y clokio-mcp
11
11
  * (set CLOKIO_API_KEY, and optionally CLOKIO_BASE_URL, in the MCP env).
12
12
  */
13
- import { createRequire } from 'node:module';
14
13
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
14
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
15
  import { loadConfig } from './client.js';
16
+ import { VERSION } from './version.js';
17
17
  import { registerTaskTools } from './tools/tasks.js';
18
18
  import { registerProjectTools } from './tools/projects.js';
19
19
  import { registerEmployeeTools } from './tools/employees.js';
20
20
  import { registerAttendanceTools } from './tools/attendance.js';
21
- // The version the MCP handshake announces is package.json's, read at runtime -
22
- // a literal here went stale on the first patch release (0.1.1 said 0.1.0).
23
- const { version } = createRequire(import.meta.url)('../package.json');
21
+ /**
22
+ * The optional tool groups a session can turn OFF.
23
+ *
24
+ * tools/list is ~8k tokens with everything registered, and a task-focused
25
+ * session never touches the 13 attendance/leave/time tools or the 5 employee
26
+ * ones. Setting e.g. CLOKIO_TOOLSETS=tasks drops them, cutting the per-session
27
+ * cost.
28
+ *
29
+ * `projects` is NOT selectable: it holds whoami and the reference lookups
30
+ * (task statuses, labels, projects, locations) that the task tools depend on
31
+ * to resolve names to ids, so it is always registered. Naming a toolset that
32
+ * does not exist is refused at startup rather than silently ignored.
33
+ */
34
+ const OPTIONAL_TOOLSETS = ['tasks', 'employees', 'attendance'];
35
+ function selectedToolsets() {
36
+ const raw = process.env.CLOKIO_TOOLSETS?.trim();
37
+ if (!raw) {
38
+ // Default: everything.
39
+ return new Set(OPTIONAL_TOOLSETS);
40
+ }
41
+ const requested = raw
42
+ .split(',')
43
+ .map((s) => s.trim().toLowerCase())
44
+ .filter(Boolean);
45
+ const unknown = requested.filter((s) => !OPTIONAL_TOOLSETS.includes(s));
46
+ if (unknown.length) {
47
+ throw new Error(`CLOKIO_TOOLSETS names unknown toolset(s): ${unknown.join(', ')}. ` +
48
+ `Valid values are: ${OPTIONAL_TOOLSETS.join(', ')} (projects is always on).`);
49
+ }
50
+ return new Set(requested);
51
+ }
24
52
  async function main() {
25
53
  const config = loadConfig();
54
+ const toolsets = selectedToolsets();
26
55
  const server = new McpServer({
27
56
  name: 'clokio-mcp',
28
- version,
57
+ version: VERSION,
29
58
  });
30
- registerTaskTools(server, config);
59
+ // Always on: whoami plus the reference lookups the task tools resolve
60
+ // names against.
31
61
  registerProjectTools(server, config);
32
- registerEmployeeTools(server, config);
33
- registerAttendanceTools(server, config);
62
+ if (toolsets.has('tasks'))
63
+ registerTaskTools(server, config);
64
+ if (toolsets.has('employees'))
65
+ registerEmployeeTools(server, config);
66
+ if (toolsets.has('attendance'))
67
+ registerAttendanceTools(server, config);
34
68
  const transport = new StdioServerTransport();
35
69
  await server.connect(transport);
36
70
  // stderr is safe for logs on stdio transport (stdout carries the protocol).
37
- process.stderr.write(`clokio-mcp connected (base: ${config.baseUrl})\n`);
71
+ const active = ['projects', ...OPTIONAL_TOOLSETS.filter((t) => toolsets.has(t))].join(', ');
72
+ process.stderr.write(`clokio-mcp ${VERSION} connected (base: ${config.baseUrl}, toolsets: ${active})\n`);
38
73
  }
39
74
  main().catch((e) => {
40
75
  process.stderr.write(`clokio-mcp failed to start: ${e.message}\n`);
@@ -17,7 +17,8 @@ export function registerEmployeeTools(server, config) {
17
17
  description: 'Find people. This is how you resolve a PERSON TO THEIR employee_code - the stable id every other tool ' +
18
18
  'wants for assignees, comment authors and task creators. Pass `search` with a name (or part of one) ' +
19
19
  'rather than listing everyone and matching yourself. Combine with status=active to skip people who have ' +
20
- 'left. email is returned only if the key holds the employees:pii scope.',
20
+ 'left. email is returned only if the key holds the employees:pii scope. ' +
21
+ 'Paginated: the response is {data, meta}, the people under data.',
21
22
  schema: {
22
23
  search: z.string().max(120).optional().describe('Free text over name and employee_code'),
23
24
  email: z.string().optional().describe('EXACT email match (not a substring search)'),
@@ -58,9 +59,30 @@ export function registerEmployeeTools(server, config) {
58
59
  schema: {
59
60
  employee_code: employeeCode,
60
61
  stale_days: z.number().int().min(1).max(365).optional(),
62
+ definitions: z
63
+ .boolean()
64
+ .optional()
65
+ .describe('false drops the definitions block once you know what the numbers mean'),
61
66
  },
62
67
  handler: (args, cfg) => request(cfg, `/employees/${seg(args.employee_code)}/task-stats`, {
63
- query: { stale_days: args.stale_days },
68
+ query: {
69
+ stale_days: args.stale_days,
70
+ definitions: args.definitions === undefined ? undefined : args.definitions ? 1 : 0,
71
+ },
72
+ }),
73
+ });
74
+ registerTool(server, config, {
75
+ name: 'clokio_get_inbox',
76
+ description: 'What needs one person\'s attention: mentions_waiting (comments that @-mention them and that they have ' +
77
+ 'NOT replied to yet) and newly_assigned (tasks assigned to them in the window). This is how you find ' +
78
+ 'that someone asked a question, without scanning the board. `since` defaults to 14 days (max 90). ' +
79
+ 'To answer for the KEY OWNER, pass their own employee_code (from clokio_whoami).',
80
+ schema: {
81
+ employee_code: employeeCode,
82
+ since: z.string().optional().describe('ISO-8601 or YYYY-MM-DD. Only items after this. Default 14 days ago'),
83
+ },
84
+ handler: (args, cfg) => request(cfg, `/employees/${seg(args.employee_code)}/inbox`, {
85
+ query: { since: args.since },
64
86
  }),
65
87
  });
66
88
  registerTool(server, config, {
@@ -1,12 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import { request } from '../client.js';
3
3
  import { registerTool } from './helpers.js';
4
+ import { VERSION } from '../version.js';
4
5
  /** Projects, clients, and the board-configuration + reference lookups. */
5
6
  export function registerProjectTools(server, config) {
6
7
  registerTool(server, config, {
7
8
  name: 'clokio_list_projects',
8
9
  description: 'List projects, optionally filtered by a search term (name / client) or status. Use this to resolve a ' +
9
- 'project NAME to the project_id every task tool needs.',
10
+ 'project NAME to the project_id every task tool needs. Paginated: {data, meta}.',
10
11
  schema: {
11
12
  search: z.string().max(200).optional(),
12
13
  status: z.enum(['active', 'archived']).optional(),
@@ -65,7 +66,9 @@ export function registerProjectTools(server, config) {
65
66
  });
66
67
  registerTool(server, config, {
67
68
  name: 'clokio_list_task_labels',
68
- description: 'List the available task labels (tags), with names and colors.',
69
+ description: 'List the available task labels (tags), with names and colors. There is no separate "create label" ' +
70
+ 'call and none is needed: naming a label that does not exist yet in clokio_create_task\'s label_names ' +
71
+ 'creates it. Check this list first so you reuse an existing label instead of creating a near-duplicate.',
69
72
  schema: {},
70
73
  handler: (_args, cfg) => request(cfg, '/task-labels'),
71
74
  });
@@ -75,6 +78,18 @@ export function registerProjectTools(server, config) {
75
78
  schema: {},
76
79
  handler: (_args, cfg) => request(cfg, '/locations'),
77
80
  });
81
+ registerTool(server, config, {
82
+ name: 'clokio_list_webhooks',
83
+ description: 'The organisation\'s webhooks with their delivery health (sent / succeeded / failed, last error), for ' +
84
+ 'answering "why did a notification not arrive?". READ ONLY - webhooks are created in the Clokio ' +
85
+ 'dashboard, never through the API. The destination URL is masked (a webhook URL carries a secret token).',
86
+ schema: {
87
+ active: z.boolean().optional().describe('Filter to active (true) or inactive (false) webhooks'),
88
+ },
89
+ handler: (args, cfg) => request(cfg, '/webhooks', {
90
+ query: { active: args.active === undefined ? undefined : args.active ? 1 : 0 },
91
+ }),
92
+ });
78
93
  registerTool(server, config, {
79
94
  name: 'clokio_whoami',
80
95
  description: 'Who this API key belongs to: the organisation (with its timezone - every date filter is evaluated in ' +
@@ -84,6 +99,17 @@ export function registerProjectTools(server, config) {
84
99
  'created_by_employee_code / author_employee_code on every write, so the work is attributed to a person ' +
85
100
  'instead of showing up as "External System" on a board the team reads. `issued_by` can be null.',
86
101
  schema: {},
87
- handler: (_args, cfg) => request(cfg, '/me'),
102
+ // mcp_version is added CLIENT-side, from the running package, because the
103
+ // server cannot know which build is talking to it.
104
+ //
105
+ // It exists for one reason: `npx -y clokio-mcp` can serve a CACHED old
106
+ // build, and a session then reports bugs that were fixed releases ago
107
+ // against tools it does not actually have. That happened on 2026-09-26 -
108
+ // a session ran 0.1.2 while 0.5.3 was current, and the mismatch took a
109
+ // while to spot. Now the first call of a session states the version.
110
+ handler: async (_args, cfg) => {
111
+ const me = (await request(cfg, '/me'));
112
+ return { ...me, mcp_version: VERSION };
113
+ },
88
114
  });
89
115
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { request, requestRaw } from '../client.js';
2
+ import { request, requestRaw, requestUpload } from '../client.js';
3
3
  import { registerTool } from './helpers.js';
4
4
  /** See employees.ts - the same stable-identifier rule applies to task filters. */
5
5
  const employeeCode = z
@@ -11,7 +11,9 @@ export function registerTaskTools(server, config) {
11
11
  name: 'clokio_list_tasks',
12
12
  description: 'List tasks across projects, with server-side filters so you do not page the whole board and count in ' +
13
13
  'your own code. An UNKNOWN query parameter is a 422 naming it, so use exactly these names. Paginate ' +
14
- 'with page, or crawl with cursor (preferred past one page). Every task carries a task_url.',
14
+ 'with page, or crawl with cursor (preferred past one page). Every task carries a task_url.\n\n' +
15
+ 'The response is {data, meta}. meta carries total, per_page, current_page and next_cursor - read ' +
16
+ 'next_cursor and pass it back as `cursor` for the following page; it is null on the last one.',
15
17
  schema: {
16
18
  project_id: z.number().int().optional(),
17
19
  status: z.string().optional().describe('Status slug, e.g. "in_progress"'),
@@ -41,6 +43,12 @@ export function registerTaskTools(server, config) {
41
43
  per_page: z.number().int().min(1).max(100).optional(),
42
44
  page: z.number().int().optional(),
43
45
  cursor: z.string().optional().describe('meta.next_cursor from a previous page; preferred over page'),
46
+ compact: z
47
+ .boolean()
48
+ .optional()
49
+ .describe('STRONGLY PREFERRED when scanning. Returns id, title, status, priority, due_date, project, ' +
50
+ 'assignees, labels and updated_at only - dropping the HTML description, which is most of the ' +
51
+ 'payload. Fetch the one task you actually need in full with clokio_get_task.'),
44
52
  sort: z
45
53
  .enum([
46
54
  'due_date',
@@ -78,21 +86,34 @@ export function registerTaskTools(server, config) {
78
86
  page: args.page,
79
87
  cursor: args.cursor,
80
88
  sort: args.sort,
89
+ compact: args.compact === undefined ? undefined : args.compact ? 1 : 0,
81
90
  },
82
91
  }),
83
92
  });
84
93
  registerTool(server, config, {
85
94
  name: 'clokio_get_task',
86
- description: 'Get one task by id, with its full detail (assignees, labels, status, custom fields, task_url).',
87
- schema: { id: z.number().int() },
88
- handler: (args, cfg) => request(cfg, `/tasks/${args.id}`),
95
+ description: 'Get one task by id, with its full detail (assignees, labels, status, custom fields, task_url) and ' +
96
+ 'its comments. A long-running task can carry dozens of comments: pass comments_limit to keep only the ' +
97
+ 'newest few, or 0 for none. comments_count always reports the real total.',
98
+ schema: {
99
+ id: z.number().int(),
100
+ comments_limit: z
101
+ .number()
102
+ .int()
103
+ .min(0)
104
+ .max(200)
105
+ .optional()
106
+ .describe('Newest N comments only (0 = none). Omitted returns every comment'),
107
+ },
108
+ handler: (args, cfg) => request(cfg, `/tasks/${args.id}`, { query: { comments_limit: args.comments_limit } }),
89
109
  });
90
110
  registerTool(server, config, {
91
111
  name: 'clokio_create_task',
92
112
  mutates: true,
93
113
  description: 'Create a task in a project. People are named by employee_code (e.g. "00080"), never internal ids. ' +
94
- 'ALWAYS set created_by_employee_code to attribute the task to a person - without it the task shows as ' +
95
- 'the API key owner ("External System"). Pass parent_task_id to create it as a SUBTASK of that task ' +
114
+ 'You do NOT normally need created_by_employee_code: an unnamed task is attributed to the key\'s issuer ' +
115
+ 'automatically (whoami shows who that is). Set it only to credit someone OTHER than the key owner. ' +
116
+ 'Pass parent_task_id to create it as a SUBTASK of that task ' +
96
117
  '(same project, and the parent must not itself be a subtask - one level deep only).',
97
118
  schema: {
98
119
  project_id: z.number().int(),
@@ -105,8 +126,12 @@ export function registerTaskTools(server, config) {
105
126
  estimated_hours: z.number().min(0).max(9999.99).optional(),
106
127
  parent_task_id: z.number().int().optional().describe('Makes this a subtask of that task'),
107
128
  assignee_employee_codes: z.array(employeeCode).max(50).optional(),
108
- label_names: z.array(z.string().max(100)).max(20).optional(),
109
- created_by_employee_code: employeeCode.optional().describe('Attribute the task to this person'),
129
+ label_names: z
130
+ .array(z.string().max(100))
131
+ .max(20)
132
+ .optional()
133
+ .describe('Label names. A name that does not exist yet is CREATED - check clokio_list_task_labels first'),
134
+ created_by_employee_code: employeeCode.optional().describe('Credit someone OTHER than the key owner; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
110
135
  },
111
136
  handler: (args, cfg) => request(cfg, '/tasks', {
112
137
  method: 'POST',
@@ -122,7 +147,7 @@ export function registerTaskTools(server, config) {
122
147
  parent_task_id: args.parent_task_id,
123
148
  assignee_employee_codes: args.assignee_employee_codes,
124
149
  label_names: args.label_names,
125
- created_by_employee_code: args.created_by_employee_code,
150
+ created_by_employee_code: args.created_by_employee_code ?? cfg.defaultActor,
126
151
  },
127
152
  }),
128
153
  });
@@ -142,7 +167,7 @@ export function registerTaskTools(server, config) {
142
167
  start_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
143
168
  estimated_hours: z.number().min(0).max(9999.99).nullable().optional(),
144
169
  project_id: z.number().int().optional().describe('Move the task to another project'),
145
- actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log'),
170
+ actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
146
171
  },
147
172
  handler: (args, cfg) => {
148
173
  // Only keys the caller actually supplied may go in the body: PATCH
@@ -163,6 +188,13 @@ export function registerTaskTools(server, config) {
163
188
  if (args[key] !== undefined)
164
189
  body[key] = args[key];
165
190
  }
191
+ // Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
192
+ // actor. The API otherwise falls back to the key's issuer on its own,
193
+ // so this only overrides for a shared/service key. An explicit
194
+ // actor_employee_code above already sits in body and is left untouched.
195
+ if (body.actor_employee_code === undefined && cfg.defaultActor) {
196
+ body.actor_employee_code = cfg.defaultActor;
197
+ }
166
198
  return request(cfg, `/tasks/${args.id}`, { method: 'PATCH', body });
167
199
  },
168
200
  });
@@ -197,25 +229,26 @@ export function registerTaskTools(server, config) {
197
229
  registerTool(server, config, {
198
230
  name: 'clokio_add_task_comment',
199
231
  mutates: true,
200
- description: 'Add a comment to a task. Set author_employee_code to attribute it to a person; otherwise it shows as ' +
201
- 'the API key owner ("External System"). Use readable formatting (paragraphs, blank lines, bullet ' +
202
- 'lists); markdown TABLES are not rendered and post as raw text. Write in the language of the person ' +
203
- 'you are answering. ' +
232
+ description: 'Add a comment to a task. author_employee_code is usually unnecessary: an unnamed comment is attributed ' +
233
+ 'to the key issuer automatically. Set it only to credit someone else. Use readable formatting ' +
234
+ '(paragraphs, blank lines, bullet lists); markdown TABLES are not rendered and post as raw text. Write ' +
235
+ 'in the language of the person you are answering. ' +
204
236
  'TO MENTION SOMEONE, write @ followed by their full name exactly as clokio_list_employees spells it ' +
205
237
  '(for example "@Kiran Bahadur") in the body text - no markup needed. It becomes a real mention and ' +
206
- 'notifies them, but only if the comment has an author_employee_code; an unattributed comment notifies ' +
207
- 'nobody. A name that matches no one stays as plain text, so check the spelling first.',
238
+ 'notifies them, but only if the comment has an author (its own field, the key issuer, or ' +
239
+ 'CLOKIO_DEFAULT_ACTOR); a truly authorless comment notifies nobody. A name that matches no one stays ' +
240
+ 'as plain text, so check the spelling first.',
208
241
  schema: {
209
242
  id: z.number().int(),
210
243
  body: z.string().max(65535),
211
- author_employee_code: employeeCode.optional().describe('Attribute the comment to this person'),
244
+ author_employee_code: employeeCode.optional().describe('Credit someone OTHER than the key owner; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
212
245
  notify: z.boolean().optional().describe('Send notifications to watchers (default: the API decides)'),
213
246
  },
214
247
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments`, {
215
248
  method: 'POST',
216
249
  body: {
217
250
  body: args.body,
218
- author_employee_code: args.author_employee_code,
251
+ author_employee_code: args.author_employee_code ?? cfg.defaultActor,
219
252
  notify: args.notify,
220
253
  },
221
254
  }),
@@ -236,7 +269,7 @@ export function registerTaskTools(server, config) {
236
269
  },
237
270
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments/${args.comment_id}`, {
238
271
  method: 'PATCH',
239
- body: { body: args.body, author_employee_code: args.author_employee_code },
272
+ body: { body: args.body, author_employee_code: args.author_employee_code ?? cfg.defaultActor },
240
273
  }),
241
274
  });
242
275
  registerTool(server, config, {
@@ -263,6 +296,24 @@ export function registerTaskTools(server, config) {
263
296
  },
264
297
  handler: (args, cfg) => requestRaw(cfg, `/tasks/${args.id}/attachments/${args.attachment_id}`, args.file_name),
265
298
  });
299
+ registerTool(server, config, {
300
+ name: 'clokio_upload_task_attachment',
301
+ mutates: true,
302
+ description: 'Upload a LOCAL FILE to a task as an attachment - a screenshot, a log, a spreadsheet. Give the path to ' +
303
+ 'a file on this machine; it is read and sent. Allowed types include images, PDF, Office docs, txt/csv/' +
304
+ 'md/json, code diffs, zip and video, up to 200 MB. The uploader is the key issuer unless you set ' +
305
+ 'uploaded_by_employee_code.',
306
+ schema: {
307
+ id: z.number().int().describe('The task id'),
308
+ file_path: z.string().describe('Path to a file on THIS machine'),
309
+ uploaded_by_employee_code: employeeCode
310
+ .optional()
311
+ .describe('Credit someone other than the key owner; omit to use the key issuer'),
312
+ },
313
+ handler: (args, cfg) => requestUpload(cfg, `/tasks/${args.id}/attachments`, args.file_path, {
314
+ uploaded_by_employee_code: args.uploaded_by_employee_code ?? cfg.defaultActor,
315
+ }),
316
+ });
266
317
  registerTool(server, config, {
267
318
  name: 'clokio_get_task_activity',
268
319
  description: 'Get the activity log (audit trail) for a task.',
@@ -315,4 +366,31 @@ export function registerTaskTools(server, config) {
315
366
  body: { field_key: args.field_key, value: args.value },
316
367
  }),
317
368
  });
369
+ registerTool(server, config, {
370
+ name: 'clokio_bulk_update_tasks',
371
+ mutates: true,
372
+ description: 'Apply ONE change (any of status, priority, due_date) to up to 200 tasks at once - for cleaning up a ' +
373
+ 'board without a call per task. Partial success is normal: the result is {updated, not_found, ' +
374
+ 'invalid_status}. not_found lists ids that do not exist in your org (skipped, not fatal); ' +
375
+ 'invalid_status lists tasks whose board does not have the given status slug. Give at least one of ' +
376
+ 'status/priority/due_date. Attribution follows the key issuer unless actor_employee_code is set.',
377
+ schema: {
378
+ ids: z.array(z.number().int()).min(1).max(200),
379
+ status: z.string().optional().describe('Status slug - validated per task against its own board'),
380
+ priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
381
+ due_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
382
+ actor_employee_code: employeeCode.optional().describe('Who to credit; omit to use the key issuer'),
383
+ },
384
+ handler: (args, cfg) => {
385
+ const body = { ids: args.ids };
386
+ if (args.status !== undefined)
387
+ body.status = args.status;
388
+ if (args.priority !== undefined)
389
+ body.priority = args.priority;
390
+ if (args.due_date !== undefined)
391
+ body.due_date = args.due_date;
392
+ body.actor_employee_code = args.actor_employee_code ?? cfg.defaultActor;
393
+ return request(cfg, '/tasks/bulk', { method: 'PATCH', body });
394
+ },
395
+ });
318
396
  }
@@ -0,0 +1,15 @@
1
+ import { createRequire } from 'node:module';
2
+ /**
3
+ * This package's version, read from package.json at runtime.
4
+ *
5
+ * ONE source for it. A literal went stale on the first patch release (0.1.1
6
+ * announced itself as 0.1.0), and there are now two consumers that must not
7
+ * drift apart: the MCP handshake in index.ts, and clokio_whoami, which
8
+ * reports it so a session can tell WHICH build it is running.
9
+ *
10
+ * That second use is not cosmetic. `npx -y clokio-mcp` can serve a cached old
11
+ * build, and a session then reports bugs fixed releases ago against tools it
12
+ * does not have - which is exactly what happened on 2026-09-26, with 0.1.2
13
+ * running while 0.5.3 was current.
14
+ */
15
+ export const VERSION = createRequire(import.meta.url)('../package.json').version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.5.3",
3
+ "version": "0.13.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": {