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,
|
||||
{
|
||||
return uploadIncomingInvoiceFile(context, {
|
||||
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),
|
||||
}
|
||||
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]
|
||||
|
||||
106
backend/tests/mcpFileUploads.test.ts
Normal file
106
backend/tests/mcpFileUploads.test.ts
Normal file
@@ -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: "<p>" })).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"/)
|
||||
})
|
||||
Reference in New Issue
Block a user