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

@@ -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")
})
})