clokio-mcp 0.17.0 → 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 +90 -5
- package/dist/attachments.js +24 -1
- package/dist/client.js +83 -72
- package/dist/index.js +9 -4
- package/dist/tools/docs.js +614 -0
- package/package.json +4 -3
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
|
|
66
|
-
task-focused session drops from
|
|
67
|
-
`employees
|
|
68
|
-
them with commas.
|
|
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
|
-
|
|
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.
|
package/dist/attachments.js
CHANGED
|
@@ -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
|
|
5
|
-
* per-user `CLOKIO_API_KEY` sent as `X-API-Key`, exactly as the
|
|
6
|
-
* does. The key carries its own scopes,
|
|
7
|
-
*
|
|
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
|
|
71
|
-
const
|
|
72
|
-
|
|
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
|
|
116
|
+
// `manual`, not 'error' and not 'follow' - see the long note in requestRaw.
|
|
78
117
|
res = await fetch(url, {
|
|
79
|
-
headers:
|
|
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
|
|
140
|
-
const
|
|
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:
|
|
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
|
|
243
|
-
const
|
|
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: {
|
|
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
|
-
|
|
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 =
|
|
315
|
-
|
|
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
|
|
354
|
-
// means something between us and it is not the API, and the key
|
|
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
|
|
6
|
-
* stdio. Auth is the per-user CLOKIO_API_KEY (an X-API-Key
|
|
7
|
-
* carries its own scopes, so this server never
|
|
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.
|
|
4
|
-
"description": "Model Context Protocol (MCP) server for Clokio
|
|
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",
|