fix: remove gnubok_categorize_with_receipt tool (#88)

* fix: remove gnubok_categorize_with_receipt tool

The tool requires Claude to generate the entire base64-encoded file as
tool arguments token-by-token, which is extremely slow for large PDFs.
Receipt attachment should happen via the widget (where JavaScript
handles encoding) or through the web app.

The receipt matcher widget and gnubok_receipt_matcher tool remain —
they provide the intended UX for matching receipts to transactions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update widget to call gnubok_categorize_transaction

The widget was still calling the removed gnubok_categorize_with_receipt
tool. Updated to call gnubok_categorize_transaction instead (text-only,
no file upload). Receipt attachment via the widget is deferred to a
future iteration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-23 11:26:23 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 140dda0c2c
commit fdcc94102a
3 changed files with 4 additions and 253 deletions
@@ -5,7 +5,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
makeTransaction,
makeDocumentAttachment,
} from '@/tests/helpers'
// ── Mocks ──
@@ -40,11 +39,6 @@ vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() },
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: vi.fn(),
getAvailableVatRates: vi.fn(),
@@ -135,17 +129,6 @@ describe('MCP Receipt Matcher', () => {
})
})
it('includes gnubok_categorize_with_receipt', async () => {
const res = await handleMcpRequest(mcpRequest('tools/list'))
const result = await parseResult(res)
const tool = result.tools.find((t: { name: string }) => t.name === 'gnubok_categorize_with_receipt')
expect(tool).toBeDefined()
expect(tool.inputSchema.required).toContain('file_data')
expect(tool.inputSchema.required).toContain('filename')
expect(tool.inputSchema.required).toContain('mime_type')
})
it('does not include _meta for tools without it', async () => {
const res = await handleMcpRequest(mcpRequest('tools/list'))
const result = await parseResult(res)
@@ -241,149 +224,6 @@ describe('MCP Receipt Matcher', () => {
})
})
// ── gnubok_categorize_with_receipt tool ──
describe('gnubok_categorize_with_receipt', () => {
const validDataUri = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='
const tx = makeTransaction({
id: 'tx-1',
description: 'Kontorsmaterial',
amount: -299,
currency: 'SEK',
})
it('categorizes and uploads receipt document', async () => {
const doc = makeDocumentAttachment({ id: 'doc-1', journal_entry_id: 'je-123' })
mockUploadDocument.mockResolvedValue(doc)
enqueueMany([
{ data: tx, error: null }, // fetch transaction
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, // company_settings
{ data: null, error: null }, // fiscal_periods upsert
{ data: null, error: null }, // transaction update
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-1',
category: 'expense_office',
vat_treatment: 'standard_25',
file_data: validDataUri,
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.success).toBe(true)
expect(parsed.journal_entry_created).toBe(true)
expect(parsed.journal_entry_id).toBe('je-123')
expect(parsed.document_id).toBe('doc-1')
expect(parsed.debit_account).toBe('6110')
expect(parsed.credit_account).toBe('1930')
// Verify uploadDocument was called with correct args
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({
name: 'receipt.jpg',
type: 'image/jpeg',
}),
expect.objectContaining({
upload_source: 'api',
journal_entry_id: 'je-123',
})
)
})
it('rejects invalid data URI', async () => {
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-1',
category: 'expense_office',
file_data: 'not-a-data-uri',
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.error).toContain('Invalid file_data')
expect(result.isError).toBe(true)
})
it('returns document_error when upload fails', async () => {
mockUploadDocument.mockRejectedValue(new Error('Storage full'))
enqueueMany([
{ data: tx, error: null }, // fetch transaction
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
{ data: null, error: null }, // fiscal_periods upsert
{ data: null, error: null }, // transaction update
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-1',
category: 'expense_office',
file_data: validDataUri,
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.success).toBe(true)
expect(parsed.journal_entry_created).toBe(true)
expect(parsed.document_id).toBeNull()
expect(parsed.document_error).toBe('Storage full')
})
it('skips document upload when transaction already has journal entry', async () => {
const bookedTx = makeTransaction({
id: 'tx-2',
journal_entry_id: 'je-existing',
amount: -100,
})
enqueueMany([
{ data: bookedTx, error: null }, // fetch transaction (already booked)
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-2',
category: 'expense_office',
file_data: validDataUri,
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
// Should not have uploaded — already booked
expect(mockUploadDocument).not.toHaveBeenCalled()
expect(parsed.document_id).toBeNull()
})
})
// ── gnubok_categorize_transaction still works after refactor ──
describe('gnubok_categorize_transaction (refactored)', () => {
+1 -87
View File
@@ -10,7 +10,6 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
calculateGrossMargin,
@@ -24,7 +23,7 @@ import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { RECEIPT_MATCHER_HTML } from './widget-html'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler — no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, DocumentUploadSource } from '@/types'
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency } from '@/types'
// ── JSON-RPC types ───────────────────────────────────────────
@@ -443,91 +442,6 @@ const tools: McpTool[] = [
},
},
{
name: 'gnubok_categorize_with_receipt',
description:
'Categorize a transaction and attach a receipt document in one operation. ' +
'Called by the receipt matcher widget — not typically used directly.\n\n' +
'Args:\n' +
' - transaction_id (string, required): UUID of the transaction\n' +
' - category (string, required): One of: ' + VALID_CATEGORIES.join(', ') + '\n' +
' - vat_treatment (string, optional): One of: ' + VALID_VAT_TREATMENTS.join(', ') + '\n' +
' - file_data (string, required): Data URI of the receipt file\n' +
' - filename (string, required): Original filename\n' +
' - mime_type (string, required): MIME type (image/jpeg, image/png, application/pdf)\n\n' +
'Returns JSON:\n' +
' { success: boolean, journal_entry_created: boolean, journal_entry_id?: string,\n' +
' document_id?: string, category: string, debit_account: string, credit_account: string }',
inputSchema: {
type: 'object',
properties: {
transaction_id: { type: 'string', description: 'UUID of the transaction' },
category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] },
vat_treatment: { type: 'string', description: 'VAT treatment override', enum: [...VALID_VAT_TREATMENTS] },
file_data: { type: 'string', description: 'Data URI of the receipt (e.g. data:image/jpeg;base64,...)' },
filename: { type: 'string', description: 'Original filename' },
mime_type: { type: 'string', description: 'MIME type' },
},
required: ['transaction_id', 'category', 'file_data', 'filename', 'mime_type'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
const fileData = args.file_data as string
const filename = args.filename as string
const mimeType = args.mime_type as string
// Validate data URI
const commaIdx = fileData.indexOf(',')
if (!fileData.startsWith('data:') || commaIdx === -1) {
throw new Error('Invalid file_data: expected a data URI (data:<mime>;base64,...)')
}
const base64 = fileData.slice(commaIdx + 1)
const buffer = Buffer.from(base64, 'base64')
// Categorize transaction
const result = await categorizeTransactionCore(
args.transaction_id as string,
args.category as TransactionCategory,
args.vat_treatment as VatTreatment | undefined,
userId,
supabase
)
// Upload document and link to journal entry
let documentId: string | null = null
let documentError: string | null = null
if (result.journal_entry_created && result.journal_entry_id) {
try {
const doc = await uploadDocument(supabase, userId, {
name: filename,
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
type: mimeType,
}, {
upload_source: 'api' as DocumentUploadSource,
journal_entry_id: result.journal_entry_id,
})
documentId = doc.id
} catch (err) {
documentError = err instanceof Error ? err.message : 'Document upload failed'
}
}
const { transaction: _tx, ...publicResult } = result
return {
...publicResult,
document_id: documentId,
document_error: documentError,
}
},
},
// ── Customer tools ───────────────────────────────────────────
{
+3 -6
View File
@@ -345,13 +345,10 @@ export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
tx._error = null;
render();
callTool('gnubok_categorize_with_receipt', {
callTool('gnubok_categorize_transaction', {
transaction_id: tx.id,
category: catEl.value,
vat_treatment: vatEl.value,
file_data: tx._dataUri,
filename: tx._file,
mime_type: tx._mimeType
vat_treatment: vatEl.value
}).then(function(res) {
tx._booking = false;
// The tool result comes back as content[0].text (JSON string)
@@ -369,7 +366,7 @@ export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
}
render();
sendNotification('ui/updateContext', {
content: [{ type: 'text', text: 'Bokf\\u00f6rt ' + booked + ' av ' + transactions.length + ' transaktioner med kvitton.' }]
content: [{ type: 'text', text: 'Bokf\\u00f6rt ' + booked + ' av ' + transactions.length + ' transaktioner.' }]
});
}).catch(function(err) {
tx._booking = false;