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
@@ -232,6 +232,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Staged bulk retag of posted-line dimensions (dimensions PR6).
|
||||
gnubok_tag_journal_lines: 'bookkeeping:write',
|
||||
// Document inbox
|
||||
gnubok_create_document_upload: 'transactions:write',
|
||||
gnubok_complete_document_upload: 'transactions:write',
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
gnubok_get_inbox_item: 'transactions:read',
|
||||
|
||||
@@ -29,10 +29,16 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
|
||||
createBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' }, error: null }),
|
||||
from: vi.fn().mockReturnValue({
|
||||
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||
createSignedUploadUrl: vi.fn().mockResolvedValue({
|
||||
data: { signedUrl: 'https://example.com/signed-upload' },
|
||||
error: null,
|
||||
}),
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob(['test content']),
|
||||
error: null,
|
||||
}),
|
||||
list: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
move: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
getPublicUrl: vi.fn().mockReturnValue({
|
||||
data: { publicUrl: 'https://example.com/file.pdf' },
|
||||
@@ -59,6 +65,14 @@ import {
|
||||
verifyIntegrity,
|
||||
validateDocumentMagicBytes,
|
||||
buildDocumentStoragePath,
|
||||
buildPendingDocumentStoragePath,
|
||||
buildReservedDocumentStoragePath,
|
||||
cleanupExpiredPendingDocumentUploads,
|
||||
createPendingDocumentUpload,
|
||||
completePendingDocumentUpload,
|
||||
computeSHA256,
|
||||
PENDING_DOCUMENT_UPLOAD_RETENTION_MS,
|
||||
SIGNED_DOCUMENT_UPLOAD_TTL_MS,
|
||||
isCompanyScopedDocumentPath,
|
||||
companyScopedDocumentPath,
|
||||
legacyDocumentPath,
|
||||
@@ -312,6 +326,163 @@ describe('uploadDocument', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('model-free signed document uploads', () => {
|
||||
const company = '11111111-1111-4111-8111-111111111111'
|
||||
const user = '22222222-2222-4222-8222-222222222222'
|
||||
const uploadId = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
it('creates a company-scoped signed upload reservation with a two-hour expiry', async () => {
|
||||
const now = Date.parse('2026-08-03T10:00:00.000Z')
|
||||
const createSignedUploadUrl = vi.fn().mockResolvedValue({
|
||||
data: { signedUrl: 'https://storage.example/upload?token=signed' },
|
||||
error: null,
|
||||
})
|
||||
const supabase = makeClient({ createSignedUploadUrl })
|
||||
|
||||
const reservation = await createPendingDocumentUpload(
|
||||
supabase as never,
|
||||
company,
|
||||
user,
|
||||
uploadId,
|
||||
'Leverantör faktura.pdf',
|
||||
now,
|
||||
)
|
||||
|
||||
expect(createSignedUploadUrl).toHaveBeenCalledWith(
|
||||
`documents/${company}/${user}/pending/${uploadId}_Leverant_r_faktura.pdf`,
|
||||
{ upsert: false },
|
||||
)
|
||||
expect(reservation).toEqual({
|
||||
uploadId,
|
||||
signedUrl: 'https://storage.example/upload?token=signed',
|
||||
expiresAt: new Date(now + SIGNED_DOCUMENT_UPLOAD_TTL_MS).toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts uploaded bytes under the reserved UUID and permanent WORM path', async () => {
|
||||
const buffer = pdfBuffer('presigned upload')
|
||||
const document = makeDocumentAttachment({
|
||||
id: uploadId,
|
||||
user_id: user,
|
||||
company_id: company,
|
||||
file_name: 'invoice.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
storage_path: buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf'),
|
||||
sha256_hash: await computeSHA256(buffer),
|
||||
})
|
||||
results = [
|
||||
{ data: null, error: null },
|
||||
{ data: document, error: null },
|
||||
]
|
||||
|
||||
const move = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const download = vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null })
|
||||
serviceClientOverride = makeClient({ download, move })
|
||||
|
||||
const completed = await completePendingDocumentUpload(
|
||||
makeClient() as never,
|
||||
company,
|
||||
user,
|
||||
uploadId,
|
||||
'invoice.pdf',
|
||||
'application/pdf',
|
||||
)
|
||||
|
||||
expect(completed.document.id).toBe(uploadId)
|
||||
expect(move).toHaveBeenCalledWith(
|
||||
buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf'),
|
||||
buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf'),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the existing immutable document on retry after verifying its hash', async () => {
|
||||
const buffer = pdfBuffer('already complete')
|
||||
const document = makeDocumentAttachment({
|
||||
id: uploadId,
|
||||
user_id: user,
|
||||
company_id: company,
|
||||
file_name: 'invoice.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
storage_path: buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf'),
|
||||
sha256_hash: await computeSHA256(buffer),
|
||||
})
|
||||
results = [{ data: document, error: null }]
|
||||
|
||||
const move = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
move,
|
||||
})
|
||||
|
||||
const completed = await completePendingDocumentUpload(
|
||||
makeClient() as never,
|
||||
company,
|
||||
user,
|
||||
uploadId,
|
||||
'invoice.pdf',
|
||||
'application/pdf',
|
||||
)
|
||||
|
||||
expect(completed.document).toEqual(document)
|
||||
expect(move).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes corrupt pending bytes before rejecting completion', async () => {
|
||||
results = [{ data: null, error: null }]
|
||||
const pendingPath = buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf')
|
||||
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob(['not a pdf']), error: null }),
|
||||
remove,
|
||||
})
|
||||
|
||||
await expect(
|
||||
completePendingDocumentUpload(
|
||||
makeClient() as never,
|
||||
company,
|
||||
user,
|
||||
uploadId,
|
||||
'invoice.pdf',
|
||||
'application/pdf',
|
||||
),
|
||||
).rejects.toThrow(/kunde inte verifieras/)
|
||||
expect(remove).toHaveBeenCalledWith([pendingPath])
|
||||
})
|
||||
|
||||
it('cleans only expired pending objects in a bounded company and user prefix', async () => {
|
||||
const now = Date.parse('2026-08-03T10:00:00.000Z')
|
||||
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
const list = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'old-object',
|
||||
name: `${uploadId}_old.pdf`,
|
||||
created_at: new Date(now - PENDING_DOCUMENT_UPLOAD_RETENTION_MS - 1).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'new-object',
|
||||
name: `${uploadId}_new.pdf`,
|
||||
created_at: new Date(now - 60_000).toISOString(),
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
serviceClientOverride = makeClient({ list, remove })
|
||||
|
||||
const removed = await cleanupExpiredPendingDocumentUploads(company, user, now)
|
||||
|
||||
expect(list).toHaveBeenCalledWith(`documents/${company}/${user}/pending`, {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
sortBy: { column: 'created_at', order: 'asc' },
|
||||
})
|
||||
expect(remove).toHaveBeenCalledWith([
|
||||
`documents/${company}/${user}/pending/${uploadId}_old.pdf`,
|
||||
])
|
||||
expect(removed).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNewVersion', () => {
|
||||
it('increments version and supersedes previous', async () => {
|
||||
const current = makeDocumentAttachment({
|
||||
@@ -426,6 +597,16 @@ describe('storage key layout helpers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('builds deterministic pending and permanent keys for a reserved upload', () => {
|
||||
const uploadId = '33333333-3333-4333-8333-333333333333'
|
||||
expect(buildPendingDocumentStoragePath(company, user, uploadId, 'faktura 1.pdf')).toBe(
|
||||
`documents/${company}/${user}/pending/${uploadId}_faktura_1.pdf`,
|
||||
)
|
||||
expect(buildReservedDocumentStoragePath(company, user, uploadId, 'faktura 1.pdf')).toBe(
|
||||
`documents/${company}/${user}/${uploadId}_faktura_1.pdf`,
|
||||
)
|
||||
})
|
||||
|
||||
it('recognises company-scoped keys', () => {
|
||||
expect(isCompanyScopedDocumentPath(`documents/${company}/${user}/1_a.pdf`, company)).toBe(true)
|
||||
expect(isCompanyScopedDocumentPath(`documents/${user}/1_a.pdf`, company)).toBe(false)
|
||||
|
||||
@@ -56,6 +56,9 @@ function sanitizeFileName(name: string): string {
|
||||
*/
|
||||
export const DOCUMENTS_BUCKET = 'documents'
|
||||
const DOCUMENTS_PATH_ROOT = 'documents'
|
||||
export const SIGNED_DOCUMENT_UPLOAD_TTL_MS = 2 * 60 * 60 * 1000
|
||||
export const PENDING_DOCUMENT_UPLOAD_RETENTION_MS = 24 * 60 * 60 * 1000
|
||||
const PENDING_DOCUMENT_UPLOAD_CLEANUP_LIMIT = 100
|
||||
|
||||
/** Build a company-scoped storage key for a new upload. */
|
||||
export function buildDocumentStoragePath(
|
||||
@@ -67,6 +70,26 @@ export function buildDocumentStoragePath(
|
||||
return `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/${timestamp}_${sanitizeFileName(fileName)}`
|
||||
}
|
||||
|
||||
/** Build the temporary key targeted by a signed, model-free upload. */
|
||||
export function buildPendingDocumentStoragePath(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
uploadId: string,
|
||||
fileName: string
|
||||
): string {
|
||||
return `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/pending/${uploadId}_${sanitizeFileName(fileName)}`
|
||||
}
|
||||
|
||||
/** Build the permanent WORM key for a completed signed upload. */
|
||||
export function buildReservedDocumentStoragePath(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
uploadId: string,
|
||||
fileName: string
|
||||
): string {
|
||||
return `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/${uploadId}_${sanitizeFileName(fileName)}`
|
||||
}
|
||||
|
||||
/** True when the key already sits under the company-scoped prefix. */
|
||||
export function isCompanyScopedDocumentPath(storagePath: string, companyId: string): boolean {
|
||||
return storagePath.startsWith(`${DOCUMENTS_PATH_ROOT}/${companyId}/`)
|
||||
@@ -321,6 +344,231 @@ async function ensureDocumentsBucket(): Promise<void> {
|
||||
bucketVerified = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bounded batch of abandoned signed-upload objects. Pending objects
|
||||
* are not accounting records and have no document_attachments row. Completed
|
||||
* documents are moved out of this prefix before the immutable row is created.
|
||||
*/
|
||||
export async function cleanupExpiredPendingDocumentUploads(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
now: number = Date.now()
|
||||
): Promise<number> {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
const prefix = `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/pending`
|
||||
const storage = serviceClient.storage.from(DOCUMENTS_BUCKET)
|
||||
const { data, error } = await storage.list(prefix, {
|
||||
limit: PENDING_DOCUMENT_UPLOAD_CLEANUP_LIMIT,
|
||||
offset: 0,
|
||||
sortBy: { column: 'created_at', order: 'asc' },
|
||||
})
|
||||
if (error || !data) return 0
|
||||
|
||||
const cutoff = now - PENDING_DOCUMENT_UPLOAD_RETENTION_MS
|
||||
const expiredPaths = data
|
||||
.filter((item) => {
|
||||
if (!item.id || !item.created_at) return false
|
||||
const createdAt = Date.parse(item.created_at)
|
||||
return Number.isFinite(createdAt) && createdAt < cutoff
|
||||
})
|
||||
.map((item) => `${prefix}/${item.name}`)
|
||||
|
||||
if (expiredPaths.length === 0) return 0
|
||||
const { error: removeError } = await storage.remove(expiredPaths)
|
||||
return removeError ? 0 : expiredPaths.length
|
||||
}
|
||||
|
||||
export interface PendingDocumentUploadReservation {
|
||||
uploadId: string
|
||||
signedUrl: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a company-scoped object key and create a short-lived upload URL.
|
||||
* The returned URL accepts the raw file bytes via PUT without authentication.
|
||||
*/
|
||||
export async function createPendingDocumentUpload(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
uploadId: string,
|
||||
fileName: string,
|
||||
now: number = Date.now()
|
||||
): Promise<PendingDocumentUploadReservation> {
|
||||
await ensureDocumentsBucket()
|
||||
await cleanupExpiredPendingDocumentUploads(companyId, userId, now)
|
||||
|
||||
const storagePath = buildPendingDocumentStoragePath(companyId, userId, uploadId, fileName)
|
||||
const { data, error } = await supabase.storage
|
||||
.from(DOCUMENTS_BUCKET)
|
||||
.createSignedUploadUrl(storagePath, { upsert: false })
|
||||
|
||||
if (error || !data?.signedUrl) {
|
||||
throw new Error(`Failed to create document upload URL: ${error?.message ?? 'no URL returned'}`)
|
||||
}
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
signedUrl: data.signedUrl,
|
||||
expiresAt: new Date(now + SIGNED_DOCUMENT_UPLOAD_TTL_MS).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompletedPendingDocumentUpload {
|
||||
document: DocumentAttachment
|
||||
buffer: ArrayBuffer
|
||||
}
|
||||
|
||||
async function findReservedDocument(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
uploadId: string
|
||||
): Promise<DocumentAttachment | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('*')
|
||||
.eq('id', uploadId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Failed to check document upload: ${error.message}`)
|
||||
return data as DocumentAttachment | null
|
||||
}
|
||||
|
||||
function validateReservedDocumentMetadata(
|
||||
document: DocumentAttachment,
|
||||
fileName: string,
|
||||
mimeType: string
|
||||
): void {
|
||||
if (document.file_name !== fileName || document.mime_type !== mimeType) {
|
||||
throw new Error('Upload ID was already completed with different file metadata')
|
||||
}
|
||||
}
|
||||
|
||||
async function validatePendingDocumentBytes(
|
||||
buffer: ArrayBuffer,
|
||||
mimeType: string
|
||||
): Promise<string> {
|
||||
if (buffer.byteLength === 0) throw new Error('Uploaded file is empty')
|
||||
if (buffer.byteLength > MAX_DOCUMENT_SIZE) {
|
||||
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
|
||||
}
|
||||
const magicError = validateDocumentMagicBytes(buffer, mimeType)
|
||||
if (magicError) throw new Error(magicError)
|
||||
return computeSHA256(buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt bytes uploaded through a signed URL into the WORM document archive.
|
||||
* The reserved UUID becomes the document id, making retries and concurrent
|
||||
* completion calls converge on the same immutable row.
|
||||
*/
|
||||
export async function completePendingDocumentUpload(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
uploadId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
now: number = Date.now()
|
||||
): Promise<CompletedPendingDocumentUpload> {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
const storage = serviceClient.storage.from(DOCUMENTS_BUCKET)
|
||||
const pendingPath = buildPendingDocumentStoragePath(companyId, userId, uploadId, fileName)
|
||||
const permanentPath = buildReservedDocumentStoragePath(companyId, userId, uploadId, fileName)
|
||||
|
||||
const existing = await findReservedDocument(supabase, companyId, userId, uploadId)
|
||||
if (existing) {
|
||||
validateReservedDocumentMetadata(existing, fileName, mimeType)
|
||||
const { data, error } = await storage.download(existing.storage_path)
|
||||
if (error || !data) {
|
||||
throw new Error(`Failed to read completed document upload: ${error?.message ?? 'no data returned'}`)
|
||||
}
|
||||
const buffer = await data.arrayBuffer()
|
||||
const hash = await validatePendingDocumentBytes(buffer, mimeType)
|
||||
if (hash !== existing.sha256_hash) throw new Error('Completed document failed its integrity check')
|
||||
return { document: existing, buffer }
|
||||
}
|
||||
|
||||
await cleanupExpiredPendingDocumentUploads(companyId, userId, now)
|
||||
|
||||
let sourcePath = pendingPath
|
||||
let { data: blob, error: downloadError } = await storage.download(pendingPath)
|
||||
if (downloadError || !blob) {
|
||||
const permanentDownload = await storage.download(permanentPath)
|
||||
blob = permanentDownload.data
|
||||
downloadError = permanentDownload.error
|
||||
sourcePath = permanentPath
|
||||
}
|
||||
if (downloadError || !blob) {
|
||||
throw new Error('Document upload was not found or has expired. Create a new upload URL and try again.')
|
||||
}
|
||||
|
||||
const buffer = await blob.arrayBuffer()
|
||||
let sha256Hash: string
|
||||
try {
|
||||
sha256Hash = await validatePendingDocumentBytes(buffer, mimeType)
|
||||
} catch (error) {
|
||||
await storage.remove([sourcePath])
|
||||
throw error
|
||||
}
|
||||
|
||||
if (sourcePath === pendingPath) {
|
||||
const { error: moveError } = await storage.move(pendingPath, permanentPath)
|
||||
if (moveError) {
|
||||
const permanentDownload = await storage.download(permanentPath)
|
||||
if (permanentDownload.error || !permanentDownload.data) {
|
||||
throw new Error(`Failed to finalize document upload: ${moveError.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
id: uploadId,
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
storage_path: permanentPath,
|
||||
file_name: fileName,
|
||||
file_size_bytes: buffer.byteLength,
|
||||
mime_type: mimeType,
|
||||
sha256_hash: sha256Hash,
|
||||
version: 1,
|
||||
is_current_version: true,
|
||||
uploaded_by: userId,
|
||||
upload_source: 'api',
|
||||
digitization_date: new Date(now).toISOString(),
|
||||
journal_entry_id: null,
|
||||
journal_entry_line_id: null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
const concurrent = await findReservedDocument(supabase, companyId, userId, uploadId)
|
||||
if (concurrent) {
|
||||
validateReservedDocumentMetadata(concurrent, fileName, mimeType)
|
||||
if (concurrent.sha256_hash !== sha256Hash) {
|
||||
throw new Error('Upload ID was completed with different file content')
|
||||
}
|
||||
return { document: concurrent, buffer }
|
||||
}
|
||||
await storage.remove([permanentPath])
|
||||
throw new Error(`Failed to create document record: ${error.message}`)
|
||||
}
|
||||
|
||||
const document = data as DocumentAttachment
|
||||
await eventBus.emit({
|
||||
type: 'document.uploaded',
|
||||
payload: { document, userId, companyId },
|
||||
})
|
||||
|
||||
return { document, buffer }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a file buffer
|
||||
*/
|
||||
|
||||
@@ -15,19 +15,27 @@ import {
|
||||
/**
|
||||
* MCP tools that invoke a paid capability directly (no stage→commit round-trip),
|
||||
* so they are gated at DISPATCH only and have no commit-time (operation-map)
|
||||
* counterpart. gnubok_upload_document runs Bedrock OCR inline via
|
||||
* extractInvoiceFields: it never stages a pending_operation.
|
||||
* counterpart. The document upload tools run Bedrock OCR inline via
|
||||
* extractInvoiceFields: they never stage a pending_operation.
|
||||
*/
|
||||
const DISPATCH_ONLY_MCP_TOOLS = new Set<string>(['gnubok_upload_document'])
|
||||
const DISPATCH_ONLY_MCP_TOOLS = new Set<string>([
|
||||
'gnubok_upload_document',
|
||||
'gnubok_create_document_upload',
|
||||
'gnubok_complete_document_upload',
|
||||
])
|
||||
|
||||
describe('MCP_TOOL_CAPABILITY_MAP', () => {
|
||||
it('gates exactly the paid MCP tools (3 external-service staging tools + the AI OCR tool)', () => {
|
||||
it('gates exactly the paid MCP tools (3 external-service staging tools + the AI OCR tools)', () => {
|
||||
expect(MCP_TOOL_CAPABILITY_MAP).toEqual({
|
||||
gnubok_send_invoice: CAPABILITY.email_send,
|
||||
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
|
||||
gnubok_agi_submit: CAPABILITY.skatteverket,
|
||||
// Dispatch-only AI tool: inline Bedrock OCR, no staged operation.
|
||||
// Dispatch-only AI tools: inline Bedrock OCR, no staged operation. The
|
||||
// signed-URL pair is gated at create AND complete so a free-tier key can
|
||||
// neither reserve nor finalize a paid extraction.
|
||||
gnubok_upload_document: CAPABILITY.ai,
|
||||
gnubok_create_document_upload: CAPABILITY.ai,
|
||||
gnubok_complete_document_upload: CAPABILITY.ai,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -65,17 +65,19 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
|
||||
* read/local SKV tools (generate_agi, vat_declaration_validate/status, agi_status)
|
||||
* stay free: the §4 carve-out forbids blocking a statutory filing obligation.
|
||||
*
|
||||
* gnubok_upload_document invokes AI (Bedrock document OCR via
|
||||
* extractInvoiceFields), so it is gated on CAPABILITY.ai: the same paywall the
|
||||
* HTTP inbox upload/attach/retry paths enforce. Without this entry a free-tier
|
||||
* API key (incl. the claude.ai connector's minted gnubok_sk_ key) could trigger
|
||||
* paid AI extraction. bank_sync has no MCP tool (bank sync is cron/HTTP only).
|
||||
* The document upload tools invoke AI (Bedrock document OCR via
|
||||
* extractInvoiceFields), so they are gated on CAPABILITY.ai: the same paywall
|
||||
* the HTTP inbox upload/attach/retry paths enforce. Without these entries a
|
||||
* free-tier API key could trigger paid AI extraction. bank_sync has no MCP
|
||||
* tool (bank sync is cron/HTTP only).
|
||||
*/
|
||||
export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, CapabilityKey>>> = {
|
||||
gnubok_send_invoice: CAPABILITY.email_send,
|
||||
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
|
||||
gnubok_agi_submit: CAPABILITY.skatteverket,
|
||||
// AI document OCR (Bedrock): the inbox's paid extraction, reachable via MCP.
|
||||
gnubok_create_document_upload: CAPABILITY.ai,
|
||||
gnubok_complete_document_upload: CAPABILITY.ai,
|
||||
gnubok_upload_document: CAPABILITY.ai,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user