diff --git a/docker-compose.mcps.yml b/docker-compose.mcps.yml index caf1ebc..d1c3e52 100644 --- a/docker-compose.mcps.yml +++ b/docker-compose.mcps.yml @@ -85,6 +85,18 @@ services: networks: - mcp-network + vikunja-mcp: + build: + context: ./packages/vikunja-mcp + container_name: vikunja-mcp + env_file: + - .env + ports: + - "3007:3007" + restart: unless-stopped + networks: + - mcp-network + networks: mcp-network: driver: bridge diff --git a/packages/vikunja-mcp/Dockerfile b/packages/vikunja-mcp/Dockerfile new file mode 100644 index 0000000..1ea52a4 --- /dev/null +++ b/packages/vikunja-mcp/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-bookworm-slim AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace +COPY tsconfig.json ./ +COPY src ./src +RUN pnpm build + +FROM node:20-bookworm-slim +WORKDIR /app +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist + +ENTRYPOINT ["node", "dist/start.js"] diff --git a/packages/vikunja-mcp/package.json b/packages/vikunja-mcp/package.json new file mode 100644 index 0000000..1035b9c --- /dev/null +++ b/packages/vikunja-mcp/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mintel/vikunja-mcp", + "version": "1.0.0", + "description": "Vikunja MCP server for Mintel infrastructure", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/start.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.5.0", + "axios": "^1.7.2", + "dotenv": "^17.3.1", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.10", + "tsx": "^4.19.2", + "typescript": "^5.5.3" + } +} diff --git a/packages/vikunja-mcp/src/index.ts b/packages/vikunja-mcp/src/index.ts new file mode 100644 index 0000000..f794b95 --- /dev/null +++ b/packages/vikunja-mcp/src/index.ts @@ -0,0 +1,215 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import express from 'express'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import axios from "axios"; +import https from "https"; + +const VIKUNJA_BASE_URL = process.env.VIKUNJA_BASE_URL || "https://tasks.infra.mintel.me"; +const VIKUNJA_API_TOKEN = process.env.VIKUNJA_API_TOKEN; + +const httpsAgent = new https.Agent({ + rejectUnauthorized: false, +}); + +if (!VIKUNJA_API_TOKEN) { + console.error("Warning: VIKUNJA_API_TOKEN is not set."); +} + +function getApi() { + if (!VIKUNJA_API_TOKEN) { + throw new Error("VIKUNJA_API_TOKEN is not configured."); + } + return axios.create({ + baseURL: `${VIKUNJA_BASE_URL}/api/v1`, + headers: { + Authorization: `Bearer ${VIKUNJA_API_TOKEN}`, + 'Content-Type': 'application/json' + }, + httpsAgent, + timeout: 10000 + }); +} + +// --- Tool Definitions --- +const GET_PROJECTS_TOOL: Tool = { + name: "vikunja_get_projects", + description: "List all projects in Vikunja", + inputSchema: { type: "object", properties: {} }, +}; + +const GET_TASKS_TOOL: Tool = { + name: "vikunja_get_tasks", + description: "Get all tasks for a specific project", + inputSchema: { + type: "object", + properties: { + project_id: { type: "number", description: "The ID of the project" }, + }, + required: ["project_id"], + }, +}; + +const CREATE_TASK_TOOL: Tool = { + name: "vikunja_create_task", + description: "Create a new task in a project", + inputSchema: { + type: "object", + properties: { + 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" }, + }, + required: ["project_id", "title"], + }, +}; + +const UPDATE_TASK_TOOL: Tool = { + name: "vikunja_update_task", + description: "Update a task (e.g., mark as done)", + inputSchema: { + type: "object", + properties: { + 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" } + }, + required: ["task_id"], + }, +}; + +// --- Server Setup --- +const server = new Server( + { name: "vikunja-mcp", version: "1.0.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + GET_PROJECTS_TOOL, + GET_TASKS_TOOL, + CREATE_TASK_TOOL, + UPDATE_TASK_TOOL + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const api = getApi(); + + if (request.params.name === "vikunja_get_projects") { + const res = await api.get('/projects'); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + + if (request.params.name === "vikunja_get_tasks") { + const { project_id } = request.params.arguments as any; + const res = await api.get(`/projects/${project_id}/tasks`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + + 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`, { + title, + description: description || "" + }); + 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 payload: any = {}; + if (done !== undefined) payload.done = done; + if (title !== undefined) payload.title = title; + if (description !== undefined) payload.description = description; + + const res = await api.post(`/tasks/${task_id}`, payload); + 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; + return { + isError: true, + content: [{ type: "text", text: `MCP Service Error: ${msg}` }] + }; + } +}); + +// --- Express / SSE Server Setup --- +async function run() { + const isStdio = process.argv.includes('--stdio'); + + if (isStdio) { + const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Vikunja MCP server is running on stdio'); + } else { + const app = express(); + const transports = new Map(); + + app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version'); + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); + }); + + app.use((req, _res, next) => { + console.error(`${req.method} ${req.url}`); + next(); + }); + + app.get('/sse', async (req, res) => { + const transport = new SSEServerTransport('/message', res as any); + const sessionId = transport.sessionId; + console.error(`New SSE connection: ${sessionId}`); + transports.set(sessionId, transport); + + const heartbeatInterval = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15000); + + req.on('close', () => { + console.error(`SSE connection closed: ${sessionId}`); + clearInterval(heartbeatInterval); + transports.delete(sessionId); + }); + + await server.connect(transport); + }); + + app.post('/message', express.json(), async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.get(sessionId); + + if (!transport) { + res.status(400).send('No active SSE connection for this session'); + return; + } + await transport.handlePostMessage(req, res); + }); + + const PORT = Number(process.env.VIKUNJA_MCP_PORT) || 3007; + app.listen(PORT, '0.0.0.0', () => { + console.error(`Vikunja MCP server running on http://0.0.0.0:${PORT}/sse`); + }); + } +} + +run().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/packages/vikunja-mcp/src/start.ts b/packages/vikunja-mcp/src/start.ts new file mode 100644 index 0000000..56231af --- /dev/null +++ b/packages/vikunja-mcp/src/start.ts @@ -0,0 +1,13 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +config({ quiet: true, path: resolve(__dirname, '../../../.env.local') }); +config({ quiet: true, path: resolve(__dirname, '../../../.env') }); + +import('./index.js').catch(err => { + console.error('Failed to start Vikunja MCP Server:', err); + process.exit(1); +}); diff --git a/packages/vikunja-mcp/tsconfig.json b/packages/vikunja-mcp/tsconfig.json new file mode 100644 index 0000000..9266b8e --- /dev/null +++ b/packages/vikunja-mcp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*" + ] +}