feat: add vikunja mcp server
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
This commit is contained in:
@@ -85,6 +85,18 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcp-network
|
- 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:
|
networks:
|
||||||
mcp-network:
|
mcp-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
15
packages/vikunja-mcp/Dockerfile
Normal file
15
packages/vikunja-mcp/Dockerfile
Normal file
@@ -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"]
|
||||||
24
packages/vikunja-mcp/package.json
Normal file
24
packages/vikunja-mcp/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
215
packages/vikunja-mcp/src/index.ts
Normal file
215
packages/vikunja-mcp/src/index.ts
Normal file
@@ -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<string, SSEServerTransport>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
13
packages/vikunja-mcp/src/start.ts
Normal file
13
packages/vikunja-mcp/src/start.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
16
packages/vikunja-mcp/tsconfig.json
Normal file
16
packages/vikunja-mcp/tsconfig.json
Normal file
@@ -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/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user