feat(mcp): expand Vikunja server with project, delete, and comment tools
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m20s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m3s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m13s
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-03 17:41:12 +02:00
parent 3de92accc4
commit 11babf3347
4 changed files with 362 additions and 15 deletions

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock axios
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock('axios', () => {
return {
default: {
create: vi.fn().mockImplementation(() => ({
get: mockGet,
post: mockPost,
put: mockPut,
delete: mockDelete,
})),
},
};
});
// Set env vars
process.env.VIKUNJA_API_TOKEN = 'test-token';
process.env.VIKUNJA_BASE_URL = 'https://tasks.test.me';
// Mock express to avoid starting a real server
vi.mock('express', () => {
const mockApp = {
use: vi.fn(),
get: vi.fn(),
post: vi.fn(),
listen: vi.fn().mockImplementation((port, host, cb) => cb && cb()),
};
const mockExpress = () => mockApp;
(mockExpress as any).json = () => vi.fn();
return { default: mockExpress };
});
let server: any;
describe('Vikunja MCP Server - New Tools (RED)', () => {
beforeEach(async () => {
vi.clearAllMocks();
if (!server) {
const mod = await import('./index.js');
server = mod.server;
}
});
it('should support vikunja_create_project', async () => {
mockPut.mockResolvedValueOnce({
data: { id: 10, title: 'New Project', description: 'Testing' }
});
const handler = (server as any)._requestHandlers.get('tools/call');
expect(handler).toBeDefined();
const response = await handler({
method: 'tools/call',
params: {
name: 'vikunja_create_project',
arguments: {
title: 'New Project',
description: 'Testing',
hex_color: 'ff0000',
},
},
});
expect(mockPut).toHaveBeenCalledWith('/projects', {
title: 'New Project',
description: 'Testing',
hex_color: 'ff0000',
});
expect(response.isError).toBeUndefined();
expect(JSON.parse(response.content[0].text)).toEqual({
id: 10,
title: 'New Project',
description: 'Testing',
});
});
it('should support vikunja_get_task', async () => {
mockGet.mockResolvedValueOnce({
data: { id: 42, title: 'My Task', project_id: 1 }
});
const handler = (server as any)._requestHandlers.get('tools/call');
const response = await handler({
method: 'tools/call',
params: {
name: 'vikunja_get_task',
arguments: {
task_id: 42,
},
},
});
expect(mockGet).toHaveBeenCalledWith('/tasks/42');
expect(response.isError).toBeUndefined();
expect(JSON.parse(response.content[0].text)).toEqual({
id: 42,
title: 'My Task',
project_id: 1,
});
});
it('should support vikunja_delete_task', async () => {
mockDelete.mockResolvedValueOnce({
data: { message: 'success' }
});
const handler = (server as any)._requestHandlers.get('tools/call');
const response = await handler({
method: 'tools/call',
params: {
name: 'vikunja_delete_task',
arguments: {
task_id: 42,
},
},
});
expect(mockDelete).toHaveBeenCalledWith('/tasks/42');
expect(response.isError).toBeUndefined();
expect(JSON.parse(response.content[0].text)).toEqual({
message: 'success',
});
});
it('should support vikunja_create_task_comment', async () => {
mockPut.mockResolvedValueOnce({
data: { id: 1, comment: 'New Comment', task_id: 42 }
});
const handler = (server as any)._requestHandlers.get('tools/call');
const response = await handler({
method: 'tools/call',
params: {
name: 'vikunja_create_task_comment',
arguments: {
task_id: 42,
comment: 'New Comment',
},
},
});
expect(mockPut).toHaveBeenCalledWith('/tasks/42/comments', {
comment: 'New Comment',
});
expect(response.isError).toBeUndefined();
expect(JSON.parse(response.content[0].text)).toEqual({
id: 1,
comment: 'New Comment',
task_id: 42,
});
});
it('should support vikunja_get_task_comments', async () => {
mockGet.mockResolvedValueOnce({
data: [{ id: 1, comment: 'New Comment', task_id: 42 }]
});
const handler = (server as any)._requestHandlers.get('tools/call');
const response = await handler({
method: 'tools/call',
params: {
name: 'vikunja_get_task_comments',
arguments: {
task_id: 42,
},
},
});
expect(mockGet).toHaveBeenCalledWith('/tasks/42/comments');
expect(response.isError).toBeUndefined();
expect(JSON.parse(response.content[0].text)).toEqual([
{ id: 1, comment: 'New Comment', task_id: 42 }
]);
});
});

View File

@@ -83,8 +83,71 @@ const UPDATE_TASK_TOOL: Tool = {
},
};
const CREATE_PROJECT_TOOL: Tool = {
name: "vikunja_create_project",
description: "Create a new project (list) in Vikunja",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "The title of the project" },
description: { type: "string", description: "Optional description of the project" },
hex_color: { type: "string", description: "Optional hex color without the '#' prefix" }
},
required: ["title"]
}
};
const GET_TASK_TOOL: Tool = {
name: "vikunja_get_task",
description: "Get details for a specific task by ID",
inputSchema: {
type: "object",
properties: {
task_id: { type: "number", description: "The ID of the task" }
},
required: ["task_id"]
}
};
const DELETE_TASK_TOOL: Tool = {
name: "vikunja_delete_task",
description: "Delete a task by ID",
inputSchema: {
type: "object",
properties: {
task_id: { type: "number", description: "The ID of the task to delete" }
},
required: ["task_id"]
}
};
const CREATE_TASK_COMMENT_TOOL: Tool = {
name: "vikunja_create_task_comment",
description: "Add a comment to a task",
inputSchema: {
type: "object",
properties: {
task_id: { type: "number", description: "The ID of the task to comment on" },
comment: { type: "string", description: "The comment text" }
},
required: ["task_id", "comment"]
}
};
const GET_TASK_COMMENTS_TOOL: Tool = {
name: "vikunja_get_task_comments",
description: "Get all comments for a specific task",
inputSchema: {
type: "object",
properties: {
task_id: { type: "number", description: "The ID of the task" }
},
required: ["task_id"]
}
};
// --- Server Setup ---
const server = new Server(
export const server = new Server(
{ name: "vikunja-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
@@ -94,7 +157,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
GET_PROJECTS_TOOL,
GET_TASKS_TOOL,
CREATE_TASK_TOOL,
UPDATE_TASK_TOOL
UPDATE_TASK_TOOL,
CREATE_PROJECT_TOOL,
GET_TASK_TOOL,
DELETE_TASK_TOOL,
CREATE_TASK_COMMENT_TOOL,
GET_TASK_COMMENTS_TOOL
],
}));
@@ -133,6 +201,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
if (request.params.name === "vikunja_create_project") {
const { title, description, hex_color } = request.params.arguments as any;
// Vikunja standard: creating a new root resource uses PUT /projects in this version
const res = await api.put('/projects', {
title,
description: description || "",
hex_color: hex_color || ""
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
if (request.params.name === "vikunja_get_task") {
const { task_id } = request.params.arguments as any;
const res = await api.get(`/tasks/${task_id}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
if (request.params.name === "vikunja_delete_task") {
const { task_id } = request.params.arguments as any;
const res = await api.delete(`/tasks/${task_id}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
if (request.params.name === "vikunja_create_task_comment") {
const { task_id, comment } = request.params.arguments as any;
// Vikunja standard: task sub-resources (like comments) are typically appended using PUT /tasks/{id}/comments
const res = await api.put(`/tasks/${task_id}/comments`, { comment });
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
if (request.params.name === "vikunja_get_task_comments") {
const { task_id } = request.params.arguments as any;
const res = await api.get(`/tasks/${task_id}/comments`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
} catch (e: any) {
const msg = e.response?.data?.message || e.message;