From fdcc94102ad63c3834e6236e381780ac7460e1c1 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:26:23 +0100 Subject: [PATCH] fix: remove gnubok_categorize_with_receipt tool (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../__tests__/receipt-matcher.test.ts | 160 ------------------ extensions/general/mcp-server/server.ts | 88 +--------- extensions/general/mcp-server/widget-html.ts | 9 +- 3 files changed, 4 insertions(+), 253 deletions(-) diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts index 06c5413f..a4136c6c 100644 --- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -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)', () => { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index caebd8ad..3ba4e452 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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:;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 ─────────────────────────────────────────── { diff --git a/extensions/general/mcp-server/widget-html.ts b/extensions/general/mcp-server/widget-html.ts index b262ffd4..0c539bbf 100644 --- a/extensions/general/mcp-server/widget-html.ts +++ b/extensions/general/mcp-server/widget-html.ts @@ -345,13 +345,10 @@ export const RECEIPT_MATCHER_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 = ` } 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;