@fswap/mcp-vikunja 0.1.6 → 0.1.7

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 +26 -7
  2. package/dist/index.js +578 -85
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @fswap/mcp-vikunja
2
2
 
3
- An [MCP](https://modelcontextprotocol.io) server for [Vikunja](https://vikunja.io) that lets Claude Desktop, Claude Code, Cursor and other MCP clients list, create, update and complete your tasks.
3
+ An [MCP](https://modelcontextprotocol.io) server for [Vikunja](https://vikunja.io) that lets Claude Desktop, Claude Code, Cursor and other MCP clients manage your tasks: create, update, complete, assign, comment, attach files, relate, move on kanban boards and everything else in the task menu.
4
4
 
5
5
  Runs locally over stdio. No install step — clients launch it with `npx`.
6
6
 
7
7
  ## Quick start
8
8
 
9
- 1. Create an API token in Vikunja under **Settings → API Tokens** (scope it to projects, tasks and labels).
9
+ 1. Create an API token in Vikunja under **Settings → API Tokens** (scope it to projects, tasks, labels, task comments, attachments, assignees, relations, subscriptions, reactions and users — tools whose scope is missing return a 401).
10
10
  2. Run the interactive setup once:
11
11
 
12
12
  ```bash
@@ -78,7 +78,7 @@ VIKUNJA_API_TOKEN = "tk_..."
78
78
  |---|---|
79
79
  | `VIKUNJA_URL` | Vikunja base URL (with or without `/api/v1`) |
80
80
  | `VIKUNJA_API_TOKEN` | API token or login JWT |
81
- | `VIKUNJA_ALLOW_DELETE` | `true` to expose `delete_task` |
81
+ | `VIKUNJA_ALLOW_DELETE` | `true` to expose `delete_task`, `delete_task_comment` and `delete_task_attachment` |
82
82
  | `VIKUNJA_DEFAULT_PROJECT_ID` | Project used by `create_task` when `projectId` is omitted |
83
83
 
84
84
  ## Tools
@@ -89,13 +89,32 @@ VIKUNJA_API_TOKEN = "tk_..."
89
89
  | `get_project` | `GET /projects/{id}` | |
90
90
  | `create_project` | `PUT /projects` | optional parent project |
91
91
  | `list_tasks` | `GET /tasks` or `GET /projects/{id}/tasks` | open tasks by default; `assignedToMe`, `filter`, `sortBy`, pagination |
92
- | `get_task` | `GET /tasks/{id}` | full task incl. description, labels, assignees |
93
- | `create_task` | `PUT /projects/{id}/tasks` | title, description, dates, priority, labels |
94
- | `update_task` | `POST /tasks/{id}` | merges your changes onto the current task; `labelIds` replaces labels |
95
- | `complete_task` | `POST /tasks/{id}` with `done: true` | `done=false` reopens |
92
+ | `get_task` | `GET /tasks/{id}` + `GET /tasks/{id}/comments` | everything on the task page, including comments (`includeComments=false` skips them) |
93
+ | `create_task` | `PUT /projects/{id}/tasks` | all task fields, see below |
94
+ | `update_task` | `POST /tasks/{id}` (+ labels/assignees bulk) | merges your changes onto the current task |
95
+ | `complete_task` | `POST /tasks/{id}` with `done: true` | `done=false` marks undone |
96
+ | `duplicate_task` | `PUT /tasks/{id}/duplicate` | copies labels, assignees, attachments, reminders |
97
+ | `set_subscription` | `PUT` / `DELETE /subscriptions/{task\|project}/{id}` | subscribe / unsubscribe |
96
98
  | `list_labels` | `GET /labels` | label ids for create/update |
97
99
  | `create_label` | `PUT /labels` | |
100
+ | `get_current_user` | `GET /user` | your own user id, e.g. to assign yourself |
101
+ | `find_users` | `GET /projects/{id}/projectusers` or `GET /users` | user ids for `assigneeIds` |
102
+ | `list_task_comments` | `GET /tasks/{id}/comments` | author, HTML body, reactions |
103
+ | `add_task_comment` | `PUT /tasks/{id}/comments` | plain text is wrapped in `<p>` |
104
+ | `update_task_comment` | `POST /tasks/{id}/comments/{commentId}` | |
105
+ | `set_reaction` | `PUT /{tasks\|comments}/{id}/reactions` | `remove=true` removes your reaction |
106
+ | `add_task_relation` | `PUT /tasks/{id}/relations` | subtask, parenttask, blocking, related, … |
107
+ | `remove_task_relation` | `DELETE /tasks/{id}/relations/{kind}/{otherId}` | |
108
+ | `list_task_attachments` | `GET /tasks/{id}/attachments` | |
109
+ | `upload_task_attachment` | `PUT /tasks/{id}/attachments` (multipart) | local file paths |
110
+ | `download_task_attachment` | `GET /tasks/{id}/attachments/{attachmentId}` | saves to disk; small images/text returned inline |
111
+ | `list_kanban_buckets` | `GET /projects/{id}/views` + `/views/{view}/buckets` | kanban columns |
112
+ | `move_task_to_bucket` | `POST /projects/{id}/views/{view}/buckets/{bucket}/tasks` | |
98
113
  | `delete_task` | `DELETE /tasks/{id}` | only when delete is allowed (setup answer or `VIKUNJA_ALLOW_DELETE=true`) |
114
+ | `delete_task_comment` | `DELETE /tasks/{id}/comments/{commentId}` | delete-gated like `delete_task` |
115
+ | `delete_task_attachment` | `DELETE /tasks/{id}/attachments/{attachmentId}` | delete-gated like `delete_task` |
116
+
117
+ `create_task` and `update_task` cover the rest of the task menu: due/start/end dates, priority, progress (`percentDone`), colour (`hexColor`), favorite (`isFavorite`), repeating interval (`repeatAfterSeconds`, `repeatMode`), reminders (absolute or relative to a date), labels (`labelIds`), assignees (`assigneeIds`) and moving to another project (`projectId`). `labelIds`, `assigneeIds` and `reminders` replace the current values. `get_task` returns all of these plus related tasks, attachments, kanban buckets, subscription and comments.
99
118
 
100
119
  Every task and project includes a `url` to its page in the Vikunja web UI (taken from `/info` `frontend_url`). Vikunja's zero date (`0001-01-01T00:00:00Z`) is normalised to `null` in every response. API errors are returned to the model as `isError` results rather than crashing the server.
101
120
 
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import envPaths from "env-paths";
8
8
  import { z } from "zod";
9
+ import os from "node:os";
9
10
  import * as p from "@clack/prompts";
10
11
  //#region \0rolldown/runtime.js
11
12
  var __defProp = Object.defineProperty;
@@ -100,7 +101,7 @@ function normalizeDate(value) {
100
101
  }
101
102
  function makeClient(baseUrl, token) {
102
103
  const base = baseUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
103
- async function request(method, path, body, query) {
104
+ async function send(method, path, init, query) {
104
105
  const url = new URL(`${base}/api/v1${path}`);
105
106
  if (query) {
106
107
  for (const [k, v] of Object.entries(query)) if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
@@ -108,13 +109,12 @@ function makeClient(baseUrl, token) {
108
109
  let res;
109
110
  try {
110
111
  res = await fetch(url, {
112
+ ...init,
111
113
  method,
112
114
  headers: {
113
115
  Authorization: `Bearer ${token}`,
114
- "Content-Type": "application/json",
115
- Accept: "application/json"
116
- },
117
- body: body !== void 0 ? JSON.stringify(body) : void 0
116
+ ...init.headers
117
+ }
118
118
  });
119
119
  } catch (err) {
120
120
  throw new Error(`${method} ${path} failed: ${err.message}`);
@@ -128,24 +128,50 @@ function makeClient(baseUrl, token) {
128
128
  } catch {}
129
129
  throw new Error(`${method} ${path} → ${res.status}: ${detail}`);
130
130
  }
131
+ return res;
132
+ }
133
+ async function parseJson(res) {
131
134
  if (res.status === 204) return void 0;
132
135
  const text = await res.text();
133
136
  return text ? JSON.parse(text) : void 0;
134
137
  }
138
+ async function request(method, path, body, query) {
139
+ return parseJson(await send(method, path, {
140
+ headers: {
141
+ "Content-Type": "application/json",
142
+ Accept: "application/json"
143
+ },
144
+ body: body !== void 0 ? JSON.stringify(body) : void 0
145
+ }, query));
146
+ }
135
147
  return {
136
148
  baseUrl: base,
137
149
  request,
138
150
  get: (path, query) => request("GET", path, void 0, query),
139
151
  put: (path, body) => request("PUT", path, body),
140
152
  post: (path, body) => request("POST", path, body),
141
- del: (path) => request("DELETE", path)
153
+ del: (path) => request("DELETE", path),
154
+ upload: async (path, form) => parseJson(await send("PUT", path, {
155
+ headers: { Accept: "application/json" },
156
+ body: form
157
+ })),
158
+ download: async (path) => {
159
+ const res = await send("GET", path, {});
160
+ return {
161
+ data: Buffer.from(await res.arrayBuffer()),
162
+ contentType: res.headers.get("content-type")
163
+ };
164
+ }
142
165
  };
143
166
  }
144
167
  /** Cheap authenticated call used by `setup` to validate URL + token. */
145
168
  async function currentUser(baseUrl, token) {
146
169
  return makeClient(baseUrl, token).get("/user");
147
170
  }
148
- var init_client = __esmMin((() => {}));
171
+ var ZERO_DATE;
172
+ var init_client = __esmMin((() => {
173
+ ZERO_DATE = "0001-01-01T00:00:00Z";
174
+ }));
149
175
  //#endregion
150
176
  //#region src/tools/_shared.ts
151
177
  init_config();
@@ -178,6 +204,39 @@ function guard(fn) {
178
204
  function stripUndefined(obj) {
179
205
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
180
206
  }
207
+ function summarizeUser(u) {
208
+ if (!u) return null;
209
+ return {
210
+ id: u.id,
211
+ username: u.username,
212
+ name: u.name || null
213
+ };
214
+ }
215
+ let frontendBase = null;
216
+ /** Resolve the web UI base URL once (Vikunja's API host may differ from its frontend host). */
217
+ async function frontendUrl(vikunja) {
218
+ if (frontendBase) return frontendBase;
219
+ try {
220
+ frontendBase = ((await vikunja.get("/info")).frontend_url || vikunja.baseUrl).replace(/\/+$/, "");
221
+ } catch {
222
+ frontendBase = vikunja.baseUrl;
223
+ }
224
+ return frontendBase;
225
+ }
226
+ /**
227
+ * Vikunja stores descriptions and comments as HTML. Pass HTML through untouched;
228
+ * wrap plain text in paragraphs so line breaks survive.
229
+ */
230
+ function toHtml(text) {
231
+ if (/<\/?[a-z][\s\S]*>/i.test(text)) return text;
232
+ const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
233
+ return text.split(/\n{2,}/).map((para) => `<p>${escape(para).replace(/\n/g, "<br>")}</p>`).join("");
234
+ }
235
+ /** Reaction map `{ "👍": [users] }` → `{ "👍": ["alice"] }`, or null when empty. */
236
+ function summarizeReactions(map) {
237
+ if (!map || Object.keys(map).length === 0) return null;
238
+ return Object.fromEntries(Object.entries(map).map(([value, users]) => [value, users.map((u) => u.username)]));
239
+ }
181
240
  //#endregion
182
241
  //#region src/tools/projects.ts
183
242
  function summarizeProject(p, base) {
@@ -192,16 +251,6 @@ function summarizeProject(p, base) {
192
251
  favorite: Boolean(p.is_favorite)
193
252
  };
194
253
  }
195
- let frontendBase$1 = null;
196
- async function frontendUrl$1(vikunja) {
197
- if (frontendBase$1) return frontendBase$1;
198
- try {
199
- frontendBase$1 = ((await vikunja.get("/info")).frontend_url || vikunja.baseUrl).replace(/\/+$/, "");
200
- } catch {
201
- frontendBase$1 = vikunja.baseUrl;
202
- }
203
- return frontendBase$1;
204
- }
205
254
  function registerProjectTools(server, vikunja) {
206
255
  server.registerTool("list_projects", {
207
256
  title: "List projects",
@@ -219,7 +268,7 @@ function registerProjectTools(server, vikunja) {
219
268
  page,
220
269
  per_page: perPage
221
270
  });
222
- const base = await frontendUrl$1(vikunja);
271
+ const base = await frontendUrl(vikunja);
223
272
  return ok(data.map((p) => summarizeProject(p, base)));
224
273
  }));
225
274
  server.registerTool("get_project", {
@@ -227,7 +276,7 @@ function registerProjectTools(server, vikunja) {
227
276
  description: "Get one Vikunja project by id.",
228
277
  inputSchema: { id: z.number().int().describe("Project id") }
229
278
  }, guard(async ({ id }) => {
230
- return ok(summarizeProject(await vikunja.get(`/projects/${id}`), await frontendUrl$1(vikunja)));
279
+ return ok(summarizeProject(await vikunja.get(`/projects/${id}`), await frontendUrl(vikunja)));
231
280
  }));
232
281
  server.registerTool("create_project", {
233
282
  title: "Create project",
@@ -241,22 +290,207 @@ function registerProjectTools(server, vikunja) {
241
290
  const body = { title };
242
291
  if (description !== void 0) body.description = description;
243
292
  if (parentProjectId !== void 0) body.parent_project_id = parentProjectId;
244
- return ok(summarizeProject(await vikunja.put("/projects", body), await frontendUrl$1(vikunja)));
293
+ return ok(summarizeProject(await vikunja.put("/projects", body), await frontendUrl(vikunja)));
245
294
  }));
246
295
  }
247
296
  //#endregion
248
- //#region src/tools/tasks.ts
249
- let frontendBase = null;
250
- /** Resolve the web UI base URL once (Vikunja's API host may differ from its frontend host). */
251
- async function frontendUrl(vikunja) {
252
- if (frontendBase) return frontendBase;
253
- try {
254
- frontendBase = ((await vikunja.get("/info")).frontend_url || vikunja.baseUrl).replace(/\/+$/, "");
255
- } catch {
256
- frontendBase = vikunja.baseUrl;
257
- }
258
- return frontendBase;
297
+ //#region src/tools/attachments.ts
298
+ function summarizeAttachment(a) {
299
+ return {
300
+ id: a.id,
301
+ name: a.file?.name ?? null,
302
+ mime: a.file?.mime || null,
303
+ size: a.file?.size ?? null,
304
+ created: a.created,
305
+ createdBy: summarizeUser(a.created_by)
306
+ };
307
+ }
308
+ const INLINE_IMAGE_TYPES = /* @__PURE__ */ new Set([
309
+ "image/png",
310
+ "image/jpeg",
311
+ "image/gif",
312
+ "image/webp"
313
+ ]);
314
+ const MAX_INLINE_IMAGE_BYTES = 1e6;
315
+ const MAX_INLINE_TEXT_BYTES = 2e5;
316
+ function isTextual(mime) {
317
+ return /^text\/|json|xml|yaml|csv|markdown|javascript/.test(mime);
318
+ }
319
+ function safeFileName(name, fallback) {
320
+ const base = path.basename((name || "").replace(/[\\/]/g, "_"));
321
+ return base && base !== "." && base !== ".." ? base : fallback;
259
322
  }
323
+ function registerAttachmentTools(server, vikunja, { allowDelete = false } = {}) {
324
+ server.registerTool("list_task_attachments", {
325
+ title: "List task attachments",
326
+ description: "List files attached to a task (id, name, mime type, size, uploader).",
327
+ inputSchema: {
328
+ taskId: z.number().int().describe("Task id"),
329
+ page: z.number().int().min(1).default(1),
330
+ perPage: z.number().int().min(1).max(100).default(50)
331
+ }
332
+ }, guard(async ({ taskId, page, perPage }) => {
333
+ return ok((await vikunja.get(`/tasks/${taskId}/attachments`, {
334
+ page,
335
+ per_page: perPage
336
+ }) ?? []).map(summarizeAttachment));
337
+ }));
338
+ server.registerTool("upload_task_attachment", {
339
+ title: "Upload task attachment",
340
+ description: "Attach one or more local files to a task.",
341
+ inputSchema: {
342
+ taskId: z.number().int().describe("Task id"),
343
+ filePaths: z.array(z.string().min(1)).min(1).describe("Absolute paths of local files to upload")
344
+ }
345
+ }, guard(async ({ taskId, filePaths }) => {
346
+ const form = new FormData();
347
+ for (const p of filePaths) {
348
+ const abs = path.resolve(p);
349
+ form.append("files", await fs.openAsBlob(abs), path.basename(abs));
350
+ }
351
+ const res = await vikunja.upload(`/tasks/${taskId}/attachments`, form);
352
+ const uploaded = (res?.success ?? []).map(summarizeAttachment);
353
+ const errors = (res?.errors ?? []).map((e) => e.message ?? JSON.stringify(e));
354
+ if (errors.length > 0 && uploaded.length === 0) throw new Error(`Upload failed: ${errors.join("; ")}`);
355
+ return ok({
356
+ uploaded,
357
+ errors
358
+ });
359
+ }));
360
+ server.registerTool("download_task_attachment", {
361
+ title: "Download task attachment",
362
+ description: "Save a task attachment to a local file. Small images and text files are also returned inline so you can read them.",
363
+ inputSchema: {
364
+ taskId: z.number().int().describe("Task id"),
365
+ attachmentId: z.number().int().describe("Attachment id (from get_task or list_task_attachments)"),
366
+ outputPath: z.string().optional().describe("Where to save the file; defaults to <tmp>/mcp-vikunja/task-<taskId>/<file name>")
367
+ }
368
+ }, guard(async ({ taskId, attachmentId, outputPath }) => {
369
+ const attachment = ((await vikunja.get(`/tasks/${taskId}`)).attachments ?? []).find((a) => a.id === attachmentId);
370
+ if (!attachment) throw new Error(`Attachment ${attachmentId} not found on task ${taskId}.`);
371
+ const { data, contentType } = await vikunja.download(`/tasks/${taskId}/attachments/${attachmentId}`);
372
+ const name = safeFileName(attachment.file?.name, `attachment-${attachmentId}`);
373
+ const target = path.resolve(outputPath ?? path.join(os.tmpdir(), "mcp-vikunja", `task-${taskId}`, name));
374
+ fs.mkdirSync(path.dirname(target), { recursive: true });
375
+ fs.writeFileSync(target, data);
376
+ const mime = (attachment.file?.mime || contentType || "application/octet-stream").split(";")[0].trim();
377
+ const content = [{
378
+ type: "text",
379
+ text: JSON.stringify({
380
+ savedTo: target,
381
+ name,
382
+ mime,
383
+ size: data.length
384
+ }, null, 2)
385
+ }];
386
+ if (INLINE_IMAGE_TYPES.has(mime) && data.length <= MAX_INLINE_IMAGE_BYTES) content.push({
387
+ type: "image",
388
+ data: data.toString("base64"),
389
+ mimeType: mime
390
+ });
391
+ else if (isTextual(mime) && data.length <= MAX_INLINE_TEXT_BYTES) content.push({
392
+ type: "text",
393
+ text: data.toString("utf8")
394
+ });
395
+ return { content };
396
+ }));
397
+ if (allowDelete) server.registerTool("delete_task_attachment", {
398
+ title: "Delete task attachment",
399
+ description: "Permanently delete an attachment. Irreversible — only use when the user explicitly asks.",
400
+ inputSchema: {
401
+ taskId: z.number().int().describe("Task id"),
402
+ attachmentId: z.number().int().describe("Attachment id")
403
+ }
404
+ }, guard(async ({ taskId, attachmentId }) => {
405
+ await vikunja.del(`/tasks/${taskId}/attachments/${attachmentId}`);
406
+ return ok({ deleted: attachmentId });
407
+ }));
408
+ }
409
+ //#endregion
410
+ //#region src/tools/comments.ts
411
+ function summarizeComment(c) {
412
+ return {
413
+ id: c.id,
414
+ author: summarizeUser(c.author),
415
+ comment: c.comment,
416
+ reactions: summarizeReactions(c.reactions),
417
+ created: c.created,
418
+ edited: c.updated && c.updated !== c.created ? c.updated : null
419
+ };
420
+ }
421
+ const commentText = z.string().min(1).describe("Comment body. HTML is sent as-is; plain text is wrapped in <p> (blank line = new paragraph)");
422
+ function registerCommentTools(server, vikunja, { allowDelete = false } = {}) {
423
+ server.registerTool("list_task_comments", {
424
+ title: "List task comments",
425
+ description: "List all comments on a task with author, HTML body, reactions and timestamps.",
426
+ inputSchema: {
427
+ taskId: z.number().int().describe("Task id"),
428
+ order: z.enum(["asc", "desc"]).default("asc").describe("asc = oldest first")
429
+ }
430
+ }, guard(async ({ taskId, order }) => {
431
+ return ok((await vikunja.get(`/tasks/${taskId}/comments`, { order_by: order }) ?? []).map(summarizeComment));
432
+ }));
433
+ server.registerTool("add_task_comment", {
434
+ title: "Add task comment",
435
+ description: "Post a comment on a task as the current user. Visible to everyone with access to the task.",
436
+ inputSchema: {
437
+ taskId: z.number().int().describe("Task id"),
438
+ comment: commentText
439
+ }
440
+ }, guard(async ({ taskId, comment }) => {
441
+ return ok(summarizeComment(await vikunja.put(`/tasks/${taskId}/comments`, { comment: toHtml(comment) })));
442
+ }));
443
+ server.registerTool("update_task_comment", {
444
+ title: "Edit task comment",
445
+ description: "Replace the text of an existing comment.",
446
+ inputSchema: {
447
+ taskId: z.number().int().describe("Task id"),
448
+ commentId: z.number().int().describe("Comment id"),
449
+ comment: commentText
450
+ }
451
+ }, guard(async ({ taskId, commentId, comment }) => {
452
+ return ok(summarizeComment(await vikunja.post(`/tasks/${taskId}/comments/${commentId}`, { comment: toHtml(comment) })));
453
+ }));
454
+ server.registerTool("set_reaction", {
455
+ title: "Add / remove reaction",
456
+ description: "Add an emoji reaction (or remove yours with remove=true) on a task or a task comment.",
457
+ inputSchema: {
458
+ entity: z.enum(["task", "comment"]).default("task"),
459
+ id: z.number().int().describe("Task id, or comment id when entity=comment"),
460
+ reaction: z.string().min(1).max(20).describe("Emoji or short text, e.g. 👍"),
461
+ remove: z.boolean().default(false)
462
+ }
463
+ }, guard(async ({ entity, id, reaction, remove }) => {
464
+ const kind = entity === "task" ? "tasks" : "comments";
465
+ if (remove) await vikunja.post(`/${kind}/${id}/reactions/delete`, { value: reaction });
466
+ else await vikunja.put(`/${kind}/${id}/reactions`, { value: reaction });
467
+ return ok({
468
+ entity,
469
+ id,
470
+ reaction,
471
+ active: !remove
472
+ });
473
+ }));
474
+ if (allowDelete) server.registerTool("delete_task_comment", {
475
+ title: "Delete task comment",
476
+ description: "Permanently delete a comment. Irreversible — only use when the user explicitly asks.",
477
+ inputSchema: {
478
+ taskId: z.number().int().describe("Task id"),
479
+ commentId: z.number().int().describe("Comment id")
480
+ }
481
+ }, guard(async ({ taskId, commentId }) => {
482
+ await vikunja.del(`/tasks/${taskId}/comments/${commentId}`);
483
+ return ok({ deleted: commentId });
484
+ }));
485
+ }
486
+ //#endregion
487
+ //#region src/tools/tasks.ts
488
+ /** Index = Vikunja's numeric repeat_mode. */
489
+ const REPEAT_MODES = [
490
+ "default",
491
+ "monthly",
492
+ "fromCurrentDate"
493
+ ];
260
494
  function summarizeTask(t, base) {
261
495
  return {
262
496
  id: t.id,
@@ -280,26 +514,100 @@ function fullTask(t, base) {
280
514
  startDate: normalizeDate(t.start_date),
281
515
  endDate: normalizeDate(t.end_date),
282
516
  percentDone: t.percent_done ?? 0,
283
- repeatAfterSeconds: t.repeat_after || null,
517
+ color: t.hex_color || null,
518
+ favorite: Boolean(t.is_favorite),
519
+ repeat: t.repeat_after || t.repeat_mode ? {
520
+ afterSeconds: t.repeat_after || 0,
521
+ mode: REPEAT_MODES[t.repeat_mode ?? 0] ?? "default"
522
+ } : null,
523
+ reminders: (t.reminders ?? []).map((r) => r.relative_to ? {
524
+ relativeTo: r.relative_to,
525
+ relativePeriodSeconds: r.relative_period ?? 0,
526
+ reminder: normalizeDate(r.reminder)
527
+ } : { reminder: normalizeDate(r.reminder) }),
284
528
  labels: (t.labels ?? []).map((l) => ({
285
529
  id: l.id,
286
530
  title: l.title,
287
531
  color: l.hex_color || null
288
532
  })),
289
- assignees: (t.assignees ?? []).map((u) => ({
290
- id: u.id,
291
- username: u.username,
292
- name: u.name || null
293
- })),
533
+ assignees: (t.assignees ?? []).map((u) => summarizeUser(u)),
534
+ subscription: t.subscription ? {
535
+ entity: t.subscription.entity,
536
+ entityId: t.subscription.entity_id
537
+ } : null,
538
+ relatedTasks: Object.fromEntries(Object.entries(t.related_tasks ?? {}).map(([kind, tasks]) => [kind, (tasks ?? []).map((r) => ({
539
+ id: r.id,
540
+ title: r.title,
541
+ done: r.done,
542
+ projectId: r.project_id,
543
+ url: `${base}/tasks/${r.id}`
544
+ }))])),
545
+ attachments: (t.attachments ?? []).map(summarizeAttachment),
546
+ coverImageAttachmentId: t.cover_image_attachment_id || null,
547
+ kanbanBuckets: t.buckets ? t.buckets.map((b) => ({
548
+ id: b.id,
549
+ title: b.title,
550
+ viewId: b.project_view_id
551
+ })) : void 0,
552
+ createdBy: summarizeUser(t.created_by),
294
553
  created: t.created
295
554
  };
296
555
  }
556
+ /** Fetch one task including its kanban buckets; falls back for Vikunja versions without `expand`. */
557
+ async function fetchTask(vikunja, id) {
558
+ try {
559
+ return await vikunja.get(`/tasks/${id}`, { expand: "buckets" });
560
+ } catch {
561
+ return vikunja.get(`/tasks/${id}`);
562
+ }
563
+ }
297
564
  const dateField = z.string().datetime({ offset: true }).describe("RFC 3339 timestamp, e.g. 2026-09-15T17:00:00Z");
298
565
  const priorityField = z.number().int().min(0).max(5).describe("0 = unset, 1 = low, 2 = medium, 3 = high, 4 = urgent, 5 = DO NOW");
566
+ const reminderField = z.object({
567
+ reminder: dateField.optional().describe("Absolute reminder time"),
568
+ relativeTo: z.enum([
569
+ "due_date",
570
+ "start_date",
571
+ "end_date"
572
+ ]).optional().describe("Date the reminder is relative to"),
573
+ relativePeriodSeconds: z.number().int().optional().describe("Offset from relativeTo in seconds; negative = before (e.g. -3600 = one hour before)")
574
+ }).refine((r) => r.reminder !== void 0 || r.relativeTo !== void 0, "Give either reminder or relativeTo");
575
+ /** Fields shared by create_task and update_task beyond the basic ones. */
576
+ const extraFields = {
577
+ percentDone: z.number().min(0).max(1).optional().describe("Progress as a fraction 0–1 (the UI uses 10% steps)"),
578
+ hexColor: z.string().regex(/^#?([0-9a-fA-F]{6})?$/).optional().describe("Task colour as 6 hex digits, e.g. 1973ff; empty string removes the colour"),
579
+ isFavorite: z.boolean().optional().describe("Add to (true) or remove from (false) the current user's favorites"),
580
+ repeatAfterSeconds: z.number().int().min(0).optional().describe("Repeat interval in seconds (3600 hourly, 86400 daily, 604800 weekly); 0 stops repeating"),
581
+ repeatMode: z.enum(REPEAT_MODES).optional().describe("default = shift dates by repeatAfterSeconds when marked done; monthly = same day next month (ignores repeatAfterSeconds); fromCurrentDate = shift from the moment it is marked done"),
582
+ reminders: z.array(reminderField).optional().describe("REPLACES all reminders; [] removes them"),
583
+ labelIds: z.array(z.number().int()).optional().describe("Label ids (list_labels); REPLACES the task's labels"),
584
+ assigneeIds: z.array(z.number().int()).optional().describe("User ids (find_users / get_current_user); REPLACES the assignees, [] unassigns everyone")
585
+ };
586
+ function toApiReminder(r) {
587
+ if (r.relativeTo) return {
588
+ relative_to: r.relativeTo,
589
+ relative_period: r.relativePeriodSeconds ?? 0
590
+ };
591
+ return { reminder: r.reminder };
592
+ }
593
+ function extraBody(a) {
594
+ return stripUndefined({
595
+ percent_done: a.percentDone,
596
+ hex_color: a.hexColor?.replace(/^#/, ""),
597
+ is_favorite: a.isFavorite,
598
+ repeat_after: a.repeatAfterSeconds,
599
+ repeat_mode: a.repeatMode === void 0 ? void 0 : REPEAT_MODES.indexOf(a.repeatMode),
600
+ reminders: a.reminders?.map(toApiReminder)
601
+ });
602
+ }
299
603
  /** Replace the task's labels with exactly `labelIds` (Vikunja has no single-call "set labels"). */
300
604
  async function setLabels(vikunja, taskId, labelIds) {
301
605
  await vikunja.post(`/tasks/${taskId}/labels/bulk`, { labels: labelIds.map((id) => ({ id })) });
302
606
  }
607
+ /** Replace the task's assignees; users missing from the list are unassigned. */
608
+ async function setAssignees(vikunja, taskId, userIds) {
609
+ await vikunja.post(`/tasks/${taskId}/assignees/bulk`, { assignees: userIds.map((id) => ({ id })) });
610
+ }
303
611
  function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjectId = null } = {}) {
304
612
  server.registerTool("list_tasks", {
305
613
  title: "List tasks",
@@ -346,84 +654,94 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
346
654
  }));
347
655
  server.registerTool("get_task", {
348
656
  title: "Get task",
349
- description: "Get a single task with full details: description, dates, priority, labels and assignees.",
350
- inputSchema: { id: z.number().int().describe("Task id") }
351
- }, guard(async ({ id }) => {
352
- return ok(fullTask(await vikunja.get(`/tasks/${id}`), await frontendUrl(vikunja)));
657
+ description: "Get a single task with full details: description, dates, priority, progress, colour, favorite, repeat, reminders, labels, assignees, subscription, related tasks, attachments, kanban buckets and (by default) comments.",
658
+ inputSchema: {
659
+ id: z.number().int().describe("Task id"),
660
+ includeComments: z.boolean().default(true).describe("Also return the task's comments (oldest first)")
661
+ }
662
+ }, guard(async ({ id, includeComments }) => {
663
+ const [task, comments] = await Promise.all([fetchTask(vikunja, id), includeComments ? vikunja.get(`/tasks/${id}/comments`) : Promise.resolve(void 0)]);
664
+ const result = fullTask(task, await frontendUrl(vikunja));
665
+ return ok(comments === void 0 ? result : {
666
+ ...result,
667
+ comments: (comments ?? []).map(summarizeComment)
668
+ });
353
669
  }));
354
670
  server.registerTool("create_task", {
355
671
  title: "Create task",
356
- description: "Create a task in a project. Use list_projects to find the projectId and list_labels for label ids." + (defaultProjectId != null ? ` If projectId is omitted, project ${defaultProjectId} is used.` : ""),
672
+ description: "Create a task in a project. Use list_projects for the projectId, list_labels for label ids and find_users for assignee ids. Descriptions are HTML." + (defaultProjectId != null ? ` If projectId is omitted, project ${defaultProjectId} is used.` : ""),
357
673
  inputSchema: {
358
674
  projectId: defaultProjectId != null ? z.number().int().default(defaultProjectId).describe("Project id") : z.number().int().describe("Project id"),
359
675
  title: z.string().min(1).describe("Task title"),
360
- description: z.string().optional().describe("Task description (markdown/HTML accepted by Vikunja)"),
676
+ description: z.string().optional().describe("Task description (HTML)"),
361
677
  dueDate: dateField.optional(),
362
678
  startDate: dateField.optional(),
363
679
  endDate: dateField.optional(),
364
680
  priority: priorityField.optional(),
365
- labelIds: z.array(z.number().int()).optional().describe("Label ids to attach")
681
+ ...extraFields
366
682
  }
367
- }, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds }) => {
368
- const body = stripUndefined({
369
- title,
370
- description,
371
- due_date: dueDate,
372
- start_date: startDate,
373
- end_date: endDate,
374
- priority
375
- });
683
+ }, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds, assigneeIds, ...extra }) => {
684
+ const body = {
685
+ ...stripUndefined({
686
+ title,
687
+ description,
688
+ due_date: dueDate,
689
+ start_date: startDate,
690
+ end_date: endDate,
691
+ priority
692
+ }),
693
+ ...extraBody(extra)
694
+ };
376
695
  let task = await vikunja.put(`/projects/${projectId}/tasks`, body);
377
- if (labelIds && labelIds.length > 0) {
378
- await setLabels(vikunja, task.id, labelIds);
379
- task = await vikunja.get(`/tasks/${task.id}`);
380
- }
696
+ if (labelIds && labelIds.length > 0) await setLabels(vikunja, task.id, labelIds);
697
+ if (assigneeIds && assigneeIds.length > 0) await setAssignees(vikunja, task.id, assigneeIds);
698
+ if (labelIds?.length || assigneeIds?.length) task = await fetchTask(vikunja, task.id);
381
699
  return ok(fullTask(task, await frontendUrl(vikunja)));
382
700
  }));
383
701
  server.registerTool("update_task", {
384
702
  title: "Update task",
385
- description: "Update fields of an existing task. Only the fields you pass are changed. To clear a date pass null. labelIds, when given, REPLACES the task's labels. Use complete_task to just mark a task done.",
703
+ description: "Update fields of an existing task. Only the fields you pass are changed. To clear a date pass null. labelIds, assigneeIds and reminders REPLACE the current values. projectId moves the task to another project. Use complete_task to just mark a task done.",
386
704
  inputSchema: {
387
705
  id: z.number().int().describe("Task id"),
388
706
  title: z.string().min(1).optional(),
389
- description: z.string().optional(),
707
+ description: z.string().optional().describe("Task description (HTML)"),
390
708
  done: z.boolean().optional(),
391
709
  dueDate: dateField.nullable().optional(),
392
710
  startDate: dateField.nullable().optional(),
393
711
  endDate: dateField.nullable().optional(),
394
712
  priority: priorityField.optional(),
395
- percentDone: z.number().min(0).max(1).optional().describe("Progress as a fraction 0–1"),
396
713
  projectId: z.number().int().optional().describe("Move the task to another project"),
397
- labelIds: z.array(z.number().int()).optional().describe("Replace labels with these ids")
714
+ ...extraFields
398
715
  }
399
- }, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, percentDone, projectId, labelIds }) => {
400
- const current = await vikunja.get(`/tasks/${id}`);
401
- const patch = stripUndefined({
402
- title,
403
- description,
404
- done,
405
- due_date: dueDate === null ? "0001-01-01T00:00:00Z" : dueDate,
406
- start_date: startDate === null ? "0001-01-01T00:00:00Z" : startDate,
407
- end_date: endDate === null ? "0001-01-01T00:00:00Z" : endDate,
408
- priority,
409
- percent_done: percentDone,
410
- project_id: projectId
411
- });
412
- if (Object.keys(patch).length === 0 && labelIds === void 0) throw new Error("Nothing to update.");
413
- let task = current;
414
- if (Object.keys(patch).length > 0) task = await vikunja.post(`/tasks/${id}`, {
415
- ...current,
416
- ...patch
417
- });
418
- if (labelIds !== void 0) {
419
- await setLabels(vikunja, id, labelIds);
420
- task = await vikunja.get(`/tasks/${id}`);
716
+ }, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, projectId, labelIds, assigneeIds, ...extra }) => {
717
+ const patch = {
718
+ ...stripUndefined({
719
+ title,
720
+ description,
721
+ done,
722
+ due_date: dueDate === null ? ZERO_DATE : dueDate,
723
+ start_date: startDate === null ? ZERO_DATE : startDate,
724
+ end_date: endDate === null ? ZERO_DATE : endDate,
725
+ priority,
726
+ project_id: projectId
727
+ }),
728
+ ...extraBody(extra)
729
+ };
730
+ if (Object.keys(patch).length === 0 && labelIds === void 0 && assigneeIds === void 0) throw new Error("Nothing to update.");
731
+ if (Object.keys(patch).length > 0) {
732
+ const current = await vikunja.get(`/tasks/${id}`);
733
+ await vikunja.post(`/tasks/${id}`, {
734
+ ...current,
735
+ ...patch
736
+ });
421
737
  }
422
- return ok(fullTask(task, await frontendUrl(vikunja)));
738
+ if (labelIds !== void 0) await setLabels(vikunja, id, labelIds);
739
+ if (assigneeIds !== void 0) await setAssignees(vikunja, id, assigneeIds);
740
+ return ok(fullTask(await fetchTask(vikunja, id), await frontendUrl(vikunja)));
423
741
  }));
424
742
  server.registerTool("complete_task", {
425
743
  title: "Complete task",
426
- description: "Mark a task as done (or reopen it with done=false).",
744
+ description: "Mark a task as done (or undone / reopen it with done=false).",
427
745
  inputSchema: {
428
746
  id: z.number().int().describe("Task id"),
429
747
  done: z.boolean().default(true).describe("false to reopen a completed task")
@@ -435,6 +753,45 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
435
753
  done
436
754
  }), await frontendUrl(vikunja)));
437
755
  }));
756
+ server.registerTool("duplicate_task", {
757
+ title: "Duplicate task",
758
+ description: "Copy a task with labels, assignees, attachments and reminders into the same project. The copy gets a \"copiedfrom\" relation to the original.",
759
+ inputSchema: { id: z.number().int().describe("Task id to duplicate") }
760
+ }, guard(async ({ id }) => {
761
+ const res = await vikunja.put(`/tasks/${id}/duplicate`);
762
+ if (!res?.duplicated_task) throw new Error("Vikunja did not return the duplicated task.");
763
+ return ok(fullTask(res.duplicated_task, await frontendUrl(vikunja)));
764
+ }));
765
+ server.registerTool("set_subscription", {
766
+ title: "Subscribe / unsubscribe",
767
+ description: "Subscribe the current user to notifications for a task or project, or unsubscribe (subscribed=false). A task subscription may be inherited from its project; then unsubscribe from the project instead.",
768
+ inputSchema: {
769
+ entity: z.enum(["task", "project"]).default("task"),
770
+ id: z.number().int().describe("Task or project id"),
771
+ subscribed: z.boolean().default(true).describe("false to unsubscribe")
772
+ }
773
+ }, guard(async ({ entity, id, subscribed }) => {
774
+ if (entity === "task") {
775
+ const task = await vikunja.get(`/tasks/${id}`);
776
+ const sub = task.subscription;
777
+ if (Boolean(sub) === subscribed) return ok({
778
+ entity,
779
+ id,
780
+ subscribed,
781
+ changed: false,
782
+ via: sub ? String(sub.entity) : null
783
+ });
784
+ if (sub && String(sub.entity) !== "task") throw new Error(`Task ${id} inherits its subscription from project ${task.project_id}; unsubscribe with entity="project", id=${task.project_id}.`);
785
+ }
786
+ if (subscribed) await vikunja.put(`/subscriptions/${entity}/${id}`);
787
+ else await vikunja.del(`/subscriptions/${entity}/${id}`);
788
+ return ok({
789
+ entity,
790
+ id,
791
+ subscribed,
792
+ changed: true
793
+ });
794
+ }));
438
795
  server.registerTool("list_labels", {
439
796
  title: "List labels",
440
797
  description: "List labels available to the user, with ids for use in create_task / update_task.",
@@ -485,6 +842,137 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
485
842
  }));
486
843
  }
487
844
  //#endregion
845
+ //#region src/tools/relations.ts
846
+ const relationInput = {
847
+ taskId: z.number().int().describe("The base task"),
848
+ kind: z.enum([
849
+ "subtask",
850
+ "parenttask",
851
+ "related",
852
+ "duplicateof",
853
+ "duplicates",
854
+ "blocking",
855
+ "blocked",
856
+ "precedes",
857
+ "follows",
858
+ "copiedfrom",
859
+ "copiedto"
860
+ ]).describe("What otherTaskId is to taskId: subtask = other is a subtask of taskId, parenttask = other is its parent, blocking = taskId blocks other, blocked = taskId is blocked by other, precedes / follows, related, duplicateof / duplicates, copiedfrom / copiedto. Vikunja creates the inverse relation automatically."),
861
+ otherTaskId: z.number().int().describe("The related task")
862
+ };
863
+ function registerRelationTools(server, vikunja) {
864
+ server.registerTool("add_task_relation", {
865
+ title: "Add task relation",
866
+ description: "Relate two tasks (subtask, parent, blocking, related, …). See get_task relatedTasks for current ones.",
867
+ inputSchema: relationInput
868
+ }, guard(async ({ taskId, kind, otherTaskId }) => {
869
+ await vikunja.put(`/tasks/${taskId}/relations`, {
870
+ task_id: taskId,
871
+ other_task_id: otherTaskId,
872
+ relation_kind: kind
873
+ });
874
+ return ok({
875
+ taskId,
876
+ kind,
877
+ otherTaskId,
878
+ related: true
879
+ });
880
+ }));
881
+ server.registerTool("remove_task_relation", {
882
+ title: "Remove task relation",
883
+ description: "Remove a relation between two tasks (the inverse relation is removed too).",
884
+ inputSchema: relationInput
885
+ }, guard(async ({ taskId, kind, otherTaskId }) => {
886
+ await vikunja.del(`/tasks/${taskId}/relations/${kind}/${otherTaskId}`);
887
+ return ok({
888
+ taskId,
889
+ kind,
890
+ otherTaskId,
891
+ related: false
892
+ });
893
+ }));
894
+ }
895
+ //#endregion
896
+ //#region src/tools/kanban.ts
897
+ async function kanbanViews(vikunja, projectId) {
898
+ return (await vikunja.get(`/projects/${projectId}/views`) ?? []).filter((v) => v.view_kind === "kanban" || v.view_kind === 3);
899
+ }
900
+ async function viewBuckets(vikunja, projectId, viewId) {
901
+ return await vikunja.get(`/projects/${projectId}/views/${viewId}/buckets`) ?? [];
902
+ }
903
+ function registerKanbanTools(server, vikunja) {
904
+ server.registerTool("list_kanban_buckets", {
905
+ title: "List kanban buckets",
906
+ description: "List a project's kanban views and their buckets (columns), with ids for move_task_to_bucket.",
907
+ inputSchema: { projectId: z.number().int().describe("Project id") }
908
+ }, guard(async ({ projectId }) => {
909
+ const views = await kanbanViews(vikunja, projectId);
910
+ return ok(await Promise.all(views.map(async (v) => ({
911
+ viewId: v.id,
912
+ title: v.title,
913
+ defaultBucketId: v.default_bucket_id || null,
914
+ doneBucketId: v.done_bucket_id || null,
915
+ buckets: (await viewBuckets(vikunja, projectId, v.id)).map((b) => ({
916
+ id: b.id,
917
+ title: b.title,
918
+ limit: b.limit || null
919
+ }))
920
+ }))));
921
+ }));
922
+ server.registerTool("move_task_to_bucket", {
923
+ title: "Move task to kanban bucket",
924
+ description: "Move a task into a kanban bucket (column) of its project. Moving into the view's done bucket marks the task done. To move a task to another project use update_task with projectId.",
925
+ inputSchema: {
926
+ taskId: z.number().int().describe("Task id"),
927
+ bucketId: z.number().int().describe("Target bucket id (list_kanban_buckets)"),
928
+ viewId: z.number().int().optional().describe("Kanban view id; looked up from the bucket when omitted")
929
+ }
930
+ }, guard(async ({ taskId, bucketId, viewId }) => {
931
+ const projectId = (await vikunja.get(`/tasks/${taskId}`)).project_id;
932
+ let view = viewId;
933
+ if (view == null) {
934
+ for (const v of await kanbanViews(vikunja, projectId)) if ((await viewBuckets(vikunja, projectId, v.id)).some((b) => b.id === bucketId)) {
935
+ view = v.id;
936
+ break;
937
+ }
938
+ if (view == null) throw new Error(`Bucket ${bucketId} is not in any kanban view of project ${projectId}; see list_kanban_buckets.`);
939
+ }
940
+ await vikunja.post(`/projects/${projectId}/views/${view}/buckets/${bucketId}/tasks`, {
941
+ task_id: taskId,
942
+ bucket_id: bucketId,
943
+ project_view_id: view
944
+ });
945
+ return ok({
946
+ taskId,
947
+ projectId,
948
+ viewId: view,
949
+ bucketId,
950
+ moved: true
951
+ });
952
+ }));
953
+ }
954
+ //#endregion
955
+ //#region src/tools/users.ts
956
+ function registerUserTools(server, vikunja) {
957
+ server.registerTool("get_current_user", {
958
+ title: "Get current user",
959
+ description: "Return the authenticated user's id, username and name (e.g. to assign a task to yourself).",
960
+ inputSchema: {}
961
+ }, guard(async () => ok(summarizeUser(await vikunja.get("/user")))));
962
+ server.registerTool("find_users", {
963
+ title: "Find users",
964
+ description: "Find user ids for assigning tasks. With projectId, lists users who have access to that project (only they can be assigned); search narrows by username or name.",
965
+ inputSchema: {
966
+ search: z.string().optional().describe("Username, name or full email"),
967
+ projectId: z.number().int().optional().describe("Only users with access to this project")
968
+ }
969
+ }, guard(async ({ search, projectId }) => {
970
+ if (projectId == null && !search) throw new Error("Pass search, projectId, or both.");
971
+ const path = projectId != null ? `/projects/${projectId}/projectusers` : "/users";
972
+ return ok((await vikunja.get(path, { s: search }) ?? []).map((u) => summarizeUser(u)));
973
+ }));
974
+ }
975
+ //#endregion
488
976
  //#region src/snippets.ts
489
977
  function jsonSnippet(missing) {
490
978
  const server = {
@@ -687,6 +1175,11 @@ async function runServer() {
687
1175
  allowDelete: config.allowDelete,
688
1176
  defaultProjectId: config.defaultProjectId
689
1177
  });
1178
+ registerCommentTools(server, vikunja, { allowDelete: config.allowDelete });
1179
+ registerAttachmentTools(server, vikunja, { allowDelete: config.allowDelete });
1180
+ registerRelationTools(server, vikunja);
1181
+ registerKanbanTools(server, vikunja);
1182
+ registerUserTools(server, vikunja);
690
1183
  const transport = new StdioServerTransport();
691
1184
  await server.connect(transport);
692
1185
  console.error(`mcp-vikunja ${VERSION} connected (${config.url}, delete tools ${config.allowDelete ? "enabled" : "disabled"})`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fswap/mcp-vikunja",
3
- "version": "0.1.6",
4
- "description": "MCP server for Vikunja — list, create, update and complete tasks from Claude",
3
+ "version": "0.1.7",
4
+ "description": "MCP server for Vikunja — manage tasks, assignees, comments, attachments, relations and kanban from Claude",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mcp-vikunja": "dist/index.js"