clokio-mcp 0.1.0 → 0.1.2
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 +44 -1
- package/dist/index.js +5 -1
- package/dist/tools/attendance.js +7 -3
- package/dist/tools/employees.js +14 -5
- package/dist/tools/helpers.js +7 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -15,6 +15,32 @@ export function loadConfig() {
|
|
|
15
15
|
const baseUrl = (process.env.CLOKIO_BASE_URL?.trim() || 'https://app.clokio.io').replace(/\/+$/, '');
|
|
16
16
|
return { baseUrl, apiKey };
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Encode ONE path segment supplied by a tool argument.
|
|
20
|
+
*
|
|
21
|
+
* Interpolating a raw argument into a request path lets it change which
|
|
22
|
+
* endpoint is called: `?` truncates the path (so a hardcoded `/status` suffix
|
|
23
|
+
* becomes query noise and the request lands on a different route), `/` adds
|
|
24
|
+
* segments, and `..` climbs. encodeURIComponent turns all of them into their
|
|
25
|
+
* percent-encoded forms, which the API then sees as a single literal value -
|
|
26
|
+
* and its own `[A-Za-z0-9_-]` route constraint rejects it.
|
|
27
|
+
*
|
|
28
|
+
* Every tool that puts a STRING into a path must wrap it in this. Numeric
|
|
29
|
+
* (z.number().int()) arguments cannot carry separators and need no wrapping,
|
|
30
|
+
* but passing them through is harmless.
|
|
31
|
+
*/
|
|
32
|
+
export function seg(value) {
|
|
33
|
+
const s = String(value);
|
|
34
|
+
// encodeURIComponent leaves `.` alone, so a bare `.` or `..` would survive
|
|
35
|
+
// as a real dot-segment and be normalised away by new URL(). The prefix
|
|
36
|
+
// guard in request() already catches the escape; refusing here keeps the
|
|
37
|
+
// error at the argument that caused it, and an empty segment would silently
|
|
38
|
+
// collapse `/a//b` into `/a/b` and hit a neighbouring route.
|
|
39
|
+
if (s === '' || s === '.' || s === '..') {
|
|
40
|
+
throw new ClokioApiError(0, `Invalid path argument ${JSON.stringify(s)}.`);
|
|
41
|
+
}
|
|
42
|
+
return encodeURIComponent(s);
|
|
43
|
+
}
|
|
18
44
|
/** A Clokio API error, carrying the HTTP status and the server's message. */
|
|
19
45
|
export class ClokioApiError extends Error {
|
|
20
46
|
status;
|
|
@@ -35,6 +61,18 @@ export class ClokioApiError extends Error {
|
|
|
35
61
|
export async function request(config, path, options = {}) {
|
|
36
62
|
const { method = 'GET', query, body } = options;
|
|
37
63
|
const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
|
|
64
|
+
// A tool argument interpolated into `path` must never be able to change
|
|
65
|
+
// WHICH endpoint is called. new URL() normalises the assembled string, so a
|
|
66
|
+
// value containing `?` truncates the path (turning `/employees/X/status`
|
|
67
|
+
// into `/employees/X/pin?...`) and `../` segments climb out of /api/v1.
|
|
68
|
+
// That is an endpoint pivot: a caller who approved "set employee status"
|
|
69
|
+
// would instead reach PATCH /employees/{code}/pin, which resets a person's
|
|
70
|
+
// clock-in PIN and returns it in the response. Callers validate their own
|
|
71
|
+
// arguments, but this is the invariant that holds even if one forgets.
|
|
72
|
+
const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
|
|
73
|
+
if (!url.pathname.startsWith(expectedPrefix)) {
|
|
74
|
+
throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1 - a path argument tried to change the endpoint.');
|
|
75
|
+
}
|
|
38
76
|
if (query) {
|
|
39
77
|
for (const [key, value] of Object.entries(query)) {
|
|
40
78
|
if (value === undefined || value === null)
|
|
@@ -59,7 +97,12 @@ export async function request(config, path, options = {}) {
|
|
|
59
97
|
}
|
|
60
98
|
let res;
|
|
61
99
|
try {
|
|
62
|
-
|
|
100
|
+
// redirect: 'error' - Node's fetch follows redirects and RESENDS the
|
|
101
|
+
// request headers, including X-API-Key, to wherever Location points, on
|
|
102
|
+
// any origin. The API never redirects an /api/v1 call, so a redirect here
|
|
103
|
+
// means something between us and it is not the API, and the key must not
|
|
104
|
+
// be handed over. Failing closed costs nothing on the happy path.
|
|
105
|
+
res = await fetch(url, { method, headers, body: payload, redirect: 'error' });
|
|
63
106
|
}
|
|
64
107
|
catch (e) {
|
|
65
108
|
throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
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';
|
|
13
14
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
14
15
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
16
|
import { loadConfig } from './client.js';
|
|
@@ -17,11 +18,14 @@ import { registerTaskTools } from './tools/tasks.js';
|
|
|
17
18
|
import { registerProjectTools } from './tools/projects.js';
|
|
18
19
|
import { registerEmployeeTools } from './tools/employees.js';
|
|
19
20
|
import { registerAttendanceTools } from './tools/attendance.js';
|
|
21
|
+
// The version the MCP handshake announces is package.json's, read at runtime -
|
|
22
|
+
// a literal here went stale on the first patch release (0.1.1 said 0.1.0).
|
|
23
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
20
24
|
async function main() {
|
|
21
25
|
const config = loadConfig();
|
|
22
26
|
const server = new McpServer({
|
|
23
27
|
name: 'clokio-mcp',
|
|
24
|
-
version
|
|
28
|
+
version,
|
|
25
29
|
});
|
|
26
30
|
registerTaskTools(server, config);
|
|
27
31
|
registerProjectTools(server, config);
|
package/dist/tools/attendance.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { request } from '../client.js';
|
|
2
|
+
import { request, seg } from '../client.js';
|
|
3
3
|
import { registerTool } from './helpers.js';
|
|
4
|
+
/** See employees.ts - an employee_code reaches the request path. */
|
|
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
|
/** Attendance reads + clock/break writes, plus time entries and balances. */
|
|
5
9
|
export function registerAttendanceTools(server, config) {
|
|
6
10
|
registerTool(server, config, {
|
|
@@ -22,11 +26,11 @@ export function registerAttendanceTools(server, config) {
|
|
|
22
26
|
name: 'clokio_attendance_for_employee',
|
|
23
27
|
description: "One employee's attendance. Optionally scope to a date range.",
|
|
24
28
|
schema: {
|
|
25
|
-
employee_code:
|
|
29
|
+
employee_code: employeeCode,
|
|
26
30
|
start_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
27
31
|
end_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
28
32
|
},
|
|
29
|
-
handler: (args, cfg) => request(cfg, `/attendance/employee/${args.employee_code}`, {
|
|
33
|
+
handler: (args, cfg) => request(cfg, `/attendance/employee/${seg(args.employee_code)}`, {
|
|
30
34
|
query: { start_date: args.start_date, end_date: args.end_date },
|
|
31
35
|
}),
|
|
32
36
|
});
|
package/dist/tools/employees.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { request } from '../client.js';
|
|
2
|
+
import { request, seg } from '../client.js';
|
|
3
3
|
import { registerTool } from './helpers.js';
|
|
4
|
+
/**
|
|
5
|
+
* An employee_code goes into the request PATH, so it must not be able to carry
|
|
6
|
+
* a path separator or a query marker - see seg() / the /api/v1 guard in
|
|
7
|
+
* client.js. This mirrors the constraint the API route itself declares
|
|
8
|
+
* (`->where('employeeCode', '[A-Za-z0-9_-]{1,50}')`).
|
|
9
|
+
*/
|
|
10
|
+
const employeeCode = z
|
|
11
|
+
.string()
|
|
12
|
+
.regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -');
|
|
4
13
|
/** Employee directory + provisioning. */
|
|
5
14
|
export function registerEmployeeTools(server, config) {
|
|
6
15
|
registerTool(server, config, {
|
|
@@ -31,10 +40,10 @@ export function registerEmployeeTools(server, config) {
|
|
|
31
40
|
description: "One employee's open / delayed_from_open / past_due task counts. stale_days sets the 'delayed' threshold " +
|
|
32
41
|
'(default 7, range 1-365). A task with N assignees counts for each of them.',
|
|
33
42
|
schema: {
|
|
34
|
-
employee_code:
|
|
43
|
+
employee_code: employeeCode,
|
|
35
44
|
stale_days: z.number().int().min(1).max(365).optional(),
|
|
36
45
|
},
|
|
37
|
-
handler: (args, cfg) => request(cfg, `/employees/${args.employee_code}/task-stats`, {
|
|
46
|
+
handler: (args, cfg) => request(cfg, `/employees/${seg(args.employee_code)}/task-stats`, {
|
|
38
47
|
query: { stale_days: args.stale_days },
|
|
39
48
|
}),
|
|
40
49
|
});
|
|
@@ -60,10 +69,10 @@ export function registerEmployeeTools(server, config) {
|
|
|
60
69
|
mutates: true,
|
|
61
70
|
description: 'Set an employee\'s status (active / inactive / resign).',
|
|
62
71
|
schema: {
|
|
63
|
-
employee_code:
|
|
72
|
+
employee_code: employeeCode,
|
|
64
73
|
status: z.enum(['active', 'inactive', 'resign']),
|
|
65
74
|
},
|
|
66
|
-
handler: (args, cfg) => request(cfg, `/employees/${args.employee_code}/status`, {
|
|
75
|
+
handler: (args, cfg) => request(cfg, `/employees/${seg(args.employee_code)}/status`, {
|
|
67
76
|
method: 'PATCH',
|
|
68
77
|
body: { status: args.status },
|
|
69
78
|
}),
|
package/dist/tools/helpers.js
CHANGED
|
@@ -16,7 +16,13 @@ export function registerTool(server, config, def) {
|
|
|
16
16
|
content: [
|
|
17
17
|
{
|
|
18
18
|
type: 'text',
|
|
19
|
-
|
|
19
|
+
// A 204 (delete) has no body, so request() resolves undefined and
|
|
20
|
+
// JSON.stringify(undefined) is undefined - not a valid text block.
|
|
21
|
+
text: result === undefined
|
|
22
|
+
? 'OK (no content)'
|
|
23
|
+
: typeof result === 'string'
|
|
24
|
+
? result
|
|
25
|
+
: JSON.stringify(result, null, 2),
|
|
20
26
|
},
|
|
21
27
|
],
|
|
22
28
|
};
|
package/package.json
CHANGED