clokio-mcp 0.18.0 → 0.18.2

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