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