feat(mcp): Dateien ohne Base64 per HTTP hochladen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m19s

- 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:
2026-10-27 20:50:34 +01:00
parent 76e82eae4f
commit 9e003125c2
6 changed files with 347 additions and 81 deletions

View 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"/)
})