- POST /api/mcp/files nimmt multipart/form-data oder Rohdaten mit dem MCP-Token an, Ziel Dokumentenablage oder Eingangsrechnung - documents.files.upload_url liefert Adresse, Parameter und curl-Beispiele - Upload einer Eingangsrechnung bereitet nur noch die hochgeladene Datei vor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
15 KiB
TypeScript
368 lines
15 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"
|
|
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<string, unknown>)
|
|
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),
|
|
})
|
|
})
|
|
}
|