feat(mcp): add document inbox tools and PDF transfers

This commit is contained in:
2026-10-09 19:13:42 +02:00
parent 15e480ee9a
commit 899e5bb339
13 changed files with 586 additions and 21 deletions

View File

@@ -71,7 +71,7 @@ export async function createMcpContext(
}
}
export function assertToolPermission(context: McpContext, tool: McpTool) {
export function assertToolPermission(context: McpContext, tool: Pick<McpTool, "name" | "requiredPermissions">) {
if (context.isAdmin) return
const allowed = tool.requiredPermissions.every((permission) =>
@@ -85,4 +85,3 @@ export function assertToolPermission(context: McpContext, tool: McpTool) {
)
}
}

View File

@@ -0,0 +1,44 @@
import { and, eq, getTableColumns } from "drizzle-orm"
import { authProfiles, files } from "../../db/schema"
import { McpContext } from "./types"
import { validateScannerFile } from "../modules/scanner-inbox.service"
import { secrets } from "../utils/secrets"
export const MCP_FILE_LIMIT = 20 * 1024 * 1024
export const MCP_BODY_LIMIT = Math.ceil(MCP_FILE_LIMIT / 3) * 4 + 64 * 1024
export const MCP_FILE_PERMISSION = "documents.read"
// Storage paths and potentially large extracted text do not belong in list responses.
const { path: _path, extractedText: _text, ...metadata } = getTableColumns(files)
export const fileMetadataColumns = metadata
export async function assertInternalDocumentAccess(context: McpContext) {
const [profile] = await context.server.db.select({ portal: authProfiles.customer_for_portal }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, context.tenantId), eq(authProfiles.user_id, context.userId))).limit(1)
if (profile?.portal) throw Object.assign(new Error("Die Dokumentenablage ist nur intern verfügbar."), { statusCode: 403 })
}
export async function loadDocumentFile(context: McpContext, id: string) {
const [file] = await context.server.db.select().from(files)
.where(and(eq(files.id, id), eq(files.tenant, context.tenantId), eq(files.archived, false))).limit(1)
if (!file) throw Object.assign(new Error("Datei nicht gefunden oder archiviert."), { statusCode: 404 })
return file
}
export function decodeDocumentUpload(filename: string, value: unknown) {
if (typeof value !== "string" || !value.length) throw new Error("contentBase64 ist erforderlich.")
if (value.length > Math.ceil(MCP_FILE_LIMIT / 3) * 4) throw new Error("MCP-Uploads sind auf 20 MiB begrenzt.")
// Buffer.from silently accepts invalid characters; require canonical, padded Base64.
if (value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value)) throw new Error("contentBase64 muss gültiges Base64 ohne Data-URL-Präfix sein.")
const body = Buffer.from(value, "base64")
if (body.toString("base64") !== value) throw new Error("contentBase64 muss gültiges Base64 sein.")
if (body.length > MCP_FILE_LIMIT) throw new Error("MCP-Uploads sind auf 20 MiB begrenzt.")
const mimeType = validateScannerFile(filename, body)
return { body, mimeType }
}
export function documentDownloadUrl(id: string) {
const path = `/api/mcp/files/${id}/content`
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
return base ? `${base}${path}` : path
}

View File

@@ -2,12 +2,14 @@ import { accountingTools } from "./tools/accounting"
import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation"
import { wikiTools } from "./tools/wiki"
import { documentTools } from "./tools/documents"
export const mcpTools = [
...accountingTools,
...masterdataTools,
...organisationTools,
...wikiTools,
...documentTools,
]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -0,0 +1,148 @@
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"
import { files, filetags, folders, scannerInboxDocuments } from "../../../db/schema"
import { assignmentKinds, assignmentOptions, assignmentTargets, FileAssignments } from "../../modules/scanner-inbox-assignments"
import { createScannerInboxService, ScannerInboxService } from "../../modules/scanner-inbox.service"
import { loadFileBuffer } from "../../utils/fileBuffer"
import { assertInternalDocumentAccess, decodeDocumentUpload, documentDownloadUrl, fileMetadataColumns, loadDocumentFile, MCP_FILE_LIMIT } from "../documentFiles"
import { InputField, idField, nullable, numericField, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
const searchField: InputField = {
schema: { type: "string", maxLength: 200, description: "Suchtext im Dateinamen bzw. Namen." },
parse(value, path) {
const text = textField().parse(value, path)
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
return text
},
}
const filenameField: InputField = {
schema: { type: "string", minLength: 1, maxLength: 200 },
parse: (value, path) => textField(true).parse(value, path),
}
const paginationFields = {
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
}
const assignmentFields = Object.fromEntries(Object.entries(assignmentTargets).map(([key, target]) =>
[key, nullable(target.uuid ? uuidField : idField)]))
const assignmentsField = objectField(assignmentFields)
const kindField: InputField = {
schema: { type: "string", enum: Object.keys(assignmentTargets) },
parse(value) {
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(assignmentTargets, value)) throw new Error("Unbekannte Zuordnungsart.")
return value
},
}
const formatField: InputField = {
schema: { type: "string", enum: ["url", "base64"], default: "url" },
parse(value) {
if (value !== "url" && value !== "base64") throw new Error("format muss url oder base64 sein.")
return value
},
}
const like = (value: string) => `%${value.replace(/[\\%_]/g, "\\$&")}%`
type Dependencies = {
inbox?: (context: McpContext) => ScannerInboxService
readFile?: typeof loadFileBuffer
}
export function createDocumentTools(dependencies: Dependencies = {}): McpTool[] {
const inbox = dependencies.inbox || ((context: McpContext) => createScannerInboxService(context.server.db))
const readFile = dependencies.readFile || loadFileBuffer
const tool = (name: string, title: string, description: string, write: boolean, fields: Record<string, InputField>,
required: string[], handler: McpTool["handler"]): McpTool => ({
name: `documents.${name}`, title, description,
requiredPermissions: [write ? "documents.write" : "documents.read"],
inputSchema: { ...objectField(fields).schema, required },
async handler(context, args) {
const input = objectField(fields).parse(args, "arguments")
for (const key of required) if (input[key] === undefined) throw new Error(`${key} ist erforderlich.`)
await assertInternalDocumentAccess(context)
return handler(context, input)
},
})
return [
tool("inbox.list", "Dokumentenablage auflisten", "Listet offene Dokumente der Dokumentenablage mit Status der KI-Ablagevorschläge. id ist die Datei-UUID.", false,
{ search: searchField, ...paginationFields }, [], async (context, args) => {
const rows = await context.server.db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt,
suggestionStatus: scannerInboxDocuments.suggestionStatus })
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
.where(and(eq(scannerInboxDocuments.tenantId, context.tenantId), eq(files.tenant, context.tenantId),
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
args.search ? ilike(scannerInboxDocuments.remoteFilename, like(args.search as string)) : undefined))
.orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
}),
tool("inbox.count", "Offene Dokumente zählen", "Zählt nicht archivierte, noch nicht abgelegte Dokumente des Mandanten.", false, {}, [],
context => inbox(context).openCount(context.tenantId)),
...([ ["folders", folders], ["types", filetags] ] as const).map(([name, table]) =>
tool(`${name}.list`, name === "folders" ? "Ablageordner auflisten" : "Dokumenttypen auflisten",
"Listet verfügbare Ablageziele. Ordner enthalten parent, standardFiletype und standardFiletypeIsOptional.", false,
{ search: searchField, ...paginationFields }, [], async (context, args) => {
const rows = await context.server.db.select().from(table)
.where(and(eq(table.tenant, context.tenantId), eq(table.archived, false), args.search ? ilike(table.name, like(args.search as string)) : undefined))
.orderBy(asc(table.name), asc(table.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
})),
tool("assignment_kinds.list", "Zuordnungsarten auflisten", "Zeigt die unterstützten Zuordnungsarten, beispielsweise Projekt, Kunde, Eingangsrechnung oder Ausgangsbeleg.", false, {}, [],
async () => ({ kinds: assignmentKinds })),
tool("assignments.search", "Zuordnungsziele suchen", "Sucht verfügbare Datensätze einer Zuordnungsart im aktiven Mandanten.", false,
{ kind: kindField, search: searchField, ...paginationFields }, ["kind"], async (context, args) =>
paginatedResult(await assignmentOptions(context.server.db, context.tenantId, args.kind as string, args.search as string || "",
limitFromArgs(args) + 1, offsetFromArgs(args)), args)),
tool("inbox.assignments.get", "Dokumentzuordnungen laden", "Lädt bestehende Zuordnungen eines offenen Dokuments aus der Dokumentenablage.", false,
{ id: uuidField }, ["id"], async (context, args) => ({ assignments: await inbox(context).getAssignments(context.tenantId, args.id as string) })),
tool("inbox.suggestion.get", "Ablagevorschlag laden", "Lädt Status und Vorschlag der KI-Erkennung. Ein Vorschlag legt das Dokument noch nicht ab.", false,
{ id: uuidField }, ["id"], (context, args) => inbox(context).getSuggestion(context.tenantId, args.id as string)),
tool("inbox.suggestion.refresh", "Ablagevorschlag neu anfordern", "Stellt die KI-Erkennung erneut in die Warteschlange. Den Status anschließend mit documents.inbox.suggestion.get abfragen.", true,
{ id: uuidField }, ["id"], (context, args) => inbox(context).queueSuggestion(context.tenantId, args.id as string)),
tool("inbox.file", "Dokument ablegen", "Legt ein offenes Dokument in einem Ordner (folder: null für Hauptebene) mit Dokumenttyp ab. Optionale Zuordnungen ändern nur angegebene Felder; null entfernt eine Zuordnung. Feste Ordnertypen werden geprüft.", true,
{ id: uuidField, folder: nullable(uuidField), type: uuidField, assignments: assignmentsField }, ["id", "folder", "type"],
(context, args) => inbox(context).fileDocument(context.tenantId, context.userId, args.id as string, args.folder as string | null,
args.type as string, args.assignments as FileAssignments)),
tool("inbox.archive", "Dokument archivieren", "Archiviert ein offenes Dokument aus der Dokumentenablage. Bereits abgelegte Dokumente werden abgewiesen.", true,
{ id: uuidField }, ["id"], async (context, args) => {
await inbox(context).archiveDocument(context.tenantId, context.userId, args.id as string)
return { id: args.id, success: true, action: "archived" }
}),
tool("files.list", "Gespeicherte Dateien suchen", "Listet nicht archivierte Dateien einschließlich PDFs zu Eingangsrechnungen und Ausgangsbelegen. Filter werden kombiniert; folder: null sucht auf der Hauptebene.", false,
{ search: searchField, folder: nullable(uuidField), type: uuidField, mimeType: textField(true), ...assignmentFields, ...paginationFields }, [],
async (context, args) => {
const conditions = [eq(files.tenant, context.tenantId), eq(files.archived, false)]
if (args.search) conditions.push(or(ilike(files.name, like(args.search as string)), ilike(files.extractedText, like(args.search as string))))
for (const key of ["folder", "type", "mimeType", ...Object.keys(assignmentFields)]) {
if (args[key] !== undefined) conditions.push(args[key] === null ? isNull(files[key]) : eq(files[key], args[key]))
}
const rows = await context.server.db.select(fileMetadataColumns).from(files).where(and(...conditions))
.orderBy(desc(files.createdAt), files.id).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
}),
tool("files.get", "Dateidetails laden", "Lädt Metadaten, Zuordnungs-IDs und bereits extrahierten Text einer Datei. Binärinhalt über documents.files.download abrufen.", false,
{ id: uuidField }, ["id"], async (context, args) => {
const { path, ...file } = await loadDocumentFile(context, args.id as string)
return { file }
}),
tool("files.download", "PDF oder Datei herunterladen", "Liefert standardmäßig eine Download-URL für die Originaldatei. GET benötigt denselben Authorization: Bearer MCP-Token. Alternativ format: base64 für Dateiinhalt bis 20 MiB direkt im Ergebnis; keine PDF-Konvertierung.", false,
{ id: uuidField, format: formatField }, ["id"], async (context, args) => {
const file = await loadDocumentFile(context, args.id as string)
if (!file.path) throw new Error("Kein Dateiinhalt verfügbar.")
const metadata = { id: file.id, filename: file.name, mimeType: file.mimeType, size: file.size }
if (args.format !== "base64") return { ...metadata, url: documentDownloadUrl(file.id), method: "GET", authentication: "Bearer" }
if (file.size > MCP_FILE_LIMIT) throw new Error("Die Datei ist für Base64 zu groß. Bitte format: url verwenden.")
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,
{ 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)
const result = await inbox(context).upload(context.tenantId, context.userId, args.filename as string, body)
return { ...result, filename: args.filename, mimeType, size: body.length }
}),
]
}
export const documentTools = createDocumentTools()

View File

@@ -61,6 +61,8 @@ const adminPermissions = [
"organisation.tasks.write",
"wiki.read",
"wiki.write",
"documents.read",
"documents.write",
]
const defaultTaxTypes = [

View File

@@ -36,13 +36,13 @@ export function assignmentId(kind: string, id: unknown) {
return Number(id)
}
type Reader = Pick<FastifyInstance["db"], "select">
export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "") {
export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "", limit = 100, offset = 0) {
const target = targetFor(kind)
const name = sql<string>`coalesce(nullif(${target.name}, ''), '#' || ${target.id}::text)`
const rows = await db.select({ id: target.id, name }).from(target.table)
.where(and(eq(target.tenant, tenantId), target.available,
search ? ilike(name, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined))
.orderBy(asc(target.name), asc(target.id)).limit(100)
.orderBy(asc(target.name), asc(target.id)).limit(limit).offset(offset)
return rows.map(row => ({ id: String(row.id), name: row.name }))
}
export async function currentAssignments(db: Reader, tenantId: number, file: Record<string, unknown>) {

View File

@@ -264,32 +264,34 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
// Epson probes writability with an empty PUT. No file, receipt or reservation is stored.
})
}
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
const storeDocument = async (tenantId: number, filename: string, body: Buffer,
authorize: (tx: Pick<FastifyInstance["db"], "select">) => Promise<void>, onlyIfNew = false, userId?: string) => {
const contentType = validateScannerFile(filename, body)
const checksum = digest(body)
let uploadedKey: string | undefined
try {
return await db.transaction(async tx => {
await assertWritable(tx, identity)
await authorize(tx)
// Serialise retries across processes; a lost HTTP response must not duplicate a document.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${tenantId}:${filename}`}, 0))`)
const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
)).limit(1)
if (existing) {
if (onlyIfNew) throw scannerError(412, "Der Dateiname ist bereits vorhanden.")
if (existing.checksum !== checksum) throw scannerError(409, "Der Dateiname ist bereits vergeben. Bitte einen eindeutigen Dateinamen verwenden.")
return { created: false, checksum }
return { id: existing.fileId, created: false, checksum }
}
const id = randomUUID()
const key = `${identity.tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
const key = `${tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
uploadedKey = key
await storage.put(key, body, contentType)
// Bypass saveFile's text extraction: the inbox only stores the original document.
await tx.insert(files).values({ id, tenant: identity.tenantId, name: filename, path: key, mimeType: contentType, size: body.length })
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId: identity.tenantId, remoteFilename: filename, checksum })
return { created: true, checksum }
await tx.insert(files).values({ id, tenant: tenantId, name: filename, path: key, mimeType: contentType, size: body.length,
...(userId ? { createdBy: userId } : {}) })
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId, remoteFilename: filename, checksum })
return { id, created: true, checksum }
})
} catch (error) {
if (uploadedKey) {
@@ -299,8 +301,17 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
throw error
}
}
const accept = (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) =>
storeDocument(identity.tenantId, filename, body, tx => assertWritable(tx, identity), onlyIfNew)
// Internal callers authorize the user before invoking this method; scanner credentials remain separate.
const upload = (tenantId: number, userId: string, filename: string, body: Buffer) =>
storeDocument(tenantId, filename, body, async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants).where(eq(tenants.id, tenantId)).for("share")
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
}, false, userId)
return { connection, credentials, disable, authenticate, find, list, openCount, filingOptions, getSuggestion, queueSuggestion, processNextSuggestion,
getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept }
getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept, upload }
}
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>

View File

@@ -6,6 +6,11 @@ import { assertToolPermission, createMcpContext } from "../mcp/authz"
import { mcpToolMap, mcpTools } from "../mcp/registry"
import { asToolError, asToolResult } from "../mcp/result"
import { JsonRpcRequest } from "../mcp/types"
import { assertInternalDocumentAccess, loadDocumentFile, MCP_BODY_LIMIT, MCP_FILE_PERMISSION } from "../mcp/documentFiles"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { sanitizeFilename } from "../utils/filename"
const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25",
@@ -195,7 +200,28 @@ export default async function mcpRoutes(server: FastifyInstance) {
}
})
server.post("/mcp", async (req, reply) => {
server.get<{ Params: { id: string } }>("/mcp/files/:id/content", {
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
}, async (req, reply) => {
try {
const context = await createMcpContext(server, req)
assertToolPermission(context, { name: "documents.files.download", requiredPermissions: [MCP_FILE_PERMISSION] })
await assertInternalDocumentAccess(context)
const file = await loadDocumentFile(context, req.params.id)
if (!file.path) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." })
const { Body } = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path }))
if (!Body) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." })
reply.header("Content-Type", file.mimeType || "application/octet-stream")
reply.header("Content-Disposition", `attachment; filename="${sanitizeFilename(file.name)}"`)
reply.header("Cache-Control", "no-store")
reply.header("X-Content-Type-Options", "nosniff")
return reply.send(Body)
} catch (error) {
return reply.code((error as any)?.statusCode || 500).send({ error: error instanceof Error ? error.message : "Download 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]
const responses = []
@@ -226,7 +252,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
name: "fedeo-mcp",
version: "1.0.0",
},
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs-, Organisations- und Dokumentenwerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten. Dokumentenuploads erscheinen in der Dokumentenablage; Download-URLs benötigen denselben Bearer-Token wie MCP.",
}))
continue
}

View File

@@ -2,17 +2,21 @@ import { GetObjectCommand } from "@aws-sdk/client-s3"
import { s3 } from "./s3"
import { secrets } from "./secrets"
export const streamToBuffer = async (stream: any): Promise<Buffer> => {
export const streamToBuffer = async (stream: any, maxBytes = Infinity): Promise<Buffer> => {
const chunks: Buffer[] = []
let size = 0
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
size += buffer.length
if (size > maxBytes) throw new Error("Die Datei ist für Base64 zu groß. Bitte den Download mit format: url verwenden.")
chunks.push(buffer)
}
return Buffer.concat(chunks)
}
export const loadFileBuffer = async (path: string): Promise<Buffer> => {
export const loadFileBuffer = async (path: string, maxBytes = Infinity): Promise<Buffer> => {
const response = await s3.send(new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: path,
@@ -22,5 +26,5 @@ export const loadFileBuffer = async (path: string): Promise<Buffer> => {
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
}
return streamToBuffer(response.Body)
return streamToBuffer(response.Body, maxBytes)
}

View File

@@ -0,0 +1,212 @@
import assert from "node:assert/strict"
import test from "node:test"
import { Readable } from "node:stream"
import Fastify from "fastify"
import { getTableColumns } from "drizzle-orm"
import { drizzle } from "drizzle-orm/pg-proxy"
import { files } from "../db/schema"
import { mcpToolMap } from "../src/mcp/registry"
import { createDocumentTools, documentTools } from "../src/mcp/tools/documents"
import { assertToolPermission } from "../src/mcp/authz"
import { asToolResult } from "../src/mcp/result"
import { decodeDocumentUpload, MCP_BODY_LIMIT, MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
import { streamToBuffer } from "../src/utils/fileBuffer"
import { secrets } from "../src/utils/secrets"
import { initS3, s3 } from "../src/utils/s3"
import mcpRoutes from "../src/routes/mcp"
import authPlugin from "../src/plugins/auth"
import { createHash } from "node:crypto"
const id = "00000000-0000-4000-8000-000000000001"
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
const contextFor = (db: any) => ({ server: { db }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read", "documents.write"] }) as any
const tool = (name: string) => mcpToolMap.get(`documents.${name}`)!
const profileDb = (portal: number | null = null) => drizzle(async () => ({ rows: portal ? [[portal]] : [] }))
test("document tools are registered with read/write permissions and reject insufficient permissions", () => {
assert.equal(documentTools.length, 15)
for (const entry of documentTools) {
assert.equal(mcpToolMap.get(entry.name), entry)
assert.equal(entry.inputSchema.additionalProperties, false)
const context = contextFor(profileDb())
assert.doesNotThrow(() => assertToolPermission(context, entry))
assert.throws(() => assertToolPermission({ ...context, permissions: [] }, entry), { statusCode: 403 })
assert.doesNotThrow(() => assertToolPermission({ ...context, permissions: [], isAdmin: true }, entry))
}
for (const name of ["files.upload", "inbox.file", "inbox.archive", "inbox.suggestion.refresh"]) {
assert.deepEqual(tool(name).requiredPermissions, ["documents.write"])
}
assert.deepEqual(tool("files.download").requiredPermissions, ["documents.read"])
})
test("validates actual upload bytes, strict Base64, filename and size before storage", () => {
assert.deepEqual(decodeDocumentUpload("scan.pdf", pdf.toString("base64")), { body: pdf, mimeType: "application/pdf" })
for (const value of ["", null, 42, "!!!!", "Zg==junk", "Zh==", "data:application/pdf;base64," + pdf.toString("base64")]) {
assert.throws(() => decodeDocumentUpload("scan.pdf", value), /Base64/)
}
for (const filename of ["../scan.pdf", "sub/scan.pdf", ".hidden.pdf", "x".repeat(201) + ".pdf", "scan.exe"]) {
assert.throws(() => decodeDocumentUpload(filename, pdf.toString("base64")))
}
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("not a PDF").toString("base64")), /vollständige PDF/)
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("%PDF-1.7 truncated").toString("base64")), /vollständige PDF/)
assert.throws(() => decodeDocumentUpload("scan.pdf", "A".repeat(Math.ceil(MCP_FILE_LIMIT / 3) * 4 + 4)), /20 MiB/)
const boundary = Buffer.alloc(MCP_FILE_LIMIT, 32)
boundary.write("%PDF-1.7\n")
boundary.write("%%EOF", boundary.length - 5)
assert.equal(decodeDocumentUpload("scan.pdf", boundary.toString("base64")).body.length, MCP_FILE_LIMIT)
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.concat([boundary, Buffer.from("\n")]).toString("base64")), /20 MiB/)
})
test("rejects unknown system fields, invalid filters, missing IDs and portal access", async () => {
const context = contextFor(profileDb())
for (const [name, args] of [
["files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64"), tenant: 9 }],
["files.get", {}], ["files.get", { id: "bad" }], ["files.list", { folder: "bad" }],
["files.list", { project: -1 }], ["files.list", { limit: 101 }], ["inbox.list", { search: "x".repeat(201) }],
["inbox.file", { id, folder: null, type: id, assignments: { tenant: 9 } }],
["inbox.file", { id, folder: null, type: id, assignments: { project: "12" } }],
["assignments.search", { kind: "__proto__" }], ["files.download", { id, format: "pdf" }],
] as const) await assert.rejects(tool(name).handler(context, args))
await assert.rejects(tool("inbox.count").handler(contextFor(profileDb(123)), {}), { statusCode: 403 })
await assert.rejects(tool("files.download").handler(contextFor(profileDb(123)), { id }), { statusCode: 403 })
await assert.rejects(tool("files.upload").handler(contextFor(profileDb(123)), { filename: "scan.pdf", contentBase64: pdf.toString("base64") }), { statusCode: 403 })
})
for (const name of ["inbox.list", "files.list", "folders.list", "types.list", "assignments.search"]) {
test(`${name}: retrieves 205 records with tenant filters and stable pagination`, async () => {
const db = drizzle(async (sql, params) => {
if (sql.includes('from "auth_profiles"')) return { rows: [] }
assert.match(sql, /"tenant(?:_id)?" = \$\d+/)
assert.ok(params.includes(7))
assert.match(sql, /"archived" = \$\d+/)
assert.match(sql, /order by .*"id"/)
if (name === "inbox.list") {
assert.match(sql, /"filed_at" is null/)
assert.match(sql, /"files"\."tenant" =/)
}
if (name === "files.list") {
assert.match(sql, /"folder" is null/)
assert.match(sql, /"createddocument" =/)
assert.ok(params.includes(42))
}
const limit = Number(params[Number(sql.match(/limit \$(\d+)/)![1]) - 1])
const offset = Number(params[Number(sql.match(/offset \$(\d+)/)?.[1]) - 1] || 0)
assert.equal(limit, 101)
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
const idIndex = selected.findIndex(column => /"id"$/.test(column))
return { rows: Array.from({ length: Math.min(limit, 205 - offset) }, (_, i) => {
const values = selected.map(() => null as any)
values[idIndex] = String(i + offset + 1)
return values
}) }
})
const ids: string[] = []
for (const offset of [0, 100, 200]) {
const args = { limit: 100, offset, ...(name === "assignments.search" ? { kind: "project" } : {}),
...(name === "files.list" ? { folder: null, createddocument: 42 } : {}) }
const result: any = await tool(name).handler(contextFor(db), args)
ids.push(...result.rows.map((row: any) => String(row.id)))
assert.equal(result.pagination.hasMore, offset < 200)
assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null)
}
assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => String(i + 1)))
})
}
test("archive confirmation survives MCP's archived-record filter and upload forwards the active user", async () => {
const calls: any[] = []
const tools = createDocumentTools({ inbox: () => ({
archiveDocument: async (...args: any[]) => { calls.push(args); return { id, archived: true } },
upload: async (...args: any[]) => { calls.push(args); return { id, created: true, checksum: "hash" } },
}) as any })
const context = contextFor(profileDb())
const archived = await tools.find(t => t.name.endsWith("inbox.archive"))!.handler(context, { id })
assert.deepEqual(asToolResult(archived).structuredContent, { id, success: true, action: "archived" })
assert.deepEqual(calls[0], [7, id, id])
await tools.find(t => t.name.endsWith("files.upload"))!.handler(context, { filename: "scan.pdf", contentBase64: pdf.toString("base64") })
assert.deepEqual(calls[1], [7, id, "scan.pdf", pdf])
})
test("bounded downloads stop a stream even when persisted size is missing or incorrect", async () => {
let closed = false
async function* chunks() { try { yield Buffer.alloc(4); yield Buffer.alloc(4); yield Buffer.alloc(4) } finally { closed = true } }
await assert.rejects(streamToBuffer(chunks(), 5), /zu groß/)
assert.equal(closed, true)
assert.deepEqual(await streamToBuffer(Readable.from([pdf]), pdf.length), pdf)
})
test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without exposing storage paths", 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 row = { id, tenant: 7, name: "scan.pdf", mimeType: "application/pdf", size: pdf.length, archived: false, path: `7/filesbyid/${id}/scan.pdf` }
let visible = true
let portal = false
let admin = true
const bearer = "fedeo_mcp_test_documents_token"
const headers = { authorization: `Bearer ${bearer}` }
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", admin]] }
}
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] }
if (sql.includes('from "auth_role_permissions"')) return { rows: [] }
if (sql.includes('from "auth_profiles"')) return { rows: portal ? [[123]] : [] }
assert.match(sql, /from "files"/)
assert.match(sql, /"tenant" =/)
assert.match(sql, /"archived" =/)
assert.ok(params.includes(7))
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
const columns = getTableColumns(files)
return { rows: visible ? [selected.map(column => {
const name = column.match(/"([^"]+)"$/)![1]
const key = Object.keys(columns).find(key => columns[key].name === name)!
return row[key] ?? null
})] : [] }
})
const app = Fastify()
t.after(() => app.close())
app.decorate("db", db as any)
await app.register(authPlugin)
await app.register(mcpRoutes, { prefix: "/api" })
await initS3()
t.mock.method(s3, "send", async command => {
assert.equal(command.input.Key, row.path)
return { Body: Readable.from([pdf]) }
})
const call = (name: string, args: any) => app.inject({ method: "POST", url: "/api/mcp", headers,
payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } } })
const download = await call("documents.files.download", { id })
assert.equal(download.statusCode, 200)
assert.equal(download.json().result.structuredContent.authentication, "Bearer")
assert.equal(download.json().result.structuredContent.url, `https://app.fedeo.test/backend/api/mcp/files/${id}/content`)
assert.ok(!download.body.includes("filesbyid"))
const url = `/api/mcp/files/${id}/content`
assert.equal((await app.inject(url)).statusCode, 401)
const binary = await app.inject({ url, headers })
assert.equal(binary.statusCode, 200, binary.body)
assert.deepEqual(binary.rawPayload, pdf)
assert.equal(binary.headers["content-type"], "application/pdf")
assert.match(binary.headers["content-disposition"] as string, /attachment; filename="scan.pdf"/)
assert.equal(binary.headers["cache-control"], "no-store")
const base64 = await call("documents.files.download", { id, format: "base64" })
assert.deepEqual(Buffer.from(base64.json().result.structuredContent.contentBase64, "base64"), pdf)
admin = false
assert.equal((await app.inject({ url, headers })).statusCode, 403)
assert.equal((await call("documents.files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64") })).json().error.code, -32003)
admin = true
portal = true
assert.equal((await app.inject({ url, headers })).statusCode, 403)
portal = false
visible = false
assert.equal((await app.inject({ url, headers })).statusCode, 404)
assert.equal((await call("documents.files.download", { id })).json().result.isError, true)
const largeInvalidUpload = await call("documents.files.upload", { filename: "scan.pdf", contentBase64: "A".repeat(2 * 1024 * 1024) })
assert.equal(largeInvalidUpload.statusCode, 200, "the request must reach file validation, not Fastify's default 1 MiB limit")
assert.equal(largeInvalidUpload.json().result.isError, true)
const oversized = await app.inject({ method: "POST", url: "/api/mcp", headers: { "content-type": "application/json", "content-length": String(MCP_BODY_LIMIT + 1) }, payload: "{}" })
assert.equal(oversized.statusCode, 413)
})

View File

@@ -10,7 +10,8 @@ const tables = Object.values(schema).filter((value: any) => {
try { return typeof getTableName(value) === "string" } catch { return false }
})
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset)) {
// Document tools also check portal profiles and join inbox receipts; covered in mcpDocuments.test.ts.
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset && !tool.name.startsWith("documents."))) {
test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => {
const db = drizzle(async (sql, params) => {
if (sql.includes('from "tenants"')) return { rows: [["skr03"]] }

View File

@@ -18,6 +18,8 @@ import inboxRoutes from "../src/routes/documentInbox"
import corsPlugin from "../src/plugins/cors"
import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, validateScannerFile, type ScannerInboxService } from "../src/modules/scanner-inbox.service"
import type { FilingSuggestionResult } from "../src/modules/scanner-inbox-suggestions"
import { createDocumentTools } from "../src/mcp/tools/documents"
import { assertToolPermission } from "../src/mcp/authz"
const execute = promisify(execFile)
const basic = (username = "scanner", password = "secret") => ({ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` })
@@ -611,4 +613,64 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
assert.equal((await service.list(1, 50, 0, "")).total, 2)
})
await t.test("MCP uploads, downloads and filing use the same tenant-scoped document lifecycle", async () => {
const tools = createDocumentTools({ inbox: () => service, readFile: async key => {
assert.ok(stored.has(key))
return stored.get(key)!
} })
const call = async (name: string, args: any = {}, tenantId = 1) => {
const tool = tools.find(tool => tool.name === `documents.${name}`)!
const context = { server: { db }, userId, tenantId, isAdmin: false, permissions: ["documents.read", "documents.write"] } as any
assertToolPermission(context, tool)
return await tool.handler(context, args) as any
}
const upload = (filename: string, body = pdf, tenant = 1) => call("files.upload", { filename, contentBase64: body.toString("base64") }, tenant)
await service.disable(1)
const created = await upload("mcp.pdf")
assert.equal(created.created, true, "MCP uploads do not require scanner credentials")
assert.equal((await upload("mcp.pdf")).id, created.id)
assert.equal((await upload("mcp.pdf")).created, false)
await assert.rejects(upload("mcp.pdf", samplePdf), { statusCode: 409 })
assert.notEqual((await upload("mcp.pdf", pdf, 2)).id, created.id)
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [created.id])).rows[0]
assert.equal(row.created_by, userId)
assert.deepEqual(stored.get(row.path), pdf)
assert.equal(row.extracted_text, null)
const list = await call("inbox.list", { search: "mcp.pdf", limit: 1 })
assert.equal(list.rows[0].id, created.id)
assert.equal(list.rows[0].suggestionStatus, "pending")
assert.equal((await call("inbox.suggestion.get", { id: created.id })).status, "pending")
const downloaded = await call("files.download", { id: created.id, format: "base64" })
assert.deepEqual(Buffer.from(downloaded.contentBase64, "base64"), pdf)
await assert.rejects(call("files.download", { id: created.id }, 2), { statusCode: 404 })
await assert.rejects(call("inbox.archive", { id: created.id }, 2), { statusCode: 404 })
await assert.rejects(call("inbox.file", { id: created.id, folder: foreignFolder, type: targetType }), { statusCode: 400 })
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: alternativeType }), { statusCode: 400 })
await pool.query("INSERT INTO projects (id, tenant, name) VALUES (901, 1, 'MCP project'), (902, 2, 'Foreign MCP project')")
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 902 } }), { statusCode: 400 })
const filed = await call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 901 } })
assert.equal(filed.id, created.id)
assert.equal((await call("inbox.list", { search: "mcp.pdf" })).rows.length, 0)
assert.equal((await call("files.list", { project: 901 })).rows[0].id, created.id)
assert.equal((await call("files.get", { id: created.id })).file.path, undefined)
await assert.rejects(call("inbox.archive", { id: created.id }), { statusCode: 409 })
const archived = await upload("mcp-archive.pdf")
await call("inbox.archive", { id: archived.id })
await assert.rejects(call("files.download", { id: archived.id }), { statusCode: 404 })
assert.equal((await call("files.list", { search: "mcp-archive.pdf" })).rows.length, 0)
const before = stored.size
storageFailure = true
await assert.rejects(upload("mcp-storage-error.pdf"), /Simulated object store failure/)
storageFailure = false
await assert.rejects(upload("reject.pdf"))
assert.equal(stored.size, before)
assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('mcp-storage-error.pdf', 'reject.pdf')")).rows[0].count, "0")
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
await assert.rejects(upload("mcp-maintenance.pdf"), { statusCode: 423 })
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
await assert.rejects(upload("mcp-portal.pdf"), { statusCode: 403 })
await assert.rejects(call("files.download", { id: created.id }), { statusCode: 403 })
await pool.query("DELETE FROM auth_profiles")
})
})