feat(mcp): model-free document upload via signed URL (#1378)
* feat(mcp): model-free document upload via signed URL (#748) Adds gnubok_create_document_upload + gnubok_complete_document_upload so document bytes reach storage through a short-lived signed PUT URL and never pass through the model context. Fixes silent base64 corruption on real-size PDFs and the context blowup on batch uploads. - pending/ staage keys with TTL cleanup; completion validates magic bytes + SHA-256, moves bytes to the WORM key and adopts the reserved UUID as document id, making retries and concurrent completions idempotent - legacy gnubok_upload_document kept for clients without file access, description now points to the signed-URL pair; shared mime resolution and inbox-item creation extracted - both new tools mapped in TOOL_SCOPE_MAP (transactions:write) and MCP_TOOL_CAPABILITY_MAP (ai) so the paywall and scope gates hold - payload guard ceiling 58.5K to 59K after trimming the create tool's outputSchema to upload_id/upload_url/expires_at Fixes #748 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): satisfy capability-map lock and phantom-column scanner The exact-entries lock in capability-maps.test.ts now includes the signed-URL pair as dispatch-only AI tools, and the inbox insert uses a literal payload (explicit UUID instead of a conditional spread) so the no-phantom-columns scanner can resolve every column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
00d4c8a49e
commit
5d7952a01e
@@ -59,7 +59,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
// Holds the SCOPES for every paid tool under test (send_invoice →
|
||||
// invoices:write, agi_submit → skatteverket:write, upload_document →
|
||||
// invoices:write, agi_submit → skatteverket:write, document uploads →
|
||||
// transactions:write) so the scope gate passes and the CAPABILITY gate is
|
||||
// what we exercise.
|
||||
scopes: ['invoices:write', 'skatteverket:write', 'reports:read', 'transactions:write'],
|
||||
@@ -152,16 +152,23 @@ describe('MCP capability gate', () => {
|
||||
expect(mockHasCapability).toHaveBeenCalledWith(expect.anything(), '11111111-1111-4111-8111-111111111111', 'skatteverket')
|
||||
})
|
||||
|
||||
it('blocks gnubok_upload_document when ai is not entitled: the paid Bedrock OCR path', async () => {
|
||||
// gnubok_upload_document runs extractInvoiceFields (Bedrock OCR) inline in
|
||||
// its handler, NOT through the entitlement-gated uploadAndExtract. The
|
||||
// central dispatch map is therefore the ONLY paywall on this transport.
|
||||
// This test locks it so a free-tier connector key can never reach OCR.
|
||||
it.each([
|
||||
['gnubok_create_document_upload', { file_name: 'faktura.pdf' }],
|
||||
[
|
||||
'gnubok_complete_document_upload',
|
||||
{
|
||||
upload_id: '33333333-3333-4333-8333-333333333333',
|
||||
file_name: 'faktura.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
],
|
||||
['gnubok_upload_document', { file_name: 'faktura.pdf', file_content_base64: 'JVBERi0=' }],
|
||||
])('blocks %s when ai is not entitled', async (toolName, args) => {
|
||||
// The central dispatch map is the paywall for every MCP document-upload
|
||||
// step, so a free-tier connector key can never reach the paid OCR flow.
|
||||
mockHasCapability.mockResolvedValue(false)
|
||||
|
||||
const response = await handleMcpRequest(
|
||||
mcpToolCall('gnubok_upload_document', { file_name: 'faktura.pdf', file_content_base64: 'JVBERi0=' }),
|
||||
)
|
||||
const response = await handleMcpRequest(mcpToolCall(toolName, args))
|
||||
const { isError, payload } = await parsedToolResult(response)
|
||||
|
||||
expect(isError).toBe(true)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeDocumentAttachment } from '@/tests/helpers'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPendingDocumentUpload: vi.fn(),
|
||||
completePendingDocumentUpload: vi.fn(),
|
||||
extractInvoiceFields: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/core/documents/document-service')>()
|
||||
return {
|
||||
...actual,
|
||||
createPendingDocumentUpload: mocks.createPendingDocumentUpload,
|
||||
completePendingDocumentUpload: mocks.completePendingDocumentUpload,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
typeof import('@/extensions/general/invoice-inbox/lib/extract-invoice-fields')
|
||||
>()
|
||||
return { ...actual, extractInvoiceFields: mocks.extractInvoiceFields }
|
||||
})
|
||||
|
||||
import { tools } from '../server'
|
||||
|
||||
const companyId = '11111111-1111-4111-8111-111111111111'
|
||||
const userId = '22222222-2222-4222-8222-222222222222'
|
||||
const uploadId = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
function findTool(name: string) {
|
||||
const tool = tools.find((candidate) => candidate.name === name)
|
||||
if (!tool) throw new Error(`Tool not found: ${name}`)
|
||||
return tool
|
||||
}
|
||||
|
||||
function makeQueryBuilder(result: { data: unknown; error: unknown }) {
|
||||
const builder: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'limit', 'insert']) {
|
||||
builder[method] = vi.fn().mockReturnValue(builder)
|
||||
}
|
||||
builder.maybeSingle = vi.fn().mockResolvedValue(result)
|
||||
builder.single = vi.fn().mockResolvedValue(result)
|
||||
return builder
|
||||
}
|
||||
|
||||
describe('MCP model-free document upload tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.createPendingDocumentUpload.mockResolvedValue({
|
||||
uploadId,
|
||||
signedUrl: 'https://storage.example/upload?token=signed',
|
||||
expiresAt: '2026-08-03T12:00:00.000Z',
|
||||
})
|
||||
mocks.completePendingDocumentUpload.mockResolvedValue({
|
||||
document: makeDocumentAttachment({
|
||||
id: uploadId,
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
file_name: 'invoice.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
}),
|
||||
buffer: new TextEncoder().encode('%PDF-1.4\n%%EOF\n').buffer,
|
||||
})
|
||||
mocks.extractInvoiceFields.mockResolvedValue({
|
||||
data: {
|
||||
supplier: { name: 'Synthetic Supplier AB', orgNumber: null },
|
||||
invoice: { number: 'INV-1' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an unauthenticated PUT URL without accepting file bytes', async () => {
|
||||
const tool = findTool('gnubok_create_document_upload')
|
||||
const result = await tool.execute(
|
||||
{ file_name: 'invoice.pdf' },
|
||||
companyId,
|
||||
userId,
|
||||
{} as never,
|
||||
)
|
||||
|
||||
expect(mocks.createPendingDocumentUpload).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
companyId,
|
||||
userId,
|
||||
expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
'invoice.pdf',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
upload_id: uploadId,
|
||||
upload_url: 'https://storage.example/upload?token=signed',
|
||||
expires_at: '2026-08-03T12:00:00.000Z',
|
||||
})
|
||||
const schema = tool.inputSchema as { properties: Record<string, unknown> }
|
||||
expect(schema.properties).not.toHaveProperty('file_content_base64')
|
||||
})
|
||||
|
||||
it('completes the reserved upload and uses the upload UUID for both records', async () => {
|
||||
const inboxInsert = makeQueryBuilder({ data: { id: uploadId, status: 'received' }, error: null })
|
||||
const invoiceLookups = [
|
||||
makeQueryBuilder({ data: null, error: null }),
|
||||
makeQueryBuilder({ data: null, error: null }),
|
||||
inboxInsert,
|
||||
]
|
||||
const supplier = makeQueryBuilder({ data: null, error: null })
|
||||
const from = vi.fn((table: string) => {
|
||||
if (table === 'invoice_inbox_items') return invoiceLookups.shift()
|
||||
if (table === 'suppliers') return supplier
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
})
|
||||
|
||||
const result = await findTool('gnubok_complete_document_upload').execute(
|
||||
{ upload_id: uploadId, file_name: 'invoice.pdf', mime_type: 'application/pdf' },
|
||||
companyId,
|
||||
userId,
|
||||
{ from } as never,
|
||||
)
|
||||
|
||||
expect(mocks.completePendingDocumentUpload).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
companyId,
|
||||
userId,
|
||||
uploadId,
|
||||
'invoice.pdf',
|
||||
'application/pdf',
|
||||
)
|
||||
expect(inboxInsert.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: uploadId, document_id: uploadId }),
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
document_id: uploadId,
|
||||
inbox_item_id: uploadId,
|
||||
status: 'received',
|
||||
})
|
||||
expect(mocks.extractInvoiceFields).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns an already completed inbox item without downloading or extracting again', async () => {
|
||||
const existing = makeQueryBuilder({
|
||||
data: {
|
||||
id: uploadId,
|
||||
document_id: uploadId,
|
||||
status: 'received',
|
||||
extracted_data: { invoice: { number: 'INV-1' } },
|
||||
matched_supplier_id: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const result = await findTool('gnubok_complete_document_upload').execute(
|
||||
{ upload_id: uploadId, file_name: 'invoice.pdf', mime_type: 'application/pdf' },
|
||||
companyId,
|
||||
userId,
|
||||
{ from: vi.fn().mockReturnValue(existing) } as never,
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ document_id: uploadId, inbox_item_id: uploadId })
|
||||
expect(mocks.completePendingDocumentUpload).not.toHaveBeenCalled()
|
||||
expect(mocks.extractInvoiceFields).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps scope and AI capability gates aligned across all upload paths', () => {
|
||||
for (const name of [
|
||||
'gnubok_create_document_upload',
|
||||
'gnubok_complete_document_upload',
|
||||
'gnubok_upload_document',
|
||||
]) {
|
||||
expect(TOOL_SCOPE_MAP[name]).toBe('transactions:write')
|
||||
expect(MCP_TOOL_CAPABILITY_MAP[name]).toBe('ai')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -152,9 +152,19 @@ describe('tools/list payload size guard', () => {
|
||||
// hint prose was trimmed to the floor first (~30 tokens recovered);
|
||||
// headroom before the change was ~14 tokens, so even the trimmed wire
|
||||
// contract crossed.
|
||||
// * 58.5K → 59K with the model-free upload pair (#748):
|
||||
// gnubok_create_document_upload + gnubok_complete_document_upload move
|
||||
// document bytes out of the model context via a signed PUT URL, fixing
|
||||
// silent base64 corruption on real-size PDFs. Neither tool can be
|
||||
// search-only: the pair is the primary upload path for harnesses with
|
||||
// file access, and the legacy inline tool stays listed for clients
|
||||
// without it. Trimmed first: the create tool's outputSchema was cut to
|
||||
// upload_id/upload_url/expires_at (method, size cap and echo fields
|
||||
// moved to description prose) and mime_type made optional on complete;
|
||||
// the ~360-token remainder is the two tools' wire contract.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(58_500)
|
||||
expect(approxTokens).toBeLessThan(59_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,7 +170,12 @@ import {
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
|
||||
import {
|
||||
completePendingDocumentUpload,
|
||||
createPendingDocumentUpload,
|
||||
uploadDocument,
|
||||
MAX_DOCUMENT_SIZE,
|
||||
} from '@/lib/core/documents/document-service'
|
||||
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
// Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned
|
||||
// pattern as invoice-inbox above: the CI guard only checks lib/, app/api/,
|
||||
@@ -290,6 +295,146 @@ const VALID_VAT_TREATMENTS = [
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
|
||||
] as const
|
||||
|
||||
const MCP_DOCUMENT_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/webp',
|
||||
] as const
|
||||
|
||||
const MCP_DOCUMENT_MIME_TYPE_SET = new Set<string>(MCP_DOCUMENT_MIME_TYPES)
|
||||
|
||||
function resolveMcpDocumentMimeType(fileName: string, requestedMimeType: unknown): string {
|
||||
let mimeType = typeof requestedMimeType === 'string' ? requestedMimeType : undefined
|
||||
if (!mimeType) {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
heic: 'image/heic',
|
||||
webp: 'image/webp',
|
||||
}
|
||||
mimeType = extension ? mimeMap[extension] : undefined
|
||||
if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${extension}`)
|
||||
}
|
||||
if (!MCP_DOCUMENT_MIME_TYPE_SET.has(mimeType)) {
|
||||
throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`)
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
|
||||
interface DocumentInboxResult {
|
||||
document_id: string
|
||||
inbox_item_id: string
|
||||
status: string
|
||||
extracted_data: Record<string, unknown>
|
||||
matched_supplier_id: string | null
|
||||
}
|
||||
|
||||
async function findCompletedDocumentInboxItem(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
inboxItemId: string
|
||||
): Promise<DocumentInboxResult | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, status, extracted_data, matched_supplier_id')
|
||||
.eq('id', inboxItemId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Failed to check completed document upload: ${error.message}`)
|
||||
if (!data) return null
|
||||
if (data.document_id !== inboxItemId) {
|
||||
throw new Error('Upload ID collides with an unrelated inbox item')
|
||||
}
|
||||
return {
|
||||
document_id: data.document_id,
|
||||
inbox_item_id: data.id,
|
||||
status: data.status,
|
||||
extracted_data: (data.extracted_data ?? {}) as Record<string, unknown>,
|
||||
matched_supplier_id: data.matched_supplier_id,
|
||||
}
|
||||
}
|
||||
|
||||
async function createDocumentInboxItem(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
documentId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
buffer: Buffer,
|
||||
reservedInboxItemId?: string
|
||||
): Promise<DocumentInboxResult> {
|
||||
if (reservedInboxItemId) {
|
||||
const existing = await findCompletedDocumentInboxItem(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
reservedInboxItemId,
|
||||
)
|
||||
if (existing) return existing
|
||||
}
|
||||
|
||||
const { data: extracted } = await extractInvoiceFields({ buffer, mimeType, fileName })
|
||||
|
||||
let matchedSupplierId: string | null = null
|
||||
if (extracted.supplier.orgNumber) {
|
||||
const { data: supplier } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('org_number', extracted.supplier.orgNumber)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (supplier) matchedSupplierId = supplier.id
|
||||
}
|
||||
|
||||
const { data: inbox, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
// Literal payload keeps the no-phantom-columns scanner able to resolve
|
||||
// every column; the legacy path gets an explicit UUID instead of the DB
|
||||
// default.
|
||||
id: reservedInboxItemId ?? crypto.randomUUID(),
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'received',
|
||||
source: 'upload',
|
||||
document_id: documentId,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.select('id, status')
|
||||
.single()
|
||||
|
||||
if (inboxError) {
|
||||
if (reservedInboxItemId) {
|
||||
const concurrent = await findCompletedDocumentInboxItem(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
reservedInboxItemId,
|
||||
)
|
||||
if (concurrent) return concurrent
|
||||
}
|
||||
throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
document_id: documentId,
|
||||
inbox_item_id: inbox.id,
|
||||
status: inbox.status,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending operations staging ───────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -9072,10 +9217,148 @@ export const tools: McpTool[] = [
|
||||
|
||||
// ── Document Inbox Tools ────────────────────────────────────
|
||||
|
||||
{
|
||||
name: 'gnubok_create_document_upload',
|
||||
title: 'Create Document Upload',
|
||||
description: 'Create a short-lived URL for a model-free document upload. PUT the raw file bytes (max 10 MB) to upload_url, then call gnubok_complete_document_upload with the same upload_id and file_name.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
file_name: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
description: 'File name with extension, for example "faktura.pdf"',
|
||||
},
|
||||
mime_type: {
|
||||
type: 'string',
|
||||
enum: [...MCP_DOCUMENT_MIME_TYPES],
|
||||
description: 'MIME type. Optional when it can be inferred from the file extension.',
|
||||
},
|
||||
},
|
||||
required: ['file_name'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
upload_id: { type: 'string' },
|
||||
upload_url: { type: 'string' },
|
||||
expires_at: { type: 'string' },
|
||||
},
|
||||
required: ['upload_id', 'upload_url', 'expires_at'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const fileName = args.file_name as string
|
||||
// Validation only: reject unsupported types before handing out a signed
|
||||
// URL. The resolved value is re-derived identically at complete time.
|
||||
resolveMcpDocumentMimeType(fileName, args.mime_type)
|
||||
const uploadId = crypto.randomUUID()
|
||||
const reservation = await createPendingDocumentUpload(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
uploadId,
|
||||
fileName,
|
||||
)
|
||||
return {
|
||||
upload_id: reservation.uploadId,
|
||||
upload_url: reservation.signedUrl,
|
||||
expires_at: reservation.expiresAt,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_complete_document_upload',
|
||||
title: 'Complete Document Upload',
|
||||
description: 'Validate and archive bytes sent to the URL from gnubok_create_document_upload, run AI extraction and create the inbox item. Idempotent: safe to retry with the same upload_id.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
upload_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Reserved UUID returned by gnubok_create_document_upload',
|
||||
},
|
||||
file_name: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
description: 'The same file name used to create the upload URL',
|
||||
},
|
||||
mime_type: {
|
||||
type: 'string',
|
||||
enum: [...MCP_DOCUMENT_MIME_TYPES],
|
||||
description: 'MIME type. Optional when it can be inferred from the file extension.',
|
||||
},
|
||||
},
|
||||
required: ['upload_id', 'file_name'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
document_id: { type: 'string' },
|
||||
inbox_item_id: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
extracted_data: { type: 'object' },
|
||||
matched_supplier_id: { type: 'string' },
|
||||
},
|
||||
required: ['document_id', 'inbox_item_id', 'status'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const uploadId = args.upload_id as string
|
||||
const fileName = args.file_name as string
|
||||
const mimeType = resolveMcpDocumentMimeType(fileName, args.mime_type)
|
||||
|
||||
const existingInbox = await findCompletedDocumentInboxItem(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
uploadId,
|
||||
)
|
||||
if (existingInbox) return existingInbox
|
||||
|
||||
const completed = await completePendingDocumentUpload(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
uploadId,
|
||||
fileName,
|
||||
mimeType,
|
||||
)
|
||||
return createDocumentInboxItem(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
completed.document.id,
|
||||
fileName,
|
||||
mimeType,
|
||||
Buffer.from(completed.buffer),
|
||||
uploadId,
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_upload_document',
|
||||
title: 'Upload Document to Inbox',
|
||||
description: 'Upload a PDF/JPEG/PNG/HEIC/WebP (max 20 MB) to the inbox. Runs AI field extraction (Bedrock OCR): requires the AI capability.',
|
||||
description: 'Legacy inline-base64 upload for small files (max 10 MB). Prefer gnubok_create_document_upload so raw bytes bypass the model. Runs AI field extraction: requires the AI capability.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -9107,28 +9390,7 @@ export const tools: McpTool[] = [
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const fileName = args.file_name as string
|
||||
const base64Content = args.file_content_base64 as string
|
||||
let mimeType = args.mime_type as string | undefined
|
||||
|
||||
if (!mimeType) {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
heic: 'image/heic',
|
||||
webp: 'image/webp',
|
||||
}
|
||||
mimeType = ext ? mimeMap[ext] : undefined
|
||||
if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${ext}`)
|
||||
}
|
||||
|
||||
const allowedMimeTypes = new Set([
|
||||
'application/pdf', 'image/jpeg', 'image/png', 'image/heic', 'image/webp',
|
||||
])
|
||||
if (!allowedMimeTypes.has(mimeType)) {
|
||||
throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`)
|
||||
}
|
||||
const mimeType = resolveMcpDocumentMimeType(fileName, args.mime_type)
|
||||
|
||||
const buffer = Buffer.from(base64Content, 'base64')
|
||||
if (buffer.byteLength > MAX_DOCUMENT_SIZE) {
|
||||
@@ -9140,48 +9402,15 @@ export const tools: McpTool[] = [
|
||||
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
||||
type: mimeType,
|
||||
}, { upload_source: 'api' })
|
||||
|
||||
const { data: extracted } = await extractInvoiceFields({
|
||||
buffer,
|
||||
mimeType,
|
||||
return createDocumentInboxItem(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
doc.id,
|
||||
fileName,
|
||||
})
|
||||
|
||||
let matchedSupplierId: string | null = null
|
||||
if (extracted.supplier.orgNumber) {
|
||||
const { data: s } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('org_number', extracted.supplier.orgNumber)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (s) matchedSupplierId = s.id
|
||||
}
|
||||
|
||||
const { data: inbox, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'received',
|
||||
source: 'upload',
|
||||
document_id: doc.id,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.select('id, status')
|
||||
.single()
|
||||
|
||||
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||||
|
||||
return {
|
||||
document_id: doc.id,
|
||||
inbox_item_id: inbox.id,
|
||||
status: inbox.status,
|
||||
extracted_data: extracted,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
}
|
||||
mimeType,
|
||||
buffer,
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user