@fswap/mcp-vikunja 0.1.4 → 0.1.7
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 +26 -7
- package/dist/index.js +586 -87
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @fswap/mcp-vikunja
|
|
2
2
|
|
|
3
|
-
An [MCP](https://modelcontextprotocol.io) server for [Vikunja](https://vikunja.io) that lets Claude Desktop, Claude Code, Cursor and other MCP clients
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for [Vikunja](https://vikunja.io) that lets Claude Desktop, Claude Code, Cursor and other MCP clients manage your tasks: create, update, complete, assign, comment, attach files, relate, move on kanban boards and everything else in the task menu.
|
|
4
4
|
|
|
5
5
|
Runs locally over stdio. No install step — clients launch it with `npx`.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
9
|
-
1. Create an API token in Vikunja under **Settings → API Tokens** (scope it to projects, tasks and
|
|
9
|
+
1. Create an API token in Vikunja under **Settings → API Tokens** (scope it to projects, tasks, labels, task comments, attachments, assignees, relations, subscriptions, reactions and users — tools whose scope is missing return a 401).
|
|
10
10
|
2. Run the interactive setup once:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
@@ -78,7 +78,7 @@ VIKUNJA_API_TOKEN = "tk_..."
|
|
|
78
78
|
|---|---|
|
|
79
79
|
| `VIKUNJA_URL` | Vikunja base URL (with or without `/api/v1`) |
|
|
80
80
|
| `VIKUNJA_API_TOKEN` | API token or login JWT |
|
|
81
|
-
| `VIKUNJA_ALLOW_DELETE` | `true` to expose `delete_task` |
|
|
81
|
+
| `VIKUNJA_ALLOW_DELETE` | `true` to expose `delete_task`, `delete_task_comment` and `delete_task_attachment` |
|
|
82
82
|
| `VIKUNJA_DEFAULT_PROJECT_ID` | Project used by `create_task` when `projectId` is omitted |
|
|
83
83
|
|
|
84
84
|
## Tools
|
|
@@ -89,13 +89,32 @@ VIKUNJA_API_TOKEN = "tk_..."
|
|
|
89
89
|
| `get_project` | `GET /projects/{id}` | |
|
|
90
90
|
| `create_project` | `PUT /projects` | optional parent project |
|
|
91
91
|
| `list_tasks` | `GET /tasks` or `GET /projects/{id}/tasks` | open tasks by default; `assignedToMe`, `filter`, `sortBy`, pagination |
|
|
92
|
-
| `get_task` | `GET /tasks/{id}` |
|
|
93
|
-
| `create_task` | `PUT /projects/{id}/tasks` |
|
|
94
|
-
| `update_task` | `POST /tasks/{id}` | merges your changes onto the current task
|
|
95
|
-
| `complete_task` | `POST /tasks/{id}` with `done: true` | `done=false`
|
|
92
|
+
| `get_task` | `GET /tasks/{id}` + `GET /tasks/{id}/comments` | everything on the task page, including comments (`includeComments=false` skips them) |
|
|
93
|
+
| `create_task` | `PUT /projects/{id}/tasks` | all task fields, see below |
|
|
94
|
+
| `update_task` | `POST /tasks/{id}` (+ labels/assignees bulk) | merges your changes onto the current task |
|
|
95
|
+
| `complete_task` | `POST /tasks/{id}` with `done: true` | `done=false` marks undone |
|
|
96
|
+
| `duplicate_task` | `PUT /tasks/{id}/duplicate` | copies labels, assignees, attachments, reminders |
|
|
97
|
+
| `set_subscription` | `PUT` / `DELETE /subscriptions/{task\|project}/{id}` | subscribe / unsubscribe |
|
|
96
98
|
| `list_labels` | `GET /labels` | label ids for create/update |
|
|
97
99
|
| `create_label` | `PUT /labels` | |
|
|
100
|
+
| `get_current_user` | `GET /user` | your own user id, e.g. to assign yourself |
|
|
101
|
+
| `find_users` | `GET /projects/{id}/projectusers` or `GET /users` | user ids for `assigneeIds` |
|
|
102
|
+
| `list_task_comments` | `GET /tasks/{id}/comments` | author, HTML body, reactions |
|
|
103
|
+
| `add_task_comment` | `PUT /tasks/{id}/comments` | plain text is wrapped in `<p>` |
|
|
104
|
+
| `update_task_comment` | `POST /tasks/{id}/comments/{commentId}` | |
|
|
105
|
+
| `set_reaction` | `PUT /{tasks\|comments}/{id}/reactions` | `remove=true` removes your reaction |
|
|
106
|
+
| `add_task_relation` | `PUT /tasks/{id}/relations` | subtask, parenttask, blocking, related, … |
|
|
107
|
+
| `remove_task_relation` | `DELETE /tasks/{id}/relations/{kind}/{otherId}` | |
|
|
108
|
+
| `list_task_attachments` | `GET /tasks/{id}/attachments` | |
|
|
109
|
+
| `upload_task_attachment` | `PUT /tasks/{id}/attachments` (multipart) | local file paths |
|
|
110
|
+
| `download_task_attachment` | `GET /tasks/{id}/attachments/{attachmentId}` | saves to disk; small images/text returned inline |
|
|
111
|
+
| `list_kanban_buckets` | `GET /projects/{id}/views` + `/views/{view}/buckets` | kanban columns |
|
|
112
|
+
| `move_task_to_bucket` | `POST /projects/{id}/views/{view}/buckets/{bucket}/tasks` | |
|
|
98
113
|
| `delete_task` | `DELETE /tasks/{id}` | only when delete is allowed (setup answer or `VIKUNJA_ALLOW_DELETE=true`) |
|
|
114
|
+
| `delete_task_comment` | `DELETE /tasks/{id}/comments/{commentId}` | delete-gated like `delete_task` |
|
|
115
|
+
| `delete_task_attachment` | `DELETE /tasks/{id}/attachments/{attachmentId}` | delete-gated like `delete_task` |
|
|
116
|
+
|
|
117
|
+
`create_task` and `update_task` cover the rest of the task menu: due/start/end dates, priority, progress (`percentDone`), colour (`hexColor`), favorite (`isFavorite`), repeating interval (`repeatAfterSeconds`, `repeatMode`), reminders (absolute or relative to a date), labels (`labelIds`), assignees (`assigneeIds`) and moving to another project (`projectId`). `labelIds`, `assigneeIds` and `reminders` replace the current values. `get_task` returns all of these plus related tasks, attachments, kanban buckets, subscription and comments.
|
|
99
118
|
|
|
100
119
|
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.
|
|
101
120
|
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import envPaths from "env-paths";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import os from "node:os";
|
|
9
10
|
import * as p from "@clack/prompts";
|
|
10
11
|
//#region \0rolldown/runtime.js
|
|
11
12
|
var __defProp = Object.defineProperty;
|
|
@@ -100,7 +101,7 @@ function normalizeDate(value) {
|
|
|
100
101
|
}
|
|
101
102
|
function makeClient(baseUrl, token) {
|
|
102
103
|
const base = baseUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
|
|
103
|
-
async function
|
|
104
|
+
async function send(method, path, init, query) {
|
|
104
105
|
const url = new URL(`${base}/api/v1${path}`);
|
|
105
106
|
if (query) {
|
|
106
107
|
for (const [k, v] of Object.entries(query)) if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
|
|
@@ -108,13 +109,12 @@ function makeClient(baseUrl, token) {
|
|
|
108
109
|
let res;
|
|
109
110
|
try {
|
|
110
111
|
res = await fetch(url, {
|
|
112
|
+
...init,
|
|
111
113
|
method,
|
|
112
114
|
headers: {
|
|
113
115
|
Authorization: `Bearer ${token}`,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
},
|
|
117
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
116
|
+
...init.headers
|
|
117
|
+
}
|
|
118
118
|
});
|
|
119
119
|
} catch (err) {
|
|
120
120
|
throw new Error(`${method} ${path} failed: ${err.message}`);
|
|
@@ -128,24 +128,50 @@ function makeClient(baseUrl, token) {
|
|
|
128
128
|
} catch {}
|
|
129
129
|
throw new Error(`${method} ${path} → ${res.status}: ${detail}`);
|
|
130
130
|
}
|
|
131
|
+
return res;
|
|
132
|
+
}
|
|
133
|
+
async function parseJson(res) {
|
|
131
134
|
if (res.status === 204) return void 0;
|
|
132
135
|
const text = await res.text();
|
|
133
136
|
return text ? JSON.parse(text) : void 0;
|
|
134
137
|
}
|
|
138
|
+
async function request(method, path, body, query) {
|
|
139
|
+
return parseJson(await send(method, path, {
|
|
140
|
+
headers: {
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
Accept: "application/json"
|
|
143
|
+
},
|
|
144
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
145
|
+
}, query));
|
|
146
|
+
}
|
|
135
147
|
return {
|
|
136
148
|
baseUrl: base,
|
|
137
149
|
request,
|
|
138
150
|
get: (path, query) => request("GET", path, void 0, query),
|
|
139
151
|
put: (path, body) => request("PUT", path, body),
|
|
140
152
|
post: (path, body) => request("POST", path, body),
|
|
141
|
-
del: (path) => request("DELETE", path)
|
|
153
|
+
del: (path) => request("DELETE", path),
|
|
154
|
+
upload: async (path, form) => parseJson(await send("PUT", path, {
|
|
155
|
+
headers: { Accept: "application/json" },
|
|
156
|
+
body: form
|
|
157
|
+
})),
|
|
158
|
+
download: async (path) => {
|
|
159
|
+
const res = await send("GET", path, {});
|
|
160
|
+
return {
|
|
161
|
+
data: Buffer.from(await res.arrayBuffer()),
|
|
162
|
+
contentType: res.headers.get("content-type")
|
|
163
|
+
};
|
|
164
|
+
}
|
|
142
165
|
};
|
|
143
166
|
}
|
|
144
167
|
/** Cheap authenticated call used by `setup` to validate URL + token. */
|
|
145
168
|
async function currentUser(baseUrl, token) {
|
|
146
169
|
return makeClient(baseUrl, token).get("/user");
|
|
147
170
|
}
|
|
148
|
-
var
|
|
171
|
+
var ZERO_DATE;
|
|
172
|
+
var init_client = __esmMin((() => {
|
|
173
|
+
ZERO_DATE = "0001-01-01T00:00:00Z";
|
|
174
|
+
}));
|
|
149
175
|
//#endregion
|
|
150
176
|
//#region src/tools/_shared.ts
|
|
151
177
|
init_config();
|
|
@@ -178,6 +204,39 @@ function guard(fn) {
|
|
|
178
204
|
function stripUndefined(obj) {
|
|
179
205
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
180
206
|
}
|
|
207
|
+
function summarizeUser(u) {
|
|
208
|
+
if (!u) return null;
|
|
209
|
+
return {
|
|
210
|
+
id: u.id,
|
|
211
|
+
username: u.username,
|
|
212
|
+
name: u.name || null
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
let frontendBase = null;
|
|
216
|
+
/** Resolve the web UI base URL once (Vikunja's API host may differ from its frontend host). */
|
|
217
|
+
async function frontendUrl(vikunja) {
|
|
218
|
+
if (frontendBase) return frontendBase;
|
|
219
|
+
try {
|
|
220
|
+
frontendBase = ((await vikunja.get("/info")).frontend_url || vikunja.baseUrl).replace(/\/+$/, "");
|
|
221
|
+
} catch {
|
|
222
|
+
frontendBase = vikunja.baseUrl;
|
|
223
|
+
}
|
|
224
|
+
return frontendBase;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Vikunja stores descriptions and comments as HTML. Pass HTML through untouched;
|
|
228
|
+
* wrap plain text in paragraphs so line breaks survive.
|
|
229
|
+
*/
|
|
230
|
+
function toHtml(text) {
|
|
231
|
+
if (/<\/?[a-z][\s\S]*>/i.test(text)) return text;
|
|
232
|
+
const escape = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
233
|
+
return text.split(/\n{2,}/).map((para) => `<p>${escape(para).replace(/\n/g, "<br>")}</p>`).join("");
|
|
234
|
+
}
|
|
235
|
+
/** Reaction map `{ "👍": [users] }` → `{ "👍": ["alice"] }`, or null when empty. */
|
|
236
|
+
function summarizeReactions(map) {
|
|
237
|
+
if (!map || Object.keys(map).length === 0) return null;
|
|
238
|
+
return Object.fromEntries(Object.entries(map).map(([value, users]) => [value, users.map((u) => u.username)]));
|
|
239
|
+
}
|
|
181
240
|
//#endregion
|
|
182
241
|
//#region src/tools/projects.ts
|
|
183
242
|
function summarizeProject(p, base) {
|
|
@@ -192,16 +251,6 @@ function summarizeProject(p, base) {
|
|
|
192
251
|
favorite: Boolean(p.is_favorite)
|
|
193
252
|
};
|
|
194
253
|
}
|
|
195
|
-
let frontendBase$1 = null;
|
|
196
|
-
async function frontendUrl$1(vikunja) {
|
|
197
|
-
if (frontendBase$1) return frontendBase$1;
|
|
198
|
-
try {
|
|
199
|
-
frontendBase$1 = ((await vikunja.get("/info")).frontend_url || vikunja.baseUrl).replace(/\/+$/, "");
|
|
200
|
-
} catch {
|
|
201
|
-
frontendBase$1 = vikunja.baseUrl;
|
|
202
|
-
}
|
|
203
|
-
return frontendBase$1;
|
|
204
|
-
}
|
|
205
254
|
function registerProjectTools(server, vikunja) {
|
|
206
255
|
server.registerTool("list_projects", {
|
|
207
256
|
title: "List projects",
|
|
@@ -219,7 +268,7 @@ function registerProjectTools(server, vikunja) {
|
|
|
219
268
|
page,
|
|
220
269
|
per_page: perPage
|
|
221
270
|
});
|
|
222
|
-
const base = await frontendUrl
|
|
271
|
+
const base = await frontendUrl(vikunja);
|
|
223
272
|
return ok(data.map((p) => summarizeProject(p, base)));
|
|
224
273
|
}));
|
|
225
274
|
server.registerTool("get_project", {
|
|
@@ -227,7 +276,7 @@ function registerProjectTools(server, vikunja) {
|
|
|
227
276
|
description: "Get one Vikunja project by id.",
|
|
228
277
|
inputSchema: { id: z.number().int().describe("Project id") }
|
|
229
278
|
}, guard(async ({ id }) => {
|
|
230
|
-
return ok(summarizeProject(await vikunja.get(`/projects/${id}`), await frontendUrl
|
|
279
|
+
return ok(summarizeProject(await vikunja.get(`/projects/${id}`), await frontendUrl(vikunja)));
|
|
231
280
|
}));
|
|
232
281
|
server.registerTool("create_project", {
|
|
233
282
|
title: "Create project",
|
|
@@ -241,22 +290,207 @@ function registerProjectTools(server, vikunja) {
|
|
|
241
290
|
const body = { title };
|
|
242
291
|
if (description !== void 0) body.description = description;
|
|
243
292
|
if (parentProjectId !== void 0) body.parent_project_id = parentProjectId;
|
|
244
|
-
return ok(summarizeProject(await vikunja.put("/projects", body), await frontendUrl
|
|
293
|
+
return ok(summarizeProject(await vikunja.put("/projects", body), await frontendUrl(vikunja)));
|
|
245
294
|
}));
|
|
246
295
|
}
|
|
247
296
|
//#endregion
|
|
248
|
-
//#region src/tools/
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
297
|
+
//#region src/tools/attachments.ts
|
|
298
|
+
function summarizeAttachment(a) {
|
|
299
|
+
return {
|
|
300
|
+
id: a.id,
|
|
301
|
+
name: a.file?.name ?? null,
|
|
302
|
+
mime: a.file?.mime || null,
|
|
303
|
+
size: a.file?.size ?? null,
|
|
304
|
+
created: a.created,
|
|
305
|
+
createdBy: summarizeUser(a.created_by)
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const INLINE_IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
309
|
+
"image/png",
|
|
310
|
+
"image/jpeg",
|
|
311
|
+
"image/gif",
|
|
312
|
+
"image/webp"
|
|
313
|
+
]);
|
|
314
|
+
const MAX_INLINE_IMAGE_BYTES = 1e6;
|
|
315
|
+
const MAX_INLINE_TEXT_BYTES = 2e5;
|
|
316
|
+
function isTextual(mime) {
|
|
317
|
+
return /^text\/|json|xml|yaml|csv|markdown|javascript/.test(mime);
|
|
318
|
+
}
|
|
319
|
+
function safeFileName(name, fallback) {
|
|
320
|
+
const base = path.basename((name || "").replace(/[\\/]/g, "_"));
|
|
321
|
+
return base && base !== "." && base !== ".." ? base : fallback;
|
|
322
|
+
}
|
|
323
|
+
function registerAttachmentTools(server, vikunja, { allowDelete = false } = {}) {
|
|
324
|
+
server.registerTool("list_task_attachments", {
|
|
325
|
+
title: "List task attachments",
|
|
326
|
+
description: "List files attached to a task (id, name, mime type, size, uploader).",
|
|
327
|
+
inputSchema: {
|
|
328
|
+
taskId: z.number().int().describe("Task id"),
|
|
329
|
+
page: z.number().int().min(1).default(1),
|
|
330
|
+
perPage: z.number().int().min(1).max(100).default(50)
|
|
331
|
+
}
|
|
332
|
+
}, guard(async ({ taskId, page, perPage }) => {
|
|
333
|
+
return ok((await vikunja.get(`/tasks/${taskId}/attachments`, {
|
|
334
|
+
page,
|
|
335
|
+
per_page: perPage
|
|
336
|
+
}) ?? []).map(summarizeAttachment));
|
|
337
|
+
}));
|
|
338
|
+
server.registerTool("upload_task_attachment", {
|
|
339
|
+
title: "Upload task attachment",
|
|
340
|
+
description: "Attach one or more local files to a task.",
|
|
341
|
+
inputSchema: {
|
|
342
|
+
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")
|
|
344
|
+
}
|
|
345
|
+
}, 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("; ")}`);
|
|
355
|
+
return ok({
|
|
356
|
+
uploaded,
|
|
357
|
+
errors
|
|
358
|
+
});
|
|
359
|
+
}));
|
|
360
|
+
server.registerTool("download_task_attachment", {
|
|
361
|
+
title: "Download task attachment",
|
|
362
|
+
description: "Save a task attachment to a local file. Small images and text files are also returned inline so you can read them.",
|
|
363
|
+
inputSchema: {
|
|
364
|
+
taskId: z.number().int().describe("Task id"),
|
|
365
|
+
attachmentId: z.number().int().describe("Attachment id (from get_task or list_task_attachments)"),
|
|
366
|
+
outputPath: z.string().optional().describe("Where to save the file; defaults to <tmp>/mcp-vikunja/task-<taskId>/<file name>")
|
|
367
|
+
}
|
|
368
|
+
}, guard(async ({ taskId, attachmentId, outputPath }) => {
|
|
369
|
+
const attachment = ((await vikunja.get(`/tasks/${taskId}`)).attachments ?? []).find((a) => a.id === attachmentId);
|
|
370
|
+
if (!attachment) throw new Error(`Attachment ${attachmentId} not found on task ${taskId}.`);
|
|
371
|
+
const { data, contentType } = await vikunja.download(`/tasks/${taskId}/attachments/${attachmentId}`);
|
|
372
|
+
const name = safeFileName(attachment.file?.name, `attachment-${attachmentId}`);
|
|
373
|
+
const target = path.resolve(outputPath ?? path.join(os.tmpdir(), "mcp-vikunja", `task-${taskId}`, name));
|
|
374
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
375
|
+
fs.writeFileSync(target, data);
|
|
376
|
+
const mime = (attachment.file?.mime || contentType || "application/octet-stream").split(";")[0].trim();
|
|
377
|
+
const content = [{
|
|
378
|
+
type: "text",
|
|
379
|
+
text: JSON.stringify({
|
|
380
|
+
savedTo: target,
|
|
381
|
+
name,
|
|
382
|
+
mime,
|
|
383
|
+
size: data.length
|
|
384
|
+
}, null, 2)
|
|
385
|
+
}];
|
|
386
|
+
if (INLINE_IMAGE_TYPES.has(mime) && data.length <= MAX_INLINE_IMAGE_BYTES) content.push({
|
|
387
|
+
type: "image",
|
|
388
|
+
data: data.toString("base64"),
|
|
389
|
+
mimeType: mime
|
|
390
|
+
});
|
|
391
|
+
else if (isTextual(mime) && data.length <= MAX_INLINE_TEXT_BYTES) content.push({
|
|
392
|
+
type: "text",
|
|
393
|
+
text: data.toString("utf8")
|
|
394
|
+
});
|
|
395
|
+
return { content };
|
|
396
|
+
}));
|
|
397
|
+
if (allowDelete) server.registerTool("delete_task_attachment", {
|
|
398
|
+
title: "Delete task attachment",
|
|
399
|
+
description: "Permanently delete an attachment. Irreversible — only use when the user explicitly asks.",
|
|
400
|
+
inputSchema: {
|
|
401
|
+
taskId: z.number().int().describe("Task id"),
|
|
402
|
+
attachmentId: z.number().int().describe("Attachment id")
|
|
403
|
+
}
|
|
404
|
+
}, guard(async ({ taskId, attachmentId }) => {
|
|
405
|
+
await vikunja.del(`/tasks/${taskId}/attachments/${attachmentId}`);
|
|
406
|
+
return ok({ deleted: attachmentId });
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/tools/comments.ts
|
|
411
|
+
function summarizeComment(c) {
|
|
412
|
+
return {
|
|
413
|
+
id: c.id,
|
|
414
|
+
author: summarizeUser(c.author),
|
|
415
|
+
comment: c.comment,
|
|
416
|
+
reactions: summarizeReactions(c.reactions),
|
|
417
|
+
created: c.created,
|
|
418
|
+
edited: c.updated && c.updated !== c.created ? c.updated : null
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const commentText = z.string().min(1).describe("Comment body. HTML is sent as-is; plain text is wrapped in <p> (blank line = new paragraph)");
|
|
422
|
+
function registerCommentTools(server, vikunja, { allowDelete = false } = {}) {
|
|
423
|
+
server.registerTool("list_task_comments", {
|
|
424
|
+
title: "List task comments",
|
|
425
|
+
description: "List all comments on a task with author, HTML body, reactions and timestamps.",
|
|
426
|
+
inputSchema: {
|
|
427
|
+
taskId: z.number().int().describe("Task id"),
|
|
428
|
+
order: z.enum(["asc", "desc"]).default("asc").describe("asc = oldest first")
|
|
429
|
+
}
|
|
430
|
+
}, guard(async ({ taskId, order }) => {
|
|
431
|
+
return ok((await vikunja.get(`/tasks/${taskId}/comments`, { order_by: order }) ?? []).map(summarizeComment));
|
|
432
|
+
}));
|
|
433
|
+
server.registerTool("add_task_comment", {
|
|
434
|
+
title: "Add task comment",
|
|
435
|
+
description: "Post a comment on a task as the current user. Visible to everyone with access to the task.",
|
|
436
|
+
inputSchema: {
|
|
437
|
+
taskId: z.number().int().describe("Task id"),
|
|
438
|
+
comment: commentText
|
|
439
|
+
}
|
|
440
|
+
}, guard(async ({ taskId, comment }) => {
|
|
441
|
+
return ok(summarizeComment(await vikunja.put(`/tasks/${taskId}/comments`, { comment: toHtml(comment) })));
|
|
442
|
+
}));
|
|
443
|
+
server.registerTool("update_task_comment", {
|
|
444
|
+
title: "Edit task comment",
|
|
445
|
+
description: "Replace the text of an existing comment.",
|
|
446
|
+
inputSchema: {
|
|
447
|
+
taskId: z.number().int().describe("Task id"),
|
|
448
|
+
commentId: z.number().int().describe("Comment id"),
|
|
449
|
+
comment: commentText
|
|
450
|
+
}
|
|
451
|
+
}, guard(async ({ taskId, commentId, comment }) => {
|
|
452
|
+
return ok(summarizeComment(await vikunja.post(`/tasks/${taskId}/comments/${commentId}`, { comment: toHtml(comment) })));
|
|
453
|
+
}));
|
|
454
|
+
server.registerTool("set_reaction", {
|
|
455
|
+
title: "Add / remove reaction",
|
|
456
|
+
description: "Add an emoji reaction (or remove yours with remove=true) on a task or a task comment.",
|
|
457
|
+
inputSchema: {
|
|
458
|
+
entity: z.enum(["task", "comment"]).default("task"),
|
|
459
|
+
id: z.number().int().describe("Task id, or comment id when entity=comment"),
|
|
460
|
+
reaction: z.string().min(1).max(20).describe("Emoji or short text, e.g. 👍"),
|
|
461
|
+
remove: z.boolean().default(false)
|
|
462
|
+
}
|
|
463
|
+
}, guard(async ({ entity, id, reaction, remove }) => {
|
|
464
|
+
const kind = entity === "task" ? "tasks" : "comments";
|
|
465
|
+
if (remove) await vikunja.post(`/${kind}/${id}/reactions/delete`, { value: reaction });
|
|
466
|
+
else await vikunja.put(`/${kind}/${id}/reactions`, { value: reaction });
|
|
467
|
+
return ok({
|
|
468
|
+
entity,
|
|
469
|
+
id,
|
|
470
|
+
reaction,
|
|
471
|
+
active: !remove
|
|
472
|
+
});
|
|
473
|
+
}));
|
|
474
|
+
if (allowDelete) server.registerTool("delete_task_comment", {
|
|
475
|
+
title: "Delete task comment",
|
|
476
|
+
description: "Permanently delete a comment. Irreversible — only use when the user explicitly asks.",
|
|
477
|
+
inputSchema: {
|
|
478
|
+
taskId: z.number().int().describe("Task id"),
|
|
479
|
+
commentId: z.number().int().describe("Comment id")
|
|
480
|
+
}
|
|
481
|
+
}, guard(async ({ taskId, commentId }) => {
|
|
482
|
+
await vikunja.del(`/tasks/${taskId}/comments/${commentId}`);
|
|
483
|
+
return ok({ deleted: commentId });
|
|
484
|
+
}));
|
|
259
485
|
}
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/tools/tasks.ts
|
|
488
|
+
/** Index = Vikunja's numeric repeat_mode. */
|
|
489
|
+
const REPEAT_MODES = [
|
|
490
|
+
"default",
|
|
491
|
+
"monthly",
|
|
492
|
+
"fromCurrentDate"
|
|
493
|
+
];
|
|
260
494
|
function summarizeTask(t, base) {
|
|
261
495
|
return {
|
|
262
496
|
id: t.id,
|
|
@@ -280,26 +514,100 @@ function fullTask(t, base) {
|
|
|
280
514
|
startDate: normalizeDate(t.start_date),
|
|
281
515
|
endDate: normalizeDate(t.end_date),
|
|
282
516
|
percentDone: t.percent_done ?? 0,
|
|
283
|
-
|
|
517
|
+
color: t.hex_color || null,
|
|
518
|
+
favorite: Boolean(t.is_favorite),
|
|
519
|
+
repeat: t.repeat_after || t.repeat_mode ? {
|
|
520
|
+
afterSeconds: t.repeat_after || 0,
|
|
521
|
+
mode: REPEAT_MODES[t.repeat_mode ?? 0] ?? "default"
|
|
522
|
+
} : null,
|
|
523
|
+
reminders: (t.reminders ?? []).map((r) => r.relative_to ? {
|
|
524
|
+
relativeTo: r.relative_to,
|
|
525
|
+
relativePeriodSeconds: r.relative_period ?? 0,
|
|
526
|
+
reminder: normalizeDate(r.reminder)
|
|
527
|
+
} : { reminder: normalizeDate(r.reminder) }),
|
|
284
528
|
labels: (t.labels ?? []).map((l) => ({
|
|
285
529
|
id: l.id,
|
|
286
530
|
title: l.title,
|
|
287
531
|
color: l.hex_color || null
|
|
288
532
|
})),
|
|
289
|
-
assignees: (t.assignees ?? []).map((u) => (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
533
|
+
assignees: (t.assignees ?? []).map((u) => summarizeUser(u)),
|
|
534
|
+
subscription: t.subscription ? {
|
|
535
|
+
entity: t.subscription.entity,
|
|
536
|
+
entityId: t.subscription.entity_id
|
|
537
|
+
} : null,
|
|
538
|
+
relatedTasks: Object.fromEntries(Object.entries(t.related_tasks ?? {}).map(([kind, tasks]) => [kind, (tasks ?? []).map((r) => ({
|
|
539
|
+
id: r.id,
|
|
540
|
+
title: r.title,
|
|
541
|
+
done: r.done,
|
|
542
|
+
projectId: r.project_id,
|
|
543
|
+
url: `${base}/tasks/${r.id}`
|
|
544
|
+
}))])),
|
|
545
|
+
attachments: (t.attachments ?? []).map(summarizeAttachment),
|
|
546
|
+
coverImageAttachmentId: t.cover_image_attachment_id || null,
|
|
547
|
+
kanbanBuckets: t.buckets ? t.buckets.map((b) => ({
|
|
548
|
+
id: b.id,
|
|
549
|
+
title: b.title,
|
|
550
|
+
viewId: b.project_view_id
|
|
551
|
+
})) : void 0,
|
|
552
|
+
createdBy: summarizeUser(t.created_by),
|
|
294
553
|
created: t.created
|
|
295
554
|
};
|
|
296
555
|
}
|
|
556
|
+
/** Fetch one task including its kanban buckets; falls back for Vikunja versions without `expand`. */
|
|
557
|
+
async function fetchTask(vikunja, id) {
|
|
558
|
+
try {
|
|
559
|
+
return await vikunja.get(`/tasks/${id}`, { expand: "buckets" });
|
|
560
|
+
} catch {
|
|
561
|
+
return vikunja.get(`/tasks/${id}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
297
564
|
const dateField = z.string().datetime({ offset: true }).describe("RFC 3339 timestamp, e.g. 2026-09-15T17:00:00Z");
|
|
298
565
|
const priorityField = z.number().int().min(0).max(5).describe("0 = unset, 1 = low, 2 = medium, 3 = high, 4 = urgent, 5 = DO NOW");
|
|
566
|
+
const reminderField = z.object({
|
|
567
|
+
reminder: dateField.optional().describe("Absolute reminder time"),
|
|
568
|
+
relativeTo: z.enum([
|
|
569
|
+
"due_date",
|
|
570
|
+
"start_date",
|
|
571
|
+
"end_date"
|
|
572
|
+
]).optional().describe("Date the reminder is relative to"),
|
|
573
|
+
relativePeriodSeconds: z.number().int().optional().describe("Offset from relativeTo in seconds; negative = before (e.g. -3600 = one hour before)")
|
|
574
|
+
}).refine((r) => r.reminder !== void 0 || r.relativeTo !== void 0, "Give either reminder or relativeTo");
|
|
575
|
+
/** Fields shared by create_task and update_task beyond the basic ones. */
|
|
576
|
+
const extraFields = {
|
|
577
|
+
percentDone: z.number().min(0).max(1).optional().describe("Progress as a fraction 0–1 (the UI uses 10% steps)"),
|
|
578
|
+
hexColor: z.string().regex(/^#?([0-9a-fA-F]{6})?$/).optional().describe("Task colour as 6 hex digits, e.g. 1973ff; empty string removes the colour"),
|
|
579
|
+
isFavorite: z.boolean().optional().describe("Add to (true) or remove from (false) the current user's favorites"),
|
|
580
|
+
repeatAfterSeconds: z.number().int().min(0).optional().describe("Repeat interval in seconds (3600 hourly, 86400 daily, 604800 weekly); 0 stops repeating"),
|
|
581
|
+
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
|
+
reminders: z.array(reminderField).optional().describe("REPLACES all reminders; [] removes them"),
|
|
583
|
+
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")
|
|
585
|
+
};
|
|
586
|
+
function toApiReminder(r) {
|
|
587
|
+
if (r.relativeTo) return {
|
|
588
|
+
relative_to: r.relativeTo,
|
|
589
|
+
relative_period: r.relativePeriodSeconds ?? 0
|
|
590
|
+
};
|
|
591
|
+
return { reminder: r.reminder };
|
|
592
|
+
}
|
|
593
|
+
function extraBody(a) {
|
|
594
|
+
return stripUndefined({
|
|
595
|
+
percent_done: a.percentDone,
|
|
596
|
+
hex_color: a.hexColor?.replace(/^#/, ""),
|
|
597
|
+
is_favorite: a.isFavorite,
|
|
598
|
+
repeat_after: a.repeatAfterSeconds,
|
|
599
|
+
repeat_mode: a.repeatMode === void 0 ? void 0 : REPEAT_MODES.indexOf(a.repeatMode),
|
|
600
|
+
reminders: a.reminders?.map(toApiReminder)
|
|
601
|
+
});
|
|
602
|
+
}
|
|
299
603
|
/** Replace the task's labels with exactly `labelIds` (Vikunja has no single-call "set labels"). */
|
|
300
604
|
async function setLabels(vikunja, taskId, labelIds) {
|
|
301
605
|
await vikunja.post(`/tasks/${taskId}/labels/bulk`, { labels: labelIds.map((id) => ({ id })) });
|
|
302
606
|
}
|
|
607
|
+
/** Replace the task's assignees; users missing from the list are unassigned. */
|
|
608
|
+
async function setAssignees(vikunja, taskId, userIds) {
|
|
609
|
+
await vikunja.post(`/tasks/${taskId}/assignees/bulk`, { assignees: userIds.map((id) => ({ id })) });
|
|
610
|
+
}
|
|
303
611
|
function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjectId = null } = {}) {
|
|
304
612
|
server.registerTool("list_tasks", {
|
|
305
613
|
title: "List tasks",
|
|
@@ -346,84 +654,94 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
346
654
|
}));
|
|
347
655
|
server.registerTool("get_task", {
|
|
348
656
|
title: "Get task",
|
|
349
|
-
description: "Get a single task with full details: description, dates, priority, labels and
|
|
350
|
-
inputSchema: {
|
|
351
|
-
|
|
352
|
-
|
|
657
|
+
description: "Get a single task with full details: description, dates, priority, progress, colour, favorite, repeat, reminders, labels, assignees, subscription, related tasks, attachments, kanban buckets and (by default) comments.",
|
|
658
|
+
inputSchema: {
|
|
659
|
+
id: z.number().int().describe("Task id"),
|
|
660
|
+
includeComments: z.boolean().default(true).describe("Also return the task's comments (oldest first)")
|
|
661
|
+
}
|
|
662
|
+
}, guard(async ({ id, includeComments }) => {
|
|
663
|
+
const [task, comments] = await Promise.all([fetchTask(vikunja, id), includeComments ? vikunja.get(`/tasks/${id}/comments`) : Promise.resolve(void 0)]);
|
|
664
|
+
const result = fullTask(task, await frontendUrl(vikunja));
|
|
665
|
+
return ok(comments === void 0 ? result : {
|
|
666
|
+
...result,
|
|
667
|
+
comments: (comments ?? []).map(summarizeComment)
|
|
668
|
+
});
|
|
353
669
|
}));
|
|
354
670
|
server.registerTool("create_task", {
|
|
355
671
|
title: "Create task",
|
|
356
|
-
description: "Create a task in a project. Use list_projects
|
|
672
|
+
description: "Create a task in a project. Use list_projects for the projectId, list_labels for label ids and find_users for assignee ids. Descriptions are HTML." + (defaultProjectId != null ? ` If projectId is omitted, project ${defaultProjectId} is used.` : ""),
|
|
357
673
|
inputSchema: {
|
|
358
674
|
projectId: defaultProjectId != null ? z.number().int().default(defaultProjectId).describe("Project id") : z.number().int().describe("Project id"),
|
|
359
675
|
title: z.string().min(1).describe("Task title"),
|
|
360
|
-
description: z.string().optional().describe("Task description (
|
|
676
|
+
description: z.string().optional().describe("Task description (HTML)"),
|
|
361
677
|
dueDate: dateField.optional(),
|
|
362
678
|
startDate: dateField.optional(),
|
|
363
679
|
endDate: dateField.optional(),
|
|
364
680
|
priority: priorityField.optional(),
|
|
365
|
-
|
|
681
|
+
...extraFields
|
|
366
682
|
}
|
|
367
|
-
}, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds }) => {
|
|
368
|
-
const body =
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
683
|
+
}, guard(async ({ projectId, title, description, dueDate, startDate, endDate, priority, labelIds, assigneeIds, ...extra }) => {
|
|
684
|
+
const body = {
|
|
685
|
+
...stripUndefined({
|
|
686
|
+
title,
|
|
687
|
+
description,
|
|
688
|
+
due_date: dueDate,
|
|
689
|
+
start_date: startDate,
|
|
690
|
+
end_date: endDate,
|
|
691
|
+
priority
|
|
692
|
+
}),
|
|
693
|
+
...extraBody(extra)
|
|
694
|
+
};
|
|
376
695
|
let task = await vikunja.put(`/projects/${projectId}/tasks`, body);
|
|
377
|
-
if (labelIds && labelIds.length > 0)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
696
|
+
if (labelIds && labelIds.length > 0) await setLabels(vikunja, task.id, labelIds);
|
|
697
|
+
if (assigneeIds && assigneeIds.length > 0) await setAssignees(vikunja, task.id, assigneeIds);
|
|
698
|
+
if (labelIds?.length || assigneeIds?.length) task = await fetchTask(vikunja, task.id);
|
|
381
699
|
return ok(fullTask(task, await frontendUrl(vikunja)));
|
|
382
700
|
}));
|
|
383
701
|
server.registerTool("update_task", {
|
|
384
702
|
title: "Update task",
|
|
385
|
-
description: "Update fields of an existing task. Only the fields you pass are changed. To clear a date pass null. labelIds,
|
|
703
|
+
description: "Update fields of an existing task. Only the fields you pass are changed. To clear a date pass null. labelIds, assigneeIds and reminders REPLACE the current values. projectId moves the task to another project. Use complete_task to just mark a task done.",
|
|
386
704
|
inputSchema: {
|
|
387
705
|
id: z.number().int().describe("Task id"),
|
|
388
706
|
title: z.string().min(1).optional(),
|
|
389
|
-
description: z.string().optional(),
|
|
707
|
+
description: z.string().optional().describe("Task description (HTML)"),
|
|
390
708
|
done: z.boolean().optional(),
|
|
391
709
|
dueDate: dateField.nullable().optional(),
|
|
392
710
|
startDate: dateField.nullable().optional(),
|
|
393
711
|
endDate: dateField.nullable().optional(),
|
|
394
712
|
priority: priorityField.optional(),
|
|
395
|
-
percentDone: z.number().min(0).max(1).optional().describe("Progress as a fraction 0–1"),
|
|
396
713
|
projectId: z.number().int().optional().describe("Move the task to another project"),
|
|
397
|
-
|
|
714
|
+
...extraFields
|
|
398
715
|
}
|
|
399
|
-
}, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority,
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (Object.keys(patch).length > 0)
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
task = await vikunja.get(`/tasks/${id}`);
|
|
716
|
+
}, guard(async ({ id, title, description, done, dueDate, startDate, endDate, priority, projectId, labelIds, assigneeIds, ...extra }) => {
|
|
717
|
+
const patch = {
|
|
718
|
+
...stripUndefined({
|
|
719
|
+
title,
|
|
720
|
+
description,
|
|
721
|
+
done,
|
|
722
|
+
due_date: dueDate === null ? ZERO_DATE : dueDate,
|
|
723
|
+
start_date: startDate === null ? ZERO_DATE : startDate,
|
|
724
|
+
end_date: endDate === null ? ZERO_DATE : endDate,
|
|
725
|
+
priority,
|
|
726
|
+
project_id: projectId
|
|
727
|
+
}),
|
|
728
|
+
...extraBody(extra)
|
|
729
|
+
};
|
|
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) {
|
|
732
|
+
const current = await vikunja.get(`/tasks/${id}`);
|
|
733
|
+
await vikunja.post(`/tasks/${id}`, {
|
|
734
|
+
...current,
|
|
735
|
+
...patch
|
|
736
|
+
});
|
|
421
737
|
}
|
|
422
|
-
|
|
738
|
+
if (labelIds !== void 0) await setLabels(vikunja, id, labelIds);
|
|
739
|
+
if (assigneeIds !== void 0) await setAssignees(vikunja, id, assigneeIds);
|
|
740
|
+
return ok(fullTask(await fetchTask(vikunja, id), await frontendUrl(vikunja)));
|
|
423
741
|
}));
|
|
424
742
|
server.registerTool("complete_task", {
|
|
425
743
|
title: "Complete task",
|
|
426
|
-
description: "Mark a task as done (or reopen it with done=false).",
|
|
744
|
+
description: "Mark a task as done (or undone / reopen it with done=false).",
|
|
427
745
|
inputSchema: {
|
|
428
746
|
id: z.number().int().describe("Task id"),
|
|
429
747
|
done: z.boolean().default(true).describe("false to reopen a completed task")
|
|
@@ -435,6 +753,45 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
435
753
|
done
|
|
436
754
|
}), await frontendUrl(vikunja)));
|
|
437
755
|
}));
|
|
756
|
+
server.registerTool("duplicate_task", {
|
|
757
|
+
title: "Duplicate task",
|
|
758
|
+
description: "Copy a task with labels, assignees, attachments and reminders into the same project. The copy gets a \"copiedfrom\" relation to the original.",
|
|
759
|
+
inputSchema: { id: z.number().int().describe("Task id to duplicate") }
|
|
760
|
+
}, guard(async ({ id }) => {
|
|
761
|
+
const res = await vikunja.put(`/tasks/${id}/duplicate`);
|
|
762
|
+
if (!res?.duplicated_task) throw new Error("Vikunja did not return the duplicated task.");
|
|
763
|
+
return ok(fullTask(res.duplicated_task, await frontendUrl(vikunja)));
|
|
764
|
+
}));
|
|
765
|
+
server.registerTool("set_subscription", {
|
|
766
|
+
title: "Subscribe / unsubscribe",
|
|
767
|
+
description: "Subscribe the current user to notifications for a task or project, or unsubscribe (subscribed=false). A task subscription may be inherited from its project; then unsubscribe from the project instead.",
|
|
768
|
+
inputSchema: {
|
|
769
|
+
entity: z.enum(["task", "project"]).default("task"),
|
|
770
|
+
id: z.number().int().describe("Task or project id"),
|
|
771
|
+
subscribed: z.boolean().default(true).describe("false to unsubscribe")
|
|
772
|
+
}
|
|
773
|
+
}, guard(async ({ entity, id, subscribed }) => {
|
|
774
|
+
if (entity === "task") {
|
|
775
|
+
const task = await vikunja.get(`/tasks/${id}`);
|
|
776
|
+
const sub = task.subscription;
|
|
777
|
+
if (Boolean(sub) === subscribed) return ok({
|
|
778
|
+
entity,
|
|
779
|
+
id,
|
|
780
|
+
subscribed,
|
|
781
|
+
changed: false,
|
|
782
|
+
via: sub ? String(sub.entity) : null
|
|
783
|
+
});
|
|
784
|
+
if (sub && String(sub.entity) !== "task") throw new Error(`Task ${id} inherits its subscription from project ${task.project_id}; unsubscribe with entity="project", id=${task.project_id}.`);
|
|
785
|
+
}
|
|
786
|
+
if (subscribed) await vikunja.put(`/subscriptions/${entity}/${id}`);
|
|
787
|
+
else await vikunja.del(`/subscriptions/${entity}/${id}`);
|
|
788
|
+
return ok({
|
|
789
|
+
entity,
|
|
790
|
+
id,
|
|
791
|
+
subscribed,
|
|
792
|
+
changed: true
|
|
793
|
+
});
|
|
794
|
+
}));
|
|
438
795
|
server.registerTool("list_labels", {
|
|
439
796
|
title: "List labels",
|
|
440
797
|
description: "List labels available to the user, with ids for use in create_task / update_task.",
|
|
@@ -485,6 +842,137 @@ function registerTaskTools(server, vikunja, { allowDelete = false, defaultProjec
|
|
|
485
842
|
}));
|
|
486
843
|
}
|
|
487
844
|
//#endregion
|
|
845
|
+
//#region src/tools/relations.ts
|
|
846
|
+
const relationInput = {
|
|
847
|
+
taskId: z.number().int().describe("The base task"),
|
|
848
|
+
kind: z.enum([
|
|
849
|
+
"subtask",
|
|
850
|
+
"parenttask",
|
|
851
|
+
"related",
|
|
852
|
+
"duplicateof",
|
|
853
|
+
"duplicates",
|
|
854
|
+
"blocking",
|
|
855
|
+
"blocked",
|
|
856
|
+
"precedes",
|
|
857
|
+
"follows",
|
|
858
|
+
"copiedfrom",
|
|
859
|
+
"copiedto"
|
|
860
|
+
]).describe("What otherTaskId is to taskId: subtask = other is a subtask of taskId, parenttask = other is its parent, blocking = taskId blocks other, blocked = taskId is blocked by other, precedes / follows, related, duplicateof / duplicates, copiedfrom / copiedto. Vikunja creates the inverse relation automatically."),
|
|
861
|
+
otherTaskId: z.number().int().describe("The related task")
|
|
862
|
+
};
|
|
863
|
+
function registerRelationTools(server, vikunja) {
|
|
864
|
+
server.registerTool("add_task_relation", {
|
|
865
|
+
title: "Add task relation",
|
|
866
|
+
description: "Relate two tasks (subtask, parent, blocking, related, …). See get_task relatedTasks for current ones.",
|
|
867
|
+
inputSchema: relationInput
|
|
868
|
+
}, guard(async ({ taskId, kind, otherTaskId }) => {
|
|
869
|
+
await vikunja.put(`/tasks/${taskId}/relations`, {
|
|
870
|
+
task_id: taskId,
|
|
871
|
+
other_task_id: otherTaskId,
|
|
872
|
+
relation_kind: kind
|
|
873
|
+
});
|
|
874
|
+
return ok({
|
|
875
|
+
taskId,
|
|
876
|
+
kind,
|
|
877
|
+
otherTaskId,
|
|
878
|
+
related: true
|
|
879
|
+
});
|
|
880
|
+
}));
|
|
881
|
+
server.registerTool("remove_task_relation", {
|
|
882
|
+
title: "Remove task relation",
|
|
883
|
+
description: "Remove a relation between two tasks (the inverse relation is removed too).",
|
|
884
|
+
inputSchema: relationInput
|
|
885
|
+
}, guard(async ({ taskId, kind, otherTaskId }) => {
|
|
886
|
+
await vikunja.del(`/tasks/${taskId}/relations/${kind}/${otherTaskId}`);
|
|
887
|
+
return ok({
|
|
888
|
+
taskId,
|
|
889
|
+
kind,
|
|
890
|
+
otherTaskId,
|
|
891
|
+
related: false
|
|
892
|
+
});
|
|
893
|
+
}));
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/tools/kanban.ts
|
|
897
|
+
async function kanbanViews(vikunja, projectId) {
|
|
898
|
+
return (await vikunja.get(`/projects/${projectId}/views`) ?? []).filter((v) => v.view_kind === "kanban" || v.view_kind === 3);
|
|
899
|
+
}
|
|
900
|
+
async function viewBuckets(vikunja, projectId, viewId) {
|
|
901
|
+
return await vikunja.get(`/projects/${projectId}/views/${viewId}/buckets`) ?? [];
|
|
902
|
+
}
|
|
903
|
+
function registerKanbanTools(server, vikunja) {
|
|
904
|
+
server.registerTool("list_kanban_buckets", {
|
|
905
|
+
title: "List kanban buckets",
|
|
906
|
+
description: "List a project's kanban views and their buckets (columns), with ids for move_task_to_bucket.",
|
|
907
|
+
inputSchema: { projectId: z.number().int().describe("Project id") }
|
|
908
|
+
}, guard(async ({ projectId }) => {
|
|
909
|
+
const views = await kanbanViews(vikunja, projectId);
|
|
910
|
+
return ok(await Promise.all(views.map(async (v) => ({
|
|
911
|
+
viewId: v.id,
|
|
912
|
+
title: v.title,
|
|
913
|
+
defaultBucketId: v.default_bucket_id || null,
|
|
914
|
+
doneBucketId: v.done_bucket_id || null,
|
|
915
|
+
buckets: (await viewBuckets(vikunja, projectId, v.id)).map((b) => ({
|
|
916
|
+
id: b.id,
|
|
917
|
+
title: b.title,
|
|
918
|
+
limit: b.limit || null
|
|
919
|
+
}))
|
|
920
|
+
}))));
|
|
921
|
+
}));
|
|
922
|
+
server.registerTool("move_task_to_bucket", {
|
|
923
|
+
title: "Move task to kanban bucket",
|
|
924
|
+
description: "Move a task into a kanban bucket (column) of its project. Moving into the view's done bucket marks the task done. To move a task to another project use update_task with projectId.",
|
|
925
|
+
inputSchema: {
|
|
926
|
+
taskId: z.number().int().describe("Task id"),
|
|
927
|
+
bucketId: z.number().int().describe("Target bucket id (list_kanban_buckets)"),
|
|
928
|
+
viewId: z.number().int().optional().describe("Kanban view id; looked up from the bucket when omitted")
|
|
929
|
+
}
|
|
930
|
+
}, guard(async ({ taskId, bucketId, viewId }) => {
|
|
931
|
+
const projectId = (await vikunja.get(`/tasks/${taskId}`)).project_id;
|
|
932
|
+
let view = viewId;
|
|
933
|
+
if (view == null) {
|
|
934
|
+
for (const v of await kanbanViews(vikunja, projectId)) if ((await viewBuckets(vikunja, projectId, v.id)).some((b) => b.id === bucketId)) {
|
|
935
|
+
view = v.id;
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
if (view == null) throw new Error(`Bucket ${bucketId} is not in any kanban view of project ${projectId}; see list_kanban_buckets.`);
|
|
939
|
+
}
|
|
940
|
+
await vikunja.post(`/projects/${projectId}/views/${view}/buckets/${bucketId}/tasks`, {
|
|
941
|
+
task_id: taskId,
|
|
942
|
+
bucket_id: bucketId,
|
|
943
|
+
project_view_id: view
|
|
944
|
+
});
|
|
945
|
+
return ok({
|
|
946
|
+
taskId,
|
|
947
|
+
projectId,
|
|
948
|
+
viewId: view,
|
|
949
|
+
bucketId,
|
|
950
|
+
moved: true
|
|
951
|
+
});
|
|
952
|
+
}));
|
|
953
|
+
}
|
|
954
|
+
//#endregion
|
|
955
|
+
//#region src/tools/users.ts
|
|
956
|
+
function registerUserTools(server, vikunja) {
|
|
957
|
+
server.registerTool("get_current_user", {
|
|
958
|
+
title: "Get current user",
|
|
959
|
+
description: "Return the authenticated user's id, username and name (e.g. to assign a task to yourself).",
|
|
960
|
+
inputSchema: {}
|
|
961
|
+
}, guard(async () => ok(summarizeUser(await vikunja.get("/user")))));
|
|
962
|
+
server.registerTool("find_users", {
|
|
963
|
+
title: "Find users",
|
|
964
|
+
description: "Find user ids for assigning tasks. With projectId, lists users who have access to that project (only they can be assigned); search narrows by username or name.",
|
|
965
|
+
inputSchema: {
|
|
966
|
+
search: z.string().optional().describe("Username, name or full email"),
|
|
967
|
+
projectId: z.number().int().optional().describe("Only users with access to this project")
|
|
968
|
+
}
|
|
969
|
+
}, guard(async ({ search, projectId }) => {
|
|
970
|
+
if (projectId == null && !search) throw new Error("Pass search, projectId, or both.");
|
|
971
|
+
const path = projectId != null ? `/projects/${projectId}/projectusers` : "/users";
|
|
972
|
+
return ok((await vikunja.get(path, { s: search }) ?? []).map((u) => summarizeUser(u)));
|
|
973
|
+
}));
|
|
974
|
+
}
|
|
975
|
+
//#endregion
|
|
488
976
|
//#region src/snippets.ts
|
|
489
977
|
function jsonSnippet(missing) {
|
|
490
978
|
const server = {
|
|
@@ -543,8 +1031,13 @@ function abort() {
|
|
|
543
1031
|
p.cancel("Setup aborted.");
|
|
544
1032
|
process.exit(1);
|
|
545
1033
|
}
|
|
1034
|
+
/** Plain stdout, no box drawing, so the snippets can be selected and copied (or piped to a file). */
|
|
546
1035
|
function printSnippets(missing) {
|
|
547
|
-
for (const s of clientSnippets(missing))
|
|
1036
|
+
for (const s of clientSnippets(missing)) {
|
|
1037
|
+
console.log(`\n# ${s.title}\n`);
|
|
1038
|
+
console.log(s.body);
|
|
1039
|
+
}
|
|
1040
|
+
console.log();
|
|
548
1041
|
}
|
|
549
1042
|
async function runSetup(args = []) {
|
|
550
1043
|
if (args.includes("--reset")) {
|
|
@@ -626,8 +1119,9 @@ async function runSetup(args = []) {
|
|
|
626
1119
|
if (!url) missing[ENV_URL] = "https://try.vikunja.io";
|
|
627
1120
|
if (!token) missing[ENV_TOKEN] = "tk_...";
|
|
628
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.`);
|
|
629
|
-
printSnippets(missing);
|
|
630
1122
|
p.outro(`Saved to ${file}`);
|
|
1123
|
+
console.log("Add one of these to your MCP client (plain text, safe to copy). Re-print any time with: setup --print");
|
|
1124
|
+
printSnippets(missing);
|
|
631
1125
|
}
|
|
632
1126
|
var init_setup = __esmMin((() => {
|
|
633
1127
|
init_config();
|
|
@@ -681,6 +1175,11 @@ async function runServer() {
|
|
|
681
1175
|
allowDelete: config.allowDelete,
|
|
682
1176
|
defaultProjectId: config.defaultProjectId
|
|
683
1177
|
});
|
|
1178
|
+
registerCommentTools(server, vikunja, { allowDelete: config.allowDelete });
|
|
1179
|
+
registerAttachmentTools(server, vikunja, { allowDelete: config.allowDelete });
|
|
1180
|
+
registerRelationTools(server, vikunja);
|
|
1181
|
+
registerKanbanTools(server, vikunja);
|
|
1182
|
+
registerUserTools(server, vikunja);
|
|
684
1183
|
const transport = new StdioServerTransport();
|
|
685
1184
|
await server.connect(transport);
|
|
686
1185
|
console.error(`mcp-vikunja ${VERSION} connected (${config.url}, delete tools ${config.allowDelete ? "enabled" : "disabled"})`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fswap/mcp-vikunja",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "MCP server for Vikunja —
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "MCP server for Vikunja — manage tasks, assignees, comments, attachments, relations and kanban from Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mcp-vikunja": "dist/index.js"
|