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 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
- res = await fetch(url, { method, headers, body: payload });
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: '0.1.0',
28
+ version,
25
29
  });
26
30
  registerTaskTools(server, config);
27
31
  registerProjectTools(server, config);
@@ -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: z.string(),
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
  });
@@ -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: z.string(),
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: z.string(),
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
  }),
@@ -16,7 +16,13 @@ export function registerTool(server, config, def) {
16
16
  content: [
17
17
  {
18
18
  type: 'text',
19
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Model Context Protocol (MCP) server for Clokio — manage tasks, projects, clients, employees and attendance from Claude and any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {