From 11babf334789b27ae3c1bb9fa7ce285631742c0c Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 3 Jun 2026 17:41:12 +0200 Subject: [PATCH] feat(mcp): expand Vikunja server with project, delete, and comment tools --- packages/vikunja-mcp/package.json | 6 +- packages/vikunja-mcp/src/index.test.ts | 180 +++++++++++++++++++++++++ packages/vikunja-mcp/src/index.ts | 108 ++++++++++++++- pnpm-lock.yaml | 83 ++++++++++-- 4 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 packages/vikunja-mcp/src/index.test.ts diff --git a/packages/vikunja-mcp/package.json b/packages/vikunja-mcp/package.json index 1035b9c..ec856f0 100644 --- a/packages/vikunja-mcp/package.json +++ b/packages/vikunja-mcp/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "tsc", "start": "node dist/start.js", - "dev": "tsx watch src/index.ts" + "dev": "tsx watch src/index.ts", + "test:unit": "vitest run" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.5.0", @@ -19,6 +20,7 @@ "@types/express": "^4.17.21", "@types/node": "^20.14.10", "tsx": "^4.19.2", - "typescript": "^5.5.3" + "typescript": "^5.5.3", + "vitest": "^2.1.3" } } diff --git a/packages/vikunja-mcp/src/index.test.ts b/packages/vikunja-mcp/src/index.test.ts new file mode 100644 index 0000000..1e51b27 --- /dev/null +++ b/packages/vikunja-mcp/src/index.test.ts @@ -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 } + ]); + }); +}); diff --git a/packages/vikunja-mcp/src/index.ts b/packages/vikunja-mcp/src/index.ts index f794b95..4ce2e97 100644 --- a/packages/vikunja-mcp/src/index.ts +++ b/packages/vikunja-mcp/src/index.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d69e02a..e24fbb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,7 +235,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/content-engine: dependencies: @@ -537,7 +537,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/meme-generator: dependencies: @@ -897,7 +897,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/serpbear-mcp: dependencies: @@ -979,6 +979,37 @@ importers: specifier: ^5.5.3 version: 5.9.3 + packages/vikunja-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.5.0 + version: 1.27.1(zod@3.25.76) + axios: + specifier: ^1.7.2 + version: 1.13.5 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 + express: + specifier: ^4.19.2 + version: 4.22.1 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.14.10 + version: 20.19.33 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.5.3 + version: 5.9.3 + vitest: + specifier: ^2.1.3 + version: 2.1.9(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + packages: '@acemir/cssom@0.9.31': @@ -16946,15 +16977,16 @@ snapshots: - supports-color - terser - vite-node@3.2.4(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): + vite-node@3.2.4(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -16963,16 +16995,19 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml - vite-node@3.2.4(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): + vite-node@3.2.4(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + vite: 7.3.1(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -16981,6 +17016,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vite@5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): dependencies: @@ -17024,6 +17061,24 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.10 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + sass: 1.97.3 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vitest@2.1.9(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): dependencies: '@vitest/expect': 2.1.9 @@ -17100,7 +17155,7 @@ snapshots: - supports-color - terser - vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -17123,7 +17178,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 5.4.21(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) - vite-node: 3.2.4(@types/node@20.19.33)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + vite-node: 3.2.4(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -17132,6 +17187,7 @@ snapshots: happy-dom: 20.5.3 jsdom: 27.4.0(canvas@3.2.1) transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -17141,8 +17197,10 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -17165,7 +17223,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) - vite-node: 3.2.4(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + vite-node: 3.2.4(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -17174,6 +17232,7 @@ snapshots: happy-dom: 20.5.3 jsdom: 27.4.0(canvas@3.2.1) transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -17183,6 +17242,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: