Files
task-manager/server/mcp.ts
T
NotiBot 4f6aff32ab
continuous-integration/drone/push Build is passing
feat: API Key Auth + erweiterte MCP Tools + Dokumentation
- API Key Middleware für alle /api/ Endpoints (X-API-Key, Bearer, Query)
- API Key Management Routes (generate, revoke, status)
- MCP Tools erweitert: 12 Tools (war 3) inkl. bulk ops, dashboard, search
- Audit Logging für alle API/MCP Aktionen
- docs/API-REFERENCE.md - vollständige API Dokumentation
- docs/ARCHITECTURE.md - Architektur-Übersicht
- Vorbereitung für NotiBot-Integration
2026-02-03 10:23:18 +01:00

569 lines
24 KiB
TypeScript

import { Request, Response } from "express";
import { storage } from "./storage";
import { User } from "../shared/schema";
import { randomBytes } from "crypto";
interface JsonRpcRequest {
jsonrpc: "2.0";
method: string;
params?: any;
id: number | string;
}
interface JsonRpcResponse {
jsonrpc: "2.0";
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
id: number | string | null;
}
export class McpServer {
private clients: Map<string, Response> = new Map();
async authenticate(req: Request): Promise<User | null> {
let key = req.headers["x-api-key"] as string;
if (!key && req.query.apiKey) {
key = req.query.apiKey as string;
}
if (!key && req.headers["authorization"]) {
const auth = req.headers["authorization"];
if (auth.startsWith("Bearer ")) {
key = auth.substring(7);
}
}
if (!key) return null;
return (await storage.getUserByApiKey(key)) || null;
}
async handleSse(req: Request, res: Response) {
const user = await this.authenticate(req);
if (!user) {
res.status(401).send("Unauthorized: Invalid API Key");
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
});
const sessionId = randomBytes(8).toString("hex");
this.clients.set(sessionId, res);
const endpoint = `/api/mcp/messages`;
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
const interval = setInterval(() => {
res.write(": keepalive\n\n");
}, 15000);
req.on("close", () => {
clearInterval(interval);
this.clients.delete(sessionId);
});
}
async handleMessage(req: Request, res: Response) {
const user = await this.authenticate(req);
if (!user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const body = req.body as JsonRpcRequest;
try {
const result = await this.processRequest(body, user);
res.json({
jsonrpc: "2.0",
result,
id: body.id
});
} catch (err: any) {
res.json({
jsonrpc: "2.0",
error: {
code: -32000,
message: err.message || "Internal Server Error"
},
id: body.id
});
}
}
private async processRequest(req: JsonRpcRequest, user: User): Promise<any> {
switch (req.method) {
case "initialize":
return {
protocolVersion: "2024-11-05",
capabilities: { tools: {}, resources: {} },
serverInfo: { name: "TaskFlow MCP", version: "2.0.0" }
};
case "tools/list":
case "listTools":
return { tools: this.getToolDefinitions() };
case "tools/call":
case "callTool":
return await this.handleToolCall(req.params.name, req.params.arguments, user);
case "notifications/initialized":
return true;
default:
throw new Error(`Method ${req.method} not found`);
}
}
private getToolDefinitions() {
return [
// ===== TASK MANAGEMENT =====
{
name: "list_tasks",
description: "List all tasks. Optional filters: status, priority, labelId, limit.",
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["todo", "inProgress", "done"], description: "Filter by status" },
priority: { type: "string", enum: ["low", "medium", "high"], description: "Filter by priority" },
labelId: { type: "string", description: "Filter by label ID" },
limit: { type: "number", description: "Max results" },
includeCompleted: { type: "boolean", description: "Include done tasks (default: false)" }
}
}
},
{
name: "get_task",
description: "Get a single task by ID with full details.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "Task ID" } },
required: ["id"]
}
},
{
name: "search_tasks",
description: "Search tasks by title/description text.",
inputSchema: {
type: "object",
properties: { query: { type: "string", description: "Search text" } },
required: ["query"]
}
},
{
name: "create_task",
description: "Create a new task. Returns the created task with ID.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Task title" },
description: { type: "string", description: "Task description (markdown supported)" },
priority: { type: "string", enum: ["low", "medium", "high"], description: "Priority level" },
status: { type: "string", enum: ["todo", "inProgress", "done"], description: "Initial status" },
dueDate: { type: "string", description: "Due date (ISO 8601)" },
labelId: { type: "string", description: "Label/category ID" },
estimatedDuration: { type: "number", description: "Estimated minutes" },
energyLevel: { type: "string", enum: ["low", "medium", "high"], description: "Required energy level" },
parentTaskId: { type: "string", description: "Parent task ID (for subtasks)" },
notes: { type: "string", description: "Additional notes" }
},
required: ["title"]
}
},
{
name: "update_task",
description: "Update an existing task. Only provided fields are changed.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Task ID" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] },
status: { type: "string", enum: ["todo", "inProgress", "done"] },
dueDate: { type: "string", description: "Due date (ISO 8601) or null to clear" },
labelId: { type: "string", description: "Label ID or null to clear" },
estimatedDuration: { type: "number" },
energyLevel: { type: "string", enum: ["low", "medium", "high"] },
notes: { type: "string" }
},
required: ["id"]
}
},
{
name: "complete_task",
description: "Mark a task as done.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "Task ID" } },
required: ["id"]
}
},
{
name: "delete_task",
description: "Permanently delete a task.",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "Task ID" } },
required: ["id"]
}
},
// ===== LABELS =====
{
name: "list_labels",
description: "List all available labels/categories.",
inputSchema: { type: "object", properties: {} }
},
{
name: "create_label",
description: "Create a new label/category.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Label name" },
color: { type: "string", description: "Hex color (e.g. #ff5733)" },
domain: { type: "string", enum: ["work", "personal", "neutral"], description: "Domain" }
},
required: ["name", "color"]
}
},
// ===== USER & STATS =====
{
name: "get_user_stats",
description: "Get current user stats: XP, level, streak, task counts.",
inputSchema: { type: "object", properties: {} }
},
{
name: "get_dashboard",
description: "Get a dashboard overview: active tasks, upcoming due, stats.",
inputSchema: { type: "object", properties: {} }
},
// ===== BULK OPERATIONS =====
{
name: "bulk_create_tasks",
description: "Create multiple tasks at once. Returns array of created tasks.",
inputSchema: {
type: "object",
properties: {
tasks: {
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] },
dueDate: { type: "string" },
labelId: { type: "string" },
estimatedDuration: { type: "number" }
},
required: ["title"]
},
description: "Array of task objects"
}
},
required: ["tasks"]
}
},
{
name: "bulk_update_tasks",
description: "Update multiple tasks. Each item needs an id + fields to change.",
inputSchema: {
type: "object",
properties: {
updates: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
status: { type: "string" },
priority: { type: "string" },
dueDate: { type: "string" }
},
required: ["id"]
}
}
},
required: ["updates"]
}
}
];
}
private async handleToolCall(name: string, args: any, user: User) {
switch (name) {
case "list_tasks": {
let tasks = await storage.getTasksForUser(user.id);
if (!args?.includeCompleted) {
tasks = tasks.filter(t => t.status !== "done");
}
if (args?.status) tasks = tasks.filter(t => t.status === args.status);
if (args?.priority) tasks = tasks.filter(t => t.priority === args.priority);
if (args?.labelId) tasks = tasks.filter(t => t.labelId === args.labelId);
if (args?.limit) tasks = tasks.slice(0, args.limit);
// Return compact format
const compact = tasks.map(t => ({
id: t.id,
title: t.title,
status: t.status,
priority: t.priority,
dueDate: t.dueDate,
labelId: t.labelId,
estimatedDuration: t.estimatedDuration,
timeTracked: t.timeTracked,
energyLevel: t.energyLevel
}));
return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
}
case "get_task": {
if (!args.id) throw new Error("Task ID is required");
const task = await storage.getTask(args.id);
if (!task || task.userId !== user.id) throw new Error("Task not found");
return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] };
}
case "search_tasks": {
if (!args.query) throw new Error("Search query is required");
const tasks = await storage.searchTasks(args.query, user.id);
const compact = tasks.map(t => ({
id: t.id, title: t.title, status: t.status,
priority: t.priority, dueDate: t.dueDate
}));
return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
}
case "create_task": {
if (!args.title) throw new Error("Title is required");
const task = await storage.createTask({
title: args.title,
description: args.description || "",
priority: args.priority || "medium",
status: args.status || "todo",
isTracking: false,
timeTracked: 0,
energyLevel: args.energyLevel || "medium",
estimatedDuration: args.estimatedDuration || 15,
dueDate: args.dueDate || null,
notes: args.notes || "",
labelId: args.labelId || null,
parentTaskId: args.parentTaskId || null,
userId: user.id
});
await storage.createAuditLog({
userId: user.id,
action: "CREATE",
entityType: "TASK",
entityId: task.id,
details: { title: task.title, source: "MCP/API" },
source: "API"
});
return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] };
}
case "update_task": {
if (!args.id) throw new Error("Task ID is required");
const task = await storage.getTask(args.id);
if (!task || task.userId !== user.id) throw new Error("Task not found");
const { id, ...updates } = args;
const updated = await storage.updateTask(id, updates);
await storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "TASK",
entityId: id,
details: { ...updates, source: "MCP/API" },
source: "API"
});
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
}
case "complete_task": {
if (!args.id) throw new Error("Task ID is required");
const task = await storage.getTask(args.id);
if (!task || task.userId !== user.id) throw new Error("Task not found");
const updated = await storage.updateTask(args.id, { status: "done" });
await storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "TASK",
entityId: args.id,
details: { status: "done", source: "MCP/API" },
source: "API"
});
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
}
case "delete_task": {
if (!args.id) throw new Error("Task ID is required");
const task = await storage.getTask(args.id);
if (!task || task.userId !== user.id) throw new Error("Task not found");
await storage.deleteTask(args.id);
await storage.createAuditLog({
userId: user.id,
action: "DELETE",
entityType: "TASK",
entityId: args.id,
details: { title: task.title, source: "MCP/API" },
source: "API"
});
return { content: [{ type: "text", text: `Task "${task.title}" deleted.` }] };
}
case "list_labels": {
const labels = await storage.getAllLabels();
const visible = labels.filter(l =>
l.creatorId === user.id || l.creatorId === null
);
return { content: [{ type: "text", text: JSON.stringify(visible, null, 2) }] };
}
case "create_label": {
if (!args.name || !args.color) throw new Error("Name and color are required");
const label = await storage.createLabel({
name: args.name,
color: args.color,
domain: args.domain || "neutral",
creatorId: user.id
});
return { content: [{ type: "text", text: JSON.stringify(label, null, 2) }] };
}
case "get_user_stats": {
const freshUser = await storage.getUser(user.id);
if (!freshUser) throw new Error("User not found");
const tasks = await storage.getTasksForUser(user.id);
const active = tasks.filter(t => t.status !== "done");
const done = tasks.filter(t => t.status === "done");
const overdue = active.filter(t => t.dueDate && new Date(t.dueDate) < new Date());
return {
content: [{
type: "text", text: JSON.stringify({
username: freshUser.username,
xp: freshUser.xp,
level: freshUser.level,
streak: freshUser.currentStreak,
tasks: {
total: tasks.length,
active: active.length,
completed: done.length,
overdue: overdue.length,
byPriority: {
high: active.filter(t => t.priority === "high").length,
medium: active.filter(t => t.priority === "medium").length,
low: active.filter(t => t.priority === "low").length
}
}
}, null, 2)
}]
};
}
case "get_dashboard": {
const tasks = await storage.getTasksForUser(user.id);
const labels = await storage.getAllLabels();
const now = new Date();
const in48h = new Date(now.getTime() + 48 * 60 * 60 * 1000);
const active = tasks.filter(t => t.status !== "done");
const upcoming = active
.filter(t => t.dueDate && new Date(t.dueDate) <= in48h)
.sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime());
const overdue = active.filter(t => t.dueDate && new Date(t.dueDate) < now);
const highPriority = active.filter(t => t.priority === "high");
const labelMap = new Map(labels.map(l => [l.id, l.name]));
return {
content: [{
type: "text", text: JSON.stringify({
summary: {
activeTasks: active.length,
overdue: overdue.length,
dueSoon: upcoming.length,
highPriority: highPriority.length
},
overdueTasks: overdue.map(t => ({
id: t.id, title: t.title, dueDate: t.dueDate,
priority: t.priority, label: labelMap.get(t.labelId || "") || null
})),
upcomingTasks: upcoming.slice(0, 10).map(t => ({
id: t.id, title: t.title, dueDate: t.dueDate,
priority: t.priority, label: labelMap.get(t.labelId || "") || null
})),
highPriorityTasks: highPriority.map(t => ({
id: t.id, title: t.title, dueDate: t.dueDate,
label: labelMap.get(t.labelId || "") || null
}))
}, null, 2)
}]
};
}
case "bulk_create_tasks": {
if (!args.tasks || !Array.isArray(args.tasks)) throw new Error("tasks array is required");
const created = [];
for (const t of args.tasks) {
if (!t.title) continue;
const task = await storage.createTask({
title: t.title,
description: t.description || "",
priority: t.priority || "medium",
status: "todo",
isTracking: false,
timeTracked: 0,
energyLevel: t.energyLevel || "medium",
estimatedDuration: t.estimatedDuration || 15,
dueDate: t.dueDate || null,
notes: "",
labelId: t.labelId || null,
userId: user.id
});
created.push({ id: task.id, title: task.title });
}
return { content: [{ type: "text", text: JSON.stringify({ created: created.length, tasks: created }, null, 2) }] };
}
case "bulk_update_tasks": {
if (!args.updates || !Array.isArray(args.updates)) throw new Error("updates array is required");
const results = [];
for (const u of args.updates) {
if (!u.id) continue;
const task = await storage.getTask(u.id);
if (!task || task.userId !== user.id) continue;
const { id, ...updates } = u;
const updated = await storage.updateTask(id, updates);
results.push({ id: updated?.id, title: updated?.title, status: updated?.status });
}
return { content: [{ type: "text", text: JSON.stringify({ updated: results.length, tasks: results }, null, 2) }] };
}
default:
throw new Error(`Tool ${name} not implemented`);
}
}
}
export const mcpServer = new McpServer();