feat(mcp): add document inbox tools and PDF transfers
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user