clokio-mcp 0.1.2 → 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.
@@ -41,6 +47,185 @@ export function seg(value) {
41
47
  }
42
48
  return encodeURIComponent(s);
43
49
  }
50
+ /**
51
+ * Fetch an endpoint that returns a FILE rather than the JSON envelope.
52
+ *
53
+ * Only the attachment download does this today. Text comes back as text;
54
+ * anything binary is DESCRIBED rather than returned, because a model that
55
+ * asks for a 4 MB PNG cannot use the bytes and pouring them into the
56
+ * conversation as mojibake costs a fortune in tokens and tells it nothing.
57
+ * The description still answers the question the caller actually had - is
58
+ * there a file here, what kind, how big.
59
+ */
60
+ export async function requestRaw(config, path, fileName) {
61
+ const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
62
+ const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
63
+ if (!url.pathname.startsWith(expectedPrefix)) {
64
+ throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1.');
65
+ }
66
+ let res;
67
+ try {
68
+ // `manual`, not 'error' and not 'follow'.
69
+ //
70
+ // The API streams small and renderable files, but 302s to a short-lived
71
+ // signed storage URL for anything over 8 MB. 'error' would report every
72
+ // large attachment as a bogus network failure. 'follow' is worse: Node
73
+ // re-sends request headers on a redirect, so X-API-Key would be handed
74
+ // to another origin, which is the leak the redirect: 'error' on the JSON
75
+ // path exists to prevent. So the redirect is followed HERE, by hand,
76
+ // with no credential attached - the signed URL needs none.
77
+ res = await fetch(url, {
78
+ headers: { 'X-API-Key': config.apiKey },
79
+ redirect: 'manual',
80
+ });
81
+ if (res.status >= 300 && res.status < 400) {
82
+ const location = res.headers.get('location');
83
+ if (!location) {
84
+ throw new ClokioApiError(0, 'Clokio redirected the download without a destination.');
85
+ }
86
+ // WHERE the redirect may point is constrained, even though the key does
87
+ // not travel with it.
88
+ //
89
+ // new URL() resolution is unrestricted: `file:///etc/passwd`, `data:...`
90
+ // and `http://169.254.169.254/...` all resolve to themselves rather than
91
+ // inheriting our origin. Node's fetch refuses most of those schemes on
92
+ // its own, but `data:` it happily resolves, and an http(s) target is a
93
+ // blind SSRF - this process would fetch an attacker-named URL and return
94
+ // up to 100k characters of the response into the model's context.
95
+ //
96
+ // Reaching that requires control of the API's own response, at which
97
+ // point the attacker could have put the same bytes in the attachment
98
+ // body - so it buys them nothing today. It is refused anyway because the
99
+ // check is free, and because "the API is trustworthy" is the assumption
100
+ // most likely to stop being true.
101
+ const target = new URL(location, url);
102
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
103
+ throw new ClokioApiError(0, `Refusing to follow a download redirect to a ${target.protocol} URL.`);
104
+ }
105
+ // NO headers: the key must not cross to the storage origin.
106
+ res = await fetch(target, { redirect: 'error' });
107
+ }
108
+ }
109
+ catch (e) {
110
+ if (e instanceof ClokioApiError)
111
+ throw e;
112
+ throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
113
+ }
114
+ if (!res.ok) {
115
+ const text = await res.text();
116
+ let message = `Clokio API returned HTTP ${res.status}`;
117
+ try {
118
+ const parsed = JSON.parse(text);
119
+ if (parsed && typeof parsed.message === 'string')
120
+ message = parsed.message;
121
+ }
122
+ catch {
123
+ /* a non-JSON error body is not more informative than the status */
124
+ }
125
+ throw new ClokioApiError(res.status, message);
126
+ }
127
+ const type = res.headers.get('content-type') ?? 'application/octet-stream';
128
+ const buffer = Buffer.from(await res.arrayBuffer());
129
+ // CONTENT-TYPE IS NOT ENOUGH. The API deliberately re-labels html, json,
130
+ // js, svg and xml attachments as application/octet-stream so a browser
131
+ // cannot render them from our origin - an XSS defence. Trusting the header
132
+ // alone would report a JSON report an agent attached as unreadable binary,
133
+ // so the file EXTENSION gets a say too.
134
+ const extension = (fileName ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '';
135
+ const TEXT_EXTENSIONS = new Set([
136
+ 'txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'xml', 'yaml', 'yml',
137
+ 'html', 'htm', 'log', 'diff', 'patch', 'sql', 'js', 'ts', 'css', 'svg',
138
+ ]);
139
+ const isText = type.startsWith('text/') ||
140
+ /\b(json|xml|csv|yaml|javascript|markdown)\b/.test(type) ||
141
+ TEXT_EXTENSIONS.has(extension);
142
+ const kb = (buffer.byteLength / 1024).toFixed(1);
143
+ if (isText) {
144
+ // A CAP ON TEXT TOO. Attachments go up to 200 MB and .csv/.log are
145
+ // allowed, so "it is text" does not make it safe to paste: a 40 MB CSV
146
+ // would be dumped whole into the conversation. Truncate and SAY SO, so
147
+ // the reader never mistakes a prefix for the entire file.
148
+ const LIMIT = 100_000;
149
+ const text = buffer.toString('utf8');
150
+ if (text.length <= LIMIT) {
151
+ return text;
152
+ }
153
+ return (`[truncated: showing the first ${LIMIT.toLocaleString()} characters of ${kb} KB]\n\n` +
154
+ text.slice(0, LIMIT) +
155
+ `\n\n[...truncated. ${(text.length - LIMIT).toLocaleString()} more characters not shown.]`);
156
+ }
157
+ return (`[binary file: ${type}, ${kb} KB]\n\n` +
158
+ 'The bytes are not included - they would be unreadable here and would cost a great deal of context. ' +
159
+ 'Ask the person to open the attachment in Clokio, or fetch this path yourself if you can handle binary.');
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
+ }
44
229
  /** A Clokio API error, carrying the HTTP status and the server's message. */
45
230
  export class ClokioApiError extends Error {
46
231
  status;
@@ -123,9 +308,24 @@ export async function request(config, path, options = {}) {
123
308
  : `Clokio API returned HTTP ${res.status}`);
124
309
  throw new ClokioApiError(res.status, message, parsed);
125
310
  }
126
- // 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.
127
323
  if (parsed && typeof parsed === 'object' && 'data' in parsed) {
128
- 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;
129
329
  }
130
330
  return parsed;
131
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`);
@@ -5,42 +5,79 @@ import { registerTool } from './helpers.js';
5
5
  const employeeCode = z
6
6
  .string()
7
7
  .regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -');
8
- /** Attendance reads + clock/break writes, plus time entries and balances. */
8
+ /** The four clock/break writes share these three optional arguments. */
9
+ const pinArg = z
10
+ .string()
11
+ .regex(/^\d{4,6}$/, 'pin must be 4 to 6 digits')
12
+ .optional()
13
+ .describe("The employee's PIN, when the organization requires one");
14
+ const timestampArg = z
15
+ .string()
16
+ .optional()
17
+ .describe("'YYYY-MM-DD HH:MM:SS'. Clamped to +/-10 minutes of server time; omit for now");
18
+ const noteArg = z.string().max(500).optional();
19
+ /** Attendance reads + clock/break writes, plus time entries and leave. */
9
20
  export function registerAttendanceTools(server, config) {
10
21
  registerTool(server, config, {
11
22
  name: 'clokio_attendance_daily',
12
- description: 'Attendance for a single date (YYYY-MM-DD, defaults to today).',
13
- schema: { date: z.string().optional().describe('YYYY-MM-DD') },
14
- handler: (args, cfg) => request(cfg, '/attendance/daily', { query: { date: args.date } }),
23
+ description: 'Attendance for ONE date. The date is REQUIRED - there is no "today" default, so pass today\'s date ' +
24
+ 'explicitly when that is what you want. Optionally scope to one employee.',
25
+ schema: {
26
+ date: z.string().describe('YYYY-MM-DD, required'),
27
+ employee_code: employeeCode.optional(),
28
+ },
29
+ handler: (args, cfg) => request(cfg, '/attendance/daily', { query: { date: args.date, employee_code: args.employee_code } }),
15
30
  });
16
31
  registerTool(server, config, {
17
32
  name: 'clokio_attendance_range',
18
- description: 'Attendance for a date range (max 31 days).',
33
+ description: 'Attendance across a date range (max 31 days). Both dates are required.',
19
34
  schema: {
20
- start_date: z.string().describe('YYYY-MM-DD'),
21
- end_date: z.string().describe('YYYY-MM-DD'),
35
+ date_from: z.string().describe('YYYY-MM-DD, required'),
36
+ date_to: z.string().describe('YYYY-MM-DD, required, on or after date_from'),
37
+ employee_code: employeeCode.optional(),
22
38
  },
23
- handler: (args, cfg) => request(cfg, '/attendance/range', { query: { start_date: args.start_date, end_date: args.end_date } }),
39
+ handler: (args, cfg) => request(cfg, '/attendance/range', {
40
+ query: { date_from: args.date_from, date_to: args.date_to, employee_code: args.employee_code },
41
+ }),
24
42
  });
25
43
  registerTool(server, config, {
26
44
  name: 'clokio_attendance_for_employee',
27
- description: "One employee's attendance. Optionally scope to a date range.",
45
+ description: "One employee's attendance, optionally narrowed to a calendar month. month and year are NUMBERS " +
46
+ '(month 1-12), not a YYYY-MM string.',
28
47
  schema: {
29
48
  employee_code: employeeCode,
30
- start_date: z.string().optional().describe('YYYY-MM-DD'),
31
- end_date: z.string().optional().describe('YYYY-MM-DD'),
49
+ month: z.number().int().min(1).max(12).optional(),
50
+ year: z.number().int().min(1900).max(2100).optional(),
32
51
  },
33
52
  handler: (args, cfg) => request(cfg, `/attendance/employee/${seg(args.employee_code)}`, {
34
- query: { start_date: args.start_date, end_date: args.end_date },
53
+ query: { month: args.month, year: args.year },
35
54
  }),
36
55
  });
56
+ registerTool(server, config, {
57
+ name: 'clokio_attendance_employee_day',
58
+ description: "One employee's detailed attendance for a single day, including breaks. Defaults to today when date " +
59
+ 'is omitted.',
60
+ schema: {
61
+ employee_code: employeeCode,
62
+ date: z.string().optional().describe('YYYY-MM-DD, defaults to today'),
63
+ },
64
+ handler: (args, cfg) => request(cfg, `/attendance/employee/${seg(args.employee_code)}/today`, { query: { date: args.date } }),
65
+ });
66
+ registerTool(server, config, {
67
+ name: 'clokio_attendance_today_detailed',
68
+ description: 'Detailed attendance for EVERY employee on one day, including breaks. Defaults to today.',
69
+ schema: { date: z.string().optional().describe('YYYY-MM-DD, defaults to today') },
70
+ handler: (args, cfg) => request(cfg, '/attendance/today/detailed', { query: { date: args.date } }),
71
+ });
37
72
  registerTool(server, config, {
38
73
  name: 'clokio_attendance_summary',
39
- description: 'Monthly attendance summary.',
74
+ description: 'Monthly attendance summary for the organization. month and year are both REQUIRED and are NUMBERS ' +
75
+ '(month 1-12), not a YYYY-MM string.',
40
76
  schema: {
41
- month: z.string().optional().describe('YYYY-MM'),
77
+ month: z.number().int().min(1).max(12).describe('1-12, required'),
78
+ year: z.number().int().min(1900).max(2100).describe('e.g. 2026, required'),
42
79
  },
43
- handler: (args, cfg) => request(cfg, '/attendance/summary', { query: { month: args.month } }),
80
+ handler: (args, cfg) => request(cfg, '/attendance/summary', { query: { month: args.month, year: args.year } }),
44
81
  });
45
82
  registerTool(server, config, {
46
83
  name: 'clokio_clock_in',
@@ -48,64 +85,132 @@ export function registerAttendanceTools(server, config) {
48
85
  description: 'Clock an employee in. A PIN may be required depending on org configuration. Timestamps are clamped to ' +
49
86
  '+/-10 minutes of server time.',
50
87
  schema: {
51
- employee_code: z.string(),
52
- pin: z.string().optional(),
53
- location_id: z.number().int().optional(),
88
+ employee_code: employeeCode,
89
+ pin: pinArg,
90
+ project_id: z.number().int().optional().describe('Attribute the session to a project'),
91
+ timestamp: timestampArg,
92
+ note: noteArg,
54
93
  },
55
94
  handler: (args, cfg) => request(cfg, '/attendance/clock-in', {
56
95
  method: 'POST',
57
- body: { employee_code: args.employee_code, pin: args.pin, location_id: args.location_id },
96
+ body: {
97
+ employee_code: args.employee_code,
98
+ pin: args.pin,
99
+ project_id: args.project_id,
100
+ timestamp: args.timestamp,
101
+ note: args.note,
102
+ },
58
103
  }),
59
104
  });
60
105
  registerTool(server, config, {
61
106
  name: 'clokio_clock_out',
62
107
  mutates: true,
63
- description: 'Clock an employee out.',
108
+ description: 'Clock an employee out. A PIN may be required depending on org configuration.',
64
109
  schema: {
65
- employee_code: z.string(),
66
- pin: z.string().optional(),
110
+ employee_code: employeeCode,
111
+ pin: pinArg,
112
+ timestamp: timestampArg,
113
+ note: noteArg,
67
114
  },
68
115
  handler: (args, cfg) => request(cfg, '/attendance/clock-out', {
69
116
  method: 'POST',
70
- body: { employee_code: args.employee_code, pin: args.pin },
117
+ body: {
118
+ employee_code: args.employee_code,
119
+ pin: args.pin,
120
+ timestamp: args.timestamp,
121
+ note: args.note,
122
+ },
71
123
  }),
72
124
  });
73
125
  registerTool(server, config, {
74
126
  name: 'clokio_break_start',
75
127
  mutates: true,
76
128
  description: 'Start a break for an employee.',
77
- schema: { employee_code: z.string(), pin: z.string().optional() },
129
+ schema: {
130
+ employee_code: employeeCode,
131
+ pin: pinArg,
132
+ timestamp: timestampArg,
133
+ note: noteArg,
134
+ },
78
135
  handler: (args, cfg) => request(cfg, '/attendance/break-start', {
79
136
  method: 'POST',
80
- body: { employee_code: args.employee_code, pin: args.pin },
137
+ body: {
138
+ employee_code: args.employee_code,
139
+ pin: args.pin,
140
+ timestamp: args.timestamp,
141
+ note: args.note,
142
+ },
81
143
  }),
82
144
  });
83
145
  registerTool(server, config, {
84
146
  name: 'clokio_break_end',
85
147
  mutates: true,
86
148
  description: 'End a break for an employee.',
87
- schema: { employee_code: z.string(), pin: z.string().optional() },
149
+ schema: {
150
+ employee_code: employeeCode,
151
+ pin: pinArg,
152
+ timestamp: timestampArg,
153
+ note: noteArg,
154
+ },
88
155
  handler: (args, cfg) => request(cfg, '/attendance/break-end', {
89
156
  method: 'POST',
90
- body: { employee_code: args.employee_code, pin: args.pin },
157
+ body: {
158
+ employee_code: args.employee_code,
159
+ pin: args.pin,
160
+ timestamp: args.timestamp,
161
+ note: args.note,
162
+ },
91
163
  }),
92
164
  });
93
165
  registerTool(server, config, {
94
166
  name: 'clokio_list_time_entries',
95
- description: 'List time-tracking entries. Filterable by employee_code and date range.',
167
+ description: 'Time-tracking entries in a date range. from and to are both REQUIRED (YYYY-MM-DD, to >= from). ' +
168
+ 'Optionally narrow to one person or one project.',
96
169
  schema: {
97
- employee_code: z.string().optional(),
98
- start_date: z.string().optional().describe('YYYY-MM-DD'),
99
- end_date: z.string().optional().describe('YYYY-MM-DD'),
170
+ from: z.string().describe('YYYY-MM-DD, required'),
171
+ to: z.string().describe('YYYY-MM-DD, required, on or after from'),
172
+ employee: employeeCode.optional().describe("An employee_code, e.g. '00080'"),
173
+ project_id: z.number().int().optional(),
174
+ per_page: z.number().int().min(1).max(100).optional(),
100
175
  },
101
176
  handler: (args, cfg) => request(cfg, '/time-entries', {
102
- query: { employee_code: args.employee_code, start_date: args.start_date, end_date: args.end_date },
177
+ query: {
178
+ from: args.from,
179
+ to: args.to,
180
+ employee: args.employee,
181
+ project_id: args.project_id,
182
+ per_page: args.per_page,
183
+ },
103
184
  }),
104
185
  });
105
186
  registerTool(server, config, {
106
- name: 'clokio_get_balances',
107
- description: 'Leave / hour balances.',
108
- schema: { employee_code: z.string().optional() },
109
- handler: (args, cfg) => request(cfg, '/balances', { query: { employee_code: args.employee_code } }),
187
+ name: 'clokio_list_leaves',
188
+ description: 'Leave requests in a date range. from and to are both REQUIRED (YYYY-MM-DD, to >= from). ' +
189
+ 'Optionally narrow to one person by employee_code, or to one approval state.',
190
+ schema: {
191
+ from: z.string().describe('YYYY-MM-DD, required'),
192
+ to: z.string().describe('YYYY-MM-DD, required, on or after from'),
193
+ employee: employeeCode.optional().describe("An employee_code, e.g. '00080'"),
194
+ status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
195
+ per_page: z.number().int().min(1).max(100).optional(),
196
+ },
197
+ handler: (args, cfg) => request(cfg, '/leaves', {
198
+ query: {
199
+ from: args.from,
200
+ to: args.to,
201
+ employee: args.employee,
202
+ status: args.status,
203
+ per_page: args.per_page,
204
+ },
205
+ }),
206
+ });
207
+ registerTool(server, config, {
208
+ name: 'clokio_get_leave_balances',
209
+ description: "One employee's leave balances (quota, used, remaining per leave type). The employee_code is REQUIRED - " +
210
+ 'there is no org-wide form of this call.',
211
+ schema: {
212
+ employee: employeeCode.describe("The employee_code, e.g. '00080'"),
213
+ },
214
+ handler: (args, cfg) => request(cfg, '/leaves/balances', { query: { employee: args.employee } }),
110
215
  });
111
216
  }
@@ -14,25 +14,42 @@ const employeeCode = z
14
14
  export function registerEmployeeTools(server, config) {
15
15
  registerTool(server, config, {
16
16
  name: 'clokio_list_employees',
17
- description: 'List employees. Returns employee_code (the stable id used everywhere else, e.g. for assignees), name, ' +
18
- 'and non-PII fields. email is only returned if the key holds the employees:pii scope.',
17
+ description: 'Find people. This is how you resolve a PERSON TO THEIR employee_code - the stable id every other tool ' +
18
+ 'wants for assignees, comment authors and task creators. Pass `search` with a name (or part of one) ' +
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. ' +
21
+ 'Paginated: the response is {data, meta}, the people under data.',
19
22
  schema: {
20
- search: z.string().optional().describe('Name filter'),
21
- page: z.number().int().optional(),
23
+ search: z.string().max(120).optional().describe('Free text over name and employee_code'),
24
+ email: z.string().optional().describe('EXACT email match (not a substring search)'),
25
+ status: z.enum(['active', 'inactive']).optional(),
26
+ department: z.string().optional().describe('Exact department name'),
27
+ per_page: z.number().int().min(1).max(100).optional().describe('1-100, default 25'),
22
28
  },
23
- handler: (args, cfg) => request(cfg, '/employees', { query: { search: args.search, page: args.page } }),
29
+ handler: (args, cfg) => request(cfg, '/employees', {
30
+ query: {
31
+ search: args.search,
32
+ email: args.email,
33
+ status: args.status,
34
+ department: args.department,
35
+ per_page: args.per_page,
36
+ },
37
+ }),
24
38
  });
25
39
  registerTool(server, config, {
26
- name: 'clokio_lookup_employee',
27
- description: 'Look up an employee by name or email to resolve their employee_code. Use this before assigning a task ' +
28
- 'when you only know the person\'s name.',
40
+ name: 'clokio_lookup_employee_by_pin',
41
+ description: 'Resolve an employee from their 4-6 digit clock-in PIN. This is for kiosk and terminal flows where a ' +
42
+ 'person identifies themselves by PIN - it is NOT a name or email search. To find someone by name or ' +
43
+ 'email use clokio_list_employees. Answers 404 when no ACTIVE employee holds that PIN.',
29
44
  schema: {
30
- name: z.string().optional(),
31
- email: z.string().optional(),
45
+ pin: z
46
+ .string()
47
+ .regex(/^\d{4,6}$/, 'pin must be 4 to 6 digits')
48
+ .describe("The employee's clock-in PIN"),
32
49
  },
33
50
  handler: (args, cfg) => request(cfg, '/employees/lookup', {
34
51
  method: 'POST',
35
- body: { name: args.name, email: args.email },
52
+ body: { pin: args.pin },
36
53
  }),
37
54
  });
38
55
  registerTool(server, config, {
@@ -42,9 +59,30 @@ export function registerEmployeeTools(server, config) {
42
59
  schema: {
43
60
  employee_code: employeeCode,
44
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'),
45
66
  },
46
67
  handler: (args, cfg) => request(cfg, `/employees/${seg(args.employee_code)}/task-stats`, {
47
- 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 },
48
86
  }),
49
87
  });
50
88
  registerTool(server, config, {
@@ -1,13 +1,21 @@
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
- description: 'List projects, optionally filtered by a search term (name / client).',
9
- schema: { search: z.string().optional() },
10
- handler: (args, cfg) => request(cfg, '/projects', { query: { search: args.search } }),
9
+ description: 'List projects, optionally filtered by a search term (name / client) or status. Use this to resolve a ' +
10
+ 'project NAME to the project_id every task tool needs. Paginated: {data, meta}.',
11
+ schema: {
12
+ search: z.string().max(200).optional(),
13
+ status: z.enum(['active', 'archived']).optional(),
14
+ per_page: z.number().int().min(1).max(100).optional(),
15
+ },
16
+ handler: (args, cfg) => request(cfg, '/projects', {
17
+ query: { search: args.search, status: args.status, per_page: args.per_page },
18
+ }),
11
19
  });
12
20
  registerTool(server, config, {
13
21
  name: 'clokio_get_project_statuses',
@@ -36,17 +44,16 @@ export function registerProjectTools(server, config) {
36
44
  registerTool(server, config, {
37
45
  name: 'clokio_add_client_contact',
38
46
  mutates: true,
39
- description: 'Add a contact to a client.',
47
+ description: 'Add a contact to a client. Every field is optional, but supply at least a name or an email.',
40
48
  schema: {
41
49
  client_id: z.number().int(),
42
- name: z.string(),
43
- email: z.string().optional(),
44
- phone: z.string().optional(),
45
- role: z.string().optional(),
50
+ name: z.string().max(255).optional(),
51
+ email: z.string().max(255).optional(),
52
+ phone: z.string().max(50).optional(),
46
53
  },
47
54
  handler: (args, cfg) => request(cfg, `/clients/${args.client_id}/contacts`, {
48
55
  method: 'POST',
49
- body: { name: args.name, email: args.email, phone: args.phone, role: args.role },
56
+ body: { name: args.name, email: args.email, phone: args.phone },
50
57
  }),
51
58
  });
52
59
  // ── Reference / vocabulary lookups ──
@@ -59,7 +66,9 @@ export function registerProjectTools(server, config) {
59
66
  });
60
67
  registerTool(server, config, {
61
68
  name: 'clokio_list_task_labels',
62
- 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.',
63
72
  schema: {},
64
73
  handler: (_args, cfg) => request(cfg, '/task-labels'),
65
74
  });
@@ -69,4 +78,38 @@ export function registerProjectTools(server, config) {
69
78
  schema: {},
70
79
  handler: (_args, cfg) => request(cfg, '/locations'),
71
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
+ });
93
+ registerTool(server, config, {
94
+ name: 'clokio_whoami',
95
+ description: 'Who this API key belongs to: the organisation (with its timezone - every date filter is evaluated in ' +
96
+ 'it), the rate limits that apply, and the person who issued it. It does NOT report the key\'s scopes ' +
97
+ 'or expiry, deliberately - find out what a key may do by using it. ' +
98
+ 'CALL THIS FIRST in a session: `issued_by.employee_code` is the value to pass as ' +
99
+ 'created_by_employee_code / author_employee_code on every write, so the work is attributed to a person ' +
100
+ 'instead of showing up as "External System" on a board the team reads. `issued_by` can be null.',
101
+ schema: {},
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
+ },
114
+ });
72
115
  }
@@ -1,63 +1,137 @@
1
1
  import { z } from 'zod';
2
- import { request } from '../client.js';
2
+ import { request, requestRaw, requestUpload } from '../client.js';
3
3
  import { registerTool } from './helpers.js';
4
+ /** See employees.ts - the same stable-identifier rule applies to task filters. */
5
+ const employeeCode = z
6
+ .string()
7
+ .regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -');
4
8
  /** Task-management tools: the core of the Clokio API surface. */
5
9
  export function registerTaskTools(server, config) {
6
10
  registerTool(server, config, {
7
11
  name: 'clokio_list_tasks',
8
- description: 'List tasks. Supports filters: project_id, status (slug), priority, assignee (employee_code), ' +
9
- 'label, search, open (1 = only open tasks), created_before / due_before (YYYY-MM-DD, org timezone). ' +
10
- 'Paginate with page, or crawl with cursor. Each task carries a task_url.',
12
+ description: 'List tasks across projects, with server-side filters so you do not page the whole board and count in ' +
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.\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.',
11
17
  schema: {
12
18
  project_id: z.number().int().optional(),
13
19
  status: z.string().optional().describe('Status slug, e.g. "in_progress"'),
14
20
  priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
15
- assignee: z.string().optional().describe('employee_code, e.g. "00080"'),
21
+ assignee_employee_code: employeeCode.optional().describe('employee_code, e.g. "00080"'),
16
22
  label: z.string().optional(),
17
- search: z.string().optional(),
18
- open: z.boolean().optional().describe('true = only tasks whose status is not "done"'),
19
- created_before: z.string().optional().describe('YYYY-MM-DD'),
20
- due_before: z.string().optional().describe('YYYY-MM-DD'),
23
+ search: z.string().optional().describe('Free text over title and description'),
24
+ open: z.boolean().optional().describe('true = only tasks whose status is not a "done" one'),
25
+ parent_task_id: z
26
+ .number()
27
+ .int()
28
+ .nullable()
29
+ .optional()
30
+ .describe('A task id returns only ITS subtasks; null returns only top-level tasks'),
31
+ created_before: z.string().optional().describe('YYYY-MM-DD, organization timezone'),
32
+ created_after: z.string().optional().describe('YYYY-MM-DD, organization timezone'),
33
+ due_before: z.string().optional().describe('YYYY-MM-DD, organization timezone'),
34
+ due_after: z
35
+ .string()
36
+ .optional()
37
+ .describe('YYYY-MM-DD. With due_before, asks for one window ("due this week") in a single request'),
38
+ updated_since: z
39
+ .string()
40
+ .optional()
41
+ .describe('ISO-8601. Incremental sync: only tasks changed at or after this instant'),
42
+ include_comments: z.boolean().optional(),
43
+ per_page: z.number().int().min(1).max(100).optional(),
21
44
  page: z.number().int().optional(),
22
- cursor: z.string().optional(),
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.'),
52
+ sort: z
53
+ .enum([
54
+ 'due_date',
55
+ 'due_date_desc',
56
+ 'created_at',
57
+ 'created_at_desc',
58
+ 'updated_at',
59
+ 'updated_at_desc',
60
+ 'title',
61
+ 'title_desc',
62
+ 'priority',
63
+ 'priority_desc',
64
+ ])
65
+ .optional()
66
+ .describe('Order the page. `priority` ascends low -> urgent. CANNOT be combined with cursor or ' +
67
+ 'updated_since - that is a 422, because re-ordering a crawl silently skips rows.'),
23
68
  },
24
69
  handler: (args, cfg) => request(cfg, '/tasks', {
25
70
  query: {
26
71
  project_id: args.project_id,
27
72
  status: args.status,
28
73
  priority: args.priority,
29
- assignee: args.assignee,
74
+ assignee_employee_code: args.assignee_employee_code,
30
75
  label: args.label,
31
76
  search: args.search,
32
- open: args.open ? 1 : undefined,
77
+ open: args.open === undefined ? undefined : args.open ? 1 : 0,
78
+ parent_task_id: args.parent_task_id === null ? '' : args.parent_task_id,
33
79
  created_before: args.created_before,
80
+ created_after: args.created_after,
34
81
  due_before: args.due_before,
82
+ due_after: args.due_after,
83
+ updated_since: args.updated_since,
84
+ include_comments: args.include_comments === undefined ? undefined : args.include_comments ? 1 : 0,
85
+ per_page: args.per_page,
35
86
  page: args.page,
36
87
  cursor: args.cursor,
88
+ sort: args.sort,
89
+ compact: args.compact === undefined ? undefined : args.compact ? 1 : 0,
37
90
  },
38
91
  }),
39
92
  });
40
93
  registerTool(server, config, {
41
94
  name: 'clokio_get_task',
42
- description: 'Get one task by id, with its full detail (assignees, labels, status, custom fields, task_url).',
43
- schema: { id: z.number().int() },
44
- 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 } }),
45
109
  });
46
110
  registerTool(server, config, {
47
111
  name: 'clokio_create_task',
48
112
  mutates: true,
49
- description: 'Create a task in a project. assignees/creator use employee_code (e.g. "00080"), not internal ids. ' +
50
- 'To attribute the task to a person set creator_employee_code, otherwise it shows as the API key owner.',
113
+ description: 'Create a task in a project. People are named by employee_code (e.g. "00080"), never internal ids. ' +
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 ' +
117
+ '(same project, and the parent must not itself be a subtask - one level deep only).',
51
118
  schema: {
52
119
  project_id: z.number().int(),
53
- title: z.string(),
120
+ title: z.string().max(255),
54
121
  description: z.string().optional().describe('HTML or plain text'),
55
122
  priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
56
- status: z.string().optional().describe('Status slug'),
123
+ status: z.string().optional().describe("Status slug valid for THIS project's board"),
57
124
  due_date: z.string().optional().describe('YYYY-MM-DD'),
58
- assignee_employee_codes: z.array(z.string()).optional(),
59
- label_names: z.array(z.string()).optional(),
60
- creator_employee_code: z.string().optional(),
125
+ start_date: z.string().optional().describe('YYYY-MM-DD'),
126
+ estimated_hours: z.number().min(0).max(9999.99).optional(),
127
+ parent_task_id: z.number().int().optional().describe('Makes this a subtask of that task'),
128
+ assignee_employee_codes: z.array(employeeCode).max(50).optional(),
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'),
61
135
  },
62
136
  handler: (args, cfg) => request(cfg, '/tasks', {
63
137
  method: 'POST',
@@ -68,36 +142,59 @@ export function registerTaskTools(server, config) {
68
142
  priority: args.priority,
69
143
  status: args.status,
70
144
  due_date: args.due_date,
71
- assignees: args.assignee_employee_codes,
72
- labels: args.label_names,
73
- created_by_employee_code: args.creator_employee_code,
145
+ start_date: args.start_date,
146
+ estimated_hours: args.estimated_hours,
147
+ parent_task_id: args.parent_task_id,
148
+ assignee_employee_codes: args.assignee_employee_codes,
149
+ label_names: args.label_names,
150
+ created_by_employee_code: args.created_by_employee_code ?? cfg.defaultActor,
74
151
  },
75
152
  }),
76
153
  });
77
154
  registerTool(server, config, {
78
155
  name: 'clokio_update_task',
79
156
  mutates: true,
80
- description: 'Update a task. Only the fields you pass are changed. status/priority/title/description/due_date are supported.',
157
+ description: 'Update a task. ONLY the fields you pass are changed; omitted fields are left alone. Pass null to ' +
158
+ 'due_date / start_date / estimated_hours / description to CLEAR them. Set actor_employee_code so the ' +
159
+ 'activity log names the person rather than the API key.',
81
160
  schema: {
82
161
  id: z.number().int(),
83
- title: z.string().optional(),
84
- description: z.string().optional(),
162
+ title: z.string().max(255).optional(),
163
+ description: z.string().nullable().optional(),
85
164
  priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
86
- status: z.string().optional().describe('Status slug'),
87
- due_date: z.string().optional().describe('YYYY-MM-DD, or empty string to clear'),
165
+ status: z.string().optional().describe("Status slug valid for this task's board"),
166
+ due_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
167
+ start_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
168
+ estimated_hours: z.number().min(0).max(9999.99).nullable().optional(),
169
+ project_id: z.number().int().optional().describe('Move the task to another project'),
170
+ actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
88
171
  },
89
172
  handler: (args, cfg) => {
173
+ // Only keys the caller actually supplied may go in the body: PATCH
174
+ // semantics are "change what is named", and sending an untouched field
175
+ // as null would CLEAR it.
90
176
  const body = {};
91
- if (args.title !== undefined)
92
- body.title = args.title;
93
- if (args.description !== undefined)
94
- body.description = args.description;
95
- if (args.priority !== undefined)
96
- body.priority = args.priority;
97
- if (args.status !== undefined)
98
- body.status = args.status;
99
- if (args.due_date !== undefined)
100
- body.due_date = args.due_date;
177
+ for (const key of [
178
+ 'title',
179
+ 'description',
180
+ 'priority',
181
+ 'status',
182
+ 'due_date',
183
+ 'start_date',
184
+ 'estimated_hours',
185
+ 'project_id',
186
+ 'actor_employee_code',
187
+ ]) {
188
+ if (args[key] !== undefined)
189
+ body[key] = args[key];
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
+ }
101
198
  return request(cfg, `/tasks/${args.id}`, { method: 'PATCH', body });
102
199
  },
103
200
  });
@@ -111,16 +208,16 @@ export function registerTaskTools(server, config) {
111
208
  registerTool(server, config, {
112
209
  name: 'clokio_set_task_assignees',
113
210
  mutates: true,
114
- description: 'Set a task\'s assignees. mode "add" appends, "replace" overwrites, "remove" removes. ' +
115
- 'Assignees are employee_codes (e.g. "00080").',
211
+ description: "Change a task's assignees. mode is REQUIRED: \"add\" appends, \"replace\" overwrites the whole list, " +
212
+ '"remove" takes those people off. People are named by employee_code (e.g. "00080").',
116
213
  schema: {
117
214
  id: z.number().int(),
118
- employee_codes: z.array(z.string()),
119
- mode: z.enum(['add', 'replace', 'remove']).optional().describe('default: add'),
215
+ employee_codes: z.array(employeeCode).min(1).max(50),
216
+ mode: z.enum(['add', 'replace', 'remove']).describe('Required'),
120
217
  },
121
218
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/assignees`, {
122
219
  method: 'PATCH',
123
- body: { assignees: args.employee_codes, mode: args.mode ?? 'add' },
220
+ body: { employee_codes: args.employee_codes, mode: args.mode },
124
221
  }),
125
222
  });
126
223
  registerTool(server, config, {
@@ -132,17 +229,89 @@ export function registerTaskTools(server, config) {
132
229
  registerTool(server, config, {
133
230
  name: 'clokio_add_task_comment',
134
231
  mutates: true,
135
- description: 'Add a comment to a task. Set author_employee_code to attribute it to a person; otherwise it shows as ' +
136
- 'the API key owner ("External System"). Use readable formatting (paragraphs, bullet lists); markdown ' +
137
- 'tables are not rendered.',
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. ' +
236
+ 'TO MENTION SOMEONE, write @ followed by their full name exactly as clokio_list_employees spells it ' +
237
+ '(for example "@Kiran Bahadur") in the body text - no markup needed. It becomes a real mention and ' +
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.',
138
241
  schema: {
139
242
  id: z.number().int(),
140
- body: z.string(),
141
- author_employee_code: z.string().optional(),
243
+ body: z.string().max(65535),
244
+ author_employee_code: employeeCode.optional().describe('Credit someone OTHER than the key owner; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
245
+ notify: z.boolean().optional().describe('Send notifications to watchers (default: the API decides)'),
142
246
  },
143
247
  handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments`, {
144
248
  method: 'POST',
145
- body: { body: args.body, author_employee_code: args.author_employee_code },
249
+ body: {
250
+ body: args.body,
251
+ author_employee_code: args.author_employee_code ?? cfg.defaultActor,
252
+ notify: args.notify,
253
+ },
254
+ }),
255
+ });
256
+ registerTool(server, config, {
257
+ name: 'clokio_update_task_comment',
258
+ mutates: true,
259
+ description: 'Correct a comment YOUR integration published - a wrong number, a dead link, a mistaken finding. ' +
260
+ 'Two conditions, both required: the comment was created through this API, and author_employee_code ' +
261
+ "names its own author. You cannot edit what a person wrote in the web or mobile app (403), and you " +
262
+ 'cannot edit someone else\'s comment (403). There is no delete: correcting leaves an audit entry, ' +
263
+ 'destroying would leave nothing.',
264
+ schema: {
265
+ id: z.number().int().describe('The task id'),
266
+ comment_id: z.number().int(),
267
+ body: z.string().max(65535),
268
+ author_employee_code: employeeCode.describe("Required: the comment's own author"),
269
+ },
270
+ handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments/${args.comment_id}`, {
271
+ method: 'PATCH',
272
+ body: { body: args.body, author_employee_code: args.author_employee_code ?? cfg.defaultActor },
273
+ }),
274
+ });
275
+ registerTool(server, config, {
276
+ name: 'clokio_list_task_attachments',
277
+ description: "A task's attachments: file name, size, mime type, who uploaded it, and the path to fetch the bytes. " +
278
+ 'A task whose real content is a screenshot or a spreadsheet reads as "(no description)" without this - ' +
279
+ 'check here before concluding a task has no detail. Use clokio_download_task_attachment for the file.',
280
+ schema: { id: z.number().int() },
281
+ handler: (args, cfg) => request(cfg, `/tasks/${args.id}/attachments`),
282
+ });
283
+ registerTool(server, config, {
284
+ name: 'clokio_download_task_attachment',
285
+ description: 'Download one attachment. Returns the FILE, not JSON: text comes back as text (truncated if very ' +
286
+ 'large, and it says so), while binary files (images, PDFs, spreadsheets) are reported with their type ' +
287
+ 'and size rather than dumped into the conversation. Get the attachment id - and file_name, which helps ' +
288
+ 'this tool recognise text - from clokio_list_task_attachments.',
289
+ schema: {
290
+ id: z.number().int().describe('The task id'),
291
+ attachment_id: z.number().int(),
292
+ file_name: z
293
+ .string()
294
+ .optional()
295
+ .describe('The file_name from clokio_list_task_attachments. Improves text detection'),
296
+ },
297
+ handler: (args, cfg) => requestRaw(cfg, `/tasks/${args.id}/attachments/${args.attachment_id}`, args.file_name),
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,
146
315
  }),
147
316
  });
148
317
  registerTool(server, config, {
@@ -171,6 +340,17 @@ export function registerTaskTools(server, config) {
171
340
  body: { depends_on_task_id: args.depends_on_task_id, type: args.type },
172
341
  }),
173
342
  });
343
+ registerTool(server, config, {
344
+ name: 'clokio_remove_task_dependency',
345
+ mutates: 'destructive',
346
+ description: 'Remove a dependency from a task. dependency_id is the id of the DEPENDENCY row, as returned by ' +
347
+ 'clokio_list_task_dependencies - not the id of the other task.',
348
+ schema: {
349
+ id: z.number().int().describe('The task that holds the dependency'),
350
+ dependency_id: z.number().int(),
351
+ },
352
+ handler: (args, cfg) => request(cfg, `/tasks/${args.id}/dependencies/${args.dependency_id}`, { method: 'DELETE' }),
353
+ });
174
354
  registerTool(server, config, {
175
355
  name: 'clokio_set_task_custom_field',
176
356
  mutates: true,
@@ -186,4 +366,31 @@ export function registerTaskTools(server, config) {
186
366
  body: { field_key: args.field_key, value: args.value },
187
367
  }),
188
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
+ });
189
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.1.2",
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": {