@fswap/mcp-vikunja 0.1.7 → 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.
- package/README.md +5 -3
- package/dist/index.js +90 -31
- 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
|
-
|
|
233
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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:
|
|
385
|
+
filePaths: filePathsField
|
|
344
386
|
}
|
|
345
387
|
}, guard(async ({ taskId, filePaths }) => {
|
|
346
|
-
const
|
|
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
|
}));
|
|
@@ -415,10 +449,11 @@ function summarizeComment(c) {
|
|
|
415
449
|
comment: c.comment,
|
|
416
450
|
reactions: summarizeReactions(c.reactions),
|
|
417
451
|
created: c.created,
|
|
418
|
-
edited: c.updated && c.updated
|
|
452
|
+
edited: c.updated && c.created && Date.parse(c.updated) - Date.parse(c.created) > 1e3 ? c.updated : null
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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",
|