clokio-mcp 0.17.1 → 0.18.0

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,92 @@ 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 content:
172
+
173
+ - **Read as Markdown, write as Markdown.** `clokio_get_doc` returns Markdown by
174
+ default (`format: "html"` or `"json"` for the others), and the create/update
175
+ tools take Markdown by default (`content_format: "html"` or `"json"`). HTML
176
+ read back is not writable as-is; HTML you write must stay inside a strict
177
+ 12-tag grammar (no headings, no attributes except `img[src]` and `a[href]`).
178
+ - **Content writes are optimistic.** `clokio_update_doc_content` REPLACES the
179
+ whole body and requires `base_version` - the `content_version` you last read.
180
+ A `409` means someone else edited in between: re-read, reconcile, write
181
+ again with the new version. Never retry with the same `base_version`.
182
+ - **Uploading does not change the text.** `clokio_upload_doc_attachment`
183
+ returns a `url`; place it in the body yourself (an `image` or
184
+ `fileAttachment` node in Tiptap JSON).
185
+ - **Members are named by `employee_code`** (e.g. `"00080"`), like everywhere
186
+ else in this server; the internal `employee_id` is accepted as an
187
+ alternative. Teams are named by `team_id`, from
188
+ `clokio_list_doc_shareable_teams`. A member's `email` is shown only when the
189
+ key has `employees:pii`.
190
+
110
191
  ## Security
111
192
 
112
193
  - The server contains **no secrets**. Your API key lives only in your local
113
194
  environment (`CLOKIO_API_KEY`), never in this code or repository.
195
+ - The key is sent **only to `/api/v1`**: a path argument cannot steer a request
196
+ outside it, and the key is never forwarded on a redirect (attachment
197
+ downloads that redirect to signed storage are followed with no credential
198
+ attached).
114
199
  - The server **adds no permissions**. What a key can do is decided by the Clokio
115
200
  API from that key's scopes — this server only forwards the request. Scope each
116
201
  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,614 @@
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, owner
11
+ * decision 2026-10-11):
12
+ * - the key's "acts as" employee, when it has one;
13
+ * - otherwise the person who CREATED the key, but only when the key lists
14
+ * docs:read / docs:write EXPLICITLY. The wildcard "*" does not grant docs
15
+ * scopes to a key with no acts-as employee, so an ordinary full-access
16
+ * integration key never opens its creator's private documents.
17
+ * Every refusal of that kind is a uniform 403 decided before any document is
18
+ * looked up. The key then sees exactly what that employee sees: a document
19
+ * they cannot see is a 404 (never a 403 - the API will not confirm that an id
20
+ * exists), and a visible document they may not change is a 403. Nothing here
21
+ * re-checks permissions; the API's answer is surfaced verbatim.
22
+ *
23
+ * Three permission layers decide every call, and the descriptions name them:
24
+ * - the KEY SCOPE: docs:read (GET routes) or docs:write (everything else);
25
+ * - the SECTION permission of the acting employee's role: docs.view
26
+ * (reads), docs.create (content writes), docs.manage (delete, sharing);
27
+ * - the per-document ROLE: view < comment < edit < admin.
28
+ */
29
+ /** Appended to every docs tool description: who the call acts as. */
30
+ const ACTS_AS = "Acts as the key's acts-as employee, else its creator (only if the key lists docs scopes explicitly).";
31
+ const docId = z.number().int().positive().describe('The document id');
32
+ const CONTENT_FORMATS = ['markdown', 'html', 'json'];
33
+ const contentArg = z
34
+ .union([z.string(), z.record(z.string(), z.unknown())])
35
+ .describe('The document body. Markdown by default (headings, lists, tables, links, bold, italic, code; raw HTML ' +
36
+ 'and images inside Markdown are dropped by the server). With content_format "html", a strict 12-tag ' +
37
+ 'grammar: p br strong em u s ul ol li blockquote img a - no headings, no attributes except img[src] and ' +
38
+ 'a[href], no nested lists; anything else is refused with 422 naming the tag. With content_format ' +
39
+ '"json", a Tiptap document {"type":"doc","content":[...]} (object or JSON string).');
40
+ const contentFormatArg = z
41
+ .enum(CONTENT_FORMATS)
42
+ .optional()
43
+ .describe('How to read `content`: markdown (default), html or json (Tiptap)');
44
+ const readFormatArg = z
45
+ .enum(CONTENT_FORMATS)
46
+ .optional()
47
+ .describe('Body format to return: markdown (default, best for reading), html, or json (Tiptap tree)');
48
+ /**
49
+ * Map (content, content_format) onto the ONE content field the API accepts.
50
+ * Sending two content fields is a 422, so exactly one key is produced.
51
+ *
52
+ * An empty Markdown/HTML body becomes an empty Tiptap document: the server's
53
+ * request filter turns "" into null, which fails validation with a misleading
54
+ * "must be a string" message, and `{"type":"doc","content":[]}` is the
55
+ * documented way to clear a document.
56
+ */
57
+ function contentBody(content, format = 'markdown') {
58
+ if (content === undefined)
59
+ return {};
60
+ if (format === 'json') {
61
+ let tree = content;
62
+ if (typeof content === 'string') {
63
+ try {
64
+ tree = JSON.parse(content);
65
+ }
66
+ catch (e) {
67
+ throw new ClokioApiError(0, `content is not valid JSON: ${e.message}`);
68
+ }
69
+ }
70
+ if (!tree || typeof tree !== 'object' || Array.isArray(tree)) {
71
+ throw new ClokioApiError(0, 'content_format "json" needs a Tiptap object {"type":"doc","content":[...]}.');
72
+ }
73
+ return { content: tree };
74
+ }
75
+ if (typeof content !== 'string') {
76
+ throw new ClokioApiError(0, `content_format "${format}" needs a string; pass content_format "json" to send a Tiptap object.`);
77
+ }
78
+ if (content.trim() === '') {
79
+ return { content: { type: 'doc', content: [] } };
80
+ }
81
+ return format === 'html' ? { content_html: content } : { content_markdown: content };
82
+ }
83
+ /** The Docs tools (/api/v1/docs, CLOKIO_API_KEY with an acts-as employee). */
84
+ export function registerDocTools(server, config) {
85
+ // ---------------------------------------------------------------- reading
86
+ registerTool(server, config, {
87
+ name: 'clokio_list_docs',
88
+ description: 'List documents the key\'s employee can view, as flat rows (id, title, icon, project_id, parent_id, ' +
89
+ 'visibility, is_archived, content_version, effective_role, ...) plus meta {page, per_page, total, has_more, ' +
90
+ 'truncated}. NOTE the default scope: omitting project_id returns STANDALONE docs only (those in no ' +
91
+ 'project); pass project_id "all" for every scope. effective_role (view/comment/edit/admin) is YOUR role ' +
92
+ 'on that row; edit or admin means content writes will be accepted. Needs section docs.view. Scope docs:read. ' +
93
+ ACTS_AS +
94
+ ' WHO: every docs tool acts as ONE employee - the key\'s "acts as" employee, or, for a key without one, the ' +
95
+ 'person who created the key, and only when docs:read / docs:write are listed on the key explicitly (the ' +
96
+ 'wildcard "*" is not enough). Otherwise every docs tool answers 403; set an acts-as employee or add the ' +
97
+ 'docs scopes explicitly in Settings > API keys.',
98
+ schema: {
99
+ project_id: z
100
+ .union([z.number().int().positive(), z.literal('all')])
101
+ .optional()
102
+ .describe('A project id, or "all". Omitted = standalone docs only'),
103
+ parent_id: z
104
+ .union([z.number().int().positive(), z.literal('root')])
105
+ .optional()
106
+ .describe('A doc id to list its children (project_id is then ignored), or "root" for top-level docs only'),
107
+ q: z.string().max(120).optional().describe('Case-insensitive title substring'),
108
+ archived: z
109
+ .enum(['exclude', 'include', 'only'])
110
+ .optional()
111
+ .describe('Archived docs: exclude (default), include, or only'),
112
+ page: z.number().int().min(1).optional(),
113
+ per_page: z.number().int().min(1).max(100).optional().describe('1-100, default 50'),
114
+ },
115
+ handler: (args, cfg) => request(cfg, '/docs', {
116
+ query: {
117
+ project_id: args.project_id,
118
+ parent_id: args.parent_id,
119
+ q: args.q,
120
+ archived: args.archived,
121
+ page: args.page,
122
+ per_page: args.per_page,
123
+ },
124
+ }),
125
+ });
126
+ registerTool(server, config, {
127
+ name: 'clokio_get_doc_tree',
128
+ description: 'The visible documents as a nested tree (data.tree, each node with `children`), paged over the top level, ' +
129
+ 'plus meta. A doc whose parent you cannot see appears at the top level. Same scope rule as ' +
130
+ 'clokio_list_docs: omitting project_id means standalone docs only; "all" means every scope. Needs ' +
131
+ 'section docs.view. Scope docs:read. ' + ACTS_AS,
132
+ schema: {
133
+ project_id: z
134
+ .union([z.number().int().positive(), z.literal('all')])
135
+ .optional()
136
+ .describe('A project id, or "all". Omitted = standalone docs only'),
137
+ archived: z.enum(['exclude', 'include', 'only']).optional(),
138
+ page: z.number().int().min(1).optional(),
139
+ per_page: z.number().int().min(1).max(100).optional().describe('Top-level nodes per page, 1-100'),
140
+ },
141
+ handler: (args, cfg) => request(cfg, '/docs', {
142
+ query: {
143
+ tree: 1,
144
+ project_id: args.project_id,
145
+ archived: args.archived,
146
+ page: args.page,
147
+ per_page: args.per_page,
148
+ },
149
+ }),
150
+ });
151
+ registerTool(server, config, {
152
+ name: 'clokio_search_docs',
153
+ description: 'Search the titles AND body text of documents you can view, newest edit first. Each row has the listing ' +
154
+ 'shape plus `snippet` (~160 chars around the first match). Archived docs are left out. Needs section ' +
155
+ 'docs.view. Scope docs:read. ' + ACTS_AS,
156
+ schema: {
157
+ q: z.string().min(2).max(120).describe('2-120 characters; % and _ match literally'),
158
+ project_id: z.number().int().positive().optional().describe('Limit to one project'),
159
+ limit: z.number().int().min(1).max(50).optional().describe('1-50, default 20'),
160
+ },
161
+ handler: (args, cfg) => request(cfg, '/docs/search', {
162
+ query: { q: args.q, project_id: args.project_id, limit: args.limit },
163
+ }),
164
+ });
165
+ registerTool(server, config, {
166
+ name: 'clokio_get_doc',
167
+ description: 'Read one document: data.doc (metadata incl. content_version), breadcrumbs, effective_role, can_comment, ' +
168
+ 'can_edit, can_manage, and the body BESIDE the record as content_markdown / content_html / content (json). ' +
169
+ 'Keep doc.content_version: clokio_update_doc_content needs it as base_version. HTML read back is NOT ' +
170
+ 'writable as-is (it carries dir/style attributes and possibly headings) - to edit, write Markdown. ' +
171
+ 'Archived docs are readable. 404 = no such doc OR one you cannot see. Needs section docs.view. Scope docs:read. ' + ACTS_AS,
172
+ schema: { id: docId, format: readFormatArg },
173
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}`, { query: { format: args.format ?? 'markdown' } }),
174
+ });
175
+ // ---------------------------------------------------------------- writing
176
+ registerTool(server, config, {
177
+ name: 'clokio_create_doc',
178
+ description: 'Create a document. Where it lands: parent_id = a sub-page (inherits the parent\'s project; needs edit on ' +
179
+ 'the parent); project_id = inside that project (needs the right to create tasks there); neither = a ' +
180
+ 'standalone org-level doc (org admins / docs.manage only, else 403). Content is Markdown by default. ' +
181
+ 'Returns data.doc with content_version 0 - the base_version for the first content write. You become the ' +
182
+ 'doc\'s admin. Needs section docs.create. Scope docs:write. ' + ACTS_AS,
183
+ mutates: true,
184
+ schema: {
185
+ title: z.string().min(1).max(255),
186
+ icon: z.string().max(10).optional().describe('A single emoji'),
187
+ project_id: z.number().int().positive().optional(),
188
+ parent_id: z.number().int().positive().optional().describe('Create as a sub-page of this doc'),
189
+ content: contentArg.optional(),
190
+ content_format: contentFormatArg,
191
+ },
192
+ handler: (args, cfg) => {
193
+ const body = {
194
+ title: args.title,
195
+ icon: args.icon,
196
+ project_id: args.project_id,
197
+ parent_id: args.parent_id,
198
+ };
199
+ // An empty body on create is simply an empty document: omit content.
200
+ const blank = typeof args.content === 'string' && args.content.trim() === '';
201
+ if (!blank)
202
+ Object.assign(body, contentBody(args.content, args.content_format));
203
+ return request(cfg, '/docs', { method: 'POST', body });
204
+ },
205
+ });
206
+ registerTool(server, config, {
207
+ name: 'clokio_update_doc_content',
208
+ description: 'REPLACE a document\'s whole body (not a patch, not an append). base_version is REQUIRED: the ' +
209
+ 'content_version you last read (clokio_get_doc, or a previous write\'s response). A 409 means someone ' +
210
+ 'else changed the doc since: do NOT retry with the same base_version - re-read with clokio_get_doc, ' +
211
+ 'reconcile, and write again with the new version (the 409 names current_version and edited_by). ' +
212
+ 'Markdown by default; an empty string clears the doc. Returns {content_version, last_edited_at}. ' +
213
+ 'Needs section docs.create and edit on the doc (a read-only share is 403). Scope docs:write. ' + ACTS_AS,
214
+ mutates: true,
215
+ schema: {
216
+ id: docId,
217
+ content: contentArg,
218
+ content_format: contentFormatArg,
219
+ base_version: z.number().int().min(0).describe('The content_version you last read'),
220
+ },
221
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/content`, {
222
+ method: 'PUT',
223
+ body: { ...contentBody(args.content, args.content_format), base_version: args.base_version },
224
+ }),
225
+ });
226
+ registerTool(server, config, {
227
+ name: 'clokio_update_doc_meta',
228
+ description: 'Change a document\'s metadata - send only what changes. title, icon (null clears), full_width (page ' +
229
+ 'layout) and sort_order need edit on the doc; visibility (private/project/organization), ' +
230
+ 'inherit_permissions and is_archived need admin on the doc (a call mixing both needs admin). To change ' +
231
+ 'parent or project use clokio_move_doc. Returns data.doc and `changed` (fields that actually changed). ' +
232
+ 'Needs section docs.create. Scope docs:write. ' + ACTS_AS,
233
+ mutates: true,
234
+ schema: {
235
+ id: docId,
236
+ title: z.string().min(1).max(255).optional(),
237
+ icon: z.string().max(10).nullable().optional().describe('An emoji, or null to clear'),
238
+ full_width: z.boolean().optional(),
239
+ sort_order: z.number().int().min(0).optional(),
240
+ visibility: z.enum(['private', 'project', 'organization']).optional(),
241
+ inherit_permissions: z.boolean().optional(),
242
+ is_archived: z.boolean().optional(),
243
+ },
244
+ handler: (args, cfg) => {
245
+ const { id, ...fields } = args;
246
+ const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
247
+ if (Object.keys(body).length === 0) {
248
+ throw new ClokioApiError(0, 'Nothing to change: pass at least one field.');
249
+ }
250
+ return request(cfg, `/docs/${seg(id)}`, { method: 'PATCH', body });
251
+ },
252
+ });
253
+ registerTool(server, config, {
254
+ name: 'clokio_move_doc',
255
+ description: 'Move a document (with its sub-pages) in the tree or between projects. BOTH parent_id and project_id ' +
256
+ 'must be given, null meaning top level / standalone, so a forgotten project_id never reads as "move to ' +
257
+ 'standalone". With a parent_id the parent\'s project wins. Moving into its own sub-tree is 422; into a ' +
258
+ 'project needs the right to create tasks there; into standalone needs docs.manage. Needs section ' +
259
+ 'docs.create and admin on the doc. Scope docs:write. ' + ACTS_AS,
260
+ mutates: true,
261
+ schema: {
262
+ id: docId,
263
+ parent_id: z.number().int().positive().nullable().describe('New parent doc id, or null for top level'),
264
+ project_id: z
265
+ .number()
266
+ .int()
267
+ .positive()
268
+ .nullable()
269
+ .describe('Target project id, or null for standalone (ignored when parent_id is set)'),
270
+ sort_order: z.number().int().min(0).optional().describe('Position among the new siblings'),
271
+ },
272
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/move`, {
273
+ method: 'POST',
274
+ body: { parent_id: args.parent_id, project_id: args.project_id, sort_order: args.sort_order },
275
+ }),
276
+ });
277
+ registerTool(server, config, {
278
+ name: 'clokio_archive_doc',
279
+ description: 'Archive a document (hidden from the default listing; still readable by id). Reversible with ' +
280
+ 'clokio_restore_doc. Needs section docs.create and admin on the doc. Scope docs:write. ' + ACTS_AS,
281
+ mutates: true,
282
+ schema: { id: docId },
283
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/archive`, { method: 'POST' }),
284
+ });
285
+ registerTool(server, config, {
286
+ name: 'clokio_restore_doc',
287
+ description: 'Un-archive a document. (A DELETED doc cannot be restored through the API.) Needs section docs.create ' +
288
+ 'and admin on the doc. Scope docs:write. ' + ACTS_AS,
289
+ mutates: true,
290
+ schema: { id: docId },
291
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/restore`, { method: 'POST' }),
292
+ });
293
+ registerTool(server, config, {
294
+ name: 'clokio_delete_doc',
295
+ description: 'DELETE a document. A pre-delete version snapshot is kept for administrators, but the doc cannot be ' +
296
+ 'restored through the API - prefer clokio_archive_doc unless deletion is explicitly wanted. Needs ' +
297
+ 'section docs.manage and admin on the doc. Scope docs:write. ' + ACTS_AS,
298
+ mutates: 'destructive',
299
+ schema: { id: docId },
300
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}`, { method: 'DELETE' }),
301
+ });
302
+ // -------------------------------------------------------------- templates
303
+ registerTool(server, config, {
304
+ name: 'clokio_list_doc_templates',
305
+ description: 'List the document templates ({key, name, icon, description}) and can_create_standalone (whether you may ' +
306
+ 'create a doc outside any project). Needs section docs.view. Scope docs:read. ' + ACTS_AS,
307
+ schema: {},
308
+ handler: (_args, cfg) => request(cfg, '/docs/templates'),
309
+ });
310
+ registerTool(server, config, {
311
+ name: 'clokio_create_doc_from_template',
312
+ description: 'Create a top-level document from a template (key from clokio_list_doc_templates; unknown is 404). With ' +
313
+ 'project_id it lands in that project (needs the right to create tasks there); without, it is standalone ' +
314
+ '(needs docs.manage). Returns data.doc. Needs section docs.create. Scope docs:write. ' + ACTS_AS,
315
+ mutates: true,
316
+ schema: {
317
+ template: z.string().min(1).max(60).describe('The template key'),
318
+ title: z.string().max(255).optional().describe('Defaults to the template name'),
319
+ project_id: z.number().int().positive().optional(),
320
+ },
321
+ handler: (args, cfg) => request(cfg, '/docs/from-template', {
322
+ method: 'POST',
323
+ body: { template: args.template, title: args.title, project_id: args.project_id },
324
+ }),
325
+ });
326
+ // ---------------------------------------------------------------- sharing
327
+ registerTool(server, config, {
328
+ name: 'clokio_list_doc_members',
329
+ description: 'Who a document is shared with: members (direct person grants: employee_code, employee_id, name, role; ' +
330
+ 'email only when the key has employees:pii), teams (team grants: team_id, name, role), inherited (people ' +
331
+ 'reaching it through a parent - change those on the parent), visibility, inherit_permissions and the ' +
332
+ 'live share_link if any. Needs section docs.view and admin on the doc. Scope docs:read. ' + ACTS_AS,
333
+ schema: { id: docId },
334
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/members`),
335
+ });
336
+ const memberRole = z.enum(['view', 'comment', 'edit', 'admin']);
337
+ // A person is named by employee_code (preferred - the way every other tool
338
+ // here names people) OR by the internal employee_id. Exactly one.
339
+ const personArgs = {
340
+ employee_code: z
341
+ .string()
342
+ .regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -')
343
+ .optional()
344
+ .describe('The person\'s employee_code, zero-padded (e.g. "00080"). Preferred. Resolve names with clokio_list_employees'),
345
+ employee_id: z
346
+ .number()
347
+ .int()
348
+ .positive()
349
+ .optional()
350
+ .describe('Alternative to employee_code: Clokio\'s INTERNAL employee id (as clokio_list_doc_members reports it)'),
351
+ };
352
+ /** Exactly one of employee_code / employee_id, as the API requires. */
353
+ function person(args) {
354
+ const hasCode = args.employee_code !== undefined;
355
+ const hasId = args.employee_id !== undefined;
356
+ if (hasCode === hasId) {
357
+ throw new ClokioApiError(0, 'Name the person with exactly one of employee_code (preferred) or employee_id.');
358
+ }
359
+ return hasCode ? { employee_code: args.employee_code } : { employee_id: args.employee_id };
360
+ }
361
+ registerTool(server, config, {
362
+ name: 'clokio_add_doc_member',
363
+ description: 'Share a document with a person, or change their direct role if already shared. Name them by ' +
364
+ 'employee_code (preferred) or employee_id. role: view, comment, edit or admin. The person must be an ' +
365
+ 'active member of your organisation (422 otherwise) and is notified on a first grant. Needs section ' +
366
+ 'docs.manage and admin on the doc. Scope docs:write. ' + ACTS_AS,
367
+ mutates: true,
368
+ schema: { id: docId, ...personArgs, role: memberRole },
369
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/members`, {
370
+ method: 'POST',
371
+ body: { ...person(args), role: args.role },
372
+ }),
373
+ });
374
+ registerTool(server, config, {
375
+ name: 'clokio_update_doc_member',
376
+ description: 'Change an existing direct grant\'s role. Name the person by employee_code (preferred) or employee_id. ' +
377
+ '404 if the person has no direct grant; demoting the last admin is 422. Needs section docs.manage and ' +
378
+ 'admin on the doc. Scope docs:write. ' + ACTS_AS,
379
+ mutates: true,
380
+ schema: { id: docId, ...personArgs, role: memberRole },
381
+ handler: (args, cfg) => {
382
+ const who = person(args);
383
+ // An id has its own path route; a code rides in the body of the
384
+ // collection route (PATCH /docs/{id}/members).
385
+ return 'employee_id' in who
386
+ ? request(cfg, `/docs/${seg(args.id)}/members/${seg(who.employee_id)}`, {
387
+ method: 'PATCH',
388
+ body: { role: args.role },
389
+ })
390
+ : request(cfg, `/docs/${seg(args.id)}/members`, {
391
+ method: 'PATCH',
392
+ body: { ...who, role: args.role },
393
+ });
394
+ },
395
+ });
396
+ registerTool(server, config, {
397
+ name: 'clokio_remove_doc_member',
398
+ description: 'Remove a person\'s direct grant. Name them by employee_code (preferred; may name someone who has since ' +
399
+ 'left, to revoke their grant) or employee_id. Removing the ' +
400
+ 'last admin is 422; inherited access must be removed on the parent. Needs section docs.manage and admin ' +
401
+ 'on the doc. Scope docs:write. ' + ACTS_AS,
402
+ mutates: 'destructive',
403
+ schema: { id: docId, ...personArgs },
404
+ handler: (args, cfg) => {
405
+ const who = person(args);
406
+ // A code travels in the query string of DELETE /docs/{id}/members (the
407
+ // API accepts body or query; a query survives any proxy that drops a
408
+ // DELETE body).
409
+ return 'employee_id' in who
410
+ ? request(cfg, `/docs/${seg(args.id)}/members/${seg(who.employee_id)}`, { method: 'DELETE' })
411
+ : request(cfg, `/docs/${seg(args.id)}/members`, {
412
+ method: 'DELETE',
413
+ query: { employee_code: who.employee_code },
414
+ });
415
+ },
416
+ });
417
+ registerTool(server, config, {
418
+ name: 'clokio_list_doc_shareable_teams',
419
+ description: 'The organisation\'s teams this document can be shared with ({id, name, color, members_count}) - use the ' +
420
+ 'id as team_id in ' +
421
+ 'clokio_add_doc_team. Needs section docs.view and admin on the doc. Scope docs:read. ' + ACTS_AS,
422
+ schema: { id: docId },
423
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/shareable-teams`),
424
+ });
425
+ const teamRole = z.enum(['view', 'comment', 'edit']).describe('Teams cannot be admins');
426
+ const teamId = z
427
+ .number()
428
+ .int()
429
+ .positive()
430
+ .describe('The team id (from clokio_list_doc_shareable_teams, or teams[].team_id in clokio_list_doc_members)');
431
+ registerTool(server, config, {
432
+ name: 'clokio_add_doc_team',
433
+ description: 'Share a document with a team (role view, comment or edit). The team must be in your organisation. ' +
434
+ 'Find team ids with clokio_list_doc_shareable_teams; current team grants are in clokio_list_doc_members. Needs section docs.manage and admin on the ' +
435
+ 'doc. Scope docs:write. ' + ACTS_AS,
436
+ mutates: true,
437
+ schema: { id: docId, team_id: teamId, role: teamRole },
438
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/teams`, {
439
+ method: 'POST',
440
+ body: { team_id: args.team_id, role: args.role },
441
+ }),
442
+ });
443
+ registerTool(server, config, {
444
+ name: 'clokio_update_doc_team',
445
+ description: 'Change a team grant\'s role (404 if the team has no grant). Needs section docs.manage and admin on the ' +
446
+ 'doc. Scope docs:write. ' + ACTS_AS,
447
+ mutates: true,
448
+ schema: { id: docId, team_id: teamId, role: teamRole },
449
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/teams/${seg(args.team_id)}`, {
450
+ method: 'PATCH',
451
+ body: { role: args.role },
452
+ }),
453
+ });
454
+ registerTool(server, config, {
455
+ name: 'clokio_remove_doc_team',
456
+ description: 'Remove a team\'s grant on a document. Needs section docs.manage and admin on the doc. Scope docs:write. ' + ACTS_AS,
457
+ mutates: 'destructive',
458
+ schema: { id: docId, team_id: teamId },
459
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/teams/${seg(args.team_id)}`, { method: 'DELETE' }),
460
+ });
461
+ registerTool(server, config, {
462
+ name: 'clokio_create_doc_share_link',
463
+ description: 'Create a PUBLIC link to a document (anyone with the URL can read it), or REPLACE the existing one - the ' +
464
+ 'old URL stops working. Returns {share_token, share_url, expires_at}. Needs section docs.manage and ' +
465
+ 'admin on the doc. Scope docs:write. ' + ACTS_AS,
466
+ mutates: true,
467
+ schema: {
468
+ id: docId,
469
+ expires_at: z
470
+ .string()
471
+ .optional()
472
+ .describe('Optional expiry, a date/time in the future (e.g. 2026-12-31 or an ISO 8601 timestamp)'),
473
+ },
474
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/share-link`, {
475
+ method: 'POST',
476
+ body: { expires_at: args.expires_at },
477
+ }),
478
+ });
479
+ registerTool(server, config, {
480
+ name: 'clokio_revoke_doc_share_link',
481
+ description: 'Revoke a document\'s public link. Needs section docs.manage and admin on the doc. Scope docs:write. ' + ACTS_AS,
482
+ mutates: 'destructive',
483
+ schema: { id: docId },
484
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/share-link`, { method: 'DELETE' }),
485
+ });
486
+ // --------------------------------------------------------------- comments
487
+ registerTool(server, config, {
488
+ name: 'clokio_list_doc_comments',
489
+ description: 'A document\'s comment threads, newest first (max 100), replies nested under `replies`, plus counts ' +
490
+ '{unresolved, resolved, total}. Needs section docs.view and view on the doc. Scope docs:read. ' + ACTS_AS,
491
+ schema: {
492
+ id: docId,
493
+ filter: z
494
+ .enum(['unresolved', 'resolved', 'all'])
495
+ .optional()
496
+ .describe('Which threads: unresolved (default), resolved or all'),
497
+ block_id: z.string().max(40).optional().describe('Only threads anchored to this block'),
498
+ },
499
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/comments`, {
500
+ query: { filter: args.filter, block_id: args.block_id },
501
+ }),
502
+ });
503
+ registerTool(server, config, {
504
+ name: 'clokio_add_doc_comment',
505
+ description: 'Comment on a document, or reply to a thread (parent_id = a thread on THIS doc; a reply to a reply is ' +
506
+ '422). The body accepts Markdown (converted to HTML) or HTML. @Full Name mentions of active colleagues ' +
507
+ 'notify them. Needs section docs.create and comment on the doc. Scope docs:write. ' + ACTS_AS,
508
+ mutates: true,
509
+ schema: {
510
+ id: docId,
511
+ body: z.string().min(1).max(20000).describe('Markdown or HTML'),
512
+ parent_id: z.number().int().positive().optional().describe('Reply to this thread'),
513
+ block_id: z.string().max(40).optional().describe('Anchor the comment to a block of the doc'),
514
+ },
515
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/comments`, {
516
+ method: 'POST',
517
+ body: { body: markdownToHtml(args.body), parent_id: args.parent_id, block_id: args.block_id },
518
+ }),
519
+ });
520
+ registerTool(server, config, {
521
+ name: 'clokio_resolve_doc_comment',
522
+ description: 'Resolve a comment thread (a thread, not a reply - a reply is 422). Needs section docs.create and ' +
523
+ 'comment on the doc. Scope docs:write. ' + ACTS_AS,
524
+ mutates: true,
525
+ schema: { id: docId, comment_id: z.number().int().positive() },
526
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/comments/${seg(args.comment_id)}/resolve`, { method: 'POST' }),
527
+ });
528
+ registerTool(server, config, {
529
+ name: 'clokio_reopen_doc_comment',
530
+ description: 'Reopen a resolved comment thread. Needs section docs.create and comment on the doc. Scope docs:write. ' + ACTS_AS,
531
+ mutates: true,
532
+ schema: { id: docId, comment_id: z.number().int().positive() },
533
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/comments/${seg(args.comment_id)}/reopen`, { method: 'POST' }),
534
+ });
535
+ // --------------------------------------------------------------- versions
536
+ registerTool(server, config, {
537
+ name: 'clokio_list_doc_versions',
538
+ description: 'A document\'s version history, newest first (max 50): {id, version, reason, created_at, creator}. reason ' +
539
+ 'is manual_save, auto_save, restore, pre_delete or conflict_backup. Pass the `id` (not the `version` ' +
540
+ 'number) to the other version tools. Needs section docs.view and view on the doc. Scope docs:read. ' + ACTS_AS,
541
+ schema: { id: docId },
542
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/versions`),
543
+ });
544
+ registerTool(server, config, {
545
+ name: 'clokio_get_doc_version',
546
+ description: 'Read one snapshot from a document\'s history, in markdown (default), html or json. version_id is the ' +
547
+ '`id` from clokio_list_doc_versions. Needs section docs.view and view on the doc. Scope docs:read. ' + ACTS_AS,
548
+ schema: { id: docId, version_id: z.number().int().positive(), format: readFormatArg },
549
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/versions/${seg(args.version_id)}`, {
550
+ query: { format: args.format ?? 'markdown' },
551
+ }),
552
+ });
553
+ registerTool(server, config, {
554
+ name: 'clokio_restore_doc_version',
555
+ description: 'Copy a snapshot back over the document\'s content (recorded as a new `restore` version). Returns ' +
556
+ '{content_version, version}: content_version MOVES, so use the returned value as base_version for the ' +
557
+ 'next content write. Needs section docs.create and edit on the doc. Scope docs:write. ' + ACTS_AS,
558
+ mutates: true,
559
+ schema: { id: docId, version_id: z.number().int().positive() },
560
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/versions/${seg(args.version_id)}/restore`, { method: 'POST' }),
561
+ });
562
+ // ------------------------------------------------------------ attachments
563
+ registerTool(server, config, {
564
+ name: 'clokio_list_doc_attachments',
565
+ description: 'Files uploaded to a document (max 200): {kind: media|file, name, path, url}. Read from storage, so a ' +
566
+ 'file removed from the text is still listed. Download with clokio_download_doc_attachment using `path` ' +
567
+ '(the `url` values need a browser session). Needs section docs.view and view on the doc. Scope docs:read. ' + ACTS_AS,
568
+ schema: { id: docId },
569
+ handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/attachments`),
570
+ });
571
+ registerTool(server, config, {
572
+ name: 'clokio_upload_doc_attachment',
573
+ description: 'Upload a LOCAL FILE to a document. kind "file" (default): anything up to 100 MB except executable and ' +
574
+ 'markup types (php, js, sh, exe, svg, html, xml...). kind "media": jpg/jpeg/png/gif/webp/heic/heif ' +
575
+ '(HEIC becomes JPEG)/mp4/webm/mov, up to 50 MB. Returns {kind, url, path, name, size, mime}. Uploading ' +
576
+ 'does NOT change the text: to show it, write content (content_format json) with an `image` node ' +
577
+ '(src = url) for media or a `fileAttachment` node (url, name, size, mime) for a file. Needs section ' +
578
+ 'docs.create and edit on the doc. Scope docs:write. ' + ACTS_AS,
579
+ mutates: true,
580
+ schema: {
581
+ id: docId,
582
+ file_path: z.string().describe('Path to a file on THIS machine'),
583
+ kind: z.enum(['file', 'media']).optional().describe('file (default) or media'),
584
+ },
585
+ handler: (args, cfg) => requestUpload(cfg, `/docs/${seg(args.id)}/attachments`, args.file_path, { kind: args.kind ?? 'file' }),
586
+ });
587
+ registerTool(server, config, {
588
+ name: 'clokio_download_doc_attachment',
589
+ description: 'Download one document attachment by its storage `path` (from clokio_list_doc_attachments). With ' +
590
+ 'save_path, writes the file to disk (creating folders, refusing to overwrite unless overwrite: true) and ' +
591
+ 'returns {saved_to, sha256, mime_type, file_size}. Without it, returns the content in the most usable ' +
592
+ 'form: text as text, a PDF as extracted text plus page count, a ZIP as its entry list, an image as an ' +
593
+ 'image you can see, anything else as a type-and-size description. Large text/PDF is truncated (use ' +
594
+ 'save_path for the whole file). Needs section docs.view and view on the doc. Scope docs:read. ' + ACTS_AS,
595
+ schema: {
596
+ id: docId,
597
+ path: z.string().min(1).max(1024).describe('The attachment `path` from clokio_list_doc_attachments'),
598
+ file_name: z
599
+ .string()
600
+ .optional()
601
+ .describe('The attachment `name` from the listing. Drives type detection (text/PDF/zip/image)'),
602
+ save_path: z
603
+ .string()
604
+ .optional()
605
+ .describe('Write the file here instead of returning its content; parent folders are created'),
606
+ overwrite: z.boolean().optional().describe('Allow save_path to replace an existing file (default false)'),
607
+ },
608
+ handler: (args, cfg) => downloadDocAttachment(cfg, args.id, args.path, {
609
+ fileName: args.file_name,
610
+ savePath: args.save_path,
611
+ overwrite: args.overwrite,
612
+ }),
613
+ });
614
+ }
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.0",
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",