dev #242
@@ -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) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
backend/src/mcp/documentFiles.ts
Normal file
44
backend/src/mcp/documentFiles.ts
Normal 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
|
||||
}
|
||||
@@ -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]))
|
||||
|
||||
148
backend/src/mcp/tools/documents.ts
Normal file
148
backend/src/mcp/tools/documents.ts
Normal 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()
|
||||
@@ -61,6 +61,8 @@ const adminPermissions = [
|
||||
"organisation.tasks.write",
|
||||
"wiki.read",
|
||||
"wiki.write",
|
||||
"documents.read",
|
||||
"documents.write",
|
||||
]
|
||||
|
||||
const defaultTaxTypes = [
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
212
backend/tests/mcpDocuments.test.ts
Normal file
212
backend/tests/mcpDocuments.test.ts
Normal 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)
|
||||
})
|
||||
@@ -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"]] }
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
# FEDEO MCP
|
||||
|
||||
## Dokumentenablage und Dateiübertragung
|
||||
|
||||
Die Werkzeuge unter `documents.*` stellen die Dokumentenablage und den Zugriff auf gespeicherte Dateien bereit. Alle arbeiten im aktiven Mandanten und schließen archivierte Dateien aus. Portalbenutzer haben keinen Zugriff, auch wenn ihrer Rolle Dokumentenrechte zugewiesen wurden.
|
||||
|
||||
| Werkzeug | Funktion |
|
||||
| --- | --- |
|
||||
| `documents.inbox.list` / `.count` | Offene Dokumente suchen bzw. zählen |
|
||||
| `documents.folders.list` / `documents.types.list` | Verfügbare Ordner und Dokumenttypen laden |
|
||||
| `documents.assignment_kinds.list` | Unterstützte Zuordnungsarten anzeigen |
|
||||
| `documents.assignments.search` | Zuordnungsziele nach `kind` und optional `search` suchen |
|
||||
| `documents.inbox.assignments.get` | Bestehende Zuordnungen eines offenen Dokuments laden |
|
||||
| `documents.inbox.suggestion.get` / `.refresh` | KI-Ablagevorschlag abrufen bzw. erneut anfordern |
|
||||
| `documents.inbox.file` | Dokument mit Ordner, Typ und optionalen Zuordnungen ablegen |
|
||||
| `documents.inbox.archive` | Offenes Dokument archivieren |
|
||||
| `documents.files.list` / `.get` | Gespeicherte Dateien suchen bzw. Details und vorhandenen extrahierten Text laden |
|
||||
| `documents.files.download` | Originaldatei als Download-URL oder Base64 abrufen |
|
||||
| `documents.files.upload` | PDF oder Scan in die Dokumentenablage hochladen |
|
||||
|
||||
Listen und Suchwerkzeuge verwenden `limit` und `offset` wie unten beschrieben. `search` ist auf 200 Zeichen begrenzt. `documents.files.list` unterstützt zusätzlich `folder`, `type`, `mimeType` sowie Zuordnungsfilter wie `project`, `customer`, `incominginvoice` und `createddocument`. `folder: null` filtert Dateien auf der Hauptebene; ohne `folder` werden alle Ordner durchsucht. Die `id` eines Dokuments ist immer die Datei-UUID, keine Rechnungs- oder Belegnummer.
|
||||
|
||||
Lesende Werkzeuge und Downloads benötigen `documents.read`; Upload, Ablegen, Archivieren und erneute KI-Erkennung benötigen `documents.write`. Beide Rechte sind in den Bootstrap-Administratorrechten enthalten. Anderen Rollen müssen sie ausdrücklich zugewiesen werden. Scanner-Zugangsdaten werden für MCP-Uploads nicht benötigt.
|
||||
|
||||
### PDF-Download
|
||||
|
||||
Zunächst die Datei-UUID beispielsweise über `documents.files.list` mit `createddocument` oder `incominginvoice` ermitteln. Der Aufruf erfolgt über `tools/call` mit diesen Parametern:
|
||||
|
||||
```json
|
||||
{"name":"documents.files.download","arguments":{"id":"00000000-0000-4000-8000-000000000001"}}
|
||||
```
|
||||
|
||||
Standardmäßig enthält das Ergebnis `url`, `method: "GET"`, `authentication: "Bearer"`, Dateiname, MIME-Type und Größe. Die URL verwendet `API_BASE_URL` und den Pfad `/api/mcp/files/<id>/content`; ohne konfigurierte Basis ist sie relativ. Bei `https://app.fedeo.de/backend` lautet der Pfad somit `https://app.fedeo.de/backend/api/mcp/files/<id>/content`.
|
||||
|
||||
Die URL ist kein öffentlicher Freigabelink: Der GET-Aufruf benötigt denselben `Authorization: Bearer <MCP-Token>`-Header wie der MCP-Aufruf. Berechtigung, Mandant, Portalzugang und Archivstatus werden beim Download erneut geprüft. Der Inhalt wird als Attachment gestreamt; interne Speicherpfade und Tokens erscheinen nicht im Werkzeugergebnis.
|
||||
|
||||
Mit `"format":"base64"` liefert das Werkzeug stattdessen `contentBase64` direkt im Ergebnis, begrenzt auf 20 MiB. Für größere Dateien den URL-Abruf verwenden. Der Download gibt die gespeicherte Originaldatei unverändert zurück; er erzeugt oder konvertiert keine PDFs.
|
||||
|
||||
### Upload und Ablage
|
||||
|
||||
```json
|
||||
{"name":"documents.files.upload","arguments":{"filename":"rechnung.pdf","contentBase64":"<Base64-Dateiinhalt>"}}
|
||||
```
|
||||
|
||||
Uploads unterstützen vollständige PDF-, JPEG-, PNG- und TIFF-Dateien bis **20 MiB**. `contentBase64` muss normales, gepaddetes Base64 ohne Data-URL-Präfix enthalten. Dateiname, Dateisignatur und bei PDFs die Abschlussmarkierung werden geprüft. Die Größenbegrenzung des MCP-JSON-Endpunkts berücksichtigt den Base64-Aufschlag; sie gilt für den gesamten Request einschließlich etwaiger Batch-Aufrufe.
|
||||
|
||||
Der Upload liefert `id`, `created`, `checksum`, `filename`, `mimeType` und `size`. Er speichert das Original in der Dokumentenablage und stellt die bestehende KI-Vorbereitung in die Warteschlange. Gleicher Dateiname und gleicher Inhalt liefern bei Wiederholung dieselbe ID mit `created: false`, auch nach Ablage oder Archivierung. Ein anderer Inhalt unter demselben Namen wird abgewiesen. Fehlgeschlagene Uploads rollen die Datenbankänderungen zurück und bereinigen das Speicherobjekt.
|
||||
|
||||
Anschließend Ordner und Dokumenttyp auswählen und die zurückgegebene UUID ablegen:
|
||||
|
||||
```json
|
||||
{"name":"documents.inbox.file","arguments":{"id":"00000000-0000-4000-8000-000000000001","folder":null,"type":"00000000-0000-4000-8000-000000000002","assignments":{"project":42}}}
|
||||
```
|
||||
|
||||
`folder: null` steht für die Hauptebene. `type` ist erforderlich. Zuordnungen verwenden numerische IDs, außer `check` und `authProfile` (UUIDs). Nur angegebene Zuordnungen werden geändert; `null` entfernt eine Zuordnung. Ordner, Dokumenttyp und Zuordnungsziele müssen im aktiven Mandanten verfügbar sein. Ein fester Dokumenttyp des Ordners wird berücksichtigt. Nach erfolgreicher Ablage verschwindet das Dokument aus `inbox.list`, bleibt aber über `files.list`, `.get` und `.download` erreichbar.
|
||||
|
||||
## Listen und Pagination
|
||||
|
||||
Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination.
|
||||
|
||||
Reference in New Issue
Block a user