@fswap/mcp-vikunja 0.1.8 → 0.1.10
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 +8 -4
- package/dist/index.js +332 -35
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,7 +15,9 @@ Runs locally over stdio. No install step — clients launch it with `npx`.
|
|
|
15
15
|
|
|
16
16
|
It asks for your Vikunja URL and token, verifies them against `/api/v1/user`, lets you pick a default project, and stores the answers in your OS config directory (mode `0600`).
|
|
17
17
|
|
|
18
|
-
3. Add the server to your client.
|
|
18
|
+
3. Add the server to your client. At the end, `setup` offers to do this for you in Claude Code, Claude Desktop, Cursor and Codex: clients it finds are pre-selected (except Claude Code when Claude Desktop is installed, because the desktop app's Code tab already loads `claude_desktop_config.json`), an existing `vikunja` entry is only replaced after you confirm, and any config file it changes gets a one-time `.bak` copy.
|
|
19
|
+
|
|
20
|
+
To add it by hand instead, use the snippets below. Every value asked in `setup` can be skipped with Enter; anything you skip goes into the `env` block instead. `setup --print` shows these snippets again at any time.
|
|
19
21
|
|
|
20
22
|
**Claude Desktop** (`claude_desktop_config.json`) and **Cursor** (`~/.cursor/mcp.json` or `<project>/.cursor/mcp.json`):
|
|
21
23
|
|
|
@@ -100,8 +102,8 @@ VIKUNJA_API_TOKEN = "tk_..."
|
|
|
100
102
|
| `get_current_user` | `GET /user` | your own user id, e.g. to assign yourself |
|
|
101
103
|
| `find_users` | `GET /projects/{id}/projectusers` or `GET /users` | user ids for `assigneeIds` |
|
|
102
104
|
| `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
|
+
| `add_task_comment` | `PUT /tasks/{id}/comments` | plain text is wrapped in `<p>`; `filePaths` uploads files and shows them in the comment |
|
|
106
|
+
| `update_task_comment` | `POST /tasks/{id}/comments/{commentId}` | `filePaths` without `comment` adds files to the existing text |
|
|
105
107
|
| `set_reaction` | `PUT /{tasks\|comments}/{id}/reactions` | `remove=true` removes your reaction |
|
|
106
108
|
| `add_task_relation` | `PUT /tasks/{id}/relations` | subtask, parenttask, blocking, related, … |
|
|
107
109
|
| `remove_task_relation` | `DELETE /tasks/{id}/relations/{kind}/{otherId}` | |
|
|
@@ -114,7 +116,9 @@ VIKUNJA_API_TOKEN = "tk_..."
|
|
|
114
116
|
| `delete_task_comment` | `DELETE /tasks/{id}/comments/{commentId}` | delete-gated like `delete_task` |
|
|
115
117
|
| `delete_task_attachment` | `DELETE /tasks/{id}/attachments/{attachmentId}` | delete-gated like `delete_task` |
|
|
116
118
|
|
|
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.
|
|
119
|
+
`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.
|
|
120
|
+
|
|
121
|
+
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
122
|
|
|
119
123
|
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
124
|
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import envPaths from "env-paths";
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import os from "node:os";
|
|
10
10
|
import * as p from "@clack/prompts";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
11
12
|
//#region \0rolldown/runtime.js
|
|
12
13
|
var __defProp = Object.defineProperty;
|
|
13
14
|
var __esmMin = (fn, res, err) => () => {
|
|
@@ -229,8 +230,10 @@ async function frontendUrl(vikunja) {
|
|
|
229
230
|
*/
|
|
230
231
|
function toHtml(text) {
|
|
231
232
|
if (/<\/?[a-z][\s\S]*>/i.test(text)) return text;
|
|
232
|
-
|
|
233
|
-
|
|
233
|
+
return text.split(/\n{2,}/).map((para) => `<p>${escapeHtml(para).replace(/\n/g, "<br>")}</p>`).join("");
|
|
234
|
+
}
|
|
235
|
+
function escapeHtml(s) {
|
|
236
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
234
237
|
}
|
|
235
238
|
/** Reaction map `{ "👍": [users] }` → `{ "👍": ["alice"] }`, or null when empty. */
|
|
236
239
|
function summarizeReactions(map) {
|
|
@@ -305,6 +308,46 @@ function summarizeAttachment(a) {
|
|
|
305
308
|
createdBy: summarizeUser(a.created_by)
|
|
306
309
|
};
|
|
307
310
|
}
|
|
311
|
+
const filePathsField = z.array(z.string().min(1)).min(1).describe("Absolute paths of local files to upload");
|
|
312
|
+
/** Upload local files as task attachments. Throws only when every file failed. */
|
|
313
|
+
async function uploadAttachments(vikunja, taskId, filePaths) {
|
|
314
|
+
const form = new FormData();
|
|
315
|
+
for (const p of filePaths) {
|
|
316
|
+
const abs = path.resolve(p);
|
|
317
|
+
form.append("files", await fs.openAsBlob(abs), path.basename(abs));
|
|
318
|
+
}
|
|
319
|
+
const res = await vikunja.upload(`/tasks/${taskId}/attachments`, form);
|
|
320
|
+
const uploaded = res?.success ?? [];
|
|
321
|
+
const errors = (res?.errors ?? []).map((e) => e.message ?? JSON.stringify(e));
|
|
322
|
+
if (errors.length > 0 && uploaded.length === 0) throw new Error(`Upload failed: ${errors.join("; ")}`);
|
|
323
|
+
return {
|
|
324
|
+
uploaded,
|
|
325
|
+
errors
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|bmp|avif)$/i;
|
|
329
|
+
/**
|
|
330
|
+
* Show attachments inside a description or comment the way the web editor does:
|
|
331
|
+
* images as `<img data-src>` (the UI fetches them with the viewer's token), other files by name.
|
|
332
|
+
* Vikunja has no comment-level attachments, so these are always task attachments.
|
|
333
|
+
*/
|
|
334
|
+
function attachmentHtml(vikunja, taskId, attachments) {
|
|
335
|
+
if (attachments.length === 0) return "";
|
|
336
|
+
return attachments.map((a) => {
|
|
337
|
+
const name = a.file?.name || `attachment-${a.id}`;
|
|
338
|
+
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}">`;
|
|
339
|
+
return `<p>📎 ${escapeHtml(name)} (attachment #${a.id})</p>`;
|
|
340
|
+
}).join("") + "<p></p>";
|
|
341
|
+
}
|
|
342
|
+
/** Add what was uploaded (and any per-file failures) to a tool result. */
|
|
343
|
+
function withUploadInfo(result, files) {
|
|
344
|
+
if (!files) return result;
|
|
345
|
+
return {
|
|
346
|
+
...result,
|
|
347
|
+
uploaded: files.uploaded.map(summarizeAttachment),
|
|
348
|
+
...files.errors.length > 0 ? { uploadErrors: files.errors } : {}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
308
351
|
const INLINE_IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
309
352
|
"image/png",
|
|
310
353
|
"image/jpeg",
|
|
@@ -337,23 +380,15 @@ function registerAttachmentTools(server, vikunja, { allowDelete = false } = {})
|
|
|
337
380
|
}));
|
|
338
381
|
server.registerTool("upload_task_attachment", {
|
|
339
382
|
title: "Upload task attachment",
|
|
340
|
-
description: "Attach one or more local files to a task.",
|
|
383
|
+
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
384
|
inputSchema: {
|
|
342
385
|
taskId: z.number().int().describe("Task id"),
|
|
343
|
-
filePaths:
|
|
386
|
+
filePaths: filePathsField
|
|
344
387
|
}
|
|
345
388
|
}, 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("; ")}`);
|
|
389
|
+
const { uploaded, errors } = await uploadAttachments(vikunja, taskId, filePaths);
|
|
355
390
|
return ok({
|
|
356
|
-
uploaded,
|
|
391
|
+
uploaded: uploaded.map(summarizeAttachment),
|
|
357
392
|
errors
|
|
358
393
|
});
|
|
359
394
|
}));
|
|
@@ -419,6 +454,7 @@ function summarizeComment(c) {
|
|
|
419
454
|
};
|
|
420
455
|
}
|
|
421
456
|
const commentText = z.string().min(1).describe("Comment body. HTML is sent as-is; plain text is wrapped in <p> (blank line = new paragraph)");
|
|
457
|
+
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
458
|
function registerCommentTools(server, vikunja, { allowDelete = false } = {}) {
|
|
423
459
|
server.registerTool("list_task_comments", {
|
|
424
460
|
title: "List task comments",
|
|
@@ -432,24 +468,33 @@ function registerCommentTools(server, vikunja, { allowDelete = false } = {}) {
|
|
|
432
468
|
}));
|
|
433
469
|
server.registerTool("add_task_comment", {
|
|
434
470
|
title: "Add task comment",
|
|
435
|
-
description: "Post a comment on a task as the current user. Visible to everyone with access to the task.",
|
|
471
|
+
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
472
|
inputSchema: {
|
|
437
473
|
taskId: z.number().int().describe("Task id"),
|
|
438
|
-
comment: commentText
|
|
474
|
+
comment: commentText.optional(),
|
|
475
|
+
filePaths: commentFiles.optional()
|
|
439
476
|
}
|
|
440
|
-
}, guard(async ({ taskId, comment }) => {
|
|
441
|
-
|
|
477
|
+
}, guard(async ({ taskId, comment, filePaths }) => {
|
|
478
|
+
if (!comment && !filePaths) throw new Error("Pass comment, filePaths, or both.");
|
|
479
|
+
const files = filePaths ? await uploadAttachments(vikunja, taskId, filePaths) : null;
|
|
480
|
+
const body = (comment ? toHtml(comment) : "") + (files ? attachmentHtml(vikunja, taskId, files.uploaded) : "");
|
|
481
|
+
return ok(withUploadInfo(summarizeComment(await vikunja.put(`/tasks/${taskId}/comments`, { comment: body })), files));
|
|
442
482
|
}));
|
|
443
483
|
server.registerTool("update_task_comment", {
|
|
444
484
|
title: "Edit task comment",
|
|
445
|
-
description: "Replace the text of an existing comment.",
|
|
485
|
+
description: "Replace the text of an existing comment and/or add files to it. filePaths without comment keeps the current text.",
|
|
446
486
|
inputSchema: {
|
|
447
487
|
taskId: z.number().int().describe("Task id"),
|
|
448
488
|
commentId: z.number().int().describe("Comment id"),
|
|
449
|
-
comment: commentText
|
|
489
|
+
comment: commentText.optional(),
|
|
490
|
+
filePaths: commentFiles.optional()
|
|
450
491
|
}
|
|
451
|
-
}, guard(async ({ taskId, commentId, comment }) => {
|
|
452
|
-
|
|
492
|
+
}, guard(async ({ taskId, commentId, comment, filePaths }) => {
|
|
493
|
+
if (!comment && !filePaths) throw new Error("Pass comment, filePaths, or both.");
|
|
494
|
+
const text = comment ? toHtml(comment) : (await vikunja.get(`/tasks/${taskId}/comments/${commentId}`)).comment ?? "";
|
|
495
|
+
const files = filePaths ? await uploadAttachments(vikunja, taskId, filePaths) : null;
|
|
496
|
+
const body = text + (files ? attachmentHtml(vikunja, taskId, files.uploaded) : "");
|
|
497
|
+
return ok(withUploadInfo(summarizeComment(await vikunja.post(`/tasks/${taskId}/comments/${commentId}`, { comment: body })), files));
|
|
453
498
|
}));
|
|
454
499
|
server.registerTool("set_reaction", {
|
|
455
500
|
title: "Add / remove reaction",
|
|
@@ -581,7 +626,8 @@ const extraFields = {
|
|
|
581
626
|
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
627
|
reminders: z.array(reminderField).optional().describe("REPLACES all reminders; [] removes them"),
|
|
583
628
|
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")
|
|
629
|
+
assigneeIds: z.array(z.number().int()).optional().describe("User ids (find_users / get_current_user); REPLACES the assignees, [] unassigns everyone"),
|
|
630
|
+
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
631
|
};
|
|
586
632
|
function toApiReminder(r) {
|
|
587
633
|
if (r.relativeTo) return {
|
|
@@ -680,7 +726,7 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
680
726
|
priority: priorityField.optional(),
|
|
681
727
|
...extraFields
|
|
682
728
|
}
|
|
683
|
-
}, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds, assigneeIds, ...extra }) => {
|
|
729
|
+
}, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds, assigneeIds, descriptionFilePaths, ...extra }) => {
|
|
684
730
|
const body = {
|
|
685
731
|
...stripUndefined({
|
|
686
732
|
title,
|
|
@@ -695,8 +741,17 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
695
741
|
let task = await vikunja.put(`/projects/${projectId}/tasks`, body);
|
|
696
742
|
if (labelIds && labelIds.length > 0) await setLabels(vikunja, task.id, labelIds);
|
|
697
743
|
if (assigneeIds && assigneeIds.length > 0) await setAssignees(vikunja, task.id, assigneeIds);
|
|
698
|
-
|
|
699
|
-
|
|
744
|
+
let files = null;
|
|
745
|
+
if (descriptionFilePaths) {
|
|
746
|
+
files = await uploadAttachments(vikunja, task.id, descriptionFilePaths);
|
|
747
|
+
const current = await vikunja.get(`/tasks/${task.id}`);
|
|
748
|
+
await vikunja.post(`/tasks/${task.id}`, {
|
|
749
|
+
...current,
|
|
750
|
+
description: (current.description || "") + attachmentHtml(vikunja, task.id, files.uploaded)
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
if (labelIds?.length || assigneeIds?.length || files) task = await fetchTask(vikunja, task.id);
|
|
754
|
+
return ok(withUploadInfo(fullTask(task, await frontendUrl(vikunja)), files));
|
|
700
755
|
}));
|
|
701
756
|
server.registerTool("update_task", {
|
|
702
757
|
title: "Update task",
|
|
@@ -713,7 +768,7 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
713
768
|
projectId: z.number().int().optional().describe("Move the task to another project"),
|
|
714
769
|
...extraFields
|
|
715
770
|
}
|
|
716
|
-
}, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, projectId, labelIds, assigneeIds, ...extra }) => {
|
|
771
|
+
}, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, projectId, labelIds, assigneeIds, descriptionFilePaths, ...extra }) => {
|
|
717
772
|
const patch = {
|
|
718
773
|
...stripUndefined({
|
|
719
774
|
title,
|
|
@@ -727,9 +782,14 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
727
782
|
}),
|
|
728
783
|
...extraBody(extra)
|
|
729
784
|
};
|
|
730
|
-
if (Object.keys(patch).length === 0 && labelIds === void 0 && assigneeIds === void 0) throw new Error("Nothing to update.");
|
|
731
|
-
|
|
785
|
+
if (Object.keys(patch).length === 0 && !descriptionFilePaths && labelIds === void 0 && assigneeIds === void 0) throw new Error("Nothing to update.");
|
|
786
|
+
let files = null;
|
|
787
|
+
if (Object.keys(patch).length > 0 || descriptionFilePaths) {
|
|
732
788
|
const current = await vikunja.get(`/tasks/${id}`);
|
|
789
|
+
if (descriptionFilePaths) {
|
|
790
|
+
files = await uploadAttachments(vikunja, id, descriptionFilePaths);
|
|
791
|
+
patch.description = (description ?? current.description ?? "") + attachmentHtml(vikunja, id, files.uploaded);
|
|
792
|
+
}
|
|
733
793
|
await vikunja.post(`/tasks/${id}`, {
|
|
734
794
|
...current,
|
|
735
795
|
...patch
|
|
@@ -737,7 +797,7 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
737
797
|
}
|
|
738
798
|
if (labelIds !== void 0) await setLabels(vikunja, id, labelIds);
|
|
739
799
|
if (assigneeIds !== void 0) await setAssignees(vikunja, id, assigneeIds);
|
|
740
|
-
return ok(fullTask(await fetchTask(vikunja, id), await frontendUrl(vikunja)));
|
|
800
|
+
return ok(withUploadInfo(fullTask(await fetchTask(vikunja, id), await frontendUrl(vikunja)), files));
|
|
741
801
|
}));
|
|
742
802
|
server.registerTool("complete_task", {
|
|
743
803
|
title: "Complete task",
|
|
@@ -974,13 +1034,17 @@ function registerUserTools(server, vikunja) {
|
|
|
974
1034
|
}
|
|
975
1035
|
//#endregion
|
|
976
1036
|
//#region src/snippets.ts
|
|
977
|
-
|
|
1037
|
+
/** The `mcpServers.<key>` value used by JSON-configured clients. */
|
|
1038
|
+
function serverEntry(missing) {
|
|
978
1039
|
const server = {
|
|
979
1040
|
command: "npx",
|
|
980
|
-
args:
|
|
1041
|
+
args: SERVER_ARGS
|
|
981
1042
|
};
|
|
982
1043
|
if (Object.keys(missing).length > 0) server.env = missing;
|
|
983
|
-
return
|
|
1044
|
+
return server;
|
|
1045
|
+
}
|
|
1046
|
+
function jsonSnippet(missing) {
|
|
1047
|
+
return JSON.stringify({ mcpServers: { [SERVER_KEY]: serverEntry(missing) } }, null, 2);
|
|
984
1048
|
}
|
|
985
1049
|
function tomlSnippet(missing) {
|
|
986
1050
|
const lines = [
|
|
@@ -1018,11 +1082,199 @@ function clientSnippets(missing = {}) {
|
|
|
1018
1082
|
}
|
|
1019
1083
|
];
|
|
1020
1084
|
}
|
|
1021
|
-
var SERVER_KEY, ENV_URL, ENV_TOKEN;
|
|
1085
|
+
var SERVER_KEY, ENV_URL, ENV_TOKEN, SERVER_ARGS;
|
|
1022
1086
|
var init_snippets = __esmMin((() => {
|
|
1023
1087
|
SERVER_KEY = "vikunja";
|
|
1024
1088
|
ENV_URL = "VIKUNJA_URL";
|
|
1025
1089
|
ENV_TOKEN = "VIKUNJA_API_TOKEN";
|
|
1090
|
+
SERVER_ARGS = ["-y", `${PACKAGE_NAME}@latest`];
|
|
1091
|
+
}));
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/install.ts
|
|
1094
|
+
function onPath(cmd) {
|
|
1095
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
1096
|
+
return (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, cmd + ext))));
|
|
1097
|
+
}
|
|
1098
|
+
function runCommand(cmd, args) {
|
|
1099
|
+
const r = spawnSync(cmd, args, {
|
|
1100
|
+
encoding: "utf8",
|
|
1101
|
+
shell: process.platform === "win32"
|
|
1102
|
+
});
|
|
1103
|
+
return {
|
|
1104
|
+
ok: r.status === 0,
|
|
1105
|
+
output: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error?.message ?? ""}`.trim()
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function claudeDesktopConfig(env) {
|
|
1109
|
+
if (env.platform === "darwin") return path.join(env.home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
1110
|
+
if (env.platform === "win32") return path.join(env.appData ?? path.join(env.home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
1111
|
+
return path.join(env.home, ".config", "Claude", "claude_desktop_config.json");
|
|
1112
|
+
}
|
|
1113
|
+
function detectClients(env = hostEnv) {
|
|
1114
|
+
const dirExists = (file) => fs.existsSync(path.dirname(file));
|
|
1115
|
+
return [
|
|
1116
|
+
{
|
|
1117
|
+
id: "claude-code",
|
|
1118
|
+
label: "Claude Code",
|
|
1119
|
+
detected: env.which("claude"),
|
|
1120
|
+
location: CLAUDE_CODE_LOCATION,
|
|
1121
|
+
hint: "terminal / IDE"
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
id: "claude-desktop",
|
|
1125
|
+
label: "Claude Desktop",
|
|
1126
|
+
detected: dirExists(claudeDesktopConfig(env)),
|
|
1127
|
+
location: claudeDesktopConfig(env),
|
|
1128
|
+
hint: "chat + Code tab"
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
id: "cursor",
|
|
1132
|
+
label: "Cursor",
|
|
1133
|
+
detected: dirExists(cursorConfig(env)),
|
|
1134
|
+
location: cursorConfig(env)
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
id: "codex",
|
|
1138
|
+
label: "Codex",
|
|
1139
|
+
detected: dirExists(codexConfig(env)) || env.which("codex"),
|
|
1140
|
+
location: codexConfig(env)
|
|
1141
|
+
}
|
|
1142
|
+
];
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Detected clients to pre-select. Claude Code is left out when Claude Desktop is present: the desktop
|
|
1146
|
+
* app's Code tab also loads claude_desktop_config.json, so selecting both would register the server twice there.
|
|
1147
|
+
*/
|
|
1148
|
+
function defaultSelection(clients) {
|
|
1149
|
+
const desktopFound = clients.some((c) => c.id === "claude-desktop" && c.detected);
|
|
1150
|
+
return clients.filter((c) => c.detected && !(desktopFound && c.id === "claude-code")).map((c) => c.id);
|
|
1151
|
+
}
|
|
1152
|
+
function readIfExists(file) {
|
|
1153
|
+
try {
|
|
1154
|
+
return fs.readFileSync(file, "utf8");
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
if (err.code === "ENOENT") return null;
|
|
1157
|
+
throw err;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
/** Writes `content`, keeping a copy of the original the first time an existing file is changed. */
|
|
1161
|
+
function writeWithBackup(file, original, content) {
|
|
1162
|
+
if (original !== null && !fs.existsSync(`${file}.bak`)) fs.writeFileSync(`${file}.bak`, original);
|
|
1163
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1164
|
+
fs.writeFileSync(file, content);
|
|
1165
|
+
}
|
|
1166
|
+
function parseJsonConfig(file, text) {
|
|
1167
|
+
if (text === null || text.trim() === "") return {};
|
|
1168
|
+
try {
|
|
1169
|
+
const parsed = JSON.parse(text);
|
|
1170
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1171
|
+
} catch {}
|
|
1172
|
+
throw new Error(`Could not parse ${file}; left it unchanged`);
|
|
1173
|
+
}
|
|
1174
|
+
function jsonHasEntry(file) {
|
|
1175
|
+
return Boolean(parseJsonConfig(file, readIfExists(file)).mcpServers?.[SERVER_KEY]);
|
|
1176
|
+
}
|
|
1177
|
+
function jsonInstall(file, missing) {
|
|
1178
|
+
const original = readIfExists(file);
|
|
1179
|
+
const config = parseJsonConfig(file, original);
|
|
1180
|
+
const replaced = Boolean(config.mcpServers?.[SERVER_KEY]);
|
|
1181
|
+
config.mcpServers = {
|
|
1182
|
+
...config.mcpServers,
|
|
1183
|
+
[SERVER_KEY]: serverEntry(missing)
|
|
1184
|
+
};
|
|
1185
|
+
writeWithBackup(file, original, JSON.stringify(config, null, 2) + "\n");
|
|
1186
|
+
return {
|
|
1187
|
+
status: replaced ? "replaced" : "added",
|
|
1188
|
+
location: file
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
function tomlHasEntry(text) {
|
|
1192
|
+
return text !== null && text.split("\n").some((line) => ownHeader.test(line));
|
|
1193
|
+
}
|
|
1194
|
+
function tomlWithoutEntry(text) {
|
|
1195
|
+
let inOwnSection = false;
|
|
1196
|
+
return text.split("\n").filter((line) => {
|
|
1197
|
+
if (anyHeader.test(line)) inOwnSection = ownHeader.test(line);
|
|
1198
|
+
return !inOwnSection;
|
|
1199
|
+
}).join("\n");
|
|
1200
|
+
}
|
|
1201
|
+
function codexInstall(file, missing) {
|
|
1202
|
+
const original = readIfExists(file);
|
|
1203
|
+
const rest = original === null ? "" : tomlWithoutEntry(original).trimEnd();
|
|
1204
|
+
writeWithBackup(file, original, (rest ? `${rest}\n\n` : "") + tomlSnippet(missing) + "\n");
|
|
1205
|
+
return {
|
|
1206
|
+
status: tomlHasEntry(original) ? "replaced" : "added",
|
|
1207
|
+
location: file
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function claude(env, args) {
|
|
1211
|
+
const r = env.run("claude", args);
|
|
1212
|
+
if (!r.ok) throw new Error(`claude ${args.join(" ")} failed${r.output ? `: ${r.output}` : ""}`);
|
|
1213
|
+
}
|
|
1214
|
+
function claudeCodeInstall(env, missing) {
|
|
1215
|
+
const replaced = hasEntry("claude-code", env);
|
|
1216
|
+
if (replaced) claude(env, [
|
|
1217
|
+
"mcp",
|
|
1218
|
+
"remove",
|
|
1219
|
+
SERVER_KEY,
|
|
1220
|
+
"-s",
|
|
1221
|
+
"user"
|
|
1222
|
+
]);
|
|
1223
|
+
const envArgs = Object.entries(missing).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
1224
|
+
claude(env, [
|
|
1225
|
+
"mcp",
|
|
1226
|
+
"add",
|
|
1227
|
+
SERVER_KEY,
|
|
1228
|
+
"-s",
|
|
1229
|
+
"user",
|
|
1230
|
+
...envArgs,
|
|
1231
|
+
"--",
|
|
1232
|
+
"npx",
|
|
1233
|
+
...SERVER_ARGS
|
|
1234
|
+
]);
|
|
1235
|
+
return {
|
|
1236
|
+
status: replaced ? "replaced" : "added",
|
|
1237
|
+
location: CLAUDE_CODE_LOCATION
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
/** Whether the client already has a server registered under SERVER_KEY. Throws if its config is unreadable. */
|
|
1241
|
+
function hasEntry(id, env = hostEnv) {
|
|
1242
|
+
switch (id) {
|
|
1243
|
+
case "claude-code": return env.run("claude", [
|
|
1244
|
+
"mcp",
|
|
1245
|
+
"get",
|
|
1246
|
+
SERVER_KEY
|
|
1247
|
+
]).ok;
|
|
1248
|
+
case "claude-desktop": return jsonHasEntry(claudeDesktopConfig(env));
|
|
1249
|
+
case "cursor": return jsonHasEntry(cursorConfig(env));
|
|
1250
|
+
case "codex": return tomlHasEntry(readIfExists(codexConfig(env)));
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/** Adds (or overwrites) this server in the client's config. Throws with a readable message on failure. */
|
|
1254
|
+
function installClient(id, missing, env = hostEnv) {
|
|
1255
|
+
switch (id) {
|
|
1256
|
+
case "claude-code": return claudeCodeInstall(env, missing);
|
|
1257
|
+
case "claude-desktop": return jsonInstall(claudeDesktopConfig(env), missing);
|
|
1258
|
+
case "cursor": return jsonInstall(cursorConfig(env), missing);
|
|
1259
|
+
case "codex": return codexInstall(codexConfig(env), missing);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
var hostEnv, cursorConfig, codexConfig, CLAUDE_CODE_LOCATION, escapeRe, ownHeader, anyHeader;
|
|
1263
|
+
var init_install = __esmMin((() => {
|
|
1264
|
+
init_snippets();
|
|
1265
|
+
hostEnv = {
|
|
1266
|
+
home: os.homedir(),
|
|
1267
|
+
platform: process.platform,
|
|
1268
|
+
appData: process.env.APPDATA,
|
|
1269
|
+
which: onPath,
|
|
1270
|
+
run: runCommand
|
|
1271
|
+
};
|
|
1272
|
+
cursorConfig = (env) => path.join(env.home, ".cursor", "mcp.json");
|
|
1273
|
+
codexConfig = (env) => path.join(env.home, ".codex", "config.toml");
|
|
1274
|
+
CLAUDE_CODE_LOCATION = "user scope via claude mcp";
|
|
1275
|
+
escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1276
|
+
ownHeader = new RegExp(`^\\s*\\[\\s*mcp_servers\\.("?)${escapeRe(SERVER_KEY)}\\1\\s*(\\]|\\.)`);
|
|
1277
|
+
anyHeader = /^\s*\[/;
|
|
1026
1278
|
}));
|
|
1027
1279
|
//#endregion
|
|
1028
1280
|
//#region src/setup.ts
|
|
@@ -1118,15 +1370,60 @@ async function runSetup(args = []) {
|
|
|
1118
1370
|
const missing = {};
|
|
1119
1371
|
if (!url) missing[ENV_URL] = "https://try.vikunja.io";
|
|
1120
1372
|
if (!token) missing[ENV_TOKEN] = "tk_...";
|
|
1121
|
-
|
|
1373
|
+
const updated = await addToClients(missing);
|
|
1374
|
+
if (Object.keys(missing).length > 0) {
|
|
1375
|
+
const where = updated ? "env block of the client config updated above" : "env block below";
|
|
1376
|
+
p.log.warn(`Still needed: ${Object.keys(missing).join(", ")}. Replace the placeholders in the ${where}, or run setup again.`);
|
|
1377
|
+
}
|
|
1122
1378
|
p.outro(`Saved to ${file}`);
|
|
1379
|
+
if (updated) {
|
|
1380
|
+
console.log("Re-print MCP client config snippets any time with: setup --print");
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1123
1383
|
console.log("Add one of these to your MCP client (plain text, safe to copy). Re-print any time with: setup --print");
|
|
1124
1384
|
printSnippets(missing);
|
|
1125
1385
|
}
|
|
1386
|
+
/** Offers to register the server in MCP clients. Returns true when at least one client was updated. */
|
|
1387
|
+
async function addToClients(missing) {
|
|
1388
|
+
const clients = detectClients();
|
|
1389
|
+
const selected = await p.multiselect({
|
|
1390
|
+
message: "Add to MCP clients? (Space to toggle, Enter to confirm, none to skip)",
|
|
1391
|
+
options: clients.map((c) => ({
|
|
1392
|
+
value: c.id,
|
|
1393
|
+
label: c.label,
|
|
1394
|
+
hint: [c.hint, c.detected ? "detected" : "not found"].filter(Boolean).join(" — ")
|
|
1395
|
+
})),
|
|
1396
|
+
initialValues: defaultSelection(clients),
|
|
1397
|
+
required: false
|
|
1398
|
+
});
|
|
1399
|
+
if (typeof selected === "symbol") abort();
|
|
1400
|
+
let updated = false;
|
|
1401
|
+
for (const client of clients.filter((c) => selected.includes(c.id))) try {
|
|
1402
|
+
if (hasEntry(client.id)) {
|
|
1403
|
+
const replace = await p.confirm({
|
|
1404
|
+
message: `${client.label} already has a "${SERVER_KEY}" server. Replace it?`,
|
|
1405
|
+
initialValue: false
|
|
1406
|
+
});
|
|
1407
|
+
if (typeof replace === "symbol") abort();
|
|
1408
|
+
if (!replace) {
|
|
1409
|
+
p.log.info(`${client.label}: skipped, existing entry kept`);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const result = installClient(client.id, missing);
|
|
1414
|
+
const restart = client.id === "claude-desktop" ? " — restart Claude Desktop to load it" : "";
|
|
1415
|
+
p.log.success(`${client.label}: ${result.status} (${result.location})${restart}`);
|
|
1416
|
+
updated = true;
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
p.log.error(`${client.label}: failed — ${err.message}`);
|
|
1419
|
+
}
|
|
1420
|
+
return updated;
|
|
1421
|
+
}
|
|
1126
1422
|
var init_setup = __esmMin((() => {
|
|
1127
1423
|
init_config();
|
|
1128
1424
|
init_client();
|
|
1129
1425
|
init_snippets();
|
|
1426
|
+
init_install();
|
|
1130
1427
|
}));
|
|
1131
1428
|
//#endregion
|
|
1132
1429
|
//#region src/index.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fswap/mcp-vikunja",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "MCP server for Vikunja — manage tasks, assignees, comments, attachments, relations and kanban from Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"dev": "tsdown --watch",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
19
|
"lint": "eslint src test",
|
|
20
|
-
"test": "npm run build && node test/smoke.test.mjs",
|
|
20
|
+
"test": "npm run build && node test/smoke.test.mjs && node --test test/install.test.mjs",
|
|
21
21
|
"check": "npm run lint && npm run typecheck && npm test",
|
|
22
22
|
"start": "node dist/index.js",
|
|
23
23
|
"setup": "node dist/index.js setup",
|