feat(vikunja-mcp): add task attachments and extra metadata parameters support
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m38s
Monorepo Pipeline / 🧪 Test (push) Successful in 54s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m38s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped

This commit is contained in:
2026-06-21 12:26:44 +02:00
parent 04462f21e1
commit 985e532c61
4 changed files with 205 additions and 16 deletions

View File

@@ -14,10 +14,14 @@
"@modelcontextprotocol/sdk": "^1.5.0",
"axios": "^1.7.2",
"dotenv": "^17.3.1",
"express": "^4.19.2"
"express": "^4.19.2",
"form-data": "^4.0.5",
"marked": "^18.0.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/form-data": "^2.5.2",
"@types/marked": "^6.0.0",
"@types/node": "^20.14.10",
"tsx": "^4.19.2",
"typescript": "^5.5.3",

View File

@@ -177,4 +177,29 @@ describe('Vikunja MCP Server - New Tools (RED)', () => {
{ id: 1, comment: 'New Comment', task_id: 42 }
]);
});
it('should support vikunja_create_task and convert markdown description to HTML', async () => {
mockPut.mockResolvedValueOnce({
data: { id: 11, title: 'Markdown Task', description: '<p><strong>Bold</strong> and <em>italic</em></p>\n' }
});
const handler = (server as any)._requestHandlers.get('tools/call');
const response = await handler({
method: 'tools/call',
params: {
name: 'vikunja_create_task',
arguments: {
project_id: 1,
title: 'Markdown Task',
description: '**Bold** and *italic*'
},
},
});
expect(mockPut).toHaveBeenCalledWith('/projects/1/tasks', expect.objectContaining({
title: 'Markdown Task',
description: '<p><strong>Bold</strong> and <em>italic</em></p>\n'
}));
expect(response.isError).toBeUndefined();
});
});

View File

@@ -8,6 +8,9 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import https from "https";
import fs from "fs";
import path from "path";
import { marked } from "marked";
const VIKUNJA_BASE_URL = process.env.VIKUNJA_BASE_URL || "https://tasks.infra.mintel.me";
const VIKUNJA_API_TOKEN = process.env.VIKUNJA_API_TOKEN;
@@ -63,6 +66,14 @@ const CREATE_TASK_TOOL: Tool = {
project_id: { type: "number", description: "The ID of the project to add the task to" },
title: { type: "string", description: "The title of the task" },
description: { type: "string", description: "Optional description of the task" },
parent_task_id: { type: "number", description: "Optional ID of a parent task to create a subtask" },
due_date: { type: "string", description: "ISO 8601 Date string for due date" },
start_date: { type: "string", description: "ISO 8601 Date string for start date" },
end_date: { type: "string", description: "ISO 8601 Date string for end date" },
priority: { type: "number", description: "Priority level (e.g. 1-5)" },
percent_done: { type: "number", description: "Progress percentage" },
hex_color: { type: "string", description: "Hex color code" },
is_favorite: { type: "boolean", description: "Mark as favorite" }
},
required: ["project_id", "title"],
},
@@ -77,7 +88,14 @@ const UPDATE_TASK_TOOL: Tool = {
task_id: { type: "number", description: "The ID of the task to update" },
done: { type: "boolean", description: "Set to true to mark the task as done" },
title: { type: "string", description: "Update the title" },
description: { type: "string", description: "Update the description" }
description: { type: "string", description: "Update the description" },
due_date: { type: "string", description: "ISO 8601 Date string for due date" },
start_date: { type: "string", description: "ISO 8601 Date string for start date" },
end_date: { type: "string", description: "ISO 8601 Date string for end date" },
priority: { type: "number", description: "Priority level (e.g. 1-5)" },
percent_done: { type: "number", description: "Progress percentage (0-1 or 0-100 depending on API version, try decimals like 0.5 first)" },
hex_color: { type: "string", description: "Hex color code" },
is_favorite: { type: "boolean", description: "Mark as favorite" }
},
required: ["task_id"],
},
@@ -146,6 +164,33 @@ const GET_TASK_COMMENTS_TOOL: Tool = {
}
};
const GET_TASK_ATTACHMENT_TOOL: Tool = {
name: "vikunja_get_task_attachment",
description: "Download a task attachment to a local file",
inputSchema: {
type: "object",
properties: {
task_id: { type: "number", description: "The ID of the task" },
attachment_id: { type: "number", description: "The ID of the attachment" },
save_path: { type: "string", description: "Absolute path to save the file to" }
},
required: ["task_id", "attachment_id", "save_path"]
}
};
const CREATE_TASK_ATTACHMENT_TOOL: Tool = {
name: "vikunja_upload_task_attachment",
description: "Upload an attachment file to a specific task",
inputSchema: {
type: "object",
properties: {
task_id: { type: "number", description: "The ID of the task" },
file_path: { type: "string", description: "Absolute path to the local file to upload" }
},
required: ["task_id", "file_path"]
}
};
// --- Server Setup ---
export const server = new Server(
{ name: "vikunja-mcp", version: "1.0.0" },
@@ -162,7 +207,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
GET_TASK_TOOL,
DELETE_TASK_TOOL,
CREATE_TASK_COMMENT_TOOL,
GET_TASK_COMMENTS_TOOL
GET_TASK_COMMENTS_TOOL,
GET_TASK_ATTACHMENT_TOOL,
CREATE_TASK_ATTACHMENT_TOOL
],
}));
@@ -182,20 +229,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
if (request.params.name === "vikunja_create_task") {
const { project_id, title, description } = request.params.arguments as any;
const res = await api.put(`/projects/${project_id}/tasks`, {
const {
project_id, title, description, parent_task_id,
due_date, start_date, end_date, priority, percent_done, hex_color, is_favorite
} = request.params.arguments as any;
const payload: any = {
title,
description: description || ""
});
description: description ? await marked.parse(description) : ""
};
if (parent_task_id !== undefined) payload.parent_task_id = parent_task_id;
if (due_date !== undefined) payload.due_date = due_date;
if (start_date !== undefined) payload.start_date = start_date;
if (end_date !== undefined) payload.end_date = end_date;
if (priority !== undefined) payload.priority = priority;
if (percent_done !== undefined) payload.percent_done = percent_done;
if (hex_color !== undefined) payload.hex_color = hex_color;
if (is_favorite !== undefined) payload.is_favorite = is_favorite;
const res = await api.put(`/projects/${project_id}/tasks`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
if (request.params.name === "vikunja_update_task") {
const { task_id, done, title, description } = request.params.arguments as any;
const {
task_id, done, title, description,
due_date, start_date, end_date,
priority, percent_done, hex_color, is_favorite
} = request.params.arguments as any;
const payload: any = {};
if (done !== undefined) payload.done = done;
if (title !== undefined) payload.title = title;
if (description !== undefined) payload.description = description;
if (description !== undefined) payload.description = await marked.parse(description);
if (due_date !== undefined) payload.due_date = due_date;
if (start_date !== undefined) payload.start_date = start_date;
if (end_date !== undefined) payload.end_date = end_date;
if (priority !== undefined) payload.priority = priority;
if (percent_done !== undefined) payload.percent_done = percent_done;
if (hex_color !== undefined) payload.hex_color = hex_color;
if (is_favorite !== undefined) payload.is_favorite = is_favorite;
const res = await api.post(`/tasks/${task_id}`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
@@ -237,6 +311,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
if (request.params.name === "vikunja_get_task_attachment") {
const { task_id, attachment_id, save_path } = request.params.arguments as any;
const res = await api.get(`/tasks/${task_id}/attachments/${attachment_id}`, {
responseType: 'arraybuffer'
});
fs.writeFileSync(save_path, Buffer.from(res.data));
return { content: [{ type: "text", text: `Successfully saved attachment to ${save_path}` }] };
}
if (request.params.name === "vikunja_upload_task_attachment") {
const { task_id, file_path } = request.params.arguments as any;
if (!fs.existsSync(file_path)) {
throw new Error(`File not found: ${file_path}`);
}
const buffer = fs.readFileSync(file_path);
const blob = new Blob([buffer]);
const formData = new FormData();
formData.append('files', blob, path.basename(file_path));
const res = await api.put(`/tasks/${task_id}/attachments`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return { content: [{ type: "text", text: `Successfully uploaded attachment to task ${task_id}.\nResponse: ${JSON.stringify(res.data)}` }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
} catch (e: any) {
const msg = e.response?.data?.message || e.message;