clokio-mcp 0.17.1 → 0.18.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 CHANGED
@@ -62,16 +62,18 @@ key. Use it only for a **shared or service key** whose issuer is not the person
62
62
  acting. An explicit code on a tool call always takes precedence.
63
63
 
64
64
  Optional: set `CLOKIO_TOOLSETS` to register only the tool groups you need, e.g.
65
- `CLOKIO_TOOLSETS=tasks`. This cuts the per-session tool-list cost — a
66
- task-focused session drops from 50 tools to 31. Optional groups are `tasks`,
67
- `employees` and `attendance` (which includes leave and time entries); combine
68
- them with commas. The `projects` group (project/client lookups, task statuses
65
+ `CLOKIO_TOOLSETS=tasks`. This cuts the per-session tool-list cost - a
66
+ task-focused session drops from 83 tools to 31. Optional groups are `tasks`,
67
+ `employees`, `attendance` (which includes leave and time entries) and `docs`;
68
+ combine them with commas. If your key cannot open documents (no acts-as
69
+ employee and no explicit docs scopes), leaving `docs` out saves 33 tools it
70
+ cannot use. The `projects` group (project/client lookups, task statuses
69
71
  and labels, and `whoami`) is always on, because the task tools resolve names
70
72
  through it.
71
73
 
72
74
  ## Tools
73
75
 
74
- 50 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
76
+ 83 tools across six areas. Read-only tools carry the MCP `readOnlyHint`; writes
75
77
  are marked mutating (deletes are `destructive`).
76
78
 
77
79
  | Area | Tools |
@@ -81,6 +83,7 @@ are marked mutating (deletes are `destructive`).
81
83
  | **Reference** | task statuses, task labels, locations, list webhooks (read-only), whoami |
82
84
  | **Employees** | list (name search), lookup by PIN, task-stats, inbox (mentions, new assignments, others' status changes), create, set status |
83
85
  | **Attendance** | daily, range, per-employee, monthly summary, clock in/out, break start/end, time entries, leaves, leave balances |
86
+ | **Documents** (see [Documents](#documents) for who a key acts as) | list, tree, search, get (Markdown / HTML / JSON), create, replace content, update meta, move, archive, restore, delete, templates + create from template, members (by `employee_code`) and teams (list/add/update/remove, shareable teams), public share link, comments (list/add/resolve/reopen), versions (list/get/restore), attachments (list/upload/download) |
84
87
 
85
88
  ### Conventions
86
89
 
@@ -107,10 +110,100 @@ are marked mutating (deletes are `destructive`).
107
110
  returns every occurrence of one series.
108
111
  - An API key sees **public custom fields only**.
109
112
 
113
+ ## Documents
114
+
115
+ The Docs tools call `/api/v1/docs...` with the same `CLOKIO_API_KEY` as
116
+ everything else, but documents are permissioned per **person**, so every
117
+ docs call acts as one employee - the key's **effective actor**:
118
+
119
+ - **The key's "acts as" employee**, when it has one (set in
120
+ **Settings > API keys**). Only an admin may make a key act as someone else.
121
+ - **Otherwise, the person who created the key** - but only when the key lists
122
+ `docs:read` / `docs:write` **explicitly**. The wildcard `*` does not grant
123
+ docs scopes to a key with no acts-as employee, so an ordinary full-access
124
+ integration key never opens its creator's private documents.
125
+
126
+ So, to use the Docs tools, either set an acts-as employee on the key, or add
127
+ `docs:read` and `docs:write` to its scope list (it then acts as you, its
128
+ creator). Otherwise every docs tool answers `403`, the same for every
129
+ document id.
130
+
131
+ **Scopes:** `docs:read` for the read tools (every `GET`) and `docs:write` for
132
+ everything else. On a key with an acts-as employee the wildcard `*` grants
133
+ both; a key with an explicit scope list must include them (otherwise `403`
134
+ naming `required_scope`).
135
+
136
+ The key then sees exactly what its acting employee sees and can do on the
137
+ web, never more, and writes (comments, versions, activity) are attributed to
138
+ that employee:
139
+
140
+ - A document the employee cannot see answers **`404`**, the same as one that
141
+ does not exist (the API never confirms an id to someone not allowed to know it).
142
+ - A visible document they may not change answers **`403`**.
143
+ - The employee's role gates each call: `docs.view` for reads, `docs.create`
144
+ for content writes, `docs.manage` for delete and sharing. Per document,
145
+ roles are `view` < `comment` < `edit` < `admin`; each tool's description
146
+ names what it needs.
147
+
148
+ | Tool | Endpoint |
149
+ |------|----------|
150
+ | `clokio_list_docs` | `GET /docs` |
151
+ | `clokio_get_doc_tree` | `GET /docs?tree=1` |
152
+ | `clokio_search_docs` | `GET /docs/search` |
153
+ | `clokio_get_doc` | `GET /docs/{id}` |
154
+ | `clokio_create_doc` | `POST /docs` |
155
+ | `clokio_update_doc_content` | `PUT /docs/{id}/content` |
156
+ | `clokio_update_doc_meta` | `PATCH /docs/{id}` |
157
+ | `clokio_move_doc` | `POST /docs/{id}/move` |
158
+ | `clokio_archive_doc` / `clokio_restore_doc` | `POST /docs/{id}/archive` / `restore` |
159
+ | `clokio_delete_doc` | `DELETE /docs/{id}` |
160
+ | `clokio_list_doc_templates` / `clokio_create_doc_from_template` | `GET /docs/templates` / `POST /docs/from-template` |
161
+ | `clokio_list_doc_members` | `GET /docs/{id}/members` (members, teams, inherited, share link) |
162
+ | `clokio_list_doc_shareable_teams` | `GET /docs/{id}/shareable-teams` |
163
+ | `clokio_add_doc_member` / `clokio_update_doc_member` / `clokio_remove_doc_member` | `POST` / `PATCH` / `DELETE /docs/{id}/members` (by `employee_code`), or `PATCH` / `DELETE .../members/{employeeId}` |
164
+ | `clokio_add_doc_team` / `clokio_update_doc_team` / `clokio_remove_doc_team` | `POST /docs/{id}/teams`, `PATCH` / `DELETE .../teams/{teamId}` |
165
+ | `clokio_create_doc_share_link` / `clokio_revoke_doc_share_link` | `POST` / `DELETE /docs/{id}/share-link` |
166
+ | `clokio_list_doc_comments` / `clokio_add_doc_comment` | `GET` / `POST /docs/{id}/comments` |
167
+ | `clokio_resolve_doc_comment` / `clokio_reopen_doc_comment` | `POST .../comments/{commentId}/resolve` / `reopen` |
168
+ | `clokio_list_doc_versions` / `clokio_get_doc_version` / `clokio_restore_doc_version` | `GET .../versions`, `GET .../versions/{versionId}`, `POST .../versions/{versionId}/restore` |
169
+ | `clokio_list_doc_attachments` / `clokio_upload_doc_attachment` / `clokio_download_doc_attachment` | `GET` / `POST /docs/{id}/attachments`, `GET .../attachments/download?path=` |
170
+
171
+ Working with documents:
172
+
173
+ - **Every docs tool answers `{data}` or `{data, meta}`**, like the task tools:
174
+ the record or rows in `data`; paging, counts, your role and permissions in
175
+ `meta`. `clokio_get_doc` puts the record and its body together in `data`.
176
+ - **Listing covers every scope by default.** `project_id` narrows it: a
177
+ project id, `"none"` for standalone documents only, or `"all"` (default).
178
+ - **Read as Markdown, write as Markdown.** `clokio_get_doc` returns Markdown by
179
+ default (`format: "html"` or `"json"` for the others) without the title;
180
+ pass `include_title: true` to start the body with it as a heading. The
181
+ create/update tools take Markdown by default (`content_format: "html"` or
182
+ `"json"`); HTML you write must stay within Clokio's restricted grammar (a
183
+ `422` names any unsupported tag).
184
+ - **Content writes are optimistic.** `clokio_update_doc_content` REPLACES the
185
+ whole body and requires `base_version` - the `content_version` you last read.
186
+ A `409` means someone else edited in between: re-read, reconcile, write
187
+ again with the new version. Never retry with the same `base_version`.
188
+ - **Public share links expire after 30 days** unless you pass `expires_at` or
189
+ `never_expires: true`.
190
+ - **Attachments** list `name`, `size`, `mime` and `uploaded_at`; download by
191
+ `path`. Uploading does not change the text: place the returned `url` in the
192
+ body yourself (an `image` or `fileAttachment` node in Tiptap JSON).
193
+ - **Members are named by `employee_code`** (e.g. `"00080"`), like everywhere
194
+ else in this server; the internal `employee_id` is accepted as an
195
+ alternative. Teams are named by `team_id`, from
196
+ `clokio_list_doc_shareable_teams`. A member's `email` is shown only when the
197
+ key has `employees:pii`; profile pictures only with `include_avatars: true`.
198
+
110
199
  ## Security
111
200
 
112
201
  - The server contains **no secrets**. Your API key lives only in your local
113
202
  environment (`CLOKIO_API_KEY`), never in this code or repository.
203
+ - The key is sent **only to `/api/v1`**: a path argument cannot steer a request
204
+ outside it, and the key is never forwarded on a redirect (attachment
205
+ downloads that redirect to signed storage are followed with no credential
206
+ attached).
114
207
  - The server **adds no permissions**. What a key can do is decided by the Clokio
115
208
  API from that key's scopes — this server only forwards the request. Scope each
116
209
  key to what its holder needs.
@@ -7,7 +7,7 @@
7
7
  * credential and redirect handling. This module never fetches; it only decides
8
8
  * what to DO with what it was given.
9
9
  */
10
- import { ClokioApiError, looksLikeText, requestRawBytes } from './client.js';
10
+ import { ClokioApiError, looksLikeText, requestRawBytes, } from './client.js';
11
11
  import { mcpContent } from './tools/helpers.js';
12
12
  /**
13
13
  * How much decoded text (PDF or text file) we return inline before truncating.
@@ -207,6 +207,29 @@ export async function saveBuffer(buffer, savePath, overwrite) {
207
207
  */
208
208
  export async function downloadAttachment(config, taskId, attachmentId, opts) {
209
209
  const { buffer, contentType } = await requestRawBytes(config, `/tasks/${taskId}/attachments/${attachmentId}`);
210
+ return renderDownload(buffer, contentType, opts);
211
+ }
212
+ /**
213
+ * Download one Docs attachment
214
+ * (GET /api/v1/docs/{id}/attachments/download?path=...) and hand it to
215
+ * the same renderer as a task attachment: save to disk, or PDF text / ZIP
216
+ * entries / image / text inline. `path` is the storage path the attachment
217
+ * listing returned; it travels as a QUERY value (URL-encoded by
218
+ * URLSearchParams), never as path segments, and the API 404s any path that is
219
+ * not one of this document's own uploads.
220
+ */
221
+ export async function downloadDocAttachment(config, docId, storagePath, opts) {
222
+ const { buffer, contentType, fileName } = await requestRawBytes(config, `/docs/${docId}/attachments/download`, { path: storagePath });
223
+ // The type-detection hint: the caller's name, else what the response
224
+ // declared, else the last segment of the storage path.
225
+ const hint = opts.fileName ?? fileName ?? storagePath.split('/').pop();
226
+ return renderDownload(buffer, contentType, { ...opts, fileName: hint });
227
+ }
228
+ /**
229
+ * Turn downloaded bytes into the most useful result for their type. Shared by
230
+ * the task and doc downloads so both behave identically.
231
+ */
232
+ async function renderDownload(buffer, contentType, opts) {
210
233
  const kb = (buffer.byteLength / 1024).toFixed(1);
211
234
  if (opts.savePath) {
212
235
  const abs = await saveBuffer(buffer, opts.savePath, opts.overwrite ?? false);
package/dist/client.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The ONE place that talks to the Clokio Public API.
3
3
  *
4
- * Every tool goes through request(); nothing else builds a fetch. Auth is the
5
- * per-user `CLOKIO_API_KEY` sent as `X-API-Key`, exactly as the clokio-task CLI
6
- * does. The key carries its own scopes, so this server never re-implements
7
- * authorization — a 403/422 from the API is surfaced verbatim.
4
+ * Every tool goes through this module; nothing else builds a fetch. Auth is
5
+ * the per-user `CLOKIO_API_KEY` sent as `X-API-Key`, exactly as the
6
+ * clokio-task CLI does. The key carries its own scopes (and, for documents,
7
+ * its "acts as" employee), so this server never re-implements authorization -
8
+ * a 403/404/422 from the API is surfaced verbatim.
8
9
  */
9
10
  export function loadConfig() {
10
11
  const apiKey = process.env.CLOKIO_API_KEY?.trim();
@@ -21,6 +22,46 @@ export function loadConfig() {
21
22
  }
22
23
  return { baseUrl, apiKey, defaultActor };
23
24
  }
25
+ /**
26
+ * Assemble an /api/v1 request URL and enforce the prefix guard.
27
+ *
28
+ * A tool argument interpolated into `path` must never be able to change
29
+ * WHICH endpoint is called. new URL() normalises the assembled string, so a
30
+ * value containing `?` truncates the path (turning `/employees/X/status`
31
+ * into `/employees/X/pin?...`) and `../` segments climb out of /api/v1.
32
+ * That is an endpoint pivot: a caller who approved "set employee status"
33
+ * would instead reach PATCH /employees/{code}/pin, which resets a person's
34
+ * clock-in PIN and returns it in the response. Callers wrap string arguments
35
+ * in seg(), but this is the invariant that holds even if one forgets.
36
+ */
37
+ export function buildUrl(config, path) {
38
+ const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
39
+ const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
40
+ if (!url.pathname.startsWith(expectedPrefix)) {
41
+ throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1 - a path argument tried to change the endpoint.');
42
+ }
43
+ return url;
44
+ }
45
+ /**
46
+ * The file name a download response declares (Content-Disposition), or
47
+ * undefined. Used only as a type-detection hint and a default display name;
48
+ * a SAVE never trusts it as a path (see safeAttachmentName in attachments.ts).
49
+ */
50
+ function dispositionFileName(header) {
51
+ if (!header)
52
+ return undefined;
53
+ const star = header.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/);
54
+ if (star) {
55
+ try {
56
+ return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ''));
57
+ }
58
+ catch {
59
+ /* fall through to the plain form */
60
+ }
61
+ }
62
+ const plain = header.match(/filename\s*=\s*"?([^";]+)"?/);
63
+ return plain ? plain[1].trim() : undefined;
64
+ }
24
65
  /**
25
66
  * Encode ONE path segment supplied by a tool argument.
26
67
  *
@@ -66,17 +107,15 @@ export function seg(value) {
66
107
  * path. `requestRaw` renders these bytes as text/description for the model;
67
108
  * the save-to-disk and typed-content tools consume the bytes directly.
68
109
  */
69
- export async function requestRawBytes(config, path) {
70
- const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
71
- const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
72
- if (!url.pathname.startsWith(expectedPrefix)) {
73
- throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1.');
74
- }
110
+ export async function requestRawBytes(config, path, query) {
111
+ const auth = { 'X-API-Key': config.apiKey };
112
+ const url = buildUrl(config, path);
113
+ applyQuery(url, query);
75
114
  let res;
76
115
  try {
77
- // `manual`, not 'error' and not 'follow' - see the long note below.
116
+ // `manual`, not 'error' and not 'follow' - see the long note in requestRaw.
78
117
  res = await fetch(url, {
79
- headers: { 'X-API-Key': config.apiKey },
118
+ headers: auth,
80
119
  redirect: 'manual',
81
120
  });
82
121
  if (res.status >= 300 && res.status < 400) {
@@ -111,8 +150,9 @@ export async function requestRawBytes(config, path) {
111
150
  throw new ClokioApiError(res.status, message);
112
151
  }
113
152
  const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
153
+ const fileName = dispositionFileName(res.headers.get('content-disposition'));
114
154
  const buffer = Buffer.from(await res.arrayBuffer());
115
- return { buffer, contentType };
155
+ return { buffer, contentType, fileName };
116
156
  }
117
157
  /**
118
158
  * Decide whether an attachment is text, from BOTH the content-type and the file
@@ -136,11 +176,8 @@ export function looksLikeText(contentType, fileName) {
136
176
  TEXT_EXTENSIONS.has(extension));
137
177
  }
138
178
  export async function requestRaw(config, path, fileName) {
139
- const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
140
- const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
141
- if (!url.pathname.startsWith(expectedPrefix)) {
142
- throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1.');
143
- }
179
+ const auth = { 'X-API-Key': config.apiKey };
180
+ const url = buildUrl(config, path);
144
181
  let res;
145
182
  try {
146
183
  // `manual`, not 'error' and not 'follow'.
@@ -153,7 +190,7 @@ export async function requestRaw(config, path, fileName) {
153
190
  // path exists to prevent. So the redirect is followed HERE, by hand,
154
191
  // with no credential attached - the signed URL needs none.
155
192
  res = await fetch(url, {
156
- headers: { 'X-API-Key': config.apiKey },
193
+ headers: auth,
157
194
  redirect: 'manual',
158
195
  });
159
196
  if (res.status >= 300 && res.status < 400) {
@@ -239,11 +276,8 @@ export async function requestRaw(config, path, fileName) {
239
276
  export async function requestUpload(config, path, filePath, fields = {}) {
240
277
  const { readFile } = await import('node:fs/promises');
241
278
  const { basename } = await import('node:path');
242
- const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
243
- const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
244
- if (!url.pathname.startsWith(expectedPrefix)) {
245
- throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1.');
246
- }
279
+ const auth = { 'X-API-Key': config.apiKey };
280
+ const url = buildUrl(config, path);
247
281
  let bytes;
248
282
  try {
249
283
  bytes = await readFile(filePath);
@@ -265,7 +299,7 @@ export async function requestUpload(config, path, filePath, fields = {}) {
265
299
  // not set it by hand or the boundary is lost.
266
300
  res = await fetch(url, {
267
301
  method: 'POST',
268
- headers: { 'X-API-Key': config.apiKey, Accept: 'application/json' },
302
+ headers: { ...auth, Accept: 'application/json' },
269
303
  body: form,
270
304
  redirect: 'error',
271
305
  });
@@ -273,24 +307,7 @@ export async function requestUpload(config, path, filePath, fields = {}) {
273
307
  catch (e) {
274
308
  throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
275
309
  }
276
- const text = await res.text();
277
- let parsed;
278
- try {
279
- parsed = text ? JSON.parse(text) : undefined;
280
- }
281
- catch {
282
- parsed = text;
283
- }
284
- if (!res.ok) {
285
- const message = parsed && typeof parsed === 'object' && 'message' in parsed && typeof parsed.message === 'string'
286
- ? parsed.message
287
- : `Clokio API returned HTTP ${res.status}`;
288
- throw new ClokioApiError(res.status, message, parsed);
289
- }
290
- if (parsed && typeof parsed === 'object' && 'data' in parsed) {
291
- return parsed.data;
292
- }
293
- return parsed;
310
+ return parseEnvelope(res);
294
311
  }
295
312
  /** A Clokio API error, carrying the HTTP status and the server's message. */
296
313
  export class ClokioApiError extends Error {
@@ -311,32 +328,8 @@ export class ClokioApiError extends Error {
311
328
  */
312
329
  export async function request(config, path, options = {}) {
313
330
  const { method = 'GET', query, body } = options;
314
- const url = new URL(`${config.baseUrl}/api/v1${path.startsWith('/') ? path : `/${path}`}`);
315
- // A tool argument interpolated into `path` must never be able to change
316
- // WHICH endpoint is called. new URL() normalises the assembled string, so a
317
- // value containing `?` truncates the path (turning `/employees/X/status`
318
- // into `/employees/X/pin?...`) and `../` segments climb out of /api/v1.
319
- // That is an endpoint pivot: a caller who approved "set employee status"
320
- // would instead reach PATCH /employees/{code}/pin, which resets a person's
321
- // clock-in PIN and returns it in the response. Callers validate their own
322
- // arguments, but this is the invariant that holds even if one forgets.
323
- const expectedPrefix = new URL(config.baseUrl).pathname.replace(/\/+$/, '') + '/api/v1/';
324
- if (!url.pathname.startsWith(expectedPrefix)) {
325
- throw new ClokioApiError(0, 'Refusing to send a request outside /api/v1 - a path argument tried to change the endpoint.');
326
- }
327
- if (query) {
328
- for (const [key, value] of Object.entries(query)) {
329
- if (value === undefined || value === null)
330
- continue;
331
- if (Array.isArray(value)) {
332
- for (const v of value)
333
- url.searchParams.append(`${key}[]`, String(v));
334
- }
335
- else {
336
- url.searchParams.set(key, String(value));
337
- }
338
- }
339
- }
331
+ const url = buildUrl(config, path);
332
+ applyQuery(url, query);
340
333
  const headers = {
341
334
  'X-API-Key': config.apiKey,
342
335
  Accept: 'application/json',
@@ -350,14 +343,32 @@ export async function request(config, path, options = {}) {
350
343
  try {
351
344
  // redirect: 'error' - Node's fetch follows redirects and RESENDS the
352
345
  // request headers, including X-API-Key, to wherever Location points, on
353
- // any origin. The API never redirects an /api/v1 call, so a redirect here
354
- // means something between us and it is not the API, and the key must not
355
- // be handed over. Failing closed costs nothing on the happy path.
346
+ // any origin. The API never redirects a JSON /api/v1 call, so a redirect
347
+ // here means something between us and it is not the API, and the key
348
+ // must not be handed over. Failing closed costs nothing on the happy path.
356
349
  res = await fetch(url, { method, headers, body: payload, redirect: 'error' });
357
350
  }
358
351
  catch (e) {
359
352
  throw new ClokioApiError(0, `Network error reaching Clokio: ${e.message}`);
360
353
  }
354
+ return parseEnvelope(res);
355
+ }
356
+ function applyQuery(url, query) {
357
+ if (!query)
358
+ return;
359
+ for (const [key, value] of Object.entries(query)) {
360
+ if (value === undefined || value === null)
361
+ continue;
362
+ if (Array.isArray(value)) {
363
+ for (const v of value)
364
+ url.searchParams.append(`${key}[]`, String(v));
365
+ }
366
+ else {
367
+ url.searchParams.set(key, String(value));
368
+ }
369
+ }
370
+ }
371
+ async function parseEnvelope(res) {
361
372
  const text = await res.text();
362
373
  let parsed = undefined;
363
374
  if (text) {
package/dist/index.js CHANGED
@@ -2,9 +2,11 @@
2
2
  /**
3
3
  * clokio-mcp — an MCP server for the Clokio Public API.
4
4
  *
5
- * Exposes tasks, projects/clients, employees and attendance as MCP tools over
6
- * stdio. Auth is the per-user CLOKIO_API_KEY (an X-API-Key credential); the key
7
- * carries its own scopes, so this server never re-implements authorization.
5
+ * Exposes tasks, projects/clients, employees, attendance and documents as MCP
6
+ * tools over stdio. Auth is the per-user CLOKIO_API_KEY (an X-API-Key
7
+ * credential); the key carries its own scopes, so this server never
8
+ * re-implements authorization. Documents additionally need a key that "acts
9
+ * as" an employee - the API decides that, not this server.
8
10
  *
9
11
  * Run: CLOKIO_API_KEY=clk_... node dist/index.js
10
12
  * Or register with: claude mcp add clokio -- npx -y clokio-mcp
@@ -18,6 +20,7 @@ import { registerTaskTools } from './tools/tasks.js';
18
20
  import { registerProjectTools } from './tools/projects.js';
19
21
  import { registerEmployeeTools } from './tools/employees.js';
20
22
  import { registerAttendanceTools } from './tools/attendance.js';
23
+ import { registerDocTools } from './tools/docs.js';
21
24
  /**
22
25
  * The optional tool groups a session can turn OFF.
23
26
  *
@@ -31,7 +34,7 @@ import { registerAttendanceTools } from './tools/attendance.js';
31
34
  * to resolve names to ids, so it is always registered. Naming a toolset that
32
35
  * does not exist is refused at startup rather than silently ignored.
33
36
  */
34
- const OPTIONAL_TOOLSETS = ['tasks', 'employees', 'attendance'];
37
+ const OPTIONAL_TOOLSETS = ['tasks', 'employees', 'attendance', 'docs'];
35
38
  function selectedToolsets() {
36
39
  const raw = process.env.CLOKIO_TOOLSETS?.trim();
37
40
  if (!raw) {
@@ -65,6 +68,8 @@ async function main() {
65
68
  registerEmployeeTools(server, config);
66
69
  if (toolsets.has('attendance'))
67
70
  registerAttendanceTools(server, config);
71
+ if (toolsets.has('docs'))
72
+ registerDocTools(server, config);
68
73
  const transport = new StdioServerTransport();
69
74
  await server.connect(transport);
70
75
  // stderr is safe for logs on stdio transport (stdout carries the protocol).
@@ -0,0 +1,607 @@
1
+ import { z } from 'zod';
2
+ import { markdownToHtml } from '../markdown.js';
3
+ import { ClokioApiError, request, requestUpload, seg } from '../client.js';
4
+ import { downloadDocAttachment } from '../attachments.js';
5
+ import { registerTool } from './helpers.js';
6
+ /**
7
+ * The Docs module, on the Public API (/api/v1/docs...) with CLOKIO_API_KEY.
8
+ *
9
+ * Documents are permissioned per PERSON, so every docs request acts as one
10
+ * employee - the key's EFFECTIVE actor (backend ActsAsAuthority): its
11
+ * "acts as" employee, else the key's creator, the latter only when the key
12
+ * lists docs:read / docs:write EXPLICITLY ("*" is not enough for a key with
13
+ * no actor). The API decides everything else: an invisible document is 404,
14
+ * a visible one the actor may not change is 403.
15
+ *
16
+ * TOKEN BUDGET. tools/list is paid by every session, so the access rules are
17
+ * spelled out ONCE, on clokio_list_docs, and every other description points
18
+ * there ("Docs access: see clokio_list_docs"). Each description says only
19
+ * what the model needs to call the tool correctly: what it does, the doc
20
+ * role it needs, and any trap (base_version, the 30-day share link default).
21
+ *
22
+ * RESPONSE SHAPE. The API answers with named keys (`docs`, `doc`, `comment`,
23
+ * `comments`, ...). These tools reshape that into the envelope the task tools
24
+ * return - `{data}` or `{data, meta}` - so a model reads every Clokio tool the
25
+ * same way: the record or rows in `data`, paging / permissions / counts in
26
+ * `meta`. See shape*() below.
27
+ */
28
+ const ACCESS = 'Docs access: see clokio_list_docs.';
29
+ const docId = z.number().int().positive().describe('Document id');
30
+ const CONTENT_FORMATS = ['markdown', 'html', 'json'];
31
+ const contentArg = z
32
+ .union([z.string(), z.record(z.string(), z.unknown())])
33
+ .describe('Body. Markdown by default; html = Clokio\'s restricted HTML (a 422 names any unsupported tag); ' +
34
+ 'json = Tiptap {"type":"doc","content":[...]}');
35
+ const contentFormatArg = z.enum(CONTENT_FORMATS).optional().describe('markdown (default), html or json');
36
+ const readFormatArg = z.enum(CONTENT_FORMATS).optional().describe('markdown (default), html or json');
37
+ const includeTitleArg = z
38
+ .boolean()
39
+ .optional()
40
+ .describe('Markdown only: start the body with the title as a heading (default false)');
41
+ const scopeArg = z
42
+ .union([z.number().int().positive(), z.literal('all'), z.literal('none')])
43
+ .optional()
44
+ .describe('Project id, "none" (standalone docs only) or "all" (default)');
45
+ const archivedArg = z.enum(['exclude', 'include', 'only']).optional().describe('Default exclude');
46
+ /**
47
+ * Map (content, content_format) onto the ONE content field the API accepts.
48
+ * Sending two content fields is a 422, so exactly one key is produced.
49
+ *
50
+ * An empty Markdown/HTML body becomes an empty Tiptap document: the server's
51
+ * request filter turns "" into null, which fails validation with a misleading
52
+ * "must be a string" message, and `{"type":"doc","content":[]}` is the
53
+ * documented way to clear a document.
54
+ */
55
+ function contentBody(content, format = 'markdown') {
56
+ if (content === undefined)
57
+ return {};
58
+ if (format === 'json') {
59
+ let tree = content;
60
+ if (typeof content === 'string') {
61
+ try {
62
+ tree = JSON.parse(content);
63
+ }
64
+ catch (e) {
65
+ throw new ClokioApiError(0, `content is not valid JSON: ${e.message}`);
66
+ }
67
+ }
68
+ if (!tree || typeof tree !== 'object' || Array.isArray(tree)) {
69
+ throw new ClokioApiError(0, 'content_format "json" needs a Tiptap object {"type":"doc","content":[...]}.');
70
+ }
71
+ return { content: tree };
72
+ }
73
+ if (typeof content !== 'string') {
74
+ throw new ClokioApiError(0, `content_format "${format}" needs a string; pass content_format "json" to send a Tiptap object.`);
75
+ }
76
+ if (content.trim() === '') {
77
+ return { content: { type: 'doc', content: [] } };
78
+ }
79
+ return format === 'html' ? { content_html: content } : { content_markdown: content };
80
+ }
81
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
82
+ /** `{data, meta}`, with meta left out when it would be empty. */
83
+ export function envelope(data, meta) {
84
+ const m = meta ? Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined)) : {};
85
+ return Object.keys(m).length ? { data, meta: m } : { data };
86
+ }
87
+ /** Lift `key` out of the API's data object into `data`; every other key becomes meta. */
88
+ export function liftKey(raw, key) {
89
+ if (!isObj(raw) || !(key in raw))
90
+ return envelope(raw);
91
+ const { [key]: data, ...rest } = raw;
92
+ // A nested meta (the list endpoints put paging in data.meta) is flattened
93
+ // into the same meta object rather than nested a second time.
94
+ const { meta: nested, ...others } = rest;
95
+ return envelope(data, { ...(isObj(nested) ? nested : {}), ...others });
96
+ }
97
+ /**
98
+ * A document read (live or a version): the record and its body together in
99
+ * `data` (the body sits BESIDE the record in the API, which is easy to miss),
100
+ * the reader's permissions and breadcrumbs in `meta`.
101
+ */
102
+ export function shapeDocRead(raw, recordKey) {
103
+ if (!isObj(raw) || !isObj(raw[recordKey]))
104
+ return envelope(raw);
105
+ const { [recordKey]: record, ...rest } = raw;
106
+ const data = { ...record };
107
+ const meta = {};
108
+ for (const [k, v] of Object.entries(rest)) {
109
+ if (k === 'content' || k === 'content_html' || k === 'content_markdown')
110
+ data[k] = v;
111
+ else
112
+ meta[k] = v;
113
+ }
114
+ return envelope(data, meta);
115
+ }
116
+ /**
117
+ * Drop every profile_pic key, at any depth, unless avatars were asked for. A
118
+ * signed avatar URL is a few hundred characters per person and tells a model
119
+ * nothing.
120
+ */
121
+ export function stripAvatars(rows, keep = false) {
122
+ if (keep)
123
+ return rows;
124
+ const strip = (v) => {
125
+ if (Array.isArray(v))
126
+ return v.map(strip);
127
+ if (!isObj(v))
128
+ return v;
129
+ const out = {};
130
+ for (const [k, val] of Object.entries(v)) {
131
+ if (k === 'profile_pic')
132
+ continue;
133
+ out[k] = strip(val);
134
+ }
135
+ return out;
136
+ };
137
+ return strip(rows);
138
+ }
139
+ /** Attachment rows with name / size / mime / uploaded_at first, null when the API omits one. */
140
+ export function shapeAttachments(raw) {
141
+ const rows = isObj(raw) && Array.isArray(raw.attachments) ? raw.attachments : Array.isArray(raw) ? raw : null;
142
+ if (!rows)
143
+ return envelope(raw);
144
+ return envelope(rows.map((r) => {
145
+ if (!isObj(r))
146
+ return r;
147
+ const { name, size, mime, uploaded_at, ...rest } = r;
148
+ return { name: name ?? null, size: size ?? null, mime: mime ?? null, uploaded_at: uploaded_at ?? null, ...rest };
149
+ }));
150
+ }
151
+ // ---------------------------------------------------------------------- tools
152
+ export function registerDocTools(server, config) {
153
+ // ---------------------------------------------------------------- reading
154
+ registerTool(server, config, {
155
+ name: 'clokio_list_docs',
156
+ description: 'List documents you can view (all scopes by default; project_id narrows). Rows include effective_role ' +
157
+ '(view/comment/edit/admin): edit or admin means you may write content. ' +
158
+ 'DOCS ACCESS (applies to every clokio_*doc* tool): each call acts as the API key\'s "acts as" employee, ' +
159
+ 'or, if the key has none, as the key\'s creator - only when the key lists docs:read / docs:write ' +
160
+ 'explicitly ("*" is not enough). Otherwise every docs tool returns 403: set an acts-as employee or add ' +
161
+ 'the docs scopes in Settings > API keys. Reads need scope docs:read, everything else docs:write. You see ' +
162
+ 'exactly what that employee sees; a doc they cannot see is 404.',
163
+ schema: {
164
+ project_id: scopeArg,
165
+ parent_id: z
166
+ .union([z.number().int().positive(), z.literal('root')])
167
+ .optional()
168
+ .describe('A doc id to list its children, or "root" for top-level docs only'),
169
+ q: z.string().max(120).optional().describe('Title substring'),
170
+ archived: archivedArg,
171
+ page: z.number().int().min(1).optional(),
172
+ per_page: z.number().int().min(1).max(100).optional().describe('Default 50'),
173
+ },
174
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs', {
175
+ query: {
176
+ project_id: args.project_id ?? 'all',
177
+ parent_id: args.parent_id,
178
+ q: args.q,
179
+ archived: args.archived,
180
+ page: args.page,
181
+ per_page: args.per_page,
182
+ },
183
+ }), 'docs'),
184
+ });
185
+ registerTool(server, config, {
186
+ name: 'clokio_get_doc_tree',
187
+ description: `Documents you can view as a nested tree (children), paged over the top level. ${ACCESS}`,
188
+ schema: {
189
+ project_id: scopeArg,
190
+ archived: archivedArg,
191
+ page: z.number().int().min(1).optional(),
192
+ per_page: z.number().int().min(1).max(100).optional(),
193
+ },
194
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs', {
195
+ query: {
196
+ tree: 1,
197
+ project_id: args.project_id ?? 'all',
198
+ archived: args.archived,
199
+ page: args.page,
200
+ per_page: args.per_page,
201
+ },
202
+ }), 'tree'),
203
+ });
204
+ registerTool(server, config, {
205
+ name: 'clokio_search_docs',
206
+ description: `Search titles and body text (not archived), newest first; each row has a snippet. ${ACCESS}`,
207
+ schema: {
208
+ q: z.string().min(2).max(120),
209
+ project_id: z.number().int().positive().optional(),
210
+ limit: z.number().int().min(1).max(50).optional().describe('Default 20'),
211
+ },
212
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs/search', { query: { q: args.q, project_id: args.project_id, limit: args.limit } }), 'docs'),
213
+ });
214
+ registerTool(server, config, {
215
+ name: 'clokio_get_doc',
216
+ description: 'Read a document: data = the record plus its body; meta = your role and breadcrumbs. Keep ' +
217
+ `data.content_version for clokio_update_doc_content. ${ACCESS}`,
218
+ schema: { id: docId, format: readFormatArg, include_title: includeTitleArg },
219
+ handler: async (args, cfg) => shapeDocRead(await request(cfg, `/docs/${seg(args.id)}`, {
220
+ query: { format: args.format ?? 'markdown', include_title: args.include_title ? 1 : undefined },
221
+ }), 'doc'),
222
+ });
223
+ // ---------------------------------------------------------------- writing
224
+ registerTool(server, config, {
225
+ name: 'clokio_create_doc',
226
+ description: 'Create a document. parent_id = sub-page (needs edit on the parent); project_id = in that project; ' +
227
+ 'neither = standalone (org admins only). Starts at content_version 0. ' +
228
+ ACCESS,
229
+ mutates: true,
230
+ schema: {
231
+ title: z.string().min(1).max(255),
232
+ icon: z.string().max(10).optional().describe('One emoji'),
233
+ project_id: z.number().int().positive().optional(),
234
+ parent_id: z.number().int().positive().optional(),
235
+ content: contentArg.optional(),
236
+ content_format: contentFormatArg,
237
+ },
238
+ handler: async (args, cfg) => {
239
+ const body = {
240
+ title: args.title,
241
+ icon: args.icon,
242
+ project_id: args.project_id,
243
+ parent_id: args.parent_id,
244
+ };
245
+ // An empty body on create is simply an empty document: omit content.
246
+ const blank = typeof args.content === 'string' && args.content.trim() === '';
247
+ if (!blank)
248
+ Object.assign(body, contentBody(args.content, args.content_format));
249
+ return liftKey(await request(cfg, '/docs', { method: 'POST', body }), 'doc');
250
+ },
251
+ });
252
+ registerTool(server, config, {
253
+ name: 'clokio_update_doc_content',
254
+ description: 'REPLACE the whole body (needs edit). base_version = the content_version you last read. On 409 do not ' +
255
+ 'retry the same base_version: re-read, reconcile, write with the new version. Empty content clears. ' +
256
+ ACCESS,
257
+ mutates: true,
258
+ schema: {
259
+ id: docId,
260
+ content: contentArg,
261
+ content_format: contentFormatArg,
262
+ base_version: z.number().int().min(0),
263
+ },
264
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/content`, {
265
+ method: 'PUT',
266
+ body: { ...contentBody(args.content, args.content_format), base_version: args.base_version },
267
+ })),
268
+ });
269
+ registerTool(server, config, {
270
+ name: 'clokio_update_doc_meta',
271
+ description: 'Change metadata; send only what changes. title/icon/full_width/sort_order need edit; visibility, ' +
272
+ 'inherit_permissions, is_archived need admin. Use clokio_move_doc for parent/project. ' +
273
+ ACCESS,
274
+ mutates: true,
275
+ schema: {
276
+ id: docId,
277
+ title: z.string().min(1).max(255).optional(),
278
+ icon: z.string().max(10).nullable().optional().describe('null clears'),
279
+ full_width: z.boolean().optional(),
280
+ sort_order: z.number().int().min(0).optional(),
281
+ visibility: z.enum(['private', 'project', 'organization']).optional(),
282
+ inherit_permissions: z.boolean().optional(),
283
+ is_archived: z.boolean().optional(),
284
+ },
285
+ handler: async (args, cfg) => {
286
+ const { id, ...fields } = args;
287
+ const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
288
+ if (Object.keys(body).length === 0) {
289
+ throw new ClokioApiError(0, 'Nothing to change: pass at least one field.');
290
+ }
291
+ return liftKey(await request(cfg, `/docs/${seg(id)}`, { method: 'PATCH', body }), 'doc');
292
+ },
293
+ });
294
+ registerTool(server, config, {
295
+ name: 'clokio_move_doc',
296
+ description: 'Move a document and its sub-pages (needs admin). Send both parent_id and project_id; null = top ' +
297
+ `level / standalone. With a parent, the parent's project wins. ${ACCESS}`,
298
+ mutates: true,
299
+ schema: {
300
+ id: docId,
301
+ parent_id: z.number().int().positive().nullable(),
302
+ project_id: z.number().int().positive().nullable(),
303
+ sort_order: z.number().int().min(0).optional(),
304
+ },
305
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/move`, {
306
+ method: 'POST',
307
+ body: { parent_id: args.parent_id, project_id: args.project_id, sort_order: args.sort_order },
308
+ }), 'doc'),
309
+ });
310
+ registerTool(server, config, {
311
+ name: 'clokio_archive_doc',
312
+ description: `Archive a document (needs admin; reversible with clokio_restore_doc). ${ACCESS}`,
313
+ mutates: true,
314
+ schema: { id: docId },
315
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/archive`, { method: 'POST' }), 'doc'),
316
+ });
317
+ registerTool(server, config, {
318
+ name: 'clokio_restore_doc',
319
+ description: `Un-archive a document (needs admin). ${ACCESS}`,
320
+ mutates: true,
321
+ schema: { id: docId },
322
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/restore`, { method: 'POST' }), 'doc'),
323
+ });
324
+ registerTool(server, config, {
325
+ name: 'clokio_delete_doc',
326
+ description: `DELETE a document (needs admin and docs.manage; not restorable via the API - prefer archive). ${ACCESS}`,
327
+ mutates: 'destructive',
328
+ schema: { id: docId },
329
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}`, { method: 'DELETE' }),
330
+ });
331
+ // -------------------------------------------------------------- templates
332
+ registerTool(server, config, {
333
+ name: 'clokio_list_doc_templates',
334
+ description: `List document templates (key, name, description). ${ACCESS}`,
335
+ schema: {},
336
+ handler: async (_args, cfg) => liftKey(await request(cfg, '/docs/templates'), 'templates'),
337
+ });
338
+ registerTool(server, config, {
339
+ name: 'clokio_create_doc_from_template',
340
+ description: `Create a top-level document from a template key. No project_id = standalone (admins only). ${ACCESS}`,
341
+ mutates: true,
342
+ schema: {
343
+ template: z.string().min(1).max(60),
344
+ title: z.string().max(255).optional(),
345
+ project_id: z.number().int().positive().optional(),
346
+ },
347
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs/from-template', {
348
+ method: 'POST',
349
+ body: { template: args.template, title: args.title, project_id: args.project_id },
350
+ }), 'doc'),
351
+ });
352
+ // ---------------------------------------------------------------- sharing
353
+ registerTool(server, config, {
354
+ name: 'clokio_list_doc_members',
355
+ description: 'Who a document is shared with (needs admin): data = members, teams, inherited (change those on the ' +
356
+ `parent); meta = visibility, inherit_permissions, share_link. ${ACCESS}`,
357
+ schema: {
358
+ id: docId,
359
+ include_avatars: z.boolean().optional().describe('Include profile pictures (default false)'),
360
+ },
361
+ handler: async (args, cfg) => {
362
+ const raw = await request(cfg, `/docs/${seg(args.id)}/members`, {
363
+ query: { include_avatars: args.include_avatars ? 1 : undefined },
364
+ });
365
+ if (!isObj(raw))
366
+ return envelope(raw);
367
+ const { members, teams, inherited, ...meta } = raw;
368
+ const keep = args.include_avatars === true;
369
+ return envelope({ members: stripAvatars(members, keep), teams, inherited: stripAvatars(inherited, keep) }, meta);
370
+ },
371
+ });
372
+ registerTool(server, config, {
373
+ name: 'clokio_list_doc_shareable_teams',
374
+ description: `Teams this document can be shared with (needs admin); use id as team_id. ${ACCESS}`,
375
+ schema: { id: docId },
376
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/shareable-teams`), 'teams'),
377
+ });
378
+ const memberRole = z.enum(['view', 'comment', 'edit', 'admin']);
379
+ // A person is named by employee_code (preferred - the way every other tool
380
+ // here names people) OR by the internal employee_id. Exactly one.
381
+ const personArgs = {
382
+ employee_code: z
383
+ .string()
384
+ .regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -')
385
+ .optional()
386
+ .describe('Zero-padded, e.g. "00080" (preferred)'),
387
+ employee_id: z.number().int().positive().optional().describe('Internal id, instead of employee_code'),
388
+ };
389
+ /** Exactly one of employee_code / employee_id, as the API requires. */
390
+ function person(args) {
391
+ const hasCode = args.employee_code !== undefined;
392
+ const hasId = args.employee_id !== undefined;
393
+ if (hasCode === hasId) {
394
+ throw new ClokioApiError(0, 'Name the person with exactly one of employee_code (preferred) or employee_id.');
395
+ }
396
+ return hasCode ? { employee_code: args.employee_code } : { employee_id: args.employee_id };
397
+ }
398
+ registerTool(server, config, {
399
+ name: 'clokio_add_doc_member',
400
+ description: `Share a document with a person or change their role (needs admin; they are notified once). ${ACCESS}`,
401
+ mutates: true,
402
+ schema: { id: docId, ...personArgs, role: memberRole },
403
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/members`, {
404
+ method: 'POST',
405
+ body: { ...person(args), role: args.role },
406
+ })),
407
+ });
408
+ registerTool(server, config, {
409
+ name: 'clokio_update_doc_member',
410
+ description: `Change a person's direct role (needs admin; demoting the last admin is 422). ${ACCESS}`,
411
+ mutates: true,
412
+ schema: { id: docId, ...personArgs, role: memberRole },
413
+ handler: async (args, cfg) => {
414
+ const who = person(args);
415
+ // An id has its own path route; a code rides in the body of the
416
+ // collection route (PATCH /docs/{id}/members).
417
+ return envelope('employee_id' in who
418
+ ? await request(cfg, `/docs/${seg(args.id)}/members/${seg(who.employee_id)}`, {
419
+ method: 'PATCH',
420
+ body: { role: args.role },
421
+ })
422
+ : await request(cfg, `/docs/${seg(args.id)}/members`, {
423
+ method: 'PATCH',
424
+ body: { ...who, role: args.role },
425
+ }));
426
+ },
427
+ });
428
+ registerTool(server, config, {
429
+ name: 'clokio_remove_doc_member',
430
+ description: `Remove a person's direct grant (needs admin; a code may name someone who has left). ${ACCESS}`,
431
+ mutates: 'destructive',
432
+ schema: { id: docId, ...personArgs },
433
+ handler: (args, cfg) => {
434
+ const who = person(args);
435
+ // A code travels in the query string of DELETE /docs/{id}/members (the
436
+ // API accepts body or query; a query survives any proxy that drops a
437
+ // DELETE body).
438
+ return 'employee_id' in who
439
+ ? request(cfg, `/docs/${seg(args.id)}/members/${seg(who.employee_id)}`, { method: 'DELETE' })
440
+ : request(cfg, `/docs/${seg(args.id)}/members`, {
441
+ method: 'DELETE',
442
+ query: { employee_code: who.employee_code },
443
+ });
444
+ },
445
+ });
446
+ const teamRole = z.enum(['view', 'comment', 'edit']);
447
+ const teamId = z.number().int().positive().describe('From clokio_list_doc_shareable_teams');
448
+ registerTool(server, config, {
449
+ name: 'clokio_add_doc_team',
450
+ description: `Share a document with a team (needs admin; teams cannot be admins). ${ACCESS}`,
451
+ mutates: true,
452
+ schema: { id: docId, team_id: teamId, role: teamRole },
453
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/teams`, {
454
+ method: 'POST',
455
+ body: { team_id: args.team_id, role: args.role },
456
+ })),
457
+ });
458
+ registerTool(server, config, {
459
+ name: 'clokio_update_doc_team',
460
+ description: `Change a team's role on a document (needs admin). ${ACCESS}`,
461
+ mutates: true,
462
+ schema: { id: docId, team_id: teamId, role: teamRole },
463
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/teams/${seg(args.team_id)}`, {
464
+ method: 'PATCH',
465
+ body: { role: args.role },
466
+ })),
467
+ });
468
+ registerTool(server, config, {
469
+ name: 'clokio_remove_doc_team',
470
+ description: `Remove a team's grant (needs admin). ${ACCESS}`,
471
+ mutates: 'destructive',
472
+ schema: { id: docId, team_id: teamId },
473
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/teams/${seg(args.team_id)}`, { method: 'DELETE' }),
474
+ });
475
+ registerTool(server, config, {
476
+ name: 'clokio_create_doc_share_link',
477
+ description: 'Create or REPLACE the public read link (needs admin; the old URL stops working). Expires in 30 days ' +
478
+ `unless expires_at or never_expires is given. ${ACCESS}`,
479
+ mutates: true,
480
+ schema: {
481
+ id: docId,
482
+ expires_at: z.string().optional().describe('Future date or ISO 8601 timestamp'),
483
+ never_expires: z.boolean().optional().describe('No expiry (overrides the 30-day default)'),
484
+ },
485
+ handler: async (args, cfg) => {
486
+ if (args.never_expires && args.expires_at) {
487
+ throw new ClokioApiError(0, 'Pass expires_at or never_expires, not both.');
488
+ }
489
+ return envelope(await request(cfg, `/docs/${seg(args.id)}/share-link`, {
490
+ method: 'POST',
491
+ body: { expires_at: args.expires_at, never_expires: args.never_expires },
492
+ }));
493
+ },
494
+ });
495
+ registerTool(server, config, {
496
+ name: 'clokio_revoke_doc_share_link',
497
+ description: `Revoke the public link (needs admin). ${ACCESS}`,
498
+ mutates: 'destructive',
499
+ schema: { id: docId },
500
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/share-link`, { method: 'DELETE' }),
501
+ });
502
+ // --------------------------------------------------------------- comments
503
+ registerTool(server, config, {
504
+ name: 'clokio_list_doc_comments',
505
+ description: `Comment threads, newest first, replies nested; meta has counts. ${ACCESS}`,
506
+ schema: {
507
+ id: docId,
508
+ filter: z.enum(['unresolved', 'resolved', 'all']).optional().describe('Default unresolved'),
509
+ block_id: z.string().max(40).optional(),
510
+ },
511
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments`, {
512
+ query: { filter: args.filter, block_id: args.block_id },
513
+ })), 'comments'),
514
+ });
515
+ registerTool(server, config, {
516
+ name: 'clokio_add_doc_comment',
517
+ description: `Comment, or reply to a thread with parent_id (needs comment). Markdown or HTML; @Full Name notifies. ${ACCESS}`,
518
+ mutates: true,
519
+ schema: {
520
+ id: docId,
521
+ body: z.string().min(1).max(20000),
522
+ parent_id: z.number().int().positive().optional(),
523
+ block_id: z.string().max(40).optional(),
524
+ },
525
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments`, {
526
+ method: 'POST',
527
+ body: { body: markdownToHtml(args.body), parent_id: args.parent_id, block_id: args.block_id },
528
+ })), 'comment'),
529
+ });
530
+ registerTool(server, config, {
531
+ name: 'clokio_resolve_doc_comment',
532
+ description: `Resolve a comment thread (needs comment). ${ACCESS}`,
533
+ mutates: true,
534
+ schema: { id: docId, comment_id: z.number().int().positive() },
535
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments/${seg(args.comment_id)}/resolve`, { method: 'POST' })), 'comment'),
536
+ });
537
+ registerTool(server, config, {
538
+ name: 'clokio_reopen_doc_comment',
539
+ description: `Reopen a resolved comment thread (needs comment). ${ACCESS}`,
540
+ mutates: true,
541
+ schema: { id: docId, comment_id: z.number().int().positive() },
542
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments/${seg(args.comment_id)}/reopen`, { method: 'POST' })), 'comment'),
543
+ });
544
+ // --------------------------------------------------------------- versions
545
+ registerTool(server, config, {
546
+ name: 'clokio_list_doc_versions',
547
+ description: `Version history, newest first. Pass a row's id (not its version number) to the other version tools. ${ACCESS}`,
548
+ schema: { id: docId },
549
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/versions`), 'versions'),
550
+ });
551
+ registerTool(server, config, {
552
+ name: 'clokio_get_doc_version',
553
+ description: `Read one version snapshot. ${ACCESS}`,
554
+ schema: {
555
+ id: docId,
556
+ version_id: z.number().int().positive(),
557
+ format: readFormatArg,
558
+ include_title: includeTitleArg,
559
+ },
560
+ handler: async (args, cfg) => shapeDocRead(await request(cfg, `/docs/${seg(args.id)}/versions/${seg(args.version_id)}`, {
561
+ query: { format: args.format ?? 'markdown', include_title: args.include_title ? 1 : undefined },
562
+ }), 'version'),
563
+ });
564
+ registerTool(server, config, {
565
+ name: 'clokio_restore_doc_version',
566
+ description: `Restore a snapshot over the content (needs edit). content_version moves: use the returned one. ${ACCESS}`,
567
+ mutates: true,
568
+ schema: { id: docId, version_id: z.number().int().positive() },
569
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/versions/${seg(args.version_id)}/restore`, { method: 'POST' })),
570
+ });
571
+ // ------------------------------------------------------------ attachments
572
+ registerTool(server, config, {
573
+ name: 'clokio_list_doc_attachments',
574
+ description: `Files uploaded to a document: name, size, mime, uploaded_at, path (for download). ${ACCESS}`,
575
+ schema: { id: docId },
576
+ handler: async (args, cfg) => shapeAttachments(await request(cfg, `/docs/${seg(args.id)}/attachments`)),
577
+ });
578
+ registerTool(server, config, {
579
+ name: 'clokio_upload_doc_attachment',
580
+ description: 'Upload a LOCAL file (needs edit). kind file (default, 100 MB) or media (images/video, 50 MB). Does not ' +
581
+ `change the text: reference the returned url in the body yourself. ${ACCESS}`,
582
+ mutates: true,
583
+ schema: {
584
+ id: docId,
585
+ file_path: z.string().describe('Path on THIS machine'),
586
+ kind: z.enum(['file', 'media']).optional(),
587
+ },
588
+ handler: async (args, cfg) => envelope(await requestUpload(cfg, `/docs/${seg(args.id)}/attachments`, args.file_path, { kind: args.kind ?? 'file' })),
589
+ });
590
+ registerTool(server, config, {
591
+ name: 'clokio_download_doc_attachment',
592
+ description: 'Download an attachment by its path. save_path writes it to disk; otherwise text, PDF text, ZIP entries ' +
593
+ `or an image come back inline. ${ACCESS}`,
594
+ schema: {
595
+ id: docId,
596
+ path: z.string().min(1).max(1024).describe('`path` from clokio_list_doc_attachments'),
597
+ file_name: z.string().optional().describe('`name` from the listing (type detection)'),
598
+ save_path: z.string().optional(),
599
+ overwrite: z.boolean().optional().describe('Replace an existing save_path file'),
600
+ },
601
+ handler: (args, cfg) => downloadDocAttachment(cfg, args.id, args.path, {
602
+ fileName: args.file_name,
603
+ savePath: args.save_path,
604
+ overwrite: args.overwrite,
605
+ }),
606
+ });
607
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clokio-mcp",
3
- "version": "0.17.1",
4
- "description": "Model Context Protocol (MCP) server for Clokio — manage tasks, projects, clients, employees and attendance from Claude and any MCP client.",
3
+ "version": "0.18.1",
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": {
7
7
  "clokio-mcp": "dist/index.js"
@@ -26,7 +26,8 @@
26
26
  "attendance",
27
27
  "task-management",
28
28
  "project-management",
29
- "time-tracking"
29
+ "time-tracking",
30
+ "documents"
30
31
  ],
31
32
  "author": "Matat Technologies LTD",
32
33
  "license": "MIT",