@fswap/mcp-vikunja 0.1.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/index.js +653 -0
  4. package/package.json +57 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gleb Okhrimenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # @fswap/mcp-vikunja
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for [Vikunja](https://vikunja.io) that lets Claude Desktop, Claude Code, Cursor and other MCP clients list, create, update and complete your tasks.
4
+
5
+ Runs locally over stdio. No install step — clients launch it with `npx`.
6
+
7
+ ## Quick start
8
+
9
+ 1. Create an API token in Vikunja under **Settings → API Tokens** (scope it to projects, tasks and labels).
10
+ 2. Run the interactive setup once:
11
+
12
+ ```bash
13
+ npx -y @fswap/mcp-vikunja setup
14
+ ```
15
+
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
+
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.
19
+
20
+ **Claude Desktop** (`claude_desktop_config.json`) and **Cursor** (`~/.cursor/mcp.json` or `<project>/.cursor/mcp.json`):
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "vikunja": {
26
+ "command": "npx",
27
+ "args": ["-y", "@fswap/mcp-vikunja"]
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ **Codex** (`~/.codex/config.toml`):
34
+
35
+ ```toml
36
+ [mcp_servers.vikunja]
37
+ command = "npx"
38
+ args = ["-y", "@fswap/mcp-vikunja"]
39
+ ```
40
+
41
+ **Claude Code**:
42
+
43
+ ```bash
44
+ claude mcp add vikunja -- npx -y @fswap/mcp-vikunja
45
+ ```
46
+
47
+ ### Without `setup` (environment variables)
48
+
49
+ Environment variables take precedence over the config file, so you can skip `setup` entirely (or skip individual values in it and set them here):
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "vikunja": {
55
+ "command": "npx",
56
+ "args": ["-y", "@fswap/mcp-vikunja"],
57
+ "env": {
58
+ "VIKUNJA_URL": "https://try.vikunja.io",
59
+ "VIKUNJA_API_TOKEN": "tk_..."
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Codex equivalent:
67
+
68
+ ```toml
69
+ [mcp_servers.vikunja]
70
+ command = "npx"
71
+ args = ["-y", "@fswap/mcp-vikunja"]
72
+ [mcp_servers.vikunja.env]
73
+ VIKUNJA_URL = "https://try.vikunja.io"
74
+ VIKUNJA_API_TOKEN = "tk_..."
75
+ ```
76
+
77
+ | Variable | Purpose |
78
+ |---|---|
79
+ | `VIKUNJA_URL` | Vikunja base URL (with or without `/api/v1`) |
80
+ | `VIKUNJA_API_TOKEN` | API token or login JWT |
81
+ | `VIKUNJA_ALLOW_DELETE` | `true` to expose `delete_task` |
82
+ | `VIKUNJA_DEFAULT_PROJECT_ID` | Project used by `create_task` when `projectId` is omitted |
83
+
84
+ ## Tools
85
+
86
+ | Tool | Method + endpoint | Notes |
87
+ |---|---|---|
88
+ | `list_projects` | `GET /projects` | id, title, description, parent |
89
+ | `get_project` | `GET /projects/{id}` | |
90
+ | `create_project` | `PUT /projects` | optional parent project |
91
+ | `list_tasks` | `GET /tasks/all` or `GET /projects/{id}/tasks` | open tasks by default; `filter`, `sortBy`, pagination |
92
+ | `get_task` | `GET /tasks/{id}` | full task incl. description, labels, assignees |
93
+ | `create_task` | `PUT /projects/{id}/tasks` | title, description, dates, priority, labels |
94
+ | `update_task` | `POST /tasks/{id}` | merges your changes onto the current task; `labelIds` replaces labels |
95
+ | `complete_task` | `POST /tasks/{id}` with `done: true` | `done=false` reopens |
96
+ | `list_labels` | `GET /labels` | label ids for create/update |
97
+ | `create_label` | `PUT /labels` | |
98
+ | `delete_task` | `DELETE /tasks/{id}` | only when delete is allowed (setup answer or `VIKUNJA_ALLOW_DELETE=true`) |
99
+
100
+ 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.
101
+
102
+ ## Development
103
+
104
+ TypeScript source in `src/`, bundled to `dist/` with [tsdown](https://tsdown.dev). Only `dist/` is published.
105
+
106
+ ```bash
107
+ npm install
108
+ npm run build # tsdown → dist/index.js
109
+ npm run lint # eslint (typescript-eslint)
110
+ npm run typecheck # tsc --noEmit
111
+ npm test # builds, then spawns the server and checks the tool list
112
+ npm run check # all of the above (also runs on prepublishOnly)
113
+ VIKUNJA_URL=... VIKUNJA_API_TOKEN=... npm run inspect # MCP Inspector UI against dist/
114
+ ```
115
+
116
+ Never write to stdout from server code — it is the protocol channel. Use `console.error`.
117
+
118
+ ## Reset
119
+
120
+ ```bash
121
+ npx -y @fswap/mcp-vikunja setup --reset
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,653 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import envPaths from "env-paths";
8
+ import { z } from "zod";
9
+ import * as p from "@clack/prompts";
10
+ //#region \0rolldown/runtime.js
11
+ var __defProp = Object.defineProperty;
12
+ var __esmMin = (fn, res, err) => () => {
13
+ if (err) throw err[0];
14
+ try {
15
+ return fn && (res = fn(fn = 0)), res;
16
+ } catch (e) {
17
+ throw err = [e], e;
18
+ }
19
+ };
20
+ var __exportAll = (all, no_symbols) => {
21
+ let target = {};
22
+ for (var name in all) __defProp(target, name, {
23
+ get: all[name],
24
+ enumerable: true
25
+ });
26
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
27
+ return target;
28
+ };
29
+ //#endregion
30
+ //#region src/config.ts
31
+ function configPath() {
32
+ return path.join(paths.config, "config.json");
33
+ }
34
+ function truthy(v) {
35
+ return v != null && /^(1|true|yes|on)$/i.test(v.trim());
36
+ }
37
+ function readConfigFile() {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(configPath(), "utf8"));
40
+ } catch (err) {
41
+ if (err.code === "ENOENT") return null;
42
+ console.error(`Warning: could not read ${configPath()}: ${err.message}`);
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * Resolve runtime configuration.
48
+ * Precedence: environment variables > config file written by `setup`.
49
+ * Returns null when neither provides url + token.
50
+ */
51
+ function loadConfig() {
52
+ const env = process.env;
53
+ const file = readConfigFile() ?? {};
54
+ const url = env.VIKUNJA_URL || file.url;
55
+ const token = env.VIKUNJA_API_TOKEN || file.token;
56
+ if (!url || !token) return null;
57
+ const deleteEnv = env.VIKUNJA_ALLOW_DELETE ?? env.ALLOW_DELETE;
58
+ const allowDelete = deleteEnv != null ? truthy(deleteEnv) : Boolean(file.allowDelete);
59
+ const envProject = env.VIKUNJA_DEFAULT_PROJECT_ID ? Number(env.VIKUNJA_DEFAULT_PROJECT_ID) : NaN;
60
+ const defaultProjectId = Number.isFinite(envProject) ? envProject : file.defaultProjectId ?? null;
61
+ return {
62
+ url: url.replace(/\/+$/, ""),
63
+ token,
64
+ allowDelete,
65
+ defaultProjectId
66
+ };
67
+ }
68
+ function saveConfig(config) {
69
+ const file = configPath();
70
+ fs.mkdirSync(path.dirname(file), {
71
+ recursive: true,
72
+ mode: 448
73
+ });
74
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
75
+ fs.chmodSync(file, 384);
76
+ return file;
77
+ }
78
+ function deleteConfig() {
79
+ try {
80
+ fs.unlinkSync(configPath());
81
+ return true;
82
+ } catch (err) {
83
+ if (err.code === "ENOENT") return false;
84
+ throw err;
85
+ }
86
+ }
87
+ var pkg, PACKAGE_NAME, VERSION, paths;
88
+ var init_config = __esmMin((() => {
89
+ pkg = createRequire(import.meta.url)("../package.json");
90
+ PACKAGE_NAME = pkg.name;
91
+ VERSION = pkg.version;
92
+ paths = envPaths("mcp-vikunja", { suffix: "" });
93
+ }));
94
+ //#endregion
95
+ //#region src/client.ts
96
+ function normalizeDate(value) {
97
+ if (!value) return null;
98
+ if (value === "0001-01-01T00:00:00Z" || value.startsWith("0001-01-01")) return null;
99
+ return value;
100
+ }
101
+ function makeClient(baseUrl, token) {
102
+ const base = baseUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
103
+ async function request(method, path, body, query) {
104
+ const url = new URL(`${base}/api/v1${path}`);
105
+ if (query) {
106
+ for (const [k, v] of Object.entries(query)) if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
107
+ }
108
+ let res;
109
+ try {
110
+ res = await fetch(url, {
111
+ method,
112
+ headers: {
113
+ Authorization: `Bearer ${token}`,
114
+ "Content-Type": "application/json",
115
+ Accept: "application/json"
116
+ },
117
+ body: body !== void 0 ? JSON.stringify(body) : void 0
118
+ });
119
+ } catch (err) {
120
+ throw new Error(`${method} ${path} failed: ${err.message}`);
121
+ }
122
+ if (!res.ok) {
123
+ const text = await res.text();
124
+ let detail = text;
125
+ try {
126
+ const json = JSON.parse(text);
127
+ detail = json.message ? `${json.message}${json.code ? ` (code ${json.code})` : ""}` : text;
128
+ } catch {}
129
+ throw new Error(`${method} ${path} → ${res.status}: ${detail}`);
130
+ }
131
+ if (res.status === 204) return void 0;
132
+ const text = await res.text();
133
+ return text ? JSON.parse(text) : void 0;
134
+ }
135
+ return {
136
+ baseUrl: base,
137
+ request,
138
+ get: (path, query) => request("GET", path, void 0, query),
139
+ put: (path, body) => request("PUT", path, body),
140
+ post: (path, body) => request("POST", path, body),
141
+ del: (path) => request("DELETE", path)
142
+ };
143
+ }
144
+ /** Cheap authenticated call used by `setup` to validate URL + token. */
145
+ async function currentUser(baseUrl, token) {
146
+ return makeClient(baseUrl, token).get("/user");
147
+ }
148
+ var init_client = __esmMin((() => {}));
149
+ //#endregion
150
+ //#region src/tools/_shared.ts
151
+ init_config();
152
+ init_client();
153
+ function ok(value) {
154
+ return { content: [{
155
+ type: "text",
156
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
157
+ }] };
158
+ }
159
+ function fail(err) {
160
+ return {
161
+ isError: true,
162
+ content: [{
163
+ type: "text",
164
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
165
+ }]
166
+ };
167
+ }
168
+ /** Wrap a tool handler so API errors become isError results instead of crashes. */
169
+ function guard(fn) {
170
+ return async (args) => {
171
+ try {
172
+ return await fn(args);
173
+ } catch (err) {
174
+ return fail(err);
175
+ }
176
+ };
177
+ }
178
+ function stripUndefined(obj) {
179
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
180
+ }
181
+ //#endregion
182
+ //#region src/tools/projects.ts
183
+ function summarizeProject(p) {
184
+ return {
185
+ id: p.id,
186
+ title: p.title,
187
+ description: p.description || null,
188
+ identifier: p.identifier || null,
189
+ parentProjectId: p.parent_project_id || null,
190
+ archived: Boolean(p.is_archived),
191
+ favorite: Boolean(p.is_favorite)
192
+ };
193
+ }
194
+ function registerProjectTools(server, vikunja) {
195
+ server.registerTool("list_projects", {
196
+ title: "List projects",
197
+ description: "List Vikunja projects the user can access. Returns id, title, description, identifier and parent project. Use the id with list_tasks or create_task.",
198
+ inputSchema: {
199
+ search: z.string().optional().describe("Filter projects by title"),
200
+ includeArchived: z.boolean().default(false).describe("Include archived projects"),
201
+ page: z.number().int().min(1).default(1),
202
+ perPage: z.number().int().min(1).max(100).default(50)
203
+ }
204
+ }, guard(async ({ search, includeArchived, page, perPage }) => {
205
+ return ok((await vikunja.get("/projects", {
206
+ s: search,
207
+ is_archived: includeArchived ? true : void 0,
208
+ page,
209
+ per_page: perPage
210
+ })).map(summarizeProject));
211
+ }));
212
+ server.registerTool("get_project", {
213
+ title: "Get project",
214
+ description: "Get one Vikunja project by id.",
215
+ inputSchema: { id: z.number().int().describe("Project id") }
216
+ }, guard(async ({ id }) => {
217
+ return ok(summarizeProject(await vikunja.get(`/projects/${id}`)));
218
+ }));
219
+ server.registerTool("create_project", {
220
+ title: "Create project",
221
+ description: "Create a new Vikunja project. Optionally nest it under a parent project.",
222
+ inputSchema: {
223
+ title: z.string().min(1).describe("Project title"),
224
+ description: z.string().optional().describe("Project description"),
225
+ parentProjectId: z.number().int().optional().describe("Parent project id for nesting")
226
+ }
227
+ }, guard(async ({ title, description, parentProjectId }) => {
228
+ const body = { title };
229
+ if (description !== void 0) body.description = description;
230
+ if (parentProjectId !== void 0) body.parent_project_id = parentProjectId;
231
+ return ok(summarizeProject(await vikunja.put("/projects", body)));
232
+ }));
233
+ }
234
+ //#endregion
235
+ //#region src/tools/tasks.ts
236
+ function summarizeTask(t) {
237
+ return {
238
+ id: t.id,
239
+ title: t.title,
240
+ done: t.done,
241
+ dueDate: normalizeDate(t.due_date),
242
+ priority: t.priority ?? 0,
243
+ projectId: t.project_id,
244
+ identifier: t.identifier || null,
245
+ labels: (t.labels ?? []).map((l) => l.title),
246
+ updated: t.updated
247
+ };
248
+ }
249
+ function fullTask(t) {
250
+ return {
251
+ ...summarizeTask(t),
252
+ description: t.description || "",
253
+ doneAt: normalizeDate(t.done_at),
254
+ startDate: normalizeDate(t.start_date),
255
+ endDate: normalizeDate(t.end_date),
256
+ percentDone: t.percent_done ?? 0,
257
+ repeatAfterSeconds: t.repeat_after || null,
258
+ labels: (t.labels ?? []).map((l) => ({
259
+ id: l.id,
260
+ title: l.title,
261
+ color: l.hex_color || null
262
+ })),
263
+ assignees: (t.assignees ?? []).map((u) => ({
264
+ id: u.id,
265
+ username: u.username,
266
+ name: u.name || null
267
+ })),
268
+ created: t.created
269
+ };
270
+ }
271
+ const dateField = z.string().datetime({ offset: true }).describe("RFC 3339 timestamp, e.g. 2026-09-15T17:00:00Z");
272
+ const priorityField = z.number().int().min(0).max(5).describe("0 = unset, 1 = low, 2 = medium, 3 = high, 4 = urgent, 5 = DO NOW");
273
+ /** Replace the task's labels with exactly `labelIds` (Vikunja has no single-call "set labels"). */
274
+ async function setLabels(vikunja, taskId, labelIds) {
275
+ await vikunja.post(`/tasks/${taskId}/labels/bulk`, { labels: labelIds.map((id) => ({ id })) });
276
+ }
277
+ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjectId = null } = {}) {
278
+ server.registerTool("list_tasks", {
279
+ title: "List tasks",
280
+ description: "List tasks, across all projects or within one project. Returns id, title, done, due date, priority and label names (no descriptions — use get_task). By default only open tasks are returned. For advanced queries pass a raw Vikunja `filter` string such as `done = false && due_date < now+7d` or `labels in 3, 5`.",
281
+ inputSchema: {
282
+ projectId: z.number().int().optional().describe("Limit to this project (omit for all projects)"),
283
+ includeDone: z.boolean().default(false).describe("Include completed tasks"),
284
+ search: z.string().optional().describe("Full-text search in title/description"),
285
+ filter: z.string().optional().describe("Raw Vikunja filter expression; overrides includeDone. Fields: done, due_date, priority, labels, assignees, project"),
286
+ sortBy: z.enum([
287
+ "due_date",
288
+ "priority",
289
+ "id",
290
+ "title",
291
+ "done",
292
+ "created",
293
+ "updated"
294
+ ]).default("due_date"),
295
+ orderBy: z.enum(["asc", "desc"]).default("asc"),
296
+ page: z.number().int().min(1).default(1),
297
+ perPage: z.number().int().min(1).max(100).default(50)
298
+ }
299
+ }, guard(async ({ projectId, includeDone, search, filter, sortBy, orderBy, page, perPage }) => {
300
+ const effectiveFilter = filter ?? (includeDone ? void 0 : "done = false");
301
+ const path = projectId != null ? `/projects/${projectId}/tasks` : "/tasks/all";
302
+ return ok((await vikunja.get(path, {
303
+ s: search,
304
+ filter: effectiveFilter,
305
+ sort_by: sortBy,
306
+ order_by: orderBy,
307
+ page,
308
+ per_page: perPage
309
+ })).map(summarizeTask));
310
+ }));
311
+ server.registerTool("get_task", {
312
+ title: "Get task",
313
+ description: "Get a single task with full details: description, dates, priority, labels and assignees.",
314
+ inputSchema: { id: z.number().int().describe("Task id") }
315
+ }, guard(async ({ id }) => {
316
+ return ok(fullTask(await vikunja.get(`/tasks/${id}`)));
317
+ }));
318
+ server.registerTool("create_task", {
319
+ title: "Create task",
320
+ description: "Create a task in a project. Use list_projects to find the projectId and list_labels for label ids." + (defaultProjectId != null ? ` If projectId is omitted, project ${defaultProjectId} is used.` : ""),
321
+ inputSchema: {
322
+ projectId: defaultProjectId != null ? z.number().int().default(defaultProjectId).describe("Project id") : z.number().int().describe("Project id"),
323
+ title: z.string().min(1).describe("Task title"),
324
+ description: z.string().optional().describe("Task description (markdown/HTML accepted by Vikunja)"),
325
+ dueDate: dateField.optional(),
326
+ startDate: dateField.optional(),
327
+ endDate: dateField.optional(),
328
+ priority: priorityField.optional(),
329
+ labelIds: z.array(z.number().int()).optional().describe("Label ids to attach")
330
+ }
331
+ }, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds }) => {
332
+ const body = stripUndefined({
333
+ title,
334
+ description,
335
+ due_date: dueDate,
336
+ start_date: startDate,
337
+ end_date: endDate,
338
+ priority
339
+ });
340
+ let task = await vikunja.put(`/projects/${projectId}/tasks`, body);
341
+ if (labelIds && labelIds.length > 0) {
342
+ await setLabels(vikunja, task.id, labelIds);
343
+ task = await vikunja.get(`/tasks/${task.id}`);
344
+ }
345
+ return ok(fullTask(task));
346
+ }));
347
+ server.registerTool("update_task", {
348
+ title: "Update task",
349
+ description: "Update fields of an existing task. Only the fields you pass are changed. To clear a date pass null. labelIds, when given, REPLACES the task's labels. Use complete_task to just mark a task done.",
350
+ inputSchema: {
351
+ id: z.number().int().describe("Task id"),
352
+ title: z.string().min(1).optional(),
353
+ description: z.string().optional(),
354
+ done: z.boolean().optional(),
355
+ dueDate: dateField.nullable().optional(),
356
+ startDate: dateField.nullable().optional(),
357
+ endDate: dateField.nullable().optional(),
358
+ priority: priorityField.optional(),
359
+ percentDone: z.number().min(0).max(1).optional().describe("Progress as a fraction 0–1"),
360
+ projectId: z.number().int().optional().describe("Move the task to another project"),
361
+ labelIds: z.array(z.number().int()).optional().describe("Replace labels with these ids")
362
+ }
363
+ }, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, percentDone, projectId, labelIds }) => {
364
+ const current = await vikunja.get(`/tasks/${id}`);
365
+ const patch = stripUndefined({
366
+ title,
367
+ description,
368
+ done,
369
+ due_date: dueDate === null ? "0001-01-01T00:00:00Z" : dueDate,
370
+ start_date: startDate === null ? "0001-01-01T00:00:00Z" : startDate,
371
+ end_date: endDate === null ? "0001-01-01T00:00:00Z" : endDate,
372
+ priority,
373
+ percent_done: percentDone,
374
+ project_id: projectId
375
+ });
376
+ if (Object.keys(patch).length === 0 && labelIds === void 0) throw new Error("Nothing to update.");
377
+ let task = current;
378
+ if (Object.keys(patch).length > 0) task = await vikunja.post(`/tasks/${id}`, {
379
+ ...current,
380
+ ...patch
381
+ });
382
+ if (labelIds !== void 0) {
383
+ await setLabels(vikunja, id, labelIds);
384
+ task = await vikunja.get(`/tasks/${id}`);
385
+ }
386
+ return ok(fullTask(task));
387
+ }));
388
+ server.registerTool("complete_task", {
389
+ title: "Complete task",
390
+ description: "Mark a task as done (or reopen it with done=false).",
391
+ inputSchema: {
392
+ id: z.number().int().describe("Task id"),
393
+ done: z.boolean().default(true).describe("false to reopen a completed task")
394
+ }
395
+ }, guard(async ({ id, done }) => {
396
+ const current = await vikunja.get(`/tasks/${id}`);
397
+ return ok(summarizeTask(await vikunja.post(`/tasks/${id}`, {
398
+ ...current,
399
+ done
400
+ })));
401
+ }));
402
+ server.registerTool("list_labels", {
403
+ title: "List labels",
404
+ description: "List labels available to the user, with ids for use in create_task / update_task.",
405
+ inputSchema: {
406
+ search: z.string().optional().describe("Filter labels by title"),
407
+ page: z.number().int().min(1).default(1),
408
+ perPage: z.number().int().min(1).max(100).default(100)
409
+ }
410
+ }, guard(async ({ search, page, perPage }) => {
411
+ return ok((await vikunja.get("/labels", {
412
+ s: search,
413
+ page,
414
+ per_page: perPage
415
+ }) ?? []).map((l) => ({
416
+ id: l.id,
417
+ title: l.title,
418
+ color: l.hex_color || null,
419
+ description: l.description || null
420
+ })));
421
+ }));
422
+ server.registerTool("create_label", {
423
+ title: "Create label",
424
+ description: "Create a new label.",
425
+ inputSchema: {
426
+ title: z.string().min(1),
427
+ hexColor: z.string().regex(/^[0-9a-fA-F]{6}$/).optional().describe("Colour as 6 hex digits without '#', e.g. e8e8e8"),
428
+ description: z.string().optional()
429
+ }
430
+ }, guard(async ({ title, hexColor, description }) => {
431
+ const data = await vikunja.put("/labels", stripUndefined({
432
+ title,
433
+ hex_color: hexColor,
434
+ description
435
+ }));
436
+ return ok({
437
+ id: data.id,
438
+ title: data.title,
439
+ color: data.hex_color || null
440
+ });
441
+ }));
442
+ if (allowDelete) server.registerTool("delete_task", {
443
+ title: "Delete task",
444
+ description: "Permanently delete a task. Irreversible — only use when the user explicitly asks.",
445
+ inputSchema: { id: z.number().int().describe("Task id") }
446
+ }, guard(async ({ id }) => {
447
+ await vikunja.del(`/tasks/${id}`);
448
+ return ok({ deleted: id });
449
+ }));
450
+ }
451
+ //#endregion
452
+ //#region src/snippets.ts
453
+ function jsonSnippet(missing) {
454
+ const server = {
455
+ command: "npx",
456
+ args: ["-y", PACKAGE_NAME]
457
+ };
458
+ if (Object.keys(missing).length > 0) server.env = missing;
459
+ return JSON.stringify({ mcpServers: { [SERVER_KEY]: server } }, null, 2);
460
+ }
461
+ function tomlSnippet(missing) {
462
+ const lines = [
463
+ `[mcp_servers.${SERVER_KEY}]`,
464
+ "command = \"npx\"",
465
+ `args = ["-y", "${PACKAGE_NAME}"]`
466
+ ];
467
+ if (Object.keys(missing).length > 0) {
468
+ lines.push(`[mcp_servers.${SERVER_KEY}.env]`);
469
+ for (const [k, v] of Object.entries(missing)) lines.push(`${k} = "${v}"`);
470
+ }
471
+ return lines.join("\n");
472
+ }
473
+ function claudeCodeSnippet(missing) {
474
+ const env = Object.entries(missing).map(([k, v]) => `-e ${k}=${v} `).join("");
475
+ return `claude mcp add ${SERVER_KEY} ${env}-- npx -y ${PACKAGE_NAME}`;
476
+ }
477
+ function clientSnippets(missing = {}) {
478
+ return [
479
+ {
480
+ title: "Claude Desktop — claude_desktop_config.json",
481
+ body: jsonSnippet(missing)
482
+ },
483
+ {
484
+ title: "Cursor — ~/.cursor/mcp.json (or <project>/.cursor/mcp.json)",
485
+ body: jsonSnippet(missing)
486
+ },
487
+ {
488
+ title: "Codex — ~/.codex/config.toml",
489
+ body: tomlSnippet(missing)
490
+ },
491
+ {
492
+ title: "Claude Code",
493
+ body: claudeCodeSnippet(missing)
494
+ }
495
+ ];
496
+ }
497
+ var SERVER_KEY, ENV_URL, ENV_TOKEN;
498
+ var init_snippets = __esmMin((() => {
499
+ SERVER_KEY = "vikunja";
500
+ ENV_URL = "VIKUNJA_URL";
501
+ ENV_TOKEN = "VIKUNJA_API_TOKEN";
502
+ }));
503
+ //#endregion
504
+ //#region src/setup.ts
505
+ var setup_exports = /* @__PURE__ */ __exportAll({ runSetup: () => runSetup });
506
+ function abort() {
507
+ p.cancel("Setup aborted.");
508
+ process.exit(1);
509
+ }
510
+ function printSnippets(missing) {
511
+ for (const s of clientSnippets(missing)) p.note(s.body, s.title);
512
+ }
513
+ async function runSetup(args = []) {
514
+ if (args.includes("--reset")) {
515
+ const removed = deleteConfig();
516
+ console.log(removed ? `Removed ${configPath()}` : `Nothing to remove (${configPath()} does not exist)`);
517
+ return;
518
+ }
519
+ const existing = readConfigFile() ?? {};
520
+ if (args.includes("--print")) {
521
+ const missing = {};
522
+ if (!existing.url) missing[ENV_URL] = "https://try.vikunja.io";
523
+ if (!existing.token) missing[ENV_TOKEN] = "tk_...";
524
+ printSnippets(missing);
525
+ return;
526
+ }
527
+ p.intro(`${PACKAGE_NAME} setup`);
528
+ p.log.info("Press Enter to skip any value. Skipped values can be supplied later via the env block of your MCP client config.");
529
+ const creds = await p.group({
530
+ url: () => p.text({
531
+ message: `Vikunja URL (${ENV_URL})`,
532
+ placeholder: "https://try.vikunja.io — Enter to skip",
533
+ initialValue: existing.url ?? "",
534
+ validate: (v) => !v || /^https?:\/\/\S+$/.test(v) ? void 0 : "Must start with http:// or https://"
535
+ }),
536
+ token: () => p.password({
537
+ message: `API token (${ENV_TOKEN}; Settings → API Tokens in Vikunja) — Enter to skip`,
538
+ mask: "▪"
539
+ })
540
+ }, { onCancel: abort });
541
+ const url = (creds.url ?? "").trim().replace(/\/+$/, "") || void 0;
542
+ const token = (creds.token ?? "").trim() || existing.token || void 0;
543
+ let defaultProjectId = existing.defaultProjectId ?? null;
544
+ if (url && token) {
545
+ const s = p.spinner();
546
+ s.start("Checking connection");
547
+ try {
548
+ const me = await currentUser(url, token);
549
+ s.stop(`ok (logged in as ${me.username})`);
550
+ } catch (err) {
551
+ s.stop("failed");
552
+ p.cancel(`Could not authenticate: ${err.message}`);
553
+ process.exit(1);
554
+ }
555
+ try {
556
+ const projects = await makeClient(url, token).get("/projects", { per_page: 100 });
557
+ if (projects.length > 0) {
558
+ const choice = await p.select({
559
+ message: "Default project for new tasks (optional)",
560
+ initialValue: defaultProjectId,
561
+ options: [{
562
+ value: null,
563
+ label: "None — always require projectId"
564
+ }, ...projects.map((pr) => ({
565
+ value: pr.id,
566
+ label: pr.title,
567
+ hint: `#${pr.id}`
568
+ }))]
569
+ });
570
+ if (typeof choice === "symbol") abort();
571
+ defaultProjectId = choice;
572
+ }
573
+ } catch (err) {
574
+ p.log.warn(`Could not list projects (${err.message}); skipping default project.`);
575
+ }
576
+ } else p.log.warn("Skipping connection check and default-project selection because URL and/or token were not provided.");
577
+ const allowDelete = await p.confirm({
578
+ message: "Allow delete tools?",
579
+ initialValue: Boolean(existing.allowDelete)
580
+ });
581
+ if (typeof allowDelete === "symbol") abort();
582
+ const config = {
583
+ defaultProjectId,
584
+ allowDelete
585
+ };
586
+ if (url) config.url = url;
587
+ if (token) config.token = token;
588
+ const file = saveConfig(config);
589
+ const missing = {};
590
+ if (!url) missing[ENV_URL] = "https://try.vikunja.io";
591
+ if (!token) missing[ENV_TOKEN] = "tk_...";
592
+ 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.`);
593
+ printSnippets(missing);
594
+ p.outro(`Saved to ${file}`);
595
+ }
596
+ var init_setup = __esmMin((() => {
597
+ init_config();
598
+ init_client();
599
+ init_snippets();
600
+ }));
601
+ //#endregion
602
+ //#region src/index.ts
603
+ init_config();
604
+ init_client();
605
+ const [cmd, ...rest] = process.argv.slice(2);
606
+ if (cmd === "setup") {
607
+ const { runSetup } = await Promise.resolve().then(() => (init_setup(), setup_exports));
608
+ await runSetup(rest);
609
+ } else if (cmd === "--help" || cmd === "-h" || cmd === "help") printHelp();
610
+ else if (cmd === "--version" || cmd === "-v") console.log(VERSION);
611
+ else await runServer();
612
+ function printHelp() {
613
+ console.log(`${PACKAGE_NAME} ${VERSION}
614
+
615
+ Usage:
616
+ npx ${PACKAGE_NAME} start the MCP server (stdio)
617
+ npx ${PACKAGE_NAME} setup interactive configuration
618
+ npx ${PACKAGE_NAME} setup --reset delete stored configuration
619
+ npx ${PACKAGE_NAME} setup --print print MCP client config snippets
620
+
621
+ Configuration precedence:
622
+ 1. VIKUNJA_URL, VIKUNJA_API_TOKEN, VIKUNJA_ALLOW_DELETE env vars
623
+ 2. ${configPath()}
624
+ `);
625
+ }
626
+ async function runServer() {
627
+ const config = loadConfig();
628
+ if (!config) {
629
+ console.error(`No configuration found. Run: npx ${PACKAGE_NAME} setup`);
630
+ console.error("(or set VIKUNJA_URL and VIKUNJA_API_TOKEN in the environment)");
631
+ process.exit(1);
632
+ }
633
+ if (process.stdin.isTTY) {
634
+ console.error(`${PACKAGE_NAME} is an MCP stdio server and expects to be launched by an MCP client.`);
635
+ console.error(`Run "npx ${PACKAGE_NAME} setup" to configure it, or "npx ${PACKAGE_NAME} --help".`);
636
+ process.exit(1);
637
+ }
638
+ const vikunja = makeClient(config.url, config.token);
639
+ const server = new McpServer({
640
+ name: "mcp-vikunja",
641
+ version: VERSION
642
+ });
643
+ registerProjectTools(server, vikunja);
644
+ registerTaskTools(server, vikunja, {
645
+ allowDelete: config.allowDelete,
646
+ defaultProjectId: config.defaultProjectId
647
+ });
648
+ const transport = new StdioServerTransport();
649
+ await server.connect(transport);
650
+ console.error(`mcp-vikunja ${VERSION} connected (${config.url}, delete tools ${config.allowDelete ? "enabled" : "disabled"})`);
651
+ }
652
+ //#endregion
653
+ export {};
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@fswap/mcp-vikunja",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Vikunja — list, create, update and complete tasks from Claude",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-vikunja": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsdown",
17
+ "dev": "tsdown --watch",
18
+ "typecheck": "tsc --noEmit",
19
+ "lint": "eslint src test",
20
+ "test": "npm run build && node test/smoke.test.mjs",
21
+ "check": "npm run lint && npm run typecheck && npm test",
22
+ "start": "node dist/index.js",
23
+ "setup": "node dist/index.js setup",
24
+ "inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
25
+ "prepublishOnly": "npm run check"
26
+ },
27
+ "dependencies": {
28
+ "@clack/prompts": "^1.0.0",
29
+ "@modelcontextprotocol/sdk": "^1.30.0",
30
+ "env-paths": "^4.0.0",
31
+ "zod": "^4.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.0.0",
35
+ "eslint": "^10.0.0",
36
+ "tsdown": "^0.23.0",
37
+ "typescript": "^5.9.0",
38
+ "typescript-eslint": "^8.70.0"
39
+ },
40
+ "keywords": [
41
+ "mcp",
42
+ "vikunja",
43
+ "tasks",
44
+ "todo",
45
+ "claude",
46
+ "modelcontextprotocol"
47
+ ],
48
+ "license": "MIT",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/sunblob/mcp-vikunja.git"
52
+ },
53
+ "homepage": "https://github.com/sunblob/mcp-vikunja#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/sunblob/mcp-vikunja/issues"
56
+ }
57
+ }