@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.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/index.js +653 -0
- 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
|
+
}
|