From 9e003125c2f8b3396b9053b2186c5506536f3643 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 27 Oct 2026 20:50:34 +0100 Subject: [PATCH] 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 --- backend/src/mcp/fileUploads.ts | 93 ++++++++++++++ backend/src/mcp/registry.ts | 2 + backend/src/mcp/tools/accounting.ts | 181 +++++++++++++++------------ backend/src/mcp/tools/documents.ts | 2 +- backend/src/routes/mcp.ts | 44 ++++++- backend/tests/mcpFileUploads.test.ts | 106 ++++++++++++++++ 6 files changed, 347 insertions(+), 81 deletions(-) create mode 100644 backend/src/mcp/fileUploads.ts create mode 100644 backend/tests/mcpFileUploads.test.ts diff --git a/backend/src/mcp/fileUploads.ts b/backend/src/mcp/fileUploads.ts new file mode 100644 index 00000000..01b46b1e --- /dev/null +++ b/backend/src/mcp/fileUploads.ts @@ -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) { + 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 ", + 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"`, + ], + } + }, +} diff --git a/backend/src/mcp/registry.ts b/backend/src/mcp/registry.ts index fabd6f95..181b8051 100644 --- a/backend/src/mcp/registry.ts +++ b/backend/src/mcp/registry.ts @@ -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])) diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 3435f95e..f220a6d1 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -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 | 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) : null, + prepared: Boolean(shouldPrepare && linkedInvoice), + } +} + const buildIncomingInvoicePayload = ( args: Record, 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 | 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) : 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 | null, + prepare: args.prepare !== false, + }) }, }, { diff --git a/backend/src/mcp/tools/documents.ts b/backend/src/mcp/tools/documents.ts index 4204ab91..ba86e99b 100644 --- a/backend/src/mcp/tools/documents.ts +++ b/backend/src/mcp/tools/documents.ts @@ -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) diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index 8fcf6a99..21f3dc5c 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -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) + 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] diff --git a/backend/tests/mcpFileUploads.test.ts b/backend/tests/mcpFileUploads.test.ts new file mode 100644 index 00000000..eb1c1250 --- /dev/null +++ b/backend/tests/mcpFileUploads.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createHash } from "node:crypto" +import Fastify from "fastify" +import { drizzle } from "drizzle-orm/pg-proxy" +import mcpRoutes from "../src/routes/mcp" +import authPlugin from "../src/plugins/auth" +import { mcpToolMap } from "../src/mcp/registry" +import { parseUploadQuery, storeMcpFileUpload } from "../src/mcp/fileUploads" +import { MCP_FILE_LIMIT } from "../src/mcp/documentFiles" +import { secrets } from "../src/utils/secrets" + +const id = "00000000-0000-4000-8000-000000000001" +const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF") +const bearer = "fedeo_mcp_test_upload_token" +const headers = { authorization: `Bearer ${bearer}` } + +async function app(t: any) { + const uploads: any[] = [] + const db = drizzle(async (sql, params) => { + if (sql.includes('from "m2m_api_keys"')) { + assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex"))) + return { rows: [[id, 7, id, true, null, "test@example.invalid", true]] } + } + if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] } + if (sql.includes('from "auth_users"')) return { rows: [[true]] } + if (sql.includes('from "tenants"')) return { rows: [[null, null]] } + return { rows: [] } + }) + const server = Fastify() + t.after(() => server.close()) + server.decorate("db", db as any) + await server.register(authPlugin) + await server.register(mcpRoutes, { + prefix: "/api", + storeUpload: async (context: any, upload: any) => { uploads.push({ tenantId: context.tenantId, ...upload }); return { stored: true } }, + }) + return { server, uploads } +} + +const multipartBody = (filename: string, content: Buffer, field = "file") => { + const boundary = "----fedeo-test" + const payload = Buffer.concat([ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: application/pdf\r\n\r\n`), + content, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]) + return { payload, headers: { ...headers, "content-type": `multipart/form-data; boundary=${boundary}` } } +} + +test("binary endpoint accepts multipart and raw uploads without Base64", async (t) => { + const { server, uploads } = await app(t) + const multipart = multipartBody("rechnung.pdf", pdf) + const first = await server.inject({ method: "POST", url: "/api/mcp/files?target=incoming_invoice&invoiceId=5&prepare=false", ...multipart }) + assert.equal(first.statusCode, 200, first.body) + assert.deepEqual(first.json(), { stored: true }) + assert.equal(uploads[0].tenantId, 7) + assert.equal(uploads[0].target, "incoming_invoice") + assert.equal(uploads[0].filename, "rechnung.pdf") + assert.equal(uploads[0].invoiceId, 5) + assert.equal(uploads[0].prepare, false) + assert.deepEqual(uploads[0].body, pdf) + + const raw = await server.inject({ method: "POST", url: "/api/mcp/files?filename=scan.pdf", headers: { ...headers, "content-type": "application/pdf" }, payload: pdf }) + assert.equal(raw.statusCode, 200, raw.body) + assert.equal(uploads[1].target, "inbox") + assert.equal(uploads[1].mimeType, "application/pdf") + assert.deepEqual(uploads[1].body, pdf) +}) + +test("binary endpoint rejects missing auth, filenames, wrong fields, types and oversized files", async (t) => { + const { server, uploads } = await app(t) + const raw = { "content-type": "application/pdf" } + assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: raw, payload: pdf })).statusCode, 401) + assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400) + assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", ...multipartBody("a.pdf", pdf, "other") })).statusCode, 400) + assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, "content-type": "text/html" }, payload: "

" })).statusCode, 415) + assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf&target=wiki", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400) + const tooLarge = Buffer.alloc(MCP_FILE_LIMIT + 1) + assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, ...raw }, payload: tooLarge })).statusCode, 413) + assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", ...multipartBody("a.pdf", tooLarge) })).statusCode, 413) + assert.equal(uploads.length, 0) +}) + +test("query parsing keeps invoice options to the incoming invoice target", () => { + assert.deepEqual(parseUploadQuery({}), { target: "inbox", invoiceId: null, prepare: true, filename: null }) + assert.throws(() => parseUploadQuery({ invoiceId: "3" }), /nur für target=incoming_invoice/) + assert.throws(() => parseUploadQuery({ target: "incoming_invoice", invoiceId: "x" }), /positive ganze Zahl/) + assert.throws(() => parseUploadQuery({ target: "incoming_invoice", prepare: "ja" }), /true oder false/) +}) + +test("stored uploads require the permission of the chosen target", async () => { + const context: any = { server: { db: null }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read"] } + await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: pdf }), /documents\.write/) + await assert.rejects(storeMcpFileUpload(context, { target: "incoming_invoice", filename: "a.pdf", body: pdf }), /accounting\.incoming_invoices\.write/) + await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: Buffer.alloc(0) }), /leer/) +}) + +test("upload_url tool returns the absolute endpoint with examples", async (t) => { + const apiBase = secrets.API_BASE_URL + secrets.API_BASE_URL = "https://app.fedeo.test/backend/" + t.after(() => { secrets.API_BASE_URL = apiBase }) + const result: any = await mcpToolMap.get("documents.files.upload_url")!.handler({} as any, {}) + assert.equal(result.url, "https://app.fedeo.test/backend/api/mcp/files") + assert.match(result.examples[0], /-F "file=@rechnung\.pdf"/) +})