@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.
Files changed (3) hide show
  1. package/README.md +8 -4
  2. package/dist/index.js +332 -35
  3. 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. Every value asked in `setup` can be skipped with Enter; anything you skip goes into the `env` block shown below instead. `setup --print` shows these snippets again at any time.
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
- 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("");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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: z.array(z.string().min(1)).min(1).describe("Absolute paths of local files to upload")
386
+ filePaths: filePathsField
344
387
  }
345
388
  }, 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("; ")}`);
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
- return ok(summarizeComment(await vikunja.put(`/tasks/${taskId}/comments`, { comment: toHtml(comment) })));
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
- return ok(summarizeComment(await vikunja.post(`/tasks/${taskId}/comments/${commentId}`, { comment: toHtml(comment) })));
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
- if (labelIds?.length || assigneeIds?.length) task = await fetchTask(vikunja, task.id);
699
- return ok(fullTask(task, await frontendUrl(vikunja)));
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
- if (Object.keys(patch).length > 0) {
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
- function jsonSnippet(missing) {
1037
+ /** The `mcpServers.<key>` value used by JSON-configured clients. */
1038
+ function serverEntry(missing) {
978
1039
  const server = {
979
1040
  command: "npx",
980
- args: ["-y", `${PACKAGE_NAME}@latest`]
1041
+ args: SERVER_ARGS
981
1042
  };
982
1043
  if (Object.keys(missing).length > 0) server.env = missing;
983
- return JSON.stringify({ mcpServers: { [SERVER_KEY]: server } }, null, 2);
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
- if (Object.keys(missing).length > 0) p.log.warn(`Still needed: ${Object.keys(missing).join(", ")}. Add them to the env block below, or run setup again.`);
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.8",
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",