Files
FEDEO/backend/src/routes/mcp.ts
florianfederspiel 4e49dd18a1
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m4s
Build and Push Docker Images / build-docs (push) Successful in 15s
MCP um dauerhafte tenantgebundene Tokens erweitern
2026-05-12 17:39:20 +02:00

300 lines
11 KiB
TypeScript

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),
})
})
}