@fswap/mcp-vikunja 0.1.8 → 0.1.9

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 +5 -3
  2. package/dist/index.js +89 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -100,8 +100,8 @@ VIKUNJA_API_TOKEN = "tk_..."
100
100
  | `get_current_user` | `GET /user` | your own user id, e.g. to assign yourself |
101
101
  | `find_users` | `GET /projects/{id}/projectusers` or `GET /users` | user ids for `assigneeIds` |
102
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}` | |
103
+ | `add_task_comment` | `PUT /tasks/{id}/comments` | plain text is wrapped in `<p>`; `filePaths` uploads files and shows them in the comment |
104
+ | `update_task_comment` | `POST /tasks/{id}/comments/{commentId}` | `filePaths` without `comment` adds files to the existing text |
105
105
  | `set_reaction` | `PUT /{tasks\|comments}/{id}/reactions` | `remove=true` removes your reaction |
106
106
  | `add_task_relation` | `PUT /tasks/{id}/relations` | subtask, parenttask, blocking, related, … |
107
107
  | `remove_task_relation` | `DELETE /tasks/{id}/relations/{kind}/{otherId}` | |
@@ -114,7 +114,9 @@ VIKUNJA_API_TOKEN = "tk_..."
114
114
  | `delete_task_comment` | `DELETE /tasks/{id}/comments/{commentId}` | delete-gated like `delete_task` |
115
115
  | `delete_task_attachment` | `DELETE /tasks/{id}/attachments/{attachmentId}` | delete-gated like `delete_task` |
116
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.
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. `descriptionFilePaths` uploads local files and shows them at the end of the description. `get_task` returns all of these plus related tasks, attachments, kanban buckets, subscription and comments.
118
+
119
+ Vikunja has no comment-level attachments. Files shown in a comment or description are task attachments, exactly as when pasting into the web editor: images are embedded as `<img data-src>`, other files are referenced by name.
118
120
 
119
121
  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.
120
122
 
package/dist/index.js CHANGED
@@ -229,8 +229,10 @@ async function frontendUrl(vikunja) {
229
229
  */
230
230
  function toHtml(text) {
231
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("");
232
+ return text.split(/\n{2,}/).map((para) => `<p>${escapeHtml(para).replace(/\n/g, "<br>")}</p>`).join("");
233
+ }
234
+ function escapeHtml(s) {
235
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
234
236
  }
235
237
  /** Reaction map `{ "👍": [users] }` → `{ "👍": ["alice"] }`, or null when empty. */
236
238
  function summarizeReactions(map) {
@@ -305,6 +307,46 @@ function summarizeAttachment(a) {
305
307
  createdBy: summarizeUser(a.created_by)
306
308
  };
307
309
  }
310
+ const filePathsField = z.array(z.string().min(1)).min(1).describe("Absolute paths of local files to upload");
311
+ /** Upload local files as task attachments. Throws only when every file failed. */
312
+ async function uploadAttachments(vikunja, taskId, filePaths) {
313
+ const form = new FormData();
314
+ for (const p of filePaths) {
315
+ const abs = path.resolve(p);
316
+ form.append("files", await fs.openAsBlob(abs), path.basename(abs));
317
+ }
318
+ const res = await vikunja.upload(`/tasks/${taskId}/attachments`, form);
319
+ const uploaded = res?.success ?? [];
320
+ const errors = (res?.errors ?? []).map((e) => e.message ?? JSON.stringify(e));
321
+ if (errors.length > 0 && uploaded.length === 0) throw new Error(`Upload failed: ${errors.join("; ")}`);
322
+ return {
323
+ uploaded,
324
+ errors
325
+ };
326
+ }
327
+ const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|bmp|avif)$/i;
328
+ /**
329
+ * Show attachments inside a description or comment the way the web editor does:
330
+ * images as `<img data-src>` (the UI fetches them with the viewer's token), other files by name.
331
+ * Vikunja has no comment-level attachments, so these are always task attachments.
332
+ */
333
+ function attachmentHtml(vikunja, taskId, attachments) {
334
+ if (attachments.length === 0) return "";
335
+ return attachments.map((a) => {
336
+ const name = a.file?.name || `attachment-${a.id}`;
337
+ if (a.file?.mime?.startsWith("image/") || IMAGE_EXTENSIONS.test(name)) return `<img data-src="${vikunja.baseUrl}/api/v1/tasks/${taskId}/attachments/${a.id}" src="#" id="tiptap-image-${taskId}-${a.id}">`;
338
+ return `<p>📎 ${escapeHtml(name)} (attachment #${a.id})</p>`;
339
+ }).join("") + "<p></p>";
340
+ }
341
+ /** Add what was uploaded (and any per-file failures) to a tool result. */
342
+ function withUploadInfo(result, files) {
343
+ if (!files) return result;
344
+ return {
345
+ ...result,
346
+ uploaded: files.uploaded.map(summarizeAttachment),
347
+ ...files.errors.length > 0 ? { uploadErrors: files.errors } : {}
348
+ };
349
+ }
308
350
  const INLINE_IMAGE_TYPES = /* @__PURE__ */ new Set([
309
351
  "image/png",
310
352
  "image/jpeg",
@@ -337,23 +379,15 @@ function registerAttachmentTools(server, vikunja, { allowDelete = false } = {})
337
379
  }));
338
380
  server.registerTool("upload_task_attachment", {
339
381
  title: "Upload task attachment",
340
- description: "Attach one or more local files to a task.",
382
+ description: "Attach one or more local files to a task. To also show them in a comment or the description, use filePaths on add_task_comment / update_task_comment or descriptionFilePaths on create_task / update_task instead.",
341
383
  inputSchema: {
342
384
  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")
385
+ filePaths: filePathsField
344
386
  }
345
387
  }, 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("; ")}`);
388
+ const { uploaded, errors } = await uploadAttachments(vikunja, taskId, filePaths);
355
389
  return ok({
356
- uploaded,
390
+ uploaded: uploaded.map(summarizeAttachment),
357
391
  errors
358
392
  });
359
393
  }));
@@ -419,6 +453,7 @@ function summarizeComment(c) {
419
453
  };
420
454
  }
421
455
  const commentText = z.string().min(1).describe("Comment body. HTML is sent as-is; plain text is wrapped in <p> (blank line = new paragraph)");
456
+ const commentFiles = filePathsField.describe("Absolute paths of local files to upload as task attachments and show in the comment: images inline, other files by name");
422
457
  function registerCommentTools(server, vikunja, { allowDelete = false } = {}) {
423
458
  server.registerTool("list_task_comments", {
424
459
  title: "List task comments",
@@ -432,24 +467,33 @@ function registerCommentTools(server, vikunja, { allowDelete = false } = {}) {
432
467
  }));
433
468
  server.registerTool("add_task_comment", {
434
469
  title: "Add task comment",
435
- description: "Post a comment on a task as the current user. Visible to everyone with access to the task.",
470
+ description: "Post a comment on a task as the current user, optionally with files shown in it. Visible to everyone with access to the task.",
436
471
  inputSchema: {
437
472
  taskId: z.number().int().describe("Task id"),
438
- comment: commentText
473
+ comment: commentText.optional(),
474
+ filePaths: commentFiles.optional()
439
475
  }
440
- }, guard(async ({ taskId, comment }) => {
441
- return ok(summarizeComment(await vikunja.put(`/tasks/${taskId}/comments`, { comment: toHtml(comment) })));
476
+ }, guard(async ({ taskId, comment, filePaths }) => {
477
+ if (!comment && !filePaths) throw new Error("Pass comment, filePaths, or both.");
478
+ const files = filePaths ? await uploadAttachments(vikunja, taskId, filePaths) : null;
479
+ const body = (comment ? toHtml(comment) : "") + (files ? attachmentHtml(vikunja, taskId, files.uploaded) : "");
480
+ return ok(withUploadInfo(summarizeComment(await vikunja.put(`/tasks/${taskId}/comments`, { comment: body })), files));
442
481
  }));
443
482
  server.registerTool("update_task_comment", {
444
483
  title: "Edit task comment",
445
- description: "Replace the text of an existing comment.",
484
+ description: "Replace the text of an existing comment and/or add files to it. filePaths without comment keeps the current text.",
446
485
  inputSchema: {
447
486
  taskId: z.number().int().describe("Task id"),
448
487
  commentId: z.number().int().describe("Comment id"),
449
- comment: commentText
488
+ comment: commentText.optional(),
489
+ filePaths: commentFiles.optional()
450
490
  }
451
- }, guard(async ({ taskId, commentId, comment }) => {
452
- return ok(summarizeComment(await vikunja.post(`/tasks/${taskId}/comments/${commentId}`, { comment: toHtml(comment) })));
491
+ }, guard(async ({ taskId, commentId, comment, filePaths }) => {
492
+ if (!comment && !filePaths) throw new Error("Pass comment, filePaths, or both.");
493
+ const text = comment ? toHtml(comment) : (await vikunja.get(`/tasks/${taskId}/comments/${commentId}`)).comment ?? "";
494
+ const files = filePaths ? await uploadAttachments(vikunja, taskId, filePaths) : null;
495
+ const body = text + (files ? attachmentHtml(vikunja, taskId, files.uploaded) : "");
496
+ return ok(withUploadInfo(summarizeComment(await vikunja.post(`/tasks/${taskId}/comments/${commentId}`, { comment: body })), files));
453
497
  }));
454
498
  server.registerTool("set_reaction", {
455
499
  title: "Add / remove reaction",
@@ -581,7 +625,8 @@ const extraFields = {
581
625
  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
626
  reminders: z.array(reminderField).optional().describe("REPLACES all reminders; [] removes them"),
583
627
  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")
628
+ assigneeIds: z.array(z.number().int()).optional().describe("User ids (find_users / get_current_user); REPLACES the assignees, [] unassigns everyone"),
629
+ descriptionFilePaths: filePathsField.optional().describe("Absolute paths of local files to upload as task attachments and show at the end of the description: images inline, other files by name")
585
630
  };
586
631
  function toApiReminder(r) {
587
632
  if (r.relativeTo) return {
@@ -680,7 +725,7 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
680
725
  priority: priorityField.optional(),
681
726
  ...extraFields
682
727
  }
683
- }, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds, assigneeIds, ...extra }) => {
728
+ }, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds, assigneeIds, descriptionFilePaths, ...extra }) => {
684
729
  const body = {
685
730
  ...stripUndefined({
686
731
  title,
@@ -695,8 +740,17 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
695
740
  let task = await vikunja.put(`/projects/${projectId}/tasks`, body);
696
741
  if (labelIds && labelIds.length > 0) await setLabels(vikunja, task.id, labelIds);
697
742
  if (assigneeIds && assigneeIds.length > 0) await setAssignees(vikunja, task.id, assigneeIds);
698
- if (labelIds?.length || assigneeIds?.length) task = await fetchTask(vikunja, task.id);
699
- return ok(fullTask(task, await frontendUrl(vikunja)));
743
+ let files = null;
744
+ if (descriptionFilePaths) {
745
+ files = await uploadAttachments(vikunja, task.id, descriptionFilePaths);
746
+ const current = await vikunja.get(`/tasks/${task.id}`);
747
+ await vikunja.post(`/tasks/${task.id}`, {
748
+ ...current,
749
+ description: (current.description || "") + attachmentHtml(vikunja, task.id, files.uploaded)
750
+ });
751
+ }
752
+ if (labelIds?.length || assigneeIds?.length || files) task = await fetchTask(vikunja, task.id);
753
+ return ok(withUploadInfo(fullTask(task, await frontendUrl(vikunja)), files));
700
754
  }));
701
755
  server.registerTool("update_task", {
702
756
  title: "Update task",
@@ -713,7 +767,7 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
713
767
  projectId: z.number().int().optional().describe("Move the task to another project"),
714
768
  ...extraFields
715
769
  }
716
- }, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, projectId, labelIds, assigneeIds, ...extra }) => {
770
+ }, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, projectId, labelIds, assigneeIds, descriptionFilePaths, ...extra }) => {
717
771
  const patch = {
718
772
  ...stripUndefined({
719
773
  title,
@@ -727,9 +781,14 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
727
781
  }),
728
782
  ...extraBody(extra)
729
783
  };
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) {
784
+ if (Object.keys(patch).length === 0 && !descriptionFilePaths && labelIds === void 0 && assigneeIds === void 0) throw new Error("Nothing to update.");
785
+ let files = null;
786
+ if (Object.keys(patch).length > 0 || descriptionFilePaths) {
732
787
  const current = await vikunja.get(`/tasks/${id}`);
788
+ if (descriptionFilePaths) {
789
+ files = await uploadAttachments(vikunja, id, descriptionFilePaths);
790
+ patch.description = (description ?? current.description ?? "") + attachmentHtml(vikunja, id, files.uploaded);
791
+ }
733
792
  await vikunja.post(`/tasks/${id}`, {
734
793
  ...current,
735
794
  ...patch
@@ -737,7 +796,7 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
737
796
  }
738
797
  if (labelIds !== void 0) await setLabels(vikunja, id, labelIds);
739
798
  if (assigneeIds !== void 0) await setAssignees(vikunja, id, assigneeIds);
740
- return ok(fullTask(await fetchTask(vikunja, id), await frontendUrl(vikunja)));
799
+ return ok(withUploadInfo(fullTask(await fetchTask(vikunja, id), await frontendUrl(vikunja)), files));
741
800
  }));
742
801
  server.registerTool("complete_task", {
743
802
  title: "Complete task",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fswap/mcp-vikunja",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "MCP server for Vikunja — manage tasks, assignees, comments, attachments, relations and kanban from Claude",
5
5
  "type": "module",
6
6
  "bin": {