clokio-mcp 0.1.2 → 0.5.3
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/dist/client.js +111 -0
- package/dist/tools/attendance.js +141 -36
- package/dist/tools/employees.js +27 -11
- package/dist/tools/projects.js +26 -9
- package/dist/tools/tasks.js +175 -46
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -41,6 +41,117 @@ export function seg(value) {
|
|
|
41
41
|
}
|
|
42
42
|
return encodeURIComponent(s);
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Fetch an endpoint that returns a FILE rather than the JSON envelope.
|
|
46
|
+
*
|
|
47
|
+
* Only the attachment download does this today. Text comes back as text;
|
|
48
|
+
* anything binary is DESCRIBED rather than returned, because a model that
|
|
49
|
+
* asks for a 4 MB PNG cannot use the bytes and pouring them into the
|
|
50
|
+
* conversation as mojibake costs a fortune in tokens and tells it nothing.
|
|
51
|
+
* The description still answers the question the caller actually had - is
|
|
52
|
+
* there a file here, what kind, how big.
|
|
53
|
+
*/
|
|
54
|
+
export async function requestRaw(config, path, fileName) {
|
|
55
|
+
const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
|
|
56
|
+
const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
|
|
57
|
+
if (!url.pathname.startsWith(expectedPrefix)) {
|
|
58
|
+
throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1.');
|
|
59
|
+
}
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
// `manual`, not 'error' and not 'follow'.
|
|
63
|
+
//
|
|
64
|
+
// The API streams small and renderable files, but 302s to a short-lived
|
|
65
|
+
// signed storage URL for anything over 8 MB. 'error' would report every
|
|
66
|
+
// large attachment as a bogus network failure. 'follow' is worse: Node
|
|
67
|
+
// re-sends request headers on a redirect, so X-API-Key would be handed
|
|
68
|
+
// to another origin, which is the leak the redirect: 'error' on the JSON
|
|
69
|
+
// path exists to prevent. So the redirect is followed HERE, by hand,
|
|
70
|
+
// with no credential attached - the signed URL needs none.
|
|
71
|
+
res = await fetch(url, {
|
|
72
|
+
headers: { 'X-API-Key': config.apiKey },
|
|
73
|
+
redirect: 'manual',
|
|
74
|
+
});
|
|
75
|
+
if (res.status >= 300 && res.status < 400) {
|
|
76
|
+
const location = res.headers.get('location');
|
|
77
|
+
if (!location) {
|
|
78
|
+
throw new ClokioApiError(0, 'Clokio redirected the download without a destination.');
|
|
79
|
+
}
|
|
80
|
+
// WHERE the redirect may point is constrained, even though the key does
|
|
81
|
+
// not travel with it.
|
|
82
|
+
//
|
|
83
|
+
// new URL() resolution is unrestricted: `file:///etc/passwd`, `data:...`
|
|
84
|
+
// and `http://169.254.169.254/...` all resolve to themselves rather than
|
|
85
|
+
// inheriting our origin. Node's fetch refuses most of those schemes on
|
|
86
|
+
// its own, but `data:` it happily resolves, and an http(s) target is a
|
|
87
|
+
// blind SSRF - this process would fetch an attacker-named URL and return
|
|
88
|
+
// up to 100k characters of the response into the model's context.
|
|
89
|
+
//
|
|
90
|
+
// Reaching that requires control of the API's own response, at which
|
|
91
|
+
// point the attacker could have put the same bytes in the attachment
|
|
92
|
+
// body - so it buys them nothing today. It is refused anyway because the
|
|
93
|
+
// check is free, and because "the API is trustworthy" is the assumption
|
|
94
|
+
// most likely to stop being true.
|
|
95
|
+
const target = new URL(location, url);
|
|
96
|
+
if (target.protocol !== 'https:' && target.protocol !== 'http:') {
|
|
97
|
+
throw new ClokioApiError(0, `Refusing to follow a download redirect to a ${target.protocol} URL.`);
|
|
98
|
+
}
|
|
99
|
+
// NO headers: the key must not cross to the storage origin.
|
|
100
|
+
res = await fetch(target, { redirect: 'error' });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (e instanceof ClokioApiError)
|
|
105
|
+
throw e;
|
|
106
|
+
throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
|
|
107
|
+
}
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
const text = await res.text();
|
|
110
|
+
let message = `Clokio API returned HTTP ${res.status}`;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(text);
|
|
113
|
+
if (parsed && typeof parsed.message === 'string')
|
|
114
|
+
message = parsed.message;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* a non-JSON error body is not more informative than the status */
|
|
118
|
+
}
|
|
119
|
+
throw new ClokioApiError(res.status, message);
|
|
120
|
+
}
|
|
121
|
+
const type = res.headers.get('content-type') ?? 'application/octet-stream';
|
|
122
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
123
|
+
// CONTENT-TYPE IS NOT ENOUGH. The API deliberately re-labels html, json,
|
|
124
|
+
// js, svg and xml attachments as application/octet-stream so a browser
|
|
125
|
+
// cannot render them from our origin - an XSS defence. Trusting the header
|
|
126
|
+
// alone would report a JSON report an agent attached as unreadable binary,
|
|
127
|
+
// so the file EXTENSION gets a say too.
|
|
128
|
+
const extension = (fileName ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '';
|
|
129
|
+
const TEXT_EXTENSIONS = new Set([
|
|
130
|
+
'txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'xml', 'yaml', 'yml',
|
|
131
|
+
'html', 'htm', 'log', 'diff', 'patch', 'sql', 'js', 'ts', 'css', 'svg',
|
|
132
|
+
]);
|
|
133
|
+
const isText = type.startsWith('text/') ||
|
|
134
|
+
/\b(json|xml|csv|yaml|javascript|markdown)\b/.test(type) ||
|
|
135
|
+
TEXT_EXTENSIONS.has(extension);
|
|
136
|
+
const kb = (buffer.byteLength / 1024).toFixed(1);
|
|
137
|
+
if (isText) {
|
|
138
|
+
// A CAP ON TEXT TOO. Attachments go up to 200 MB and .csv/.log are
|
|
139
|
+
// allowed, so "it is text" does not make it safe to paste: a 40 MB CSV
|
|
140
|
+
// would be dumped whole into the conversation. Truncate and SAY SO, so
|
|
141
|
+
// the reader never mistakes a prefix for the entire file.
|
|
142
|
+
const LIMIT = 100_000;
|
|
143
|
+
const text = buffer.toString('utf8');
|
|
144
|
+
if (text.length <= LIMIT) {
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
return (`[truncated: showing the first ${LIMIT.toLocaleString()} characters of ${kb} KB]\n\n` +
|
|
148
|
+
text.slice(0, LIMIT) +
|
|
149
|
+
`\n\n[...truncated. ${(text.length - LIMIT).toLocaleString()} more characters not shown.]`);
|
|
150
|
+
}
|
|
151
|
+
return (`[binary file: ${type}, ${kb} KB]\n\n` +
|
|
152
|
+
'The bytes are not included - they would be unreadable here and would cost a great deal of context. ' +
|
|
153
|
+
'Ask the person to open the attachment in Clokio, or fetch this path yourself if you can handle binary.');
|
|
154
|
+
}
|
|
44
155
|
/** A Clokio API error, carrying the HTTP status and the server's message. */
|
|
45
156
|
export class ClokioApiError extends Error {
|
|
46
157
|
status;
|
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,41 @@ 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.',
|
|
19
21
|
schema: {
|
|
20
|
-
search: z.string().optional().describe('
|
|
21
|
-
|
|
22
|
+
search: z.string().max(120).optional().describe('Free text over name and employee_code'),
|
|
23
|
+
email: z.string().optional().describe('EXACT email match (not a substring search)'),
|
|
24
|
+
status: z.enum(['active', 'inactive']).optional(),
|
|
25
|
+
department: z.string().optional().describe('Exact department name'),
|
|
26
|
+
per_page: z.number().int().min(1).max(100).optional().describe('1-100, default 25'),
|
|
22
27
|
},
|
|
23
|
-
handler: (args, cfg) => request(cfg, '/employees', {
|
|
28
|
+
handler: (args, cfg) => request(cfg, '/employees', {
|
|
29
|
+
query: {
|
|
30
|
+
search: args.search,
|
|
31
|
+
email: args.email,
|
|
32
|
+
status: args.status,
|
|
33
|
+
department: args.department,
|
|
34
|
+
per_page: args.per_page,
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
24
37
|
});
|
|
25
38
|
registerTool(server, config, {
|
|
26
|
-
name: '
|
|
27
|
-
description: '
|
|
28
|
-
'
|
|
39
|
+
name: 'clokio_lookup_employee_by_pin',
|
|
40
|
+
description: 'Resolve an employee from their 4-6 digit clock-in PIN. This is for kiosk and terminal flows where a ' +
|
|
41
|
+
'person identifies themselves by PIN - it is NOT a name or email search. To find someone by name or ' +
|
|
42
|
+
'email use clokio_list_employees. Answers 404 when no ACTIVE employee holds that PIN.',
|
|
29
43
|
schema: {
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
pin: z
|
|
45
|
+
.string()
|
|
46
|
+
.regex(/^\d{4,6}$/, 'pin must be 4 to 6 digits')
|
|
47
|
+
.describe("The employee's clock-in PIN"),
|
|
32
48
|
},
|
|
33
49
|
handler: (args, cfg) => request(cfg, '/employees/lookup', {
|
|
34
50
|
method: 'POST',
|
|
35
|
-
body: {
|
|
51
|
+
body: { pin: args.pin },
|
|
36
52
|
}),
|
|
37
53
|
});
|
|
38
54
|
registerTool(server, config, {
|
package/dist/tools/projects.js
CHANGED
|
@@ -5,9 +5,16 @@ import { registerTool } from './helpers.js';
|
|
|
5
5
|
export function registerProjectTools(server, config) {
|
|
6
6
|
registerTool(server, config, {
|
|
7
7
|
name: 'clokio_list_projects',
|
|
8
|
-
description: 'List projects, optionally filtered by a search term (name / client).'
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
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
|
+
schema: {
|
|
11
|
+
search: z.string().max(200).optional(),
|
|
12
|
+
status: z.enum(['active', 'archived']).optional(),
|
|
13
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
14
|
+
},
|
|
15
|
+
handler: (args, cfg) => request(cfg, '/projects', {
|
|
16
|
+
query: { search: args.search, status: args.status, per_page: args.per_page },
|
|
17
|
+
}),
|
|
11
18
|
});
|
|
12
19
|
registerTool(server, config, {
|
|
13
20
|
name: 'clokio_get_project_statuses',
|
|
@@ -36,17 +43,16 @@ export function registerProjectTools(server, config) {
|
|
|
36
43
|
registerTool(server, config, {
|
|
37
44
|
name: 'clokio_add_client_contact',
|
|
38
45
|
mutates: true,
|
|
39
|
-
description: 'Add a contact to a client.',
|
|
46
|
+
description: 'Add a contact to a client. Every field is optional, but supply at least a name or an email.',
|
|
40
47
|
schema: {
|
|
41
48
|
client_id: z.number().int(),
|
|
42
|
-
name: z.string(),
|
|
43
|
-
email: z.string().optional(),
|
|
44
|
-
phone: z.string().optional(),
|
|
45
|
-
role: z.string().optional(),
|
|
49
|
+
name: z.string().max(255).optional(),
|
|
50
|
+
email: z.string().max(255).optional(),
|
|
51
|
+
phone: z.string().max(50).optional(),
|
|
46
52
|
},
|
|
47
53
|
handler: (args, cfg) => request(cfg, `/clients/${args.client_id}/contacts`, {
|
|
48
54
|
method: 'POST',
|
|
49
|
-
body: { name: args.name, email: args.email, phone: args.phone
|
|
55
|
+
body: { name: args.name, email: args.email, phone: args.phone },
|
|
50
56
|
}),
|
|
51
57
|
});
|
|
52
58
|
// ── Reference / vocabulary lookups ──
|
|
@@ -69,4 +75,15 @@ export function registerProjectTools(server, config) {
|
|
|
69
75
|
schema: {},
|
|
70
76
|
handler: (_args, cfg) => request(cfg, '/locations'),
|
|
71
77
|
});
|
|
78
|
+
registerTool(server, config, {
|
|
79
|
+
name: 'clokio_whoami',
|
|
80
|
+
description: 'Who this API key belongs to: the organisation (with its timezone - every date filter is evaluated in ' +
|
|
81
|
+
'it), the rate limits that apply, and the person who issued it. It does NOT report the key\'s scopes ' +
|
|
82
|
+
'or expiry, deliberately - find out what a key may do by using it. ' +
|
|
83
|
+
'CALL THIS FIRST in a session: `issued_by.employee_code` is the value to pass as ' +
|
|
84
|
+
'created_by_employee_code / author_employee_code on every write, so the work is attributed to a person ' +
|
|
85
|
+
'instead of showing up as "External System" on a board the team reads. `issued_by` can be null.',
|
|
86
|
+
schema: {},
|
|
87
|
+
handler: (_args, cfg) => request(cfg, '/me'),
|
|
88
|
+
});
|
|
72
89
|
}
|
package/dist/tools/tasks.js
CHANGED
|
@@ -1,39 +1,83 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { request } from '../client.js';
|
|
2
|
+
import { request, requestRaw } 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.',
|
|
11
15
|
schema: {
|
|
12
16
|
project_id: z.number().int().optional(),
|
|
13
17
|
status: z.string().optional().describe('Status slug, e.g. "in_progress"'),
|
|
14
18
|
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
|
15
|
-
|
|
19
|
+
assignee_employee_code: employeeCode.optional().describe('employee_code, e.g. "00080"'),
|
|
16
20
|
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
|
-
|
|
21
|
+
search: z.string().optional().describe('Free text over title and description'),
|
|
22
|
+
open: z.boolean().optional().describe('true = only tasks whose status is not a "done" one'),
|
|
23
|
+
parent_task_id: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.nullable()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe('A task id returns only ITS subtasks; null returns only top-level tasks'),
|
|
29
|
+
created_before: z.string().optional().describe('YYYY-MM-DD, organization timezone'),
|
|
30
|
+
created_after: z.string().optional().describe('YYYY-MM-DD, organization timezone'),
|
|
31
|
+
due_before: z.string().optional().describe('YYYY-MM-DD, organization timezone'),
|
|
32
|
+
due_after: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('YYYY-MM-DD. With due_before, asks for one window ("due this week") in a single request'),
|
|
36
|
+
updated_since: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('ISO-8601. Incremental sync: only tasks changed at or after this instant'),
|
|
40
|
+
include_comments: z.boolean().optional(),
|
|
41
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
21
42
|
page: z.number().int().optional(),
|
|
22
|
-
cursor: z.string().optional(),
|
|
43
|
+
cursor: z.string().optional().describe('meta.next_cursor from a previous page; preferred over page'),
|
|
44
|
+
sort: z
|
|
45
|
+
.enum([
|
|
46
|
+
'due_date',
|
|
47
|
+
'due_date_desc',
|
|
48
|
+
'created_at',
|
|
49
|
+
'created_at_desc',
|
|
50
|
+
'updated_at',
|
|
51
|
+
'updated_at_desc',
|
|
52
|
+
'title',
|
|
53
|
+
'title_desc',
|
|
54
|
+
'priority',
|
|
55
|
+
'priority_desc',
|
|
56
|
+
])
|
|
57
|
+
.optional()
|
|
58
|
+
.describe('Order the page. `priority` ascends low -> urgent. CANNOT be combined with cursor or ' +
|
|
59
|
+
'updated_since - that is a 422, because re-ordering a crawl silently skips rows.'),
|
|
23
60
|
},
|
|
24
61
|
handler: (args, cfg) => request(cfg, '/tasks', {
|
|
25
62
|
query: {
|
|
26
63
|
project_id: args.project_id,
|
|
27
64
|
status: args.status,
|
|
28
65
|
priority: args.priority,
|
|
29
|
-
|
|
66
|
+
assignee_employee_code: args.assignee_employee_code,
|
|
30
67
|
label: args.label,
|
|
31
68
|
search: args.search,
|
|
32
|
-
open: args.open ? 1 :
|
|
69
|
+
open: args.open === undefined ? undefined : args.open ? 1 : 0,
|
|
70
|
+
parent_task_id: args.parent_task_id === null ? '' : args.parent_task_id,
|
|
33
71
|
created_before: args.created_before,
|
|
72
|
+
created_after: args.created_after,
|
|
34
73
|
due_before: args.due_before,
|
|
74
|
+
due_after: args.due_after,
|
|
75
|
+
updated_since: args.updated_since,
|
|
76
|
+
include_comments: args.include_comments === undefined ? undefined : args.include_comments ? 1 : 0,
|
|
77
|
+
per_page: args.per_page,
|
|
35
78
|
page: args.page,
|
|
36
79
|
cursor: args.cursor,
|
|
80
|
+
sort: args.sort,
|
|
37
81
|
},
|
|
38
82
|
}),
|
|
39
83
|
});
|
|
@@ -46,18 +90,23 @@ export function registerTaskTools(server, config) {
|
|
|
46
90
|
registerTool(server, config, {
|
|
47
91
|
name: 'clokio_create_task',
|
|
48
92
|
mutates: true,
|
|
49
|
-
description: 'Create a task in a project.
|
|
50
|
-
'
|
|
93
|
+
description: 'Create a task in a project. People are named by employee_code (e.g. "00080"), never internal ids. ' +
|
|
94
|
+
'ALWAYS set created_by_employee_code to attribute the task to a person - without it the task shows as ' +
|
|
95
|
+
'the API key owner ("External System"). Pass parent_task_id to create it as a SUBTASK of that task ' +
|
|
96
|
+
'(same project, and the parent must not itself be a subtask - one level deep only).',
|
|
51
97
|
schema: {
|
|
52
98
|
project_id: z.number().int(),
|
|
53
|
-
title: z.string(),
|
|
99
|
+
title: z.string().max(255),
|
|
54
100
|
description: z.string().optional().describe('HTML or plain text'),
|
|
55
101
|
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
|
56
|
-
status: z.string().optional().describe(
|
|
102
|
+
status: z.string().optional().describe("Status slug valid for THIS project's board"),
|
|
57
103
|
due_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
start_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
105
|
+
estimated_hours: z.number().min(0).max(9999.99).optional(),
|
|
106
|
+
parent_task_id: z.number().int().optional().describe('Makes this a subtask of that task'),
|
|
107
|
+
assignee_employee_codes: z.array(employeeCode).max(50).optional(),
|
|
108
|
+
label_names: z.array(z.string().max(100)).max(20).optional(),
|
|
109
|
+
created_by_employee_code: employeeCode.optional().describe('Attribute the task to this person'),
|
|
61
110
|
},
|
|
62
111
|
handler: (args, cfg) => request(cfg, '/tasks', {
|
|
63
112
|
method: 'POST',
|
|
@@ -68,36 +117,52 @@ export function registerTaskTools(server, config) {
|
|
|
68
117
|
priority: args.priority,
|
|
69
118
|
status: args.status,
|
|
70
119
|
due_date: args.due_date,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
120
|
+
start_date: args.start_date,
|
|
121
|
+
estimated_hours: args.estimated_hours,
|
|
122
|
+
parent_task_id: args.parent_task_id,
|
|
123
|
+
assignee_employee_codes: args.assignee_employee_codes,
|
|
124
|
+
label_names: args.label_names,
|
|
125
|
+
created_by_employee_code: args.created_by_employee_code,
|
|
74
126
|
},
|
|
75
127
|
}),
|
|
76
128
|
});
|
|
77
129
|
registerTool(server, config, {
|
|
78
130
|
name: 'clokio_update_task',
|
|
79
131
|
mutates: true,
|
|
80
|
-
description: 'Update a task.
|
|
132
|
+
description: 'Update a task. ONLY the fields you pass are changed; omitted fields are left alone. Pass null to ' +
|
|
133
|
+
'due_date / start_date / estimated_hours / description to CLEAR them. Set actor_employee_code so the ' +
|
|
134
|
+
'activity log names the person rather than the API key.',
|
|
81
135
|
schema: {
|
|
82
136
|
id: z.number().int(),
|
|
83
|
-
title: z.string().optional(),
|
|
84
|
-
description: z.string().optional(),
|
|
137
|
+
title: z.string().max(255).optional(),
|
|
138
|
+
description: z.string().nullable().optional(),
|
|
85
139
|
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
|
|
140
|
+
status: z.string().optional().describe("Status slug valid for this task's board"),
|
|
141
|
+
due_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
|
|
142
|
+
start_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
|
|
143
|
+
estimated_hours: z.number().min(0).max(9999.99).nullable().optional(),
|
|
144
|
+
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'),
|
|
88
146
|
},
|
|
89
147
|
handler: (args, cfg) => {
|
|
148
|
+
// Only keys the caller actually supplied may go in the body: PATCH
|
|
149
|
+
// semantics are "change what is named", and sending an untouched field
|
|
150
|
+
// as null would CLEAR it.
|
|
90
151
|
const body = {};
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
152
|
+
for (const key of [
|
|
153
|
+
'title',
|
|
154
|
+
'description',
|
|
155
|
+
'priority',
|
|
156
|
+
'status',
|
|
157
|
+
'due_date',
|
|
158
|
+
'start_date',
|
|
159
|
+
'estimated_hours',
|
|
160
|
+
'project_id',
|
|
161
|
+
'actor_employee_code',
|
|
162
|
+
]) {
|
|
163
|
+
if (args[key] !== undefined)
|
|
164
|
+
body[key] = args[key];
|
|
165
|
+
}
|
|
101
166
|
return request(cfg, `/tasks/${args.id}`, { method: 'PATCH', body });
|
|
102
167
|
},
|
|
103
168
|
});
|
|
@@ -111,16 +176,16 @@ export function registerTaskTools(server, config) {
|
|
|
111
176
|
registerTool(server, config, {
|
|
112
177
|
name: 'clokio_set_task_assignees',
|
|
113
178
|
mutates: true,
|
|
114
|
-
description:
|
|
115
|
-
'
|
|
179
|
+
description: "Change a task's assignees. mode is REQUIRED: \"add\" appends, \"replace\" overwrites the whole list, " +
|
|
180
|
+
'"remove" takes those people off. People are named by employee_code (e.g. "00080").',
|
|
116
181
|
schema: {
|
|
117
182
|
id: z.number().int(),
|
|
118
|
-
employee_codes: z.array(
|
|
119
|
-
mode: z.enum(['add', 'replace', 'remove']).
|
|
183
|
+
employee_codes: z.array(employeeCode).min(1).max(50),
|
|
184
|
+
mode: z.enum(['add', 'replace', 'remove']).describe('Required'),
|
|
120
185
|
},
|
|
121
186
|
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/assignees`, {
|
|
122
187
|
method: 'PATCH',
|
|
123
|
-
body: {
|
|
188
|
+
body: { employee_codes: args.employee_codes, mode: args.mode },
|
|
124
189
|
}),
|
|
125
190
|
});
|
|
126
191
|
registerTool(server, config, {
|
|
@@ -133,18 +198,71 @@ export function registerTaskTools(server, config) {
|
|
|
133
198
|
name: 'clokio_add_task_comment',
|
|
134
199
|
mutates: true,
|
|
135
200
|
description: 'Add a comment to a task. Set author_employee_code to attribute it to a person; otherwise it shows as ' +
|
|
136
|
-
'the API key owner ("External System"). Use readable formatting (paragraphs,
|
|
137
|
-
'
|
|
201
|
+
'the API key owner ("External System"). Use readable formatting (paragraphs, blank lines, bullet ' +
|
|
202
|
+
'lists); markdown TABLES are not rendered and post as raw text. Write in the language of the person ' +
|
|
203
|
+
'you are answering. ' +
|
|
204
|
+
'TO MENTION SOMEONE, write @ followed by their full name exactly as clokio_list_employees spells it ' +
|
|
205
|
+
'(for example "@Kiran Bahadur") in the body text - no markup needed. It becomes a real mention and ' +
|
|
206
|
+
'notifies them, but only if the comment has an author_employee_code; an unattributed comment notifies ' +
|
|
207
|
+
'nobody. A name that matches no one stays as plain text, so check the spelling first.',
|
|
138
208
|
schema: {
|
|
139
209
|
id: z.number().int(),
|
|
140
|
-
body: z.string(),
|
|
141
|
-
author_employee_code:
|
|
210
|
+
body: z.string().max(65535),
|
|
211
|
+
author_employee_code: employeeCode.optional().describe('Attribute the comment to this person'),
|
|
212
|
+
notify: z.boolean().optional().describe('Send notifications to watchers (default: the API decides)'),
|
|
142
213
|
},
|
|
143
214
|
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments`, {
|
|
144
215
|
method: 'POST',
|
|
216
|
+
body: {
|
|
217
|
+
body: args.body,
|
|
218
|
+
author_employee_code: args.author_employee_code,
|
|
219
|
+
notify: args.notify,
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
registerTool(server, config, {
|
|
224
|
+
name: 'clokio_update_task_comment',
|
|
225
|
+
mutates: true,
|
|
226
|
+
description: 'Correct a comment YOUR integration published - a wrong number, a dead link, a mistaken finding. ' +
|
|
227
|
+
'Two conditions, both required: the comment was created through this API, and author_employee_code ' +
|
|
228
|
+
"names its own author. You cannot edit what a person wrote in the web or mobile app (403), and you " +
|
|
229
|
+
'cannot edit someone else\'s comment (403). There is no delete: correcting leaves an audit entry, ' +
|
|
230
|
+
'destroying would leave nothing.',
|
|
231
|
+
schema: {
|
|
232
|
+
id: z.number().int().describe('The task id'),
|
|
233
|
+
comment_id: z.number().int(),
|
|
234
|
+
body: z.string().max(65535),
|
|
235
|
+
author_employee_code: employeeCode.describe("Required: the comment's own author"),
|
|
236
|
+
},
|
|
237
|
+
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments/${args.comment_id}`, {
|
|
238
|
+
method: 'PATCH',
|
|
145
239
|
body: { body: args.body, author_employee_code: args.author_employee_code },
|
|
146
240
|
}),
|
|
147
241
|
});
|
|
242
|
+
registerTool(server, config, {
|
|
243
|
+
name: 'clokio_list_task_attachments',
|
|
244
|
+
description: "A task's attachments: file name, size, mime type, who uploaded it, and the path to fetch the bytes. " +
|
|
245
|
+
'A task whose real content is a screenshot or a spreadsheet reads as "(no description)" without this - ' +
|
|
246
|
+
'check here before concluding a task has no detail. Use clokio_download_task_attachment for the file.',
|
|
247
|
+
schema: { id: z.number().int() },
|
|
248
|
+
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/attachments`),
|
|
249
|
+
});
|
|
250
|
+
registerTool(server, config, {
|
|
251
|
+
name: 'clokio_download_task_attachment',
|
|
252
|
+
description: 'Download one attachment. Returns the FILE, not JSON: text comes back as text (truncated if very ' +
|
|
253
|
+
'large, and it says so), while binary files (images, PDFs, spreadsheets) are reported with their type ' +
|
|
254
|
+
'and size rather than dumped into the conversation. Get the attachment id - and file_name, which helps ' +
|
|
255
|
+
'this tool recognise text - from clokio_list_task_attachments.',
|
|
256
|
+
schema: {
|
|
257
|
+
id: z.number().int().describe('The task id'),
|
|
258
|
+
attachment_id: z.number().int(),
|
|
259
|
+
file_name: z
|
|
260
|
+
.string()
|
|
261
|
+
.optional()
|
|
262
|
+
.describe('The file_name from clokio_list_task_attachments. Improves text detection'),
|
|
263
|
+
},
|
|
264
|
+
handler: (args, cfg) => requestRaw(cfg, `/tasks/${args.id}/attachments/${args.attachment_id}`, args.file_name),
|
|
265
|
+
});
|
|
148
266
|
registerTool(server, config, {
|
|
149
267
|
name: 'clokio_get_task_activity',
|
|
150
268
|
description: 'Get the activity log (audit trail) for a task.',
|
|
@@ -171,6 +289,17 @@ export function registerTaskTools(server, config) {
|
|
|
171
289
|
body: { depends_on_task_id: args.depends_on_task_id, type: args.type },
|
|
172
290
|
}),
|
|
173
291
|
});
|
|
292
|
+
registerTool(server, config, {
|
|
293
|
+
name: 'clokio_remove_task_dependency',
|
|
294
|
+
mutates: 'destructive',
|
|
295
|
+
description: 'Remove a dependency from a task. dependency_id is the id of the DEPENDENCY row, as returned by ' +
|
|
296
|
+
'clokio_list_task_dependencies - not the id of the other task.',
|
|
297
|
+
schema: {
|
|
298
|
+
id: z.number().int().describe('The task that holds the dependency'),
|
|
299
|
+
dependency_id: z.number().int(),
|
|
300
|
+
},
|
|
301
|
+
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/dependencies/${args.dependency_id}`, { method: 'DELETE' }),
|
|
302
|
+
});
|
|
174
303
|
registerTool(server, config, {
|
|
175
304
|
name: 'clokio_set_task_custom_field',
|
|
176
305
|
mutates: true,
|
package/package.json
CHANGED