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" import { assertInternalDocumentAccess, loadDocumentFile, MCP_BODY_LIMIT, MCP_FILE_PERMISSION } from "../mcp/documentFiles" import { s3 } from "../utils/s3" import { secrets } from "../utils/secrets" import { GetObjectCommand } from "@aws-sdk/client-s3" import { sanitizeFilename } from "../utils/filename" import multipart from "@fastify/multipart" import { MCP_FILE_LIMIT } from "../mcp/documentFiles" import { parseUploadQuery, storeMcpFileUpload } from "../mcp/fileUploads" const RAW_UPLOAD_TYPES = ["application/pdf", "application/octet-stream", "application/xml", "text/xml", "image/jpeg", "image/png", "image/tiff"] 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 }) } type McpRouteOptions = { /** Replaceable in tests to avoid object storage. */ storeUpload?: typeof storeMcpFileUpload } export default async function mcpRoutes(server: FastifyInstance, options: McpRouteOptions = {}) { const storeUpload = options.storeUpload || storeMcpFileUpload 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.get<{ Params: { id: string } }>("/mcp/files/:id/content", { schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } }, }, async (req, reply) => { try { const context = await createMcpContext(server, req) assertToolPermission(context, { name: "documents.files.download", requiredPermissions: [MCP_FILE_PERMISSION] }) await assertInternalDocumentAccess(context) const file = await loadDocumentFile(context, req.params.id) if (!file.path) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." }) const { Body } = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path })) if (!Body) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." }) reply.header("Content-Type", file.mimeType || "application/octet-stream") reply.header("Content-Disposition", `attachment; filename="${sanitizeFilename(file.name)}"`) reply.header("Cache-Control", "no-store") reply.header("X-Content-Type-Options", "nosniff") return reply.send(Body) } catch (error) { return reply.code((error as any)?.statusCode || 500).send({ error: error instanceof Error ? error.message : "Download fehlgeschlagen." }) } }) // Binary uploads without Base64; parsers stay scoped so JSON-RPC parsing is unaffected. await server.register(async (uploads) => { await uploads.register(multipart, { limits: { fileSize: MCP_FILE_LIMIT, files: 1 } }) uploads.addContentTypeParser(RAW_UPLOAD_TYPES, { parseAs: "buffer", bodyLimit: MCP_FILE_LIMIT }, (_req, body, done) => done(null, body)) uploads.post("/mcp/files", async (req, reply) => { try { const context = await createMcpContext(server, req) const query = parseUploadQuery(req.query as Record) let body: Buffer let filename = query.filename let mimeType = req.headers["content-type"]?.split(";")[0].trim() || null if (req.isMultipart()) { const part = await req.file() if (!part || part.fieldname !== "file") return reply.code(400).send({ error: "Bitte die Datei im Feld file übertragen." }) body = await part.toBuffer() filename ||= part.filename mimeType = part.mimetype } else if (Buffer.isBuffer(req.body)) { body = req.body } else { return reply.code(415).send({ error: `Bitte multipart/form-data oder einen der Content-Types ${RAW_UPLOAD_TYPES.join(", ")} verwenden.` }) } if (!filename) return reply.code(400).send({ error: "filename ist erforderlich." }) return await storeUpload(context, { ...query, filename, body, mimeType }) } catch (error) { return reply.code((error as any)?.statusCode || 400).send({ error: error instanceof Error ? error.message : "Upload fehlgeschlagen." }) } }) }) server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, 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-, Organisations- und Dokumentenwerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten. Dokumentenuploads erscheinen in der Dokumentenablage; Download-URLs benötigen denselben Bearer-Token wie MCP.", })) 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.readOnly ?? !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), }) }) }