clokio-mcp 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -168,25 +168,33 @@ that employee:
168
168
  | `clokio_list_doc_versions` / `clokio_get_doc_version` / `clokio_restore_doc_version` | `GET .../versions`, `GET .../versions/{versionId}`, `POST .../versions/{versionId}/restore` |
169
169
  | `clokio_list_doc_attachments` / `clokio_upload_doc_attachment` / `clokio_download_doc_attachment` | `GET` / `POST /docs/{id}/attachments`, `GET .../attachments/download?path=` |
170
170
 
171
- Working with content:
171
+ Working with documents:
172
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).
173
178
  - **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]`).
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).
178
184
  - **Content writes are optimistic.** `clokio_update_doc_content` REPLACES the
179
185
  whole body and requires `base_version` - the `content_version` you last read.
180
186
  A `409` means someone else edited in between: re-read, reconcile, write
181
187
  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).
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).
185
193
  - **Members are named by `employee_code`** (e.g. `"00080"`), like everywhere
186
194
  else in this server; the internal `employee_id` is accepted as an
187
195
  alternative. Teams are named by `team_id`, from
188
196
  `clokio_list_doc_shareable_teams`. A member's `email` is shown only when the
189
- key has `employees:pii`.
197
+ key has `employees:pii`; profile pictures only with `include_avatars: true`.
190
198
 
191
199
  ## Security
192
200
 
@@ -7,44 +7,42 @@ 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.
28
27
  */
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');
28
+ const ACCESS = 'Docs access: see clokio_list_docs.';
29
+ const docId = z.number().int().positive().describe('Document id');
32
30
  const CONTENT_FORMATS = ['markdown', 'html', 'json'];
33
31
  const contentArg = z
34
32
  .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)
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()
42
39
  .optional()
43
- .describe('How to read `content`: markdown (default), html or json (Tiptap)');
44
- const readFormatArg = z
45
- .enum(CONTENT_FORMATS)
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')])
46
43
  .optional()
47
- .describe('Body format to return: markdown (default, best for reading), html, or json (Tiptap tree)');
44
+ .describe('Project id, "none" (standalone docs only) or "all" (default)');
45
+ const archivedArg = z.enum(['exclude', 'include', 'only']).optional().describe('Default exclude');
48
46
  /**
49
47
  * Map (content, content_format) onto the ONE content field the API accepts.
50
48
  * Sending two content fields is a 422, so exactly one key is produced.
@@ -80,116 +78,164 @@ function contentBody(content, format = 'markdown') {
80
78
  }
81
79
  return format === 'html' ? { content_html: content } : { content_markdown: content };
82
80
  }
83
- /** The Docs tools (/api/v1/docs, CLOKIO_API_KEY with an acts-as employee). */
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
84
152
  export function registerDocTools(server, config) {
85
153
  // ---------------------------------------------------------------- reading
86
154
  registerTool(server, config, {
87
155
  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.',
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.',
98
163
  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'),
164
+ project_id: scopeArg,
103
165
  parent_id: z
104
166
  .union([z.number().int().positive(), z.literal('root')])
105
167
  .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'),
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,
112
171
  page: z.number().int().min(1).optional(),
113
- per_page: z.number().int().min(1).max(100).optional().describe('1-100, default 50'),
172
+ per_page: z.number().int().min(1).max(100).optional().describe('Default 50'),
114
173
  },
115
- handler: (args, cfg) => request(cfg, '/docs', {
174
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs', {
116
175
  query: {
117
- project_id: args.project_id,
176
+ project_id: args.project_id ?? 'all',
118
177
  parent_id: args.parent_id,
119
178
  q: args.q,
120
179
  archived: args.archived,
121
180
  page: args.page,
122
181
  per_page: args.per_page,
123
182
  },
124
- }),
183
+ }), 'docs'),
125
184
  });
126
185
  registerTool(server, config, {
127
186
  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,
187
+ description: `Documents you can view as a nested tree (children), paged over the top level. ${ACCESS}`,
132
188
  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(),
189
+ project_id: scopeArg,
190
+ archived: archivedArg,
138
191
  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'),
192
+ per_page: z.number().int().min(1).max(100).optional(),
140
193
  },
141
- handler: (args, cfg) => request(cfg, '/docs', {
194
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs', {
142
195
  query: {
143
196
  tree: 1,
144
- project_id: args.project_id,
197
+ project_id: args.project_id ?? 'all',
145
198
  archived: args.archived,
146
199
  page: args.page,
147
200
  per_page: args.per_page,
148
201
  },
149
- }),
202
+ }), 'tree'),
150
203
  });
151
204
  registerTool(server, config, {
152
205
  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,
206
+ description: `Search titles and body text (not archived), newest first; each row has a snippet. ${ACCESS}`,
156
207
  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'),
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'),
160
211
  },
161
- handler: (args, cfg) => request(cfg, '/docs/search', {
162
- query: { q: args.q, project_id: args.project_id, limit: args.limit },
163
- }),
212
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs/search', { query: { q: args.q, project_id: args.project_id, limit: args.limit } }), 'docs'),
164
213
  });
165
214
  registerTool(server, config, {
166
215
  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' } }),
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'),
174
222
  });
175
223
  // ---------------------------------------------------------------- writing
176
224
  registerTool(server, config, {
177
225
  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,
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,
183
229
  mutates: true,
184
230
  schema: {
185
231
  title: z.string().min(1).max(255),
186
- icon: z.string().max(10).optional().describe('A single emoji'),
232
+ icon: z.string().max(10).optional().describe('One emoji'),
187
233
  project_id: z.number().int().positive().optional(),
188
- parent_id: z.number().int().positive().optional().describe('Create as a sub-page of this doc'),
234
+ parent_id: z.number().int().positive().optional(),
189
235
  content: contentArg.optional(),
190
236
  content_format: contentFormatArg,
191
237
  },
192
- handler: (args, cfg) => {
238
+ handler: async (args, cfg) => {
193
239
  const body = {
194
240
  title: args.title,
195
241
  icon: args.icon,
@@ -200,101 +246,84 @@ export function registerDocTools(server, config) {
200
246
  const blank = typeof args.content === 'string' && args.content.trim() === '';
201
247
  if (!blank)
202
248
  Object.assign(body, contentBody(args.content, args.content_format));
203
- return request(cfg, '/docs', { method: 'POST', body });
249
+ return liftKey(await request(cfg, '/docs', { method: 'POST', body }), 'doc');
204
250
  },
205
251
  });
206
252
  registerTool(server, config, {
207
253
  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,
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,
214
257
  mutates: true,
215
258
  schema: {
216
259
  id: docId,
217
260
  content: contentArg,
218
261
  content_format: contentFormatArg,
219
- base_version: z.number().int().min(0).describe('The content_version you last read'),
262
+ base_version: z.number().int().min(0),
220
263
  },
221
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/content`, {
264
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/content`, {
222
265
  method: 'PUT',
223
266
  body: { ...contentBody(args.content, args.content_format), base_version: args.base_version },
224
- }),
267
+ })),
225
268
  });
226
269
  registerTool(server, config, {
227
270
  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,
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,
233
274
  mutates: true,
234
275
  schema: {
235
276
  id: docId,
236
277
  title: z.string().min(1).max(255).optional(),
237
- icon: z.string().max(10).nullable().optional().describe('An emoji, or null to clear'),
278
+ icon: z.string().max(10).nullable().optional().describe('null clears'),
238
279
  full_width: z.boolean().optional(),
239
280
  sort_order: z.number().int().min(0).optional(),
240
281
  visibility: z.enum(['private', 'project', 'organization']).optional(),
241
282
  inherit_permissions: z.boolean().optional(),
242
283
  is_archived: z.boolean().optional(),
243
284
  },
244
- handler: (args, cfg) => {
285
+ handler: async (args, cfg) => {
245
286
  const { id, ...fields } = args;
246
287
  const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
247
288
  if (Object.keys(body).length === 0) {
248
289
  throw new ClokioApiError(0, 'Nothing to change: pass at least one field.');
249
290
  }
250
- return request(cfg, `/docs/${seg(id)}`, { method: 'PATCH', body });
291
+ return liftKey(await request(cfg, `/docs/${seg(id)}`, { method: 'PATCH', body }), 'doc');
251
292
  },
252
293
  });
253
294
  registerTool(server, config, {
254
295
  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,
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}`,
260
298
  mutates: true,
261
299
  schema: {
262
300
  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'),
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(),
271
304
  },
272
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/move`, {
305
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/move`, {
273
306
  method: 'POST',
274
307
  body: { parent_id: args.parent_id, project_id: args.project_id, sort_order: args.sort_order },
275
- }),
308
+ }), 'doc'),
276
309
  });
277
310
  registerTool(server, config, {
278
311
  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,
312
+ description: `Archive a document (needs admin; reversible with clokio_restore_doc). ${ACCESS}`,
281
313
  mutates: true,
282
314
  schema: { id: docId },
283
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/archive`, { method: 'POST' }),
315
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/archive`, { method: 'POST' }), 'doc'),
284
316
  });
285
317
  registerTool(server, config, {
286
318
  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,
319
+ description: `Un-archive a document (needs admin). ${ACCESS}`,
289
320
  mutates: true,
290
321
  schema: { id: docId },
291
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/restore`, { method: 'POST' }),
322
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/restore`, { method: 'POST' }), 'doc'),
292
323
  });
293
324
  registerTool(server, config, {
294
325
  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,
326
+ description: `DELETE a document (needs admin and docs.manage; not restorable via the API - prefer archive). ${ACCESS}`,
298
327
  mutates: 'destructive',
299
328
  schema: { id: docId },
300
329
  handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}`, { method: 'DELETE' }),
@@ -302,36 +331,49 @@ export function registerDocTools(server, config) {
302
331
  // -------------------------------------------------------------- templates
303
332
  registerTool(server, config, {
304
333
  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,
334
+ description: `List document templates (key, name, description). ${ACCESS}`,
307
335
  schema: {},
308
- handler: (_args, cfg) => request(cfg, '/docs/templates'),
336
+ handler: async (_args, cfg) => liftKey(await request(cfg, '/docs/templates'), 'templates'),
309
337
  });
310
338
  registerTool(server, config, {
311
339
  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,
340
+ description: `Create a top-level document from a template key. No project_id = standalone (admins only). ${ACCESS}`,
315
341
  mutates: true,
316
342
  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'),
343
+ template: z.string().min(1).max(60),
344
+ title: z.string().max(255).optional(),
319
345
  project_id: z.number().int().positive().optional(),
320
346
  },
321
- handler: (args, cfg) => request(cfg, '/docs/from-template', {
347
+ handler: async (args, cfg) => liftKey(await request(cfg, '/docs/from-template', {
322
348
  method: 'POST',
323
349
  body: { template: args.template, title: args.title, project_id: args.project_id },
324
- }),
350
+ }), 'doc'),
325
351
  });
326
352
  // ---------------------------------------------------------------- sharing
327
353
  registerTool(server, config, {
328
354
  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,
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}`,
333
375
  schema: { id: docId },
334
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/members`),
376
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/shareable-teams`), 'teams'),
335
377
  });
336
378
  const memberRole = z.enum(['view', 'comment', 'edit', 'admin']);
337
379
  // A person is named by employee_code (preferred - the way every other tool
@@ -341,13 +383,8 @@ export function registerDocTools(server, config) {
341
383
  .string()
342
384
  .regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -')
343
385
  .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)'),
386
+ .describe('Zero-padded, e.g. "00080" (preferred)'),
387
+ employee_id: z.number().int().positive().optional().describe('Internal id, instead of employee_code'),
351
388
  };
352
389
  /** Exactly one of employee_code / employee_id, as the API requires. */
353
390
  function person(args) {
@@ -360,45 +397,37 @@ export function registerDocTools(server, config) {
360
397
  }
361
398
  registerTool(server, config, {
362
399
  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,
400
+ description: `Share a document with a person or change their role (needs admin; they are notified once). ${ACCESS}`,
367
401
  mutates: true,
368
402
  schema: { id: docId, ...personArgs, role: memberRole },
369
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/members`, {
403
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/members`, {
370
404
  method: 'POST',
371
405
  body: { ...person(args), role: args.role },
372
- }),
406
+ })),
373
407
  });
374
408
  registerTool(server, config, {
375
409
  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,
410
+ description: `Change a person's direct role (needs admin; demoting the last admin is 422). ${ACCESS}`,
379
411
  mutates: true,
380
412
  schema: { id: docId, ...personArgs, role: memberRole },
381
- handler: (args, cfg) => {
413
+ handler: async (args, cfg) => {
382
414
  const who = person(args);
383
415
  // An id has its own path route; a code rides in the body of the
384
416
  // collection route (PATCH /docs/{id}/members).
385
- return 'employee_id' in who
386
- ? request(cfg, `/docs/${seg(args.id)}/members/${seg(who.employee_id)}`, {
417
+ return envelope('employee_id' in who
418
+ ? await request(cfg, `/docs/${seg(args.id)}/members/${seg(who.employee_id)}`, {
387
419
  method: 'PATCH',
388
420
  body: { role: args.role },
389
421
  })
390
- : request(cfg, `/docs/${seg(args.id)}/members`, {
422
+ : await request(cfg, `/docs/${seg(args.id)}/members`, {
391
423
  method: 'PATCH',
392
424
  body: { ...who, role: args.role },
393
- });
425
+ }));
394
426
  },
395
427
  });
396
428
  registerTool(server, config, {
397
429
  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,
430
+ description: `Remove a person's direct grant (needs admin; a code may name someone who has left). ${ACCESS}`,
402
431
  mutates: 'destructive',
403
432
  schema: { id: docId, ...personArgs },
404
433
  handler: (args, cfg) => {
@@ -414,71 +443,58 @@ export function registerDocTools(server, config) {
414
443
  });
415
444
  },
416
445
  });
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)');
446
+ const teamRole = z.enum(['view', 'comment', 'edit']);
447
+ const teamId = z.number().int().positive().describe('From clokio_list_doc_shareable_teams');
431
448
  registerTool(server, config, {
432
449
  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,
450
+ description: `Share a document with a team (needs admin; teams cannot be admins). ${ACCESS}`,
436
451
  mutates: true,
437
452
  schema: { id: docId, team_id: teamId, role: teamRole },
438
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/teams`, {
453
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/teams`, {
439
454
  method: 'POST',
440
455
  body: { team_id: args.team_id, role: args.role },
441
- }),
456
+ })),
442
457
  });
443
458
  registerTool(server, config, {
444
459
  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,
460
+ description: `Change a team's role on a document (needs admin). ${ACCESS}`,
447
461
  mutates: true,
448
462
  schema: { id: docId, team_id: teamId, role: teamRole },
449
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/teams/${seg(args.team_id)}`, {
463
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/teams/${seg(args.team_id)}`, {
450
464
  method: 'PATCH',
451
465
  body: { role: args.role },
452
- }),
466
+ })),
453
467
  });
454
468
  registerTool(server, config, {
455
469
  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,
470
+ description: `Remove a team's grant (needs admin). ${ACCESS}`,
457
471
  mutates: 'destructive',
458
472
  schema: { id: docId, team_id: teamId },
459
473
  handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/teams/${seg(args.team_id)}`, { method: 'DELETE' }),
460
474
  });
461
475
  registerTool(server, config, {
462
476
  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,
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}`,
466
479
  mutates: true,
467
480
  schema: {
468
481
  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)'),
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
+ }));
473
493
  },
474
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/share-link`, {
475
- method: 'POST',
476
- body: { expires_at: args.expires_at },
477
- }),
478
494
  });
479
495
  registerTool(server, config, {
480
496
  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,
497
+ description: `Revoke the public link (needs admin). ${ACCESS}`,
482
498
  mutates: 'destructive',
483
499
  schema: { id: docId },
484
500
  handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/share-link`, { method: 'DELETE' }),
@@ -486,124 +502,101 @@ export function registerDocTools(server, config) {
486
502
  // --------------------------------------------------------------- comments
487
503
  registerTool(server, config, {
488
504
  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,
505
+ description: `Comment threads, newest first, replies nested; meta has counts. ${ACCESS}`,
491
506
  schema: {
492
507
  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'),
508
+ filter: z.enum(['unresolved', 'resolved', 'all']).optional().describe('Default unresolved'),
509
+ block_id: z.string().max(40).optional(),
498
510
  },
499
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/comments`, {
511
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments`, {
500
512
  query: { filter: args.filter, block_id: args.block_id },
501
- }),
513
+ })), 'comments'),
502
514
  });
503
515
  registerTool(server, config, {
504
516
  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,
517
+ description: `Comment, or reply to a thread with parent_id (needs comment). Markdown or HTML; @Full Name notifies. ${ACCESS}`,
508
518
  mutates: true,
509
519
  schema: {
510
520
  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'),
521
+ body: z.string().min(1).max(20000),
522
+ parent_id: z.number().int().positive().optional(),
523
+ block_id: z.string().max(40).optional(),
514
524
  },
515
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/comments`, {
525
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments`, {
516
526
  method: 'POST',
517
527
  body: { body: markdownToHtml(args.body), parent_id: args.parent_id, block_id: args.block_id },
518
- }),
528
+ })), 'comment'),
519
529
  });
520
530
  registerTool(server, config, {
521
531
  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,
532
+ description: `Resolve a comment thread (needs comment). ${ACCESS}`,
524
533
  mutates: true,
525
534
  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' }),
535
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments/${seg(args.comment_id)}/resolve`, { method: 'POST' })), 'comment'),
527
536
  });
528
537
  registerTool(server, config, {
529
538
  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,
539
+ description: `Reopen a resolved comment thread (needs comment). ${ACCESS}`,
531
540
  mutates: true,
532
541
  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' }),
542
+ handler: async (args, cfg) => liftKey(stripAvatars(await request(cfg, `/docs/${seg(args.id)}/comments/${seg(args.comment_id)}/reopen`, { method: 'POST' })), 'comment'),
534
543
  });
535
544
  // --------------------------------------------------------------- versions
536
545
  registerTool(server, config, {
537
546
  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,
547
+ description: `Version history, newest first. Pass a row's id (not its version number) to the other version tools. ${ACCESS}`,
541
548
  schema: { id: docId },
542
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/versions`),
549
+ handler: async (args, cfg) => liftKey(await request(cfg, `/docs/${seg(args.id)}/versions`), 'versions'),
543
550
  });
544
551
  registerTool(server, config, {
545
552
  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
- }),
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'),
552
563
  });
553
564
  registerTool(server, config, {
554
565
  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,
566
+ description: `Restore a snapshot over the content (needs edit). content_version moves: use the returned one. ${ACCESS}`,
558
567
  mutates: true,
559
568
  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' }),
569
+ handler: async (args, cfg) => envelope(await request(cfg, `/docs/${seg(args.id)}/versions/${seg(args.version_id)}/restore`, { method: 'POST' })),
561
570
  });
562
571
  // ------------------------------------------------------------ attachments
563
572
  registerTool(server, config, {
564
573
  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,
574
+ description: `Files uploaded to a document: name, size, mime, uploaded_at, path (for download). ${ACCESS}`,
568
575
  schema: { id: docId },
569
- handler: (args, cfg) => request(cfg, `/docs/${seg(args.id)}/attachments`),
576
+ handler: async (args, cfg) => shapeAttachments(await request(cfg, `/docs/${seg(args.id)}/attachments`)),
570
577
  });
571
578
  registerTool(server, config, {
572
579
  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,
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}`,
579
582
  mutates: true,
580
583
  schema: {
581
584
  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'),
585
+ file_path: z.string().describe('Path on THIS machine'),
586
+ kind: z.enum(['file', 'media']).optional(),
584
587
  },
585
- handler: (args, cfg) => requestUpload(cfg, `/docs/${seg(args.id)}/attachments`, args.file_path, { kind: args.kind ?? 'file' }),
588
+ handler: async (args, cfg) => envelope(await requestUpload(cfg, `/docs/${seg(args.id)}/attachments`, args.file_path, { kind: args.kind ?? 'file' })),
586
589
  });
587
590
  registerTool(server, config, {
588
591
  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,
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}`,
595
594
  schema: {
596
595
  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)'),
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'),
607
600
  },
608
601
  handler: (args, cfg) => downloadDocAttachment(cfg, args.id, args.path, {
609
602
  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.1",
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": {