clokio-mcp 0.28.1 → 0.29.1
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 +24 -0
- package/dist/client.js +40 -14
- package/dist/index.js +56 -4
- package/dist/tools/employee.js +175 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,30 @@ Add to `claude_desktop_config.json`:
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
### Acting as yourself: an employee key
|
|
53
|
+
|
|
54
|
+
An admin `clk_` key reaches the whole organisation. If you are not an admin, or you want
|
|
55
|
+
every write bound by **your own** roles and project permissions, use a personal employee key
|
|
56
|
+
instead - any employee can issue one at **Profile → API keys**:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
claude mcp add clokio \
|
|
60
|
+
--env CLOKIO_EMPLOYEE_KEY=your_personal_key \
|
|
61
|
+
-- npx -y clokio-mcp@latest
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Set exactly one of `CLOKIO_API_KEY` / `CLOKIO_EMPLOYEE_KEY`. In employee mode the server
|
|
65
|
+
talks to `/api/employee` with `X-Employee-Key`, announces who it is acting as at startup
|
|
66
|
+
(`acting as Anil Pujara (00081)`), and fails there if the key is revoked or expired. It
|
|
67
|
+
registers only what that surface allows: your assigned-task queue, reading and creating
|
|
68
|
+
tasks where you may, updating, commenting, attachments, logging time, and the whole Docs
|
|
69
|
+
module. Nothing takes an `employee_code` to credit a writer: the key is the identity, and a
|
|
70
|
+
`403` from Clokio means you may not do that in that project. `CLOKIO_TOOLSETS` may name
|
|
71
|
+
`tasks` and/or `docs` (`clokio_whoami` and `clokio_list_projects` are always on);
|
|
72
|
+
`CLOKIO_DEFAULT_ACTOR` is refused. Employee mode needs a Clokio server with
|
|
73
|
+
`GET /api/employee/me` (app.clokio.io has it since 2026-10-26). If the startup check cannot
|
|
74
|
+
reach Clokio, the server says so and does not blame the key.
|
|
75
|
+
|
|
52
76
|
### Cursor / Cline / Zed / any MCP client
|
|
53
77
|
|
|
54
78
|
Use the same command - `npx -y clokio-mcp@latest` with `CLOKIO_API_KEY` in the env.
|
package/dist/client.js
CHANGED
|
@@ -8,19 +8,38 @@
|
|
|
8
8
|
* a 403/404/422 from the API is surfaced verbatim.
|
|
9
9
|
*/
|
|
10
10
|
export function loadConfig() {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const adminKey = process.env.CLOKIO_API_KEY?.trim();
|
|
12
|
+
const employeeKey = process.env.CLOKIO_EMPLOYEE_KEY?.trim();
|
|
13
|
+
if (adminKey && employeeKey) {
|
|
14
|
+
throw new Error('Both CLOKIO_API_KEY and CLOKIO_EMPLOYEE_KEY are set. Set exactly one: CLOKIO_API_KEY for the ' +
|
|
15
|
+
'organisation-wide Public API (admin, clk_...), or CLOKIO_EMPLOYEE_KEY to act as one person with ' +
|
|
16
|
+
'their own permissions.');
|
|
15
17
|
}
|
|
18
|
+
if (!adminKey && !employeeKey) {
|
|
19
|
+
throw new Error('No Clokio key is set. Provide CLOKIO_API_KEY (an admin Public API key, clk_...) or ' +
|
|
20
|
+
'CLOKIO_EMPLOYEE_KEY (a personal key from Profile -> API keys, acts as you with your own permissions).');
|
|
21
|
+
}
|
|
22
|
+
const mode = employeeKey ? 'employee' : 'admin';
|
|
23
|
+
const apiKey = (employeeKey ?? adminKey);
|
|
16
24
|
const baseUrl = (process.env.CLOKIO_BASE_URL?.trim() || 'https://app.clokio.io').replace(/\/+$/, '');
|
|
17
25
|
const defaultActor = process.env.CLOKIO_DEFAULT_ACTOR?.trim() || undefined;
|
|
18
26
|
// Validate the same way the API constrains an employee_code, so a
|
|
19
27
|
// misconfigured value fails at startup rather than silently on every write.
|
|
28
|
+
if (defaultActor && mode === 'employee') {
|
|
29
|
+
throw new Error('CLOKIO_DEFAULT_ACTOR has no meaning with CLOKIO_EMPLOYEE_KEY: the key already IS the actor. Unset it.');
|
|
30
|
+
}
|
|
20
31
|
if (defaultActor && !/^[A-Za-z0-9_-]{1,50}$/.test(defaultActor)) {
|
|
21
32
|
throw new Error('CLOKIO_DEFAULT_ACTOR must be an employee_code (1-50 chars of letters, digits, _ or -).');
|
|
22
33
|
}
|
|
23
|
-
return { baseUrl, apiKey, defaultActor };
|
|
34
|
+
return { baseUrl, mode, apiKey, defaultActor };
|
|
35
|
+
}
|
|
36
|
+
/** The URL prefix the mode talks to. */
|
|
37
|
+
export function apiPrefix(config) {
|
|
38
|
+
return config.mode === 'employee' ? '/api/employee' : '/api/v1';
|
|
39
|
+
}
|
|
40
|
+
/** The one header that authenticates the mode's key. */
|
|
41
|
+
export function authHeaders(config) {
|
|
42
|
+
return config.mode === 'employee' ? { 'X-Employee-Key': config.apiKey } : { 'X-API-Key': config.apiKey };
|
|
24
43
|
}
|
|
25
44
|
/**
|
|
26
45
|
* Assemble an /api/v1 request URL and enforce the prefix guard.
|
|
@@ -35,10 +54,11 @@ export function loadConfig() {
|
|
|
35
54
|
* in seg(), but this is the invariant that holds even if one forgets.
|
|
36
55
|
*/
|
|
37
56
|
export function buildUrl(config, path) {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
57
|
+
const prefix = apiPrefix(config);
|
|
58
|
+
const url = new URL(`${config.baseUrl}${prefix}${path.startsWith('/') ? path : `/${path}`}`);
|
|
59
|
+
const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + prefix + '/';
|
|
40
60
|
if (!url.pathname.startsWith(expectedPrefix)) {
|
|
41
|
-
throw new ClokioApiError(0,
|
|
61
|
+
throw new ClokioApiError(0, `Refusing to send a request outside ${prefix} - a path argument tried to change the endpoint.`);
|
|
42
62
|
}
|
|
43
63
|
return url;
|
|
44
64
|
}
|
|
@@ -108,7 +128,7 @@ export function seg(value) {
|
|
|
108
128
|
* the save-to-disk and typed-content tools consume the bytes directly.
|
|
109
129
|
*/
|
|
110
130
|
export async function requestRawBytes(config, path, query) {
|
|
111
|
-
const auth =
|
|
131
|
+
const auth = authHeaders(config);
|
|
112
132
|
const url = buildUrl(config, path);
|
|
113
133
|
applyQuery(url, query);
|
|
114
134
|
let res;
|
|
@@ -176,7 +196,7 @@ export function looksLikeText(contentType, fileName) {
|
|
|
176
196
|
TEXT_EXTENSIONS.has(extension));
|
|
177
197
|
}
|
|
178
198
|
export async function requestRaw(config, path, fileName) {
|
|
179
|
-
const auth =
|
|
199
|
+
const auth = authHeaders(config);
|
|
180
200
|
const url = buildUrl(config, path);
|
|
181
201
|
let res;
|
|
182
202
|
try {
|
|
@@ -276,7 +296,7 @@ export async function requestRaw(config, path, fileName) {
|
|
|
276
296
|
export async function requestUpload(config, path, filePath, fields = {}) {
|
|
277
297
|
const { readFile } = await import('node:fs/promises');
|
|
278
298
|
const { basename } = await import('node:path');
|
|
279
|
-
const auth =
|
|
299
|
+
const auth = authHeaders(config);
|
|
280
300
|
const url = buildUrl(config, path);
|
|
281
301
|
let bytes;
|
|
282
302
|
try {
|
|
@@ -327,12 +347,12 @@ export class ClokioApiError extends Error {
|
|
|
327
347
|
* the server's own message.
|
|
328
348
|
*/
|
|
329
349
|
export async function request(config, path, options = {}) {
|
|
330
|
-
const { method = 'GET', query, body, headers: extra, onHeaders } = options;
|
|
350
|
+
const { method = 'GET', query, body, headers: extra, onHeaders, timeoutMs } = options;
|
|
331
351
|
const url = buildUrl(config, path);
|
|
332
352
|
applyQuery(url, query);
|
|
333
353
|
const headers = {
|
|
334
354
|
...(extra ?? {}),
|
|
335
|
-
|
|
355
|
+
...authHeaders(config),
|
|
336
356
|
Accept: 'application/json',
|
|
337
357
|
};
|
|
338
358
|
let payload;
|
|
@@ -347,7 +367,13 @@ export async function request(config, path, options = {}) {
|
|
|
347
367
|
// any origin. The API never redirects a JSON /api/v1 call, so a redirect
|
|
348
368
|
// here means something between us and it is not the API, and the key
|
|
349
369
|
// must not be handed over. Failing closed costs nothing on the happy path.
|
|
350
|
-
res = await fetch(url, {
|
|
370
|
+
res = await fetch(url, {
|
|
371
|
+
method,
|
|
372
|
+
headers,
|
|
373
|
+
body: payload,
|
|
374
|
+
redirect: 'error',
|
|
375
|
+
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
376
|
+
});
|
|
351
377
|
}
|
|
352
378
|
catch (e) {
|
|
353
379
|
throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
|
package/dist/index.js
CHANGED
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
16
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
-
import { loadConfig } from './client.js';
|
|
17
|
+
import { ClokioApiError, loadConfig, request } from './client.js';
|
|
18
18
|
import { VERSION } from './version.js';
|
|
19
19
|
import { registerTaskTools } from './tools/tasks.js';
|
|
20
20
|
import { registerProjectTools } from './tools/projects.js';
|
|
21
21
|
import { registerEmployeeTools } from './tools/employees.js';
|
|
22
22
|
import { registerAttendanceTools } from './tools/attendance.js';
|
|
23
23
|
import { registerDocTools } from './tools/docs.js';
|
|
24
|
+
import { registerEmployeeIdentityTools, registerEmployeeModeTools } from './tools/employee.js';
|
|
24
25
|
import { registerTicketTools } from './tools/tickets.js';
|
|
25
26
|
/**
|
|
26
27
|
* The optional tool groups a session can turn OFF.
|
|
@@ -53,15 +54,66 @@ function selectedToolsets() {
|
|
|
53
54
|
}
|
|
54
55
|
return new Set(requested);
|
|
55
56
|
}
|
|
57
|
+
const EMPLOYEE_TOOLSETS = ['tasks', 'docs'];
|
|
58
|
+
/**
|
|
59
|
+
* Employee mode: the personal X-Employee-Key surface. Only tasks and docs
|
|
60
|
+
* exist there, so CLOKIO_TOOLSETS may name those two and nothing else.
|
|
61
|
+
*/
|
|
62
|
+
function selectedEmployeeToolsets() {
|
|
63
|
+
const raw = process.env.CLOKIO_TOOLSETS?.trim();
|
|
64
|
+
if (!raw)
|
|
65
|
+
return new Set(EMPLOYEE_TOOLSETS);
|
|
66
|
+
// Its own check, never the admin one: that error lists the admin toolsets
|
|
67
|
+
// and says "projects is always on", neither of which is true here.
|
|
68
|
+
const requested = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
69
|
+
const unsupported = requested.filter((t) => !EMPLOYEE_TOOLSETS.includes(t));
|
|
70
|
+
if (unsupported.length) {
|
|
71
|
+
throw new Error(`CLOKIO_TOOLSETS names toolset(s) the employee surface does not have: ${unsupported.join(', ')}. ` +
|
|
72
|
+
`With CLOKIO_EMPLOYEE_KEY the choices are: ${EMPLOYEE_TOOLSETS.join(', ')} (clokio_whoami and clokio_list_projects are always on).`);
|
|
73
|
+
}
|
|
74
|
+
return new Set(requested);
|
|
75
|
+
}
|
|
76
|
+
/** How long the startup /me check may take before the server says so and stops. */
|
|
77
|
+
const STARTUP_TIMEOUT_MS = 15_000;
|
|
56
78
|
async function main() {
|
|
57
79
|
const config = loadConfig();
|
|
58
|
-
const toolsets = selectedToolsets();
|
|
59
80
|
const server = new McpServer({
|
|
60
81
|
name: 'clokio-mcp',
|
|
61
82
|
version: VERSION,
|
|
62
83
|
});
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
if (config.mode === 'employee') {
|
|
85
|
+
// Fail at startup, not on the first write: a revoked or expired key is
|
|
86
|
+
// 401 here, and the person the key acts as is announced so a session
|
|
87
|
+
// never has to guess whose name its writes will carry.
|
|
88
|
+
const employeeToolsets = selectedEmployeeToolsets();
|
|
89
|
+
let me = {};
|
|
90
|
+
try {
|
|
91
|
+
me = (await request(config, '/me', { timeoutMs: STARTUP_TIMEOUT_MS }));
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
// Only a 401/403 means the KEY is bad. A network failure, a timeout, a
|
|
95
|
+
// 5xx during a deploy, a 429 or a server without /me (404) must not send
|
|
96
|
+
// the person to rotate a key that works (review of PR #2).
|
|
97
|
+
const status = e instanceof ClokioApiError ? e.status : 0;
|
|
98
|
+
if (status === 401 || status === 403) {
|
|
99
|
+
throw new Error(`CLOKIO_EMPLOYEE_KEY was refused by ${config.baseUrl} (${e.message}). The key may be revoked or expired - ` +
|
|
100
|
+
'issue a new one at Profile -> API keys.');
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Could not check CLOKIO_EMPLOYEE_KEY with ${config.baseUrl}: ${e.message}` +
|
|
103
|
+
(status === 404 ? ' (this Clokio server has no /api/employee/me yet: it needs a newer Clokio).' : '') +
|
|
104
|
+
' The key itself was not refused; try again once Clokio answers.');
|
|
105
|
+
}
|
|
106
|
+
registerEmployeeIdentityTools(server, config);
|
|
107
|
+
if (employeeToolsets.has('tasks'))
|
|
108
|
+
registerEmployeeModeTools(server, config);
|
|
109
|
+
if (employeeToolsets.has('docs'))
|
|
110
|
+
registerDocTools(server, config);
|
|
111
|
+
await server.connect(new StdioServerTransport());
|
|
112
|
+
process.stderr.write(`clokio-mcp ${VERSION} connected in employee mode, acting as ${me.name ?? '?'} (${me.employee_code ?? '?'}) ` +
|
|
113
|
+
`(base: ${config.baseUrl}, toolsets: ${[...employeeToolsets].join(', ')})\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const toolsets = selectedToolsets();
|
|
65
117
|
registerProjectTools(server, config);
|
|
66
118
|
if (toolsets.has('tasks'))
|
|
67
119
|
registerTaskTools(server, config);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { markdownToHtml } from '../markdown.js';
|
|
3
|
+
import { request, requestUpload, seg } from '../client.js';
|
|
4
|
+
import { VERSION } from '../version.js';
|
|
5
|
+
import { ESTIMATED_HOURS_MAX } from './tasks.js';
|
|
6
|
+
import { downloadAttachment } from '../attachments.js';
|
|
7
|
+
import { registerTool } from './helpers.js';
|
|
8
|
+
/**
|
|
9
|
+
* Employee mode: the tools a personal X-Employee-Key can use, acting AS that
|
|
10
|
+
* person. The server judges every call against the employee's own roles and
|
|
11
|
+
* project permissions, so nothing here takes an employee_code to credit a
|
|
12
|
+
* writer - the key is the identity, and a 403 means "you may not do that in
|
|
13
|
+
* this project", verbatim from Clokio.
|
|
14
|
+
*
|
|
15
|
+
* Same tool names as admin mode where the contract is a subset of it (the
|
|
16
|
+
* model's habits carry over); the admin-only tools simply do not exist here.
|
|
17
|
+
* The docs tools are the admin ones unchanged: that surface is identical for
|
|
18
|
+
* both keys, only the identity differs.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Always registered in employee mode, whatever CLOKIO_TOOLSETS names, like the
|
|
22
|
+
* admin `projects` group: a session is told to call clokio_whoami first, and
|
|
23
|
+
* without it a docs-only session could not learn who it acts as or which
|
|
24
|
+
* build it runs (review of PR #2, 2026-10-26).
|
|
25
|
+
*/
|
|
26
|
+
export function registerEmployeeIdentityTools(server, config) {
|
|
27
|
+
registerTool(server, config, {
|
|
28
|
+
name: 'clokio_whoami',
|
|
29
|
+
description: 'Who this employee key acts as: the person (employee_code, name, role, position, department), their ' +
|
|
30
|
+
'organisation, and the key\'s name and expiry. Every write this session makes carries that name. ' +
|
|
31
|
+
'Also `mcp_version`, the running clokio-mcp build: if it is older than the current release, start a NEW session. ' +
|
|
32
|
+
'CALL THIS FIRST in a session.',
|
|
33
|
+
schema: {},
|
|
34
|
+
// mcp_version is added client-side, as the admin whoami does: `npx -y
|
|
35
|
+
// clokio-mcp` can serve a cached old build, and only the running package
|
|
36
|
+
// knows which one it is.
|
|
37
|
+
handler: async (_args, cfg) => ({ ...(await request(cfg, '/me')), mcp_version: VERSION }),
|
|
38
|
+
});
|
|
39
|
+
registerTool(server, config, {
|
|
40
|
+
name: 'clokio_list_projects',
|
|
41
|
+
description: 'Projects this person can create tasks in, with ids. Acting as the key\'s employee, with their own permissions.',
|
|
42
|
+
schema: {},
|
|
43
|
+
handler: (_args, cfg) => request(cfg, '/projects'),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function registerEmployeeModeTools(server, config) {
|
|
47
|
+
const ACTS = 'Acting as the key\'s employee, with their own permissions.';
|
|
48
|
+
registerTool(server, config, {
|
|
49
|
+
name: 'clokio_list_tasks',
|
|
50
|
+
description: 'Tasks ASSIGNED to this person (their queue), open ones by default. Filter by status slug, ' +
|
|
51
|
+
'updated_since (ISO-8601, incremental sync) or include_done. limit 1-100. ' +
|
|
52
|
+
'Tasks they are not assigned to are not listed here; open one directly with clokio_get_task if they can see it.',
|
|
53
|
+
schema: {
|
|
54
|
+
status: z.string().max(60).optional().describe('Status slug, e.g. "in_progress"'),
|
|
55
|
+
updated_since: z.string().optional().describe('ISO-8601: only tasks changed at or after this instant'),
|
|
56
|
+
include_done: z.boolean().optional().describe('Include completed tasks (default false)'),
|
|
57
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
58
|
+
},
|
|
59
|
+
// include_done as 1/0: the server's `boolean` rule refuses the strings
|
|
60
|
+
// "true"/"false" a query would otherwise carry (a 422 on every call).
|
|
61
|
+
handler: (args, cfg) => request(cfg, '/tasks', {
|
|
62
|
+
query: { ...args, include_done: args.include_done === undefined ? undefined : args.include_done ? 1 : 0 },
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
registerTool(server, config, {
|
|
66
|
+
name: 'clokio_get_task',
|
|
67
|
+
description: `One task in full: description, subtasks, assignees, the newest 50 comments, attachments. ${ACTS}`,
|
|
68
|
+
schema: { id: z.number().int() },
|
|
69
|
+
handler: (args, cfg) => request(cfg, `/tasks/${seg(args.id)}`),
|
|
70
|
+
});
|
|
71
|
+
// description, dates and estimate take null to CLEAR them, as the server
|
|
72
|
+
// allows (`nullable`) and as the admin update_task does.
|
|
73
|
+
const taskFields = {
|
|
74
|
+
description: z.string().nullable().optional().describe('Markdown, HTML or plain text (Markdown is converted to HTML); null clears it'),
|
|
75
|
+
status: z.string().max(60).optional().describe("Status slug valid for THIS project's board"),
|
|
76
|
+
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
|
77
|
+
due_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
|
|
78
|
+
start_date: z.string().nullable().optional().describe('YYYY-MM-DD, or null to clear'),
|
|
79
|
+
estimated_hours: z.number().min(0).max(ESTIMATED_HOURS_MAX).nullable().optional().describe('null clears it'),
|
|
80
|
+
};
|
|
81
|
+
registerTool(server, config, {
|
|
82
|
+
name: 'clokio_create_task',
|
|
83
|
+
mutates: true,
|
|
84
|
+
description: `Create a task in a project the person may create in (clokio_list_projects); it is created BY them and they watch it. ${ACTS} ` +
|
|
85
|
+
'Mention someone in the description as plain text "@Full Name".',
|
|
86
|
+
schema: { project_id: z.number().int(), title: z.string().min(1).max(255), ...taskFields },
|
|
87
|
+
handler: (args, cfg) => request(cfg, '/tasks', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: strip({ ...args, description: htmlField(args.description, 'description', false) }),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
registerTool(server, config, {
|
|
93
|
+
name: 'clokio_update_task',
|
|
94
|
+
mutates: true,
|
|
95
|
+
description: `Change title, description, status, priority, dates or estimate on a task the person may edit. Only the fields you pass change. ${ACTS}`,
|
|
96
|
+
schema: { id: z.number().int(), title: z.string().min(1).max(255).optional(), ...taskFields },
|
|
97
|
+
handler: (args, cfg) => {
|
|
98
|
+
const { id, ...rest } = args;
|
|
99
|
+
const body = strip({ ...rest, description: htmlField(rest.description, 'description', false) });
|
|
100
|
+
return request(cfg, `/tasks/${seg(id)}`, { method: 'PATCH', body });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
registerTool(server, config, {
|
|
104
|
+
name: 'clokio_add_task_comment',
|
|
105
|
+
mutates: true,
|
|
106
|
+
description: `Comment on a task the person may comment on, as them. ${ACTS} Markdown is converted to HTML. ` +
|
|
107
|
+
'TO MENTION SOMEONE, write @ followed by their full name as Clokio spells it; it becomes a real mention and notifies them.',
|
|
108
|
+
schema: { id: z.number().int(), body: z.string().min(1).max(65535) },
|
|
109
|
+
handler: (args, cfg) => request(cfg, `/tasks/${seg(args.id)}/comments`, { method: 'POST', body: { body: htmlField(args.body, 'body', true) } }),
|
|
110
|
+
});
|
|
111
|
+
registerTool(server, config, {
|
|
112
|
+
name: 'clokio_upload_task_attachment',
|
|
113
|
+
mutates: true,
|
|
114
|
+
description: `Upload a LOCAL FILE to a task as an attachment, uploaded by the person. ${ACTS}`,
|
|
115
|
+
schema: { id: z.number().int(), file_path: z.string().describe('Path to a file on THIS machine') },
|
|
116
|
+
handler: (args, cfg) => requestUpload(cfg, `/tasks/${seg(args.id)}/attachments`, args.file_path),
|
|
117
|
+
});
|
|
118
|
+
registerTool(server, config, {
|
|
119
|
+
name: 'clokio_download_task_attachment',
|
|
120
|
+
description: 'Download one attachment of a task the person can see. With save_path, writes it to disk; otherwise text, ' +
|
|
121
|
+
'PDF text, ZIP entries or an image come back inline. Attachment ids are in clokio_get_task.',
|
|
122
|
+
schema: {
|
|
123
|
+
id: z.number().int().describe('The task id'),
|
|
124
|
+
attachment_id: z.number().int(),
|
|
125
|
+
file_name: z.string().optional().describe('Drives type detection (text/PDF/zip/image)'),
|
|
126
|
+
save_path: z.string().optional(),
|
|
127
|
+
overwrite: z.boolean().optional(),
|
|
128
|
+
},
|
|
129
|
+
handler: (args, cfg) => downloadAttachment(cfg, args.id, args.attachment_id, {
|
|
130
|
+
fileName: args.file_name,
|
|
131
|
+
savePath: args.save_path,
|
|
132
|
+
overwrite: args.overwrite,
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
registerTool(server, config, {
|
|
136
|
+
name: 'clokio_log_time',
|
|
137
|
+
mutates: true,
|
|
138
|
+
description: `Record a work session of the person on a task (a timer entry between two instants). ${ACTS}`,
|
|
139
|
+
schema: {
|
|
140
|
+
id: z.number().int(),
|
|
141
|
+
started_at: z.string().describe('ISO-8601 with offset'),
|
|
142
|
+
ended_at: z.string().describe('ISO-8601 with offset, after started_at'),
|
|
143
|
+
description: z.string().max(255).optional(),
|
|
144
|
+
},
|
|
145
|
+
handler: (args, cfg) => {
|
|
146
|
+
const { id, ...body } = args;
|
|
147
|
+
return request(cfg, `/tasks/${seg(id)}/time`, { method: 'POST', body: strip(body) });
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** The server's cap on a description or a comment body, measured AFTER the Markdown conversion. */
|
|
152
|
+
const HTML_MAX = 65535;
|
|
153
|
+
/**
|
|
154
|
+
* Markdown to the HTML Clokio stores, checked the way the server will check
|
|
155
|
+
* it: the conversion can turn whitespace into '' and grow long Markdown past
|
|
156
|
+
* the cap, so the limits are judged on the result, with a sentence the model
|
|
157
|
+
* can act on instead of the server's bare 422 (review of PR #2). undefined
|
|
158
|
+
* stays undefined (not sent) and null stays null (clears the field).
|
|
159
|
+
*/
|
|
160
|
+
function htmlField(value, field, required) {
|
|
161
|
+
if (value === undefined || value === null)
|
|
162
|
+
return value;
|
|
163
|
+
const html = markdownToHtml(value);
|
|
164
|
+
if (html.trim() === '' && (required || value.trim() !== '')) {
|
|
165
|
+
throw new Error(`The ${field} is empty once converted to HTML. Write some text, or ${required ? 'leave the comment out' : 'pass null to clear it'}.`);
|
|
166
|
+
}
|
|
167
|
+
if (html.length > HTML_MAX) {
|
|
168
|
+
throw new Error(`The ${field} is ${html.length} characters once converted to HTML; the limit is ${HTML_MAX}. Shorten it.`);
|
|
169
|
+
}
|
|
170
|
+
return html;
|
|
171
|
+
}
|
|
172
|
+
/** Drop undefined entries so a PATCH changes only what was named. */
|
|
173
|
+
function strip(o) {
|
|
174
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clokio-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Model Context Protocol (MCP) server for Clokio - manage tasks, projects, clients, employees, attendance and documents from Claude and any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|