From 7571b85d1a2f51ba08895a735298405ba4eb4667 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:13:18 +0100 Subject: [PATCH] feat: MCP Apps receipt matcher widget (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MCP Apps receipt matcher widget for inline receipt-to-transaction matching Adds an interactive HTML widget that renders inline in Claude Desktop via MCP Apps (SEP-1865), letting users drag receipts onto uncategorized transactions to categorize, book, and attach documents in one flow without leaving the conversation. - Extract categorizeTransactionCore() shared by both categorization tools - Add gnubok_receipt_matcher tool with _meta.ui for MCP Apps widget rendering - Add gnubok_categorize_with_receipt tool (categorize + upload document) - Add resources/list and resources/read protocol handlers - Self-contained widget HTML with MCP Apps bridge, drag-and-drop, client-side resize Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — buffer slice, MIME type, HTML escaping - Fix Buffer.buffer slab pool issue: slice to exact byte range to avoid corrupted uploads for small files (≤4096 bytes) - Update MIME type to image/jpeg after canvas re-encode (was keeping original file.type, causing content-type mismatch in DB) - Escape tx.date through esc() for consistent innerHTML sanitization - Remove unused `reset` variable in tests Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../__tests__/receipt-matcher.test.ts | 452 ++++++++++++++++ extensions/general/mcp-server/server.ts | 510 +++++++++++++----- extensions/general/mcp-server/widget-html.ts | 383 +++++++++++++ 3 files changed, 1197 insertions(+), 148 deletions(-) create mode 100644 extensions/general/mcp-server/__tests__/receipt-matcher.test.ts create mode 100644 extensions/general/mcp-server/widget-html.ts diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts new file mode 100644 index 00000000..06c5413f --- /dev/null +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -0,0 +1,452 @@ +/** + * Tests for receipt matcher tools and MCP Apps protocol additions + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createQueuedMockSupabase, + makeTransaction, + makeDocumentAttachment, +} from '@/tests/helpers' + +// ── Mocks ── +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: vi.fn().mockResolvedValue({ + storage: { + getBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' } }), + }, + }), +})) + +vi.mock('@/lib/auth/api-keys', () => ({ + extractBearerToken: vi.fn().mockReturnValue('test-token'), + validateApiKey: vi.fn().mockResolvedValue({ userId: 'user-1' }), + createServiceClientNoCookies: vi.fn(), +})) + +vi.mock('@/lib/bookkeeping/category-mapping', () => ({ + buildMappingResultFromCategory: vi.fn().mockReturnValue({ + debit_account: '6110', + credit_account: '1930', + vat_lines: [{ account_number: '2641', amount: 74.75 }], + }), +})) + +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: vi.fn().mockResolvedValue({ id: 'je-123' }), +})) + +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(), +})) + +vi.mock('@/lib/currency/riksbanken', () => ({ + fetchExchangeRate: vi.fn(), + convertToSEK: vi.fn(), +})) + +vi.mock('@/lib/reports/income-statement', () => ({ + generateIncomeStatement: vi.fn(), +})) + +vi.mock('@/lib/reports/kpi', () => ({ + calculateGrossMargin: vi.fn(), + calculateCashPosition: vi.fn(), + calculateExpenseRatio: vi.fn(), + calculateAvgPaymentDays: vi.fn(), +})) + +vi.mock('@/lib/reports/trial-balance', () => ({ + generateTrialBalance: vi.fn(), +})) + +vi.mock('@/lib/reports/ar-ledger', () => ({ + generateARLedger: vi.fn(), +})) + +vi.mock('@/lib/reports/monthly-breakdown', () => ({ + generateMonthlyBreakdown: vi.fn(), +})) + +import { handleMcpRequest } from '../server' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { eventBus } from '@/lib/events/bus' + +// Helper: make a JSON-RPC request +function mcpRequest(method: string, params?: Record, id: number | string = 1): Request { + return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }) +} + +async function parseResult(response: Response) { + const json = await response.json() + return json.result +} + +describe('MCP Receipt Matcher', () => { + let supabase: ReturnType['supabase'] + let enqueueMany: ReturnType['enqueueMany'] + + beforeEach(() => { + vi.clearAllMocks() + ;(eventBus as { clear: ReturnType }).clear() + const mock = createQueuedMockSupabase() + supabase = mock.supabase + enqueueMany = mock.enqueueMany + vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never) + }) + + // ── Protocol: initialize includes resources capability ── + + describe('initialize', () => { + it('returns resources capability', async () => { + const res = await handleMcpRequest(mcpRequest('initialize', { protocolVersion: '2025-03-26' })) + const result = await parseResult(res) + + expect(result.capabilities.tools).toEqual({ listChanged: false }) + expect(result.capabilities.resources).toEqual({ listChanged: false }) + }) + }) + + // ── Protocol: tools/list includes _meta for receipt matcher ── + + describe('tools/list', () => { + it('includes _meta.ui for gnubok_receipt_matcher', async () => { + const res = await handleMcpRequest(mcpRequest('tools/list')) + const result = await parseResult(res) + + const receiptTool = result.tools.find((t: { name: string }) => t.name === 'gnubok_receipt_matcher') + expect(receiptTool).toBeDefined() + expect(receiptTool._meta).toEqual({ + ui: { resourceUri: 'ui://receipt-matcher/app.html' }, + }) + }) + + 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) + + const categorizeTool = result.tools.find( + (t: { name: string }) => t.name === 'gnubok_categorize_transaction' + ) + expect(categorizeTool).toBeDefined() + expect(categorizeTool._meta).toBeUndefined() + }) + }) + + // ── Protocol: resources/list ── + + describe('resources/list', () => { + it('returns the receipt-matcher resource', async () => { + const res = await handleMcpRequest(mcpRequest('resources/list')) + const result = await parseResult(res) + + expect(result.resources).toHaveLength(1) + expect(result.resources[0]).toEqual({ + uri: 'ui://receipt-matcher/app.html', + name: 'Receipt Matcher', + description: 'Interactive widget for matching receipts to uncategorized transactions', + mimeType: 'text/html;profile=mcp-app', + }) + }) + }) + + // ── Protocol: resources/read ── + + describe('resources/read', () => { + it('returns HTML for the receipt matcher', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'ui://receipt-matcher/app.html' }) + ) + const result = await parseResult(res) + + expect(result.contents).toHaveLength(1) + expect(result.contents[0].uri).toBe('ui://receipt-matcher/app.html') + expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app') + expect(result.contents[0].text).toContain('') + expect(result.contents[0].text).toContain('Kvittomatchning') + }) + + it('returns error for unknown resource URI', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'ui://unknown/thing' }) + ) + const json = await res.json() + + expect(json.error).toBeDefined() + expect(json.error.code).toBe(-32602) + expect(json.error.message).toContain('Resource not found') + }) + }) + + // ── gnubok_receipt_matcher tool ── + + describe('gnubok_receipt_matcher', () => { + it('returns uncategorized transactions with categories and vat_treatments', async () => { + const tx1 = makeTransaction({ id: 'tx-1', description: 'ICA', amount: -150 }) + const tx2 = makeTransaction({ id: 'tx-2', description: 'Consulting', amount: 15000 }) + enqueueMany([ + { data: [tx1, tx2], error: null }, + ]) + + const res = await handleMcpRequest( + mcpRequest('tools/call', { name: 'gnubok_receipt_matcher', arguments: {} }) + ) + const result = await parseResult(res) + + // Should have structuredContent for MCP Apps + expect(result.structuredContent).toBeDefined() + expect(result.structuredContent.transactions).toHaveLength(2) + expect(result.structuredContent.categories).toContain('expense_office') + expect(result.structuredContent.vat_treatments).toContain('standard_25') + + // Should also have regular content + expect(result.content).toHaveLength(1) + expect(result.content[0].type).toBe('text') + }) + + it('returns empty array when no uncategorized transactions', async () => { + enqueueMany([{ data: [], error: null }]) + + const res = await handleMcpRequest( + mcpRequest('tools/call', { name: 'gnubok_receipt_matcher', arguments: {} }) + ) + const result = await parseResult(res) + + expect(result.structuredContent.transactions).toHaveLength(0) + }) + }) + + // ── 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)', () => { + it('returns result without transaction field', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: -500 }) + + 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_transaction', + arguments: { transaction_id: 'tx-1', category: 'expense_office' }, + }) + ) + 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.category).toBe('expense_office') + expect(parsed.transaction).toBeUndefined() + }) + }) + + // ── tools/call structuredContent ── + + describe('tools/call structuredContent', () => { + it('includes structuredContent for tools with _meta.ui', async () => { + enqueueMany([{ data: [], error: null }]) + + const res = await handleMcpRequest( + mcpRequest('tools/call', { name: 'gnubok_receipt_matcher', arguments: {} }) + ) + const result = await parseResult(res) + + expect(result.structuredContent).toBeDefined() + expect(result.content).toBeDefined() + }) + + it('does not include structuredContent for regular tools', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: -500 }) + enqueueMany([ + { data: tx, error: null }, + { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, + { data: null, error: null }, + { data: null, error: null }, + ]) + + const res = await handleMcpRequest( + mcpRequest('tools/call', { + name: 'gnubok_categorize_transaction', + arguments: { transaction_id: 'tx-1', category: 'expense_office' }, + }) + ) + const result = await parseResult(res) + + expect(result.structuredContent).toBeUndefined() + expect(result.content).toBeDefined() + }) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 8701befd..caebd8ad 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -10,6 +10,7 @@ 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, @@ -20,9 +21,10 @@ import { import { generateTrialBalance } from '@/lib/reports/trial-balance' import { generateARLedger } from '@/lib/reports/ar-ledger' 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 } from '@/types' +import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, DocumentUploadSource } from '@/types' // ── JSON-RPC types ─────────────────────────────────────────── @@ -54,6 +56,7 @@ interface McpTool { description: string inputSchema: Record annotations: McpToolAnnotations + _meta?: { ui: { resourceUri: string } } execute: ( args: Record, userId: string, @@ -76,6 +79,180 @@ const VALID_VAT_TREATMENTS = [ 'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt', ] as const +// ── Shared categorization logic ────────────────────────────── + +async function categorizeTransactionCore( + txId: string, + category: TransactionCategory, + vatTreatment: VatTreatment | undefined, + userId: string, + supabase: SupabaseClient +): Promise<{ + success: boolean + journal_entry_created: boolean + journal_entry_id: string | null + journal_entry_error: string | null + category: string + debit_account: string + credit_account: string + amount: number + currency: string + transaction: Transaction +}> { + // Validate category + if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) { + throw new Error( + `Invalid category "${category}". Valid categories: ${VALID_CATEGORIES.join(', ')}` + ) + } + + if (vatTreatment && !VALID_VAT_TREATMENTS.includes(vatTreatment as typeof VALID_VAT_TREATMENTS[number])) { + throw new Error( + `Invalid vat_treatment "${vatTreatment}". Valid: ${VALID_VAT_TREATMENTS.join(', ')}` + ) + } + + const isBusiness = category !== 'private' + + // Fetch the transaction + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('id', txId) + .eq('user_id', userId) + .single() + + if (fetchError || !transaction) { + throw new Error('Transaction not found. Check the transaction_id is correct.') + } + + if (transaction.journal_entry_id) { + return { + success: true, + journal_entry_created: false, + journal_entry_id: transaction.journal_entry_id, + journal_entry_error: 'Transaction already has a journal entry — use gnubok_list_uncategorized_transactions to find unbooked ones.', + category, + debit_account: '', + credit_account: '', + amount: Math.abs(transaction.amount), + currency: transaction.currency, + transaction: transaction as Transaction, + } + } + + // Get entity type + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type, fiscal_year_start_month') + .eq('user_id', userId) + .single() + + const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' + + // Build mapping + const mappingResult = buildMappingResultFromCategory( + category, + transaction as Transaction, + isBusiness, + entityType, + vatTreatment + ) + + if (!mappingResult.debit_account || !mappingResult.credit_account) { + throw new Error( + `No account mapping for category "${category}" with entity type "${entityType}". ` + + 'Try a different category or check your chart of accounts.' + ) + } + + // Ensure fiscal period exists + const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1 + const txDate = new Date(transaction.date) + const txMonth = txDate.getMonth() + 1 + const txYear = txDate.getFullYear() + + let periodStartYear: number + if (fiscalYearStartMonth === 1) { + periodStartYear = txYear + } else if (txMonth >= fiscalYearStartMonth) { + periodStartYear = txYear + } else { + periodStartYear = txYear - 1 + } + + const startMonth = String(fiscalYearStartMonth).padStart(2, '0') + const periodStart = `${periodStartYear}-${startMonth}-01` + + const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1 + const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1 + const lastDay = new Date(endYear, endMonth, 0).getDate() + const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + + const periodName = fiscalYearStartMonth === 1 + ? `Räkenskapsår ${periodStartYear}` + : `Räkenskapsår ${periodStartYear}/${endYear}` + + await supabase + .from('fiscal_periods') + .upsert( + { user_id: userId, name: periodName, period_start: periodStart, period_end: periodEnd }, + { onConflict: 'user_id,period_start,period_end' } + ) + + // Create journal entry + let journalEntryId: string | null = null + let journalEntryError: string | null = null + + try { + const journalEntry = await createTransactionJournalEntry( + supabase, + userId, + transaction as Transaction, + mappingResult + ) + if (journalEntry) { + journalEntryId = journalEntry.id + } + } catch (err) { + journalEntryError = err instanceof Error ? err.message : 'Unknown error' + } + + // Update transaction + await supabase + .from('transactions') + .update({ + is_business: isBusiness, + category, + journal_entry_id: journalEntryId, + }) + .eq('id', txId) + + // Emit event so extensions (mapping rules, etc.) can react + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: transaction as Transaction, + account: mappingResult.debit_account, + taxCode: mappingResult.vat_lines[0]?.account_number || '', + userId, + }, + }) + + return { + success: true, + journal_entry_created: !!journalEntryId, + journal_entry_id: journalEntryId, + journal_entry_error: journalEntryError, + category, + debit_account: mappingResult.debit_account, + credit_account: mappingResult.credit_account, + amount: Math.abs(transaction.amount), + currency: transaction.currency, + transaction: transaction as Transaction, + } +} + // ── Tools ──────────────────────────────────────────────────── const tools: McpTool[] = [ @@ -200,154 +377,153 @@ const tools: McpTool[] = [ openWorldHint: false, }, async execute(args, userId, supabase) { - const txId = args.transaction_id as string - const category = args.category as TransactionCategory - const vatTreatment = args.vat_treatment as VatTreatment | undefined - - // Validate category - if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) { - throw new Error( - `Invalid category "${category}". Valid categories: ${VALID_CATEGORIES.join(', ')}` - ) - } - - if (vatTreatment && !VALID_VAT_TREATMENTS.includes(vatTreatment as typeof VALID_VAT_TREATMENTS[number])) { - throw new Error( - `Invalid vat_treatment "${vatTreatment}". Valid: ${VALID_VAT_TREATMENTS.join(', ')}` - ) - } - - const isBusiness = category !== 'private' - - // Fetch the transaction - const { data: transaction, error: fetchError } = await supabase - .from('transactions') - .select('*') - .eq('id', txId) - .eq('user_id', userId) - .single() - - if (fetchError || !transaction) { - throw new Error('Transaction not found. Check the transaction_id is correct.') - } - - if (transaction.journal_entry_id) { - return { - success: true, - journal_entry_created: false, - message: 'Transaction already has a journal entry — use gnubok_list_uncategorized_transactions to find unboooked ones.', - journal_entry_id: transaction.journal_entry_id, - } - } - - // Get entity type - const { data: settings } = await supabase - .from('company_settings') - .select('entity_type, fiscal_year_start_month') - .eq('user_id', userId) - .single() - - const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' - - // Build mapping - const mappingResult = buildMappingResultFromCategory( - category, - transaction as Transaction, - isBusiness, - entityType, - vatTreatment + const result = await categorizeTransactionCore( + args.transaction_id as string, + args.category as TransactionCategory, + args.vat_treatment as VatTreatment | undefined, + userId, + supabase ) + // Strip internal transaction field from public response + const { transaction: _tx, ...publicResult } = result + return publicResult + }, + }, - if (!mappingResult.debit_account || !mappingResult.credit_account) { - throw new Error( - `No account mapping for category "${category}" with entity type "${entityType}". ` + - 'Try a different category or check your chart of accounts.' - ) - } + // ── Receipt matcher tool ────────────────────────────────────── - // Ensure fiscal period exists - const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1 - const txDate = new Date(transaction.date) - const txMonth = txDate.getMonth() + 1 - const txYear = txDate.getFullYear() - - let periodStartYear: number - if (fiscalYearStartMonth === 1) { - periodStartYear = txYear - } else if (txMonth >= fiscalYearStartMonth) { - periodStartYear = txYear - } else { - periodStartYear = txYear - 1 - } - - const startMonth = String(fiscalYearStartMonth).padStart(2, '0') - const periodStart = `${periodStartYear}-${startMonth}-01` - - const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1 - const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1 - const lastDay = new Date(endYear, endMonth, 0).getDate() - const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - - const periodName = fiscalYearStartMonth === 1 - ? `Räkenskapsår ${periodStartYear}` - : `Räkenskapsår ${periodStartYear}/${endYear}` - - await supabase - .from('fiscal_periods') - .upsert( - { user_id: userId, name: periodName, period_start: periodStart, period_end: periodEnd }, - { onConflict: 'user_id,period_start,period_end' } - ) - - // Create journal entry - let journalEntryId: string | null = null - let journalEntryError: string | null = null - - try { - const journalEntry = await createTransactionJournalEntry( - supabase, - userId, - transaction as Transaction, - mappingResult - ) - if (journalEntry) { - journalEntryId = journalEntry.id - } - } catch (err) { - journalEntryError = err instanceof Error ? err.message : 'Unknown error' - } - - // Update transaction - await supabase - .from('transactions') - .update({ - is_business: isBusiness, - category, - journal_entry_id: journalEntryId, - }) - .eq('id', txId) - - // Emit event so extensions (mapping rules, etc.) can react - await eventBus.emit({ - type: 'transaction.categorized', - payload: { - transaction: transaction as Transaction, - account: mappingResult.debit_account, - taxCode: mappingResult.vat_lines[0]?.account_number || '', - userId, + { + name: 'gnubok_receipt_matcher', + description: + 'Open the receipt matcher widget. Shows uncategorized transactions with drag-and-drop ' + + 'receipt attachment. Renders an interactive UI inline in the conversation.\n\n' + + 'Args:\n' + + ' - limit (number, optional): Max transactions to show, 1–50 (default: 20)\n\n' + + 'Returns JSON:\n' + + ' { transactions: [...], categories: [...], vat_treatments: [...] }\n\n' + + 'Examples:\n' + + ' - "Match my receipts" → call with no args\n' + + ' - "Open receipt matcher" → call with no args', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Max transactions to show, 1–50 (default 20)', }, - }) + }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { ui: { resourceUri: 'ui://receipt-matcher/app.html' } }, + async execute(args, userId, supabase) { + const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) + + const { data, error } = await supabase + .from('transactions') + .select( + 'id, date, description, amount, currency, merchant_name, reference, is_business, category' + ) + .eq('user_id', userId) + .is('journal_entry_id', null) + .order('date', { ascending: false }) + .limit(limit) + + if (error) throw new Error(`Database error: ${error.message}`) return { - success: true, - journal_entry_created: !!journalEntryId, - journal_entry_id: journalEntryId, - journal_entry_error: journalEntryError, - category, - debit_account: mappingResult.debit_account, - credit_account: mappingResult.credit_account, - amount: Math.abs(transaction.amount), - currency: transaction.currency, + transactions: data ?? [], + categories: [...VALID_CATEGORIES], + vat_treatments: [...VALID_VAT_TREATMENTS], + } + }, + }, + + { + 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, } }, }, @@ -1303,6 +1479,7 @@ export async function handleMcpRequest(request: Request): Promise { protocolVersion: negotiatedVersion, capabilities: { tools: { listChanged: false }, + resources: { listChanged: false }, }, serverInfo: SERVER_INFO, instructions: 'gnubok — Swedish bookkeeping via conversation. List transactions, categorize, create invoices, view reports.', @@ -1325,6 +1502,7 @@ export async function handleMcpRequest(request: Request): Promise { description: t.description, inputSchema: t.inputSchema, annotations: t.annotations, + ...(t._meta ? { _meta: t._meta } : {}), })), }) ) @@ -1346,11 +1524,13 @@ export async function handleMcpRequest(request: Request): Promise { try { const result = await tool.execute(toolArgs, userId, supabase) - return NextResponse.json( - jsonRpc(id ?? null, { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - }) - ) + const response: Record = { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + } + if (tool._meta?.ui) { + response.structuredContent = result + } + return NextResponse.json(jsonRpc(id ?? null, response)) } catch (err) { const message = err instanceof Error ? err.message : 'Tool execution failed' return NextResponse.json( @@ -1362,6 +1542,40 @@ export async function handleMcpRequest(request: Request): Promise { } } + case 'resources/list': + return NextResponse.json( + jsonRpc(id ?? null, { + resources: [ + { + uri: 'ui://receipt-matcher/app.html', + name: 'Receipt Matcher', + description: 'Interactive widget for matching receipts to uncategorized transactions', + mimeType: 'text/html;profile=mcp-app', + }, + ], + }) + ) + + case 'resources/read': { + const uri = (params as Record)?.uri as string + if (uri === 'ui://receipt-matcher/app.html') { + return NextResponse.json( + jsonRpc(id ?? null, { + contents: [ + { + uri, + mimeType: 'text/html;profile=mcp-app', + text: RECEIPT_MATCHER_HTML, + }, + ], + }) + ) + } + return NextResponse.json( + jsonRpcError(id ?? null, -32602, `Resource not found: "${uri}"`) + ) + } + default: return NextResponse.json( jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`) diff --git a/extensions/general/mcp-server/widget-html.ts b/extensions/general/mcp-server/widget-html.ts new file mode 100644 index 00000000..b262ffd4 --- /dev/null +++ b/extensions/general/mcp-server/widget-html.ts @@ -0,0 +1,383 @@ +/** + * Receipt Matcher Widget — MCP Apps inline HTML + * + * Self-contained HTML document rendered in an iframe by MCP Apps hosts + * (Claude Desktop, etc.). Communicates exclusively via postMessage + * (JSON-RPC 2.0 over the MCP Apps protocol). No fetch() calls. + */ + +export const RECEIPT_MATCHER_HTML = ` + + + + +Kvittomatchning — gnubok + + + +
+

Kvittomatchning

+ +
+
Laddar transaktioner\u2026
+ + + +`;