@fswap/mcp-vikunja 0.1.9 → 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 +3 -1
- package/dist/index.js +243 -5
- 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
|
|
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) => () => {
|
|
@@ -1033,13 +1034,17 @@ function registerUserTools(server, vikunja) {
|
|
|
1033
1034
|
}
|
|
1034
1035
|
//#endregion
|
|
1035
1036
|
//#region src/snippets.ts
|
|
1036
|
-
|
|
1037
|
+
/** The `mcpServers.<key>` value used by JSON-configured clients. */
|
|
1038
|
+
function serverEntry(missing) {
|
|
1037
1039
|
const server = {
|
|
1038
1040
|
command: "npx",
|
|
1039
|
-
args:
|
|
1041
|
+
args: SERVER_ARGS
|
|
1040
1042
|
};
|
|
1041
1043
|
if (Object.keys(missing).length > 0) server.env = missing;
|
|
1042
|
-
return
|
|
1044
|
+
return server;
|
|
1045
|
+
}
|
|
1046
|
+
function jsonSnippet(missing) {
|
|
1047
|
+
return JSON.stringify({ mcpServers: { [SERVER_KEY]: serverEntry(missing) } }, null, 2);
|
|
1043
1048
|
}
|
|
1044
1049
|
function tomlSnippet(missing) {
|
|
1045
1050
|
const lines = [
|
|
@@ -1077,11 +1082,199 @@ function clientSnippets(missing = {}) {
|
|
|
1077
1082
|
}
|
|
1078
1083
|
];
|
|
1079
1084
|
}
|
|
1080
|
-
var SERVER_KEY, ENV_URL, ENV_TOKEN;
|
|
1085
|
+
var SERVER_KEY, ENV_URL, ENV_TOKEN, SERVER_ARGS;
|
|
1081
1086
|
var init_snippets = __esmMin((() => {
|
|
1082
1087
|
SERVER_KEY = "vikunja";
|
|
1083
1088
|
ENV_URL = "VIKUNJA_URL";
|
|
1084
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*\[/;
|
|
1085
1278
|
}));
|
|
1086
1279
|
//#endregion
|
|
1087
1280
|
//#region src/setup.ts
|
|
@@ -1177,15 +1370,60 @@ async function runSetup(args = []) {
|
|
|
1177
1370
|
const missing = {};
|
|
1178
1371
|
if (!url) missing[ENV_URL] = "https://try.vikunja.io";
|
|
1179
1372
|
if (!token) missing[ENV_TOKEN] = "tk_...";
|
|
1180
|
-
|
|
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
|
+
}
|
|
1181
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
|
+
}
|
|
1182
1383
|
console.log("Add one of these to your MCP client (plain text, safe to copy). Re-print any time with: setup --print");
|
|
1183
1384
|
printSnippets(missing);
|
|
1184
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
|
+
}
|
|
1185
1422
|
var init_setup = __esmMin((() => {
|
|
1186
1423
|
init_config();
|
|
1187
1424
|
init_client();
|
|
1188
1425
|
init_snippets();
|
|
1426
|
+
init_install();
|
|
1189
1427
|
}));
|
|
1190
1428
|
//#endregion
|
|
1191
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",
|