import { FastifyInstance } from "fastify" import { and, desc, eq } from "drizzle-orm" import { createHash, randomBytes } from "node:crypto" import { m2mApiKeys } from "../../db/schema" import { assertToolPermission, createMcpContext } from "../mcp/authz" import { mcpToolMap, mcpTools } from "../mcp/registry" import { asToolError, asToolResult } from "../mcp/result" import { JsonRpcRequest } from "../mcp/types" const SUPPORTED_PROTOCOL_VERSIONS = [ "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", ] function jsonRpcResult(id: JsonRpcRequest["id"], result: unknown) { return { jsonrpc: "2.0", id, result, } } function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: string) { return { jsonrpc: "2.0", id: id ?? null, error: { code, message, }, } } function selectProtocolVersion(clientVersion?: string) { if (clientVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) { return clientVersion } return SUPPORTED_PROTOCOL_VERSIONS[0] } const hashApiKey = (apiKey: string) => createHash("sha256").update(apiKey, "utf8").digest("hex") const createMcpToken = () => `fedeo_mcp_${randomBytes(32).toString("base64url")}` const requireMcpTokenManagementPermission = (req: any) => { if (req.mcpTokenAuth) { throw Object.assign(new Error("MCP Tokens können nur mit einer Benutzersitzung verwaltet werden"), { statusCode: 403 }) } if (req.user?.is_admin) return if (typeof req.hasPermission === "function" && req.hasPermission("mcp.tokens.write")) return throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 }) } export default async function mcpRoutes(server: FastifyInstance) { server.get("/mcp/tokens", async (req, reply) => { try { requireMcpTokenManagementPermission(req) if (!req.user?.tenant_id) { return reply.code(400).send({ error: "No tenant selected" }) } const rows = await server.db .select({ id: m2mApiKeys.id, createdAt: m2mApiKeys.createdAt, updatedAt: m2mApiKeys.updatedAt, name: m2mApiKeys.name, keyPrefix: m2mApiKeys.keyPrefix, active: m2mApiKeys.active, lastUsedAt: m2mApiKeys.lastUsedAt, expiresAt: m2mApiKeys.expiresAt, userId: m2mApiKeys.userId, createdBy: m2mApiKeys.createdBy, }) .from(m2mApiKeys) .where(and( eq(m2mApiKeys.tenantId, req.user.tenant_id) )) .orderBy(desc(m2mApiKeys.createdAt)) return { rows: rows.filter((row) => row.keyPrefix.startsWith("fedeo_mcp_")) } } catch (error) { const statusCode = (error as any)?.statusCode || 500 return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" }) } }) server.post("/mcp/tokens", async (req, reply) => { try { requireMcpTokenManagementPermission(req) if (!req.user?.tenant_id || !req.user?.user_id) { return reply.code(400).send({ error: "No tenant selected" }) } const body = req.body as { name?: string expiresAt?: string | null } const token = createMcpToken() const expiresAt = body?.expiresAt ? new Date(body.expiresAt) : null if (expiresAt && Number.isNaN(expiresAt.getTime())) { return reply.code(400).send({ error: "expiresAt must be a valid date" }) } const [created] = await server.db .insert(m2mApiKeys) .values({ tenantId: req.user.tenant_id, userId: req.user.user_id, createdBy: req.user.user_id, name: body?.name?.trim() || "FEDEO MCP Token", keyPrefix: token.slice(0, 20), keyHash: hashApiKey(token), active: true, expiresAt, }) .returning({ id: m2mApiKeys.id, createdAt: m2mApiKeys.createdAt, name: m2mApiKeys.name, keyPrefix: m2mApiKeys.keyPrefix, active: m2mApiKeys.active, expiresAt: m2mApiKeys.expiresAt, }) return { token, tokenType: "Bearer", note: "Token wird nur einmal angezeigt. Bitte direkt in Codex oder einem Secret Store hinterlegen.", record: created, } } catch (error) { const statusCode = (error as any)?.statusCode || 500 return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" }) } }) server.delete("/mcp/tokens/:id", async (req, reply) => { try { requireMcpTokenManagementPermission(req) if (!req.user?.tenant_id || !req.user?.user_id) { return reply.code(400).send({ error: "No tenant selected" }) } const { id } = req.params as { id: string } const [existing] = await server.db .select({ id: m2mApiKeys.id, keyPrefix: m2mApiKeys.keyPrefix, }) .from(m2mApiKeys) .where(and( eq(m2mApiKeys.id, id), eq(m2mApiKeys.tenantId, req.user.tenant_id) )) .limit(1) if (!existing || !existing.keyPrefix.startsWith("fedeo_mcp_")) { return reply.code(404).send({ error: "MCP token not found" }) } const [updated] = await server.db .update(m2mApiKeys) .set({ active: false, updatedAt: new Date(), }) .where(and( eq(m2mApiKeys.id, id), eq(m2mApiKeys.tenantId, req.user.tenant_id) )) .returning({ id: m2mApiKeys.id, name: m2mApiKeys.name, keyPrefix: m2mApiKeys.keyPrefix, active: m2mApiKeys.active, }) return { token: updated } } catch (error) { const statusCode = (error as any)?.statusCode || 500 return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" }) } }) server.post("/mcp", async (req, reply) => { const body = req.body as JsonRpcRequest | JsonRpcRequest[] const requests = Array.isArray(body) ? body : [body] const responses = [] for (const request of requests) { const id = request?.id if (!request || request.jsonrpc !== "2.0" || !request.method) { responses.push(jsonRpcError(id, -32600, "Invalid JSON-RPC request")) continue } if (request.method === "notifications/initialized") { continue } if (request.method === "initialize") { const clientVersion = request.params?.protocolVersion responses.push(jsonRpcResult(id, { protocolVersion: selectProtocolVersion(clientVersion), capabilities: { tools: { listChanged: false, }, }, serverInfo: { name: "fedeo-mcp", version: "1.0.0", }, instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.", })) continue } if (request.method === "ping") { responses.push(jsonRpcResult(id, {})) continue } if (request.method === "tools/list") { responses.push(jsonRpcResult(id, { tools: mcpTools.map((tool) => ({ name: tool.name, title: tool.title, description: tool.description, inputSchema: tool.inputSchema, annotations: { readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), }, })), })) continue } if (request.method === "tools/call") { const toolName = request.params?.name const tool = typeof toolName === "string" ? mcpToolMap.get(toolName) : null if (!tool) { responses.push(jsonRpcError(id, -32602, `Unknown tool: ${toolName}`)) continue } try { const context = await createMcpContext(server, req) assertToolPermission(context, tool) const result = await tool.handler(context, request.params?.arguments || {}) responses.push(jsonRpcResult(id, asToolResult(result))) } catch (error) { const statusCode = (error as any)?.statusCode if (statusCode === 401 || statusCode === 403) { responses.push(jsonRpcError(id, statusCode === 401 ? -32001 : -32003, error instanceof Error ? error.message : "Forbidden")) } else { responses.push(jsonRpcResult(id, asToolError(error))) } } continue } responses.push(jsonRpcError(id, -32601, `Method not found: ${request.method}`)) } if (responses.length === 0) { return reply.code(204).send() } return Array.isArray(body) ? responses : responses[0] }) server.get("/mcp", async (_req, reply) => { return reply.send({ name: "fedeo-mcp", transport: "http-json-rpc", endpoint: "/api/mcp", tools: mcpTools.map((tool) => tool.name), }) }) }