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 { wikiTools } from "./tools/wiki"
|
||||||
import { documentTools } from "./tools/documents"
|
import { documentTools } from "./tools/documents"
|
||||||
import { archiveTools } from "./tools/archive"
|
import { archiveTools } from "./tools/archive"
|
||||||
|
import { uploadUrlTool } from "./fileUploads"
|
||||||
|
|
||||||
export const mcpTools = [
|
export const mcpTools = [
|
||||||
...accountingTools,
|
...accountingTools,
|
||||||
@@ -12,6 +13,7 @@ export const mcpTools = [
|
|||||||
...wikiTools,
|
...wikiTools,
|
||||||
...documentTools,
|
...documentTools,
|
||||||
...archiveTools,
|
...archiveTools,
|
||||||
|
uploadUrlTool,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
prepareStatementAllocationInput,
|
prepareStatementAllocationInput,
|
||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
} from "../statementAllocations"
|
} from "../statementAllocations"
|
||||||
import { McpTool } from "../types"
|
import { McpContext, McpTool } from "../types"
|
||||||
import { outgoingStatusTools } from "./outgoingStatus"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
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 = (
|
const buildIncomingInvoicePayload = (
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1014,7 +1106,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.files.upload",
|
name: "accounting.incoming_invoices.files.upload",
|
||||||
title: "Datei für Eingangsbeleg hochladen",
|
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"],
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -1050,83 +1142,14 @@ export const accountingTools: McpTool[] = [
|
|||||||
const filename = stringArg(args, "filename")
|
const filename = stringArg(args, "filename")
|
||||||
if (!filename) throw new Error("filename ist erforderlich")
|
if (!filename) throw new Error("filename ist erforderlich")
|
||||||
|
|
||||||
const buffer = base64BufferArg(args, "contentBase64")
|
return uploadIncomingInvoiceFile(context, {
|
||||||
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
|
filename,
|
||||||
const invoiceId = numberArg(args, "invoiceId")
|
buffer: base64BufferArg(args, "contentBase64"),
|
||||||
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
|
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
|
||||||
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
|
invoiceId: numberArg(args, "invoiceId"),
|
||||||
|
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
|
||||||
let linkedInvoice: any = null
|
prepare: args.prepare !== false,
|
||||||
|
})
|
||||||
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),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function createDocumentTools(dependencies: Dependencies = {}): McpTool[]
|
|||||||
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
const body = await readFile(file.path, MCP_FILE_LIMIT)
|
||||||
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
|
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: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
|
||||||
["filename", "contentBase64"], async (context, args) => {
|
["filename", "contentBase64"], async (context, args) => {
|
||||||
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
|
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 { secrets } from "../utils/secrets"
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
||||||
import { sanitizeFilename } from "../utils/filename"
|
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 = [
|
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
@@ -62,7 +67,13 @@ const requireMcpTokenManagementPermission = (req: any) => {
|
|||||||
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
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) => {
|
server.get("/mcp/tokens", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
requireMcpTokenManagementPermission(req)
|
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) => {
|
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
|
||||||
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||||
const requests = Array.isArray(body) ? body : [body]
|
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