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:
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