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 +28 -12
- package/dist/client.js +203 -3
- package/dist/index.js +44 -9
- package/dist/tools/attendance.js +141 -36
- package/dist/tools/employees.js +50 -12
- package/dist/tools/projects.js +53 -10
- package/dist/tools/tasks.js +258 -51
- 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.
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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/attendance.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
33
|
+
description: 'Attendance across a date range (max 31 days). Both dates are required.',
|
|
19
34
|
schema: {
|
|
20
|
-
|
|
21
|
-
|
|
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', {
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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: {
|
|
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.
|
|
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:
|
|
52
|
-
pin:
|
|
53
|
-
|
|
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: {
|
|
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:
|
|
66
|
-
pin:
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: '
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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: {
|
|
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: '
|
|
107
|
-
description: 'Leave
|
|
108
|
-
|
|
109
|
-
|
|
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
|
}
|
package/dist/tools/employees.js
CHANGED
|
@@ -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: '
|
|
18
|
-
'and
|
|
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('
|
|
21
|
-
|
|
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', {
|
|
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: '
|
|
27
|
-
description: '
|
|
28
|
-
'
|
|
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
|
-
|
|
31
|
-
|
|
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: {
|
|
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: {
|
|
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, {
|
package/dist/tools/projects.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
}
|
package/dist/tools/tasks.js
CHANGED
|
@@ -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
|
|
9
|
-
'
|
|
10
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
74
|
+
assignee_employee_code: args.assignee_employee_code,
|
|
30
75
|
label: args.label,
|
|
31
76
|
search: args.search,
|
|
32
|
-
open: args.open ? 1 :
|
|
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
|
-
|
|
44
|
-
|
|
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.
|
|
50
|
-
'
|
|
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(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
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(
|
|
87
|
-
due_date: z.string().optional().describe('YYYY-MM-DD, or
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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:
|
|
115
|
-
'
|
|
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(
|
|
119
|
-
mode: z.enum(['add', 'replace', 'remove']).
|
|
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: {
|
|
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.
|
|
136
|
-
'the
|
|
137
|
-
'
|
|
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:
|
|
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: {
|
|
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
|
}
|
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