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, 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()