feat(mcp): Dateien ohne Base64 per HTTP hochladen
- 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>
This commit is contained in:
93
backend/src/mcp/fileUploads.ts
Normal file
93
backend/src/mcp/fileUploads.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { assertToolPermission } from "./authz"
|
||||
import { assertInternalDocumentAccess, MCP_FILE_LIMIT } from "./documentFiles"
|
||||
import { McpContext, McpTool } from "./types"
|
||||
import { createScannerInboxService, validateScannerFile } from "../modules/scanner-inbox.service"
|
||||
import { uploadIncomingInvoiceFile } from "./tools/accounting"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
export const MCP_UPLOAD_PATH = "/api/mcp/files"
|
||||
export const uploadTargets = ["inbox", "incoming_invoice"] as const
|
||||
export type UploadTarget = typeof uploadTargets[number]
|
||||
|
||||
export type McpFileUpload = {
|
||||
target: UploadTarget
|
||||
filename: string
|
||||
body: Buffer
|
||||
mimeType?: string | null
|
||||
invoiceId?: number | null
|
||||
prepare?: boolean
|
||||
}
|
||||
|
||||
export function mcpUploadUrl() {
|
||||
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
|
||||
return base ? `${base}${MCP_UPLOAD_PATH}` : MCP_UPLOAD_PATH
|
||||
}
|
||||
|
||||
const badRequest = (message: string, statusCode = 400) => Object.assign(new Error(message), { statusCode })
|
||||
|
||||
/** Reads the query options of the binary upload endpoint. */
|
||||
export function parseUploadQuery(query: Record<string, unknown>) {
|
||||
const target = (query.target ?? "inbox") as UploadTarget
|
||||
if (!uploadTargets.includes(target)) throw badRequest(`target muss ${uploadTargets.join(" oder ")} sein.`)
|
||||
let invoiceId: number | null = null
|
||||
if (query.invoiceId !== undefined && query.invoiceId !== "") {
|
||||
invoiceId = Number(query.invoiceId)
|
||||
if (!Number.isSafeInteger(invoiceId) || invoiceId < 1) throw badRequest("invoiceId muss eine positive ganze Zahl sein.")
|
||||
}
|
||||
if (query.prepare !== undefined && !["true", "false"].includes(String(query.prepare))) throw badRequest("prepare muss true oder false sein.")
|
||||
if (target === "inbox" && (invoiceId || query.prepare !== undefined)) throw badRequest("invoiceId und prepare gelten nur für target=incoming_invoice.")
|
||||
const filename = typeof query.filename === "string" && query.filename.trim() ? query.filename.trim() : null
|
||||
return { target, invoiceId, prepare: String(query.prepare) !== "false", filename }
|
||||
}
|
||||
|
||||
export async function storeMcpFileUpload(context: McpContext, upload: McpFileUpload) {
|
||||
if (!upload.body.length) throw badRequest("Die Datei ist leer.")
|
||||
if (upload.body.length > MCP_FILE_LIMIT) throw badRequest("MCP-Uploads sind auf 20 MiB begrenzt.", 413)
|
||||
if (upload.target === "inbox") {
|
||||
assertToolPermission(context, { name: "documents.files.upload", requiredPermissions: ["documents.write"] })
|
||||
await assertInternalDocumentAccess(context)
|
||||
const mimeType = validateScannerFile(upload.filename, upload.body)
|
||||
const result = await createScannerInboxService(context.server.db).upload(context.tenantId, context.userId, upload.filename, upload.body)
|
||||
return { ...result, filename: upload.filename, mimeType, size: upload.body.length }
|
||||
}
|
||||
assertToolPermission(context, { name: "accounting.incoming_invoices.files.upload", requiredPermissions: ["accounting.incoming_invoices.write"] })
|
||||
return uploadIncomingInvoiceFile(context, {
|
||||
filename: upload.filename,
|
||||
buffer: upload.body,
|
||||
mimeType: upload.mimeType || "application/octet-stream",
|
||||
invoiceId: upload.invoiceId,
|
||||
prepare: upload.prepare,
|
||||
})
|
||||
}
|
||||
|
||||
export const uploadUrlTool: McpTool = {
|
||||
name: "documents.files.upload_url",
|
||||
title: "Upload-Adresse für Dateien",
|
||||
description: "Liefert die HTTP-Adresse, über die Dateien ohne Base64 direkt hochgeladen werden (multipart/form-data oder Rohdaten, maximal 20 MiB). Für Uploads mit Dateizugriff, z. B. per curl, statt documents.files.upload oder accounting.incoming_invoices.files.upload verwenden.",
|
||||
// Informational only; the endpoint checks the permission of the chosen target.
|
||||
requiredPermissions: [],
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
async handler() {
|
||||
const url = mcpUploadUrl()
|
||||
return {
|
||||
url,
|
||||
method: "POST",
|
||||
authentication: "Authorization: Bearer <derselbe MCP-Token>",
|
||||
bodies: {
|
||||
multipart: "multipart/form-data mit dem Dateifeld file; der Dateiname wird aus dem Upload übernommen.",
|
||||
raw: "Dateiinhalt als Body mit passendem Content-Type (application/pdf, image/jpeg, image/png, image/tiff, application/xml oder application/octet-stream) und Query-Parameter filename.",
|
||||
},
|
||||
query: {
|
||||
target: "inbox (Standard, Dokumentenablage, benötigt documents.write) oder incoming_invoice (Eingangsrechnung, benötigt accounting.incoming_invoices.write).",
|
||||
filename: "Dateiname; bei Rohdaten erforderlich, bei multipart optional zum Überschreiben.",
|
||||
invoiceId: "Nur incoming_invoice: vorhandenen Eingangsbeleg verknüpfen.",
|
||||
prepare: "Nur incoming_invoice: false unterdrückt die automatische Belegvorbereitung (Standard true).",
|
||||
},
|
||||
response: "JSON wie beim entsprechenden Base64-Werkzeug.",
|
||||
examples: [
|
||||
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -F "file=@rechnung.pdf" "${url}?target=incoming_invoice"`,
|
||||
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -H "Content-Type: application/pdf" --data-binary @scan.pdf "${url}?filename=scan.pdf"`,
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { organisationTools } from "./tools/organisation"
|
||||
import { wikiTools } from "./tools/wiki"
|
||||
import { documentTools } from "./tools/documents"
|
||||
import { archiveTools } from "./tools/archive"
|
||||
import { uploadUrlTool } from "./fileUploads"
|
||||
|
||||
export const mcpTools = [
|
||||
...accountingTools,
|
||||
@@ -12,6 +13,7 @@ export const mcpTools = [
|
||||
...wikiTools,
|
||||
...documentTools,
|
||||
...archiveTools,
|
||||
uploadUrlTool,
|
||||
]
|
||||
|
||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
prepareStatementAllocationInput,
|
||||
statementAllocationUuidArg,
|
||||
} from "../statementAllocations"
|
||||
import { McpTool } from "../types"
|
||||
import { McpContext, McpTool } from "../types"
|
||||
import { outgoingStatusTools } from "./outgoingStatus"
|
||||
|
||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||
@@ -312,6 +312,98 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId:
|
||||
}
|
||||
}
|
||||
|
||||
export type IncomingInvoiceFileUpload = {
|
||||
filename: string
|
||||
buffer: Buffer
|
||||
mimeType: string
|
||||
invoiceId?: number | null
|
||||
invoiceArgs?: Record<string, unknown> | null
|
||||
prepare?: boolean
|
||||
}
|
||||
|
||||
/** Shared by the Base64 tool and the binary MCP upload endpoint. */
|
||||
export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) {
|
||||
const { filename, buffer, mimeType } = upload
|
||||
const invoiceId = upload.invoiceId || null
|
||||
const invoiceArgs = upload.invoiceArgs || null
|
||||
const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs
|
||||
if (!buffer.length) throw new Error("Datei ist leer")
|
||||
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
|
||||
|
||||
let linkedInvoice: any = null
|
||||
|
||||
if (invoiceId) {
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
linkedInvoice = existing
|
||||
} else if (invoiceArgs) {
|
||||
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
||||
payload.state = "Entwurf"
|
||||
|
||||
const [createdInvoice] = await context.server.db
|
||||
.insert(incominginvoices)
|
||||
.values(payload as any)
|
||||
.returning()
|
||||
|
||||
linkedInvoice = createdInvoice
|
||||
}
|
||||
|
||||
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
||||
const saved = await saveFile(
|
||||
context.server,
|
||||
context.tenantId,
|
||||
null,
|
||||
{
|
||||
filename,
|
||||
content: buffer,
|
||||
contentType: mimeType,
|
||||
},
|
||||
defaults.folderId,
|
||||
defaults.fileTagId,
|
||||
{
|
||||
incominginvoice: linkedInvoice?.id || null,
|
||||
createdBy: context.userId,
|
||||
updatedBy: context.userId,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
)
|
||||
|
||||
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
||||
|
||||
if (shouldPrepare) {
|
||||
// Only the uploaded file; other open invoice files stay for an explicit preparation run.
|
||||
await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] })
|
||||
}
|
||||
|
||||
const [file] = await context.server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (file?.incominginvoice && !linkedInvoice) {
|
||||
const [preparedInvoice] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
linkedInvoice = preparedInvoice || null
|
||||
}
|
||||
|
||||
return {
|
||||
file,
|
||||
invoice: linkedInvoice,
|
||||
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
||||
prepared: Boolean(shouldPrepare && linkedInvoice),
|
||||
}
|
||||
}
|
||||
|
||||
const buildIncomingInvoicePayload = (
|
||||
args: Record<string, unknown>,
|
||||
userId: string,
|
||||
@@ -1014,7 +1106,7 @@ export const accountingTools: McpTool[] = [
|
||||
{
|
||||
name: "accounting.incoming_invoices.files.upload",
|
||||
title: "Datei für Eingangsbeleg hochladen",
|
||||
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
|
||||
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.",
|
||||
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
@@ -1050,83 +1142,14 @@ export const accountingTools: McpTool[] = [
|
||||
const filename = stringArg(args, "filename")
|
||||
if (!filename) throw new Error("filename ist erforderlich")
|
||||
|
||||
const buffer = base64BufferArg(args, "contentBase64")
|
||||
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
|
||||
const invoiceId = numberArg(args, "invoiceId")
|
||||
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
|
||||
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
|
||||
|
||||
let linkedInvoice: any = null
|
||||
|
||||
if (invoiceId) {
|
||||
const [existing] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||
linkedInvoice = existing
|
||||
} else if (invoiceArgs) {
|
||||
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
||||
payload.state = "Entwurf"
|
||||
|
||||
const [createdInvoice] = await context.server.db
|
||||
.insert(incominginvoices)
|
||||
.values(payload as any)
|
||||
.returning()
|
||||
|
||||
linkedInvoice = createdInvoice
|
||||
}
|
||||
|
||||
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
||||
const saved = await saveFile(
|
||||
context.server,
|
||||
context.tenantId,
|
||||
null,
|
||||
{
|
||||
filename,
|
||||
content: buffer,
|
||||
contentType: mimeType,
|
||||
},
|
||||
defaults.folderId,
|
||||
defaults.fileTagId,
|
||||
{
|
||||
incominginvoice: linkedInvoice?.id || null,
|
||||
createdBy: context.userId,
|
||||
updatedBy: context.userId,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
)
|
||||
|
||||
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
||||
|
||||
if (shouldPrepare) {
|
||||
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
|
||||
}
|
||||
|
||||
const [file] = await context.server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (file?.incominginvoice && !linkedInvoice) {
|
||||
const [preparedInvoice] = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.limit(1)
|
||||
|
||||
linkedInvoice = preparedInvoice || null
|
||||
}
|
||||
|
||||
return {
|
||||
file,
|
||||
invoice: linkedInvoice,
|
||||
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
||||
prepared: Boolean(shouldPrepare && linkedInvoice),
|
||||
}
|
||||
return uploadIncomingInvoiceFile(context, {
|
||||
filename,
|
||||
buffer: base64BufferArg(args, "contentBase64"),
|
||||
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
|
||||
invoiceId: numberArg(args, "invoiceId"),
|
||||
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
|
||||
prepare: args.prepare !== false,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -135,7 +135,7 @@ export function createDocumentTools(dependencies: Dependencies = {}): McpTool[]
|
||||
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
||||
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
|
||||
}),
|
||||
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
|
||||
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url hochladen. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
|
||||
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
|
||||
["filename", "contentBase64"], async (context, args) => {
|
||||
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
|
||||
|
||||
@@ -11,6 +11,11 @@ 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",
|
||||
@@ -62,7 +67,13 @@ const requireMcpTokenManagementPermission = (req: any) => {
|
||||
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
||||
}
|
||||
|
||||
export default async function mcpRoutes(server: FastifyInstance) {
|
||||
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)
|
||||
@@ -221,6 +232,37 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
// 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]
|
||||
|
||||
Reference in New Issue
Block a user