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 +28 -12
- package/dist/client.js +92 -3
- package/dist/index.js +44 -9
- package/dist/tools/employees.js +24 -2
- package/dist/tools/projects.js +29 -3
- package/dist/tools/tasks.js +98 -20
- package/dist/version.js +15 -0
- package/package.json +1 -1
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
|
-
|
|
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 `
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
59
|
+
// Always on: whoami plus the reference lookups the task tools resolve
|
|
60
|
+
// names against.
|
|
31
61
|
registerProjectTools(server, config);
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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`);
|
package/dist/tools/employees.js
CHANGED
|
@@ -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: {
|
|
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, {
|
package/dist/tools/projects.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/tools/tasks.js
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
'
|
|
95
|
-
'
|
|
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
|
|
109
|
-
|
|
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.
|
|
201
|
-
'the
|
|
202
|
-
'lists); markdown TABLES are not rendered and post as raw text. Write
|
|
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
|
|
207
|
-
'nobody. A name that matches no one stays
|
|
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('
|
|
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
|
}
|
package/dist/version.js
ADDED
|
@@ -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