Files
accounted/extensions/general/mcp-server/server.ts
T
Jakob WennbergandClaude Opus 4.6 d8e0a22495 feat: counterparty templates, Skatteverket extension, complete VAT form (#117)
* feat: separate AR/AP/accounting into distinct nav groups (#92)

Split the flat "Finans" sidebar group into three visually distinct
sections — Försäljning (AR), Inköp (AP), and Redovisning — so users
coming from Fortnox immediately find customer invoicing and supplier
invoices as top-level concepts.

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

* feat: journal entry detail view, correction chain, and account name display

- Add journal entry detail page at /bookkeeping/[id] with full entry view
- Add correction chain API and component showing storno relationships
- Add JournalEntryStatusBadge component for entry status display
- Show debit/credit account names in template picker and review dialogs
- Expand client-side BAS account name mapping with additional accounts
- Show account codes on transaction inbox suggestion buttons

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

* fix: address review feedback — N+1 query, duplicate name, nav dedup

- Batch reverse-lookup into single query per BFS iteration (was N+1)
- Differentiate account 2393 from 2893 in display names
- Extract shared loop for desktop/mobile nav group rendering

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

* feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup

- Add counterparty-based categorization templates (learned from user approvals
  and auto-ingestion) with fuzzy matching in the mapping engine
- Add Skatteverket extension for direct VAT declaration submission via API
- Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62)
- Fix ruta 49 formula to include import VAT (ruta 60+61+62)
- Simplify dashboard UI: remove redundant icons from stat cards, customer cards,
  invoice list, supplier invoices; use Badge variants consistently
- Add SkatteverketPanel component to reports page
- Add categorization_templates and skatteverket_tokens migrations
- Update tests and helpers for new types

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

* fix: address PR review feedback — VAT detection, migration timestamps, dedup

- Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line
  description instead of hardcoding standard_25
- Rename skatteverket_tokens migration to 20260324120001 to avoid
  duplicate timestamp with categorization_templates (fixes Supabase
  deployment failure)
- Make refreshAccessToken accept previousRefreshCount param to enforce
  refresh limit contract at the type level
- Fix rate limiter TOCTOU by claiming slot before await
- Extract formatRedovisare/formatRedovisningsperiod to shared
  lib/skatteverket/format.ts — eliminates duplication between
  mappers.ts and SkatteverketPanel.tsx

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:41:12 +01:00

1509 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from 'next/server'
import {
extractBearerToken,
validateApiKey,
createServiceClientNoCookies,
} from '@/lib/auth/api-keys'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
calculateGrossMargin,
calculateCashPosition,
calculateExpenseRatio,
calculateAvgPaymentDays,
} from '@/lib/reports/kpi'
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'
// ── JSON-RPC types ───────────────────────────────────────────
interface JsonRpcRequest {
jsonrpc: '2.0'
id?: string | number
method: string
params?: Record<string, unknown>
}
interface JsonRpcResponse {
jsonrpc: '2.0'
id: string | number | null
result?: unknown
error?: { code: number; message: string; data?: unknown }
}
// ── MCP Tool definition ──────────────────────────────────────
interface McpToolAnnotations {
readOnlyHint?: boolean
destructiveHint?: boolean
idempotentHint?: boolean
openWorldHint?: boolean
}
interface McpTool {
name: string
description: string
inputSchema: Record<string, unknown>
annotations: McpToolAnnotations
_meta?: { ui: { resourceUri: string } }
execute: (
args: Record<string, unknown>,
userId: string,
supabase: SupabaseClient
) => Promise<unknown>
}
// ── Shared constants ─────────────────────────────────────────
const VALID_CATEGORIES = [
'income_services', 'income_products', 'income_other',
'expense_equipment', 'expense_software', 'expense_travel', 'expense_office',
'expense_marketing', 'expense_professional_services', 'expense_education',
'expense_representation', 'expense_consumables', 'expense_vehicle',
'expense_telecom', 'expense_bank_fees', 'expense_card_fees',
'expense_currency_exchange', 'expense_other', 'private',
] as const
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,
},
})
// Upsert counterparty template for future auto-matching
try {
await upsertCounterpartyTemplate(
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
)
} catch {
// Non-critical
}
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[] = [
{
name: 'gnubok_list_uncategorized_transactions',
description:
'List bank transactions that have not been categorized (no journal entry yet). ' +
'Use this to see what needs bookkeeping attention.\n\n' +
'Args:\n' +
' - limit (number, optional): Max results, 1–100 (default: 20)\n' +
' - offset (number, optional): Skip first N results for pagination (default: 0)\n\n' +
'Returns JSON:\n' +
' { transactions: [{ id, date, description, amount, currency, merchant_name, reference }],\n' +
' count: number, total_count: number, has_more: boolean, next_offset?: number }\n\n' +
'Examples:\n' +
' - "Show my uncategorized transactions" → call with no args\n' +
' - "Show next 50" → call with limit=50\n' +
' - "Show page 2" → call with offset=20\n\n' +
'Error: Returns error text if the database query fails.',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Max results to return, 1–100 (default 20)',
},
offset: {
type: 'number',
description: 'Number of results to skip for pagination (default 0)',
},
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100)
const offset = Math.max(0, Number(args.offset) || 0)
// Get total count
const { count: totalCount, error: countError } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.is('journal_entry_id', null)
if (countError) throw new Error(`Database error: ${countError.message}`)
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 })
.range(offset, offset + limit - 1)
if (error) throw new Error(`Database error: ${error.message}`)
const total = totalCount ?? 0
const hasMore = total > offset + (data?.length ?? 0)
return {
transactions: data,
count: data?.length ?? 0,
total_count: total,
has_more: hasMore,
...(hasMore ? { next_offset: offset + (data?.length ?? 0) } : {}),
}
},
},
{
name: 'gnubok_categorize_transaction',
description:
'Categorize a bank transaction and create the corresponding double-entry journal entry. ' +
'This books the transaction in the accounting ledger using Swedish BAS accounts.\n\n' +
'Args:\n' +
' - transaction_id (string, required): UUID of the transaction from gnubok_list_uncategorized_transactions\n' +
' - category (string, required): One of: ' + VALID_CATEGORIES.join(', ') + '\n' +
' - vat_treatment (string, optional): One of: ' + VALID_VAT_TREATMENTS.join(', ') + '. ' +
'Defaults to standard_25 for business expenses.\n\n' +
'Returns JSON:\n' +
' { success: boolean, journal_entry_created: boolean, journal_entry_id?: string,\n' +
' category: string, debit_account: string, credit_account: string }\n\n' +
'Examples:\n' +
' - "Book that as office supplies, 25% VAT" → category="expense_office"\n' +
' - "Mark as private" → category="private" (no journal entry created for private)\n' +
' - "Book as consulting income" → category="income_services"\n\n' +
'Errors:\n' +
' - "Transaction not found" if the ID is invalid or belongs to another user\n' +
' - "Transaction already has a journal entry" if already categorized\n' +
' - "Invalid account mapping" if the category/entity type combination has no mapping',
inputSchema: {
type: 'object',
properties: {
transaction_id: {
type: 'string',
description: 'UUID of the transaction to categorize',
},
category: {
type: 'string',
description: 'Transaction category',
enum: [...VALID_CATEGORIES],
},
vat_treatment: {
type: 'string',
description: 'VAT treatment override',
enum: [...VALID_VAT_TREATMENTS],
},
},
required: ['transaction_id', 'category'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
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
},
},
// ── Receipt matcher tool ──────────────────────────────────────
{
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 {
transactions: data ?? [],
categories: [...VALID_CATEGORIES],
vat_treatments: [...VALID_VAT_TREATMENTS],
}
},
},
// ── Customer tools ───────────────────────────────────────────
{
name: 'gnubok_list_customers',
description:
'List all customers. Use this to look up customer IDs for invoice creation.\n\n' +
'Args: none\n\n' +
'Returns JSON:\n' +
' { customers: [{ id, name, customer_type, email, org_number, vat_number, default_payment_terms }],\n' +
' count: number }',
inputSchema: { type: 'object', properties: {} },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, userId, supabase) {
const { data, error } = await supabase
.from('customers')
.select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country')
.eq('user_id', userId)
.order('name')
if (error) throw new Error(`Database error: ${error.message}`)
return { customers: data, count: data?.length ?? 0 }
},
},
{
name: 'gnubok_create_customer',
description:
'Create a new customer. Required for invoice creation.\n\n' +
'Args:\n' +
' - name (string, required): Customer/company name\n' +
' - customer_type (string, required): individual, swedish_business, eu_business, non_eu_business\n' +
' - email (string, optional): Contact email\n' +
' - org_number (string, optional): Swedish org number (for swedish_business)\n' +
' - vat_number (string, optional): EU VAT number (for eu_business, triggers VIES validation)\n' +
' - payment_terms (number, optional): Days until due (default 30)\n' +
' - address (string, optional): Street address\n' +
' - postal_code (string, optional)\n' +
' - city (string, optional)\n' +
' - country (string, optional): Defaults to Sweden\n\n' +
'Returns JSON: the created customer object with id.\n\n' +
'Examples:\n' +
' - "Add Acme AB" → name="Acme AB", customer_type="swedish_business"\n' +
' - "Add a German client" → customer_type="eu_business", country="Germany"',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Customer name' },
customer_type: {
type: 'string',
enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'],
description: 'Customer type',
},
email: { type: 'string', description: 'Email address' },
org_number: { type: 'string', description: 'Swedish org number' },
vat_number: { type: 'string', description: 'EU VAT number' },
payment_terms: { type: 'number', description: 'Payment terms in days (default 30)' },
address: { type: 'string', description: 'Street address' },
postal_code: { type: 'string' },
city: { type: 'string' },
country: { type: 'string', description: 'Country (default Sweden)' },
},
required: ['name', 'customer_type'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
const name = args.name as string
const customerType = args.customer_type as string
if (!name?.trim()) throw new Error('Customer name is required.')
if (!['individual', 'swedish_business', 'eu_business', 'non_eu_business'].includes(customerType)) {
throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business')
}
const { data, error } = await supabase
.from('customers')
.insert({
user_id: userId,
name: name.trim(),
customer_type: customerType,
email: (args.email as string) || null,
org_number: (args.org_number as string) || null,
vat_number: (args.vat_number as string) || null,
default_payment_terms: Number(args.payment_terms) || 30,
address_line1: (args.address as string) || null,
postal_code: (args.postal_code as string) || null,
city: (args.city as string) || null,
country: (args.country as string) || 'Sweden',
})
.select()
.single()
if (error) throw new Error(`Failed to create customer: ${error.message}`)
return { customer: data }
},
},
// ── Invoice tools ────────────────────────────────────────────
{
name: 'gnubok_list_invoices',
description:
'List invoices, optionally filtered by status.\n\n' +
'Args:\n' +
' - status (string, optional): Filter by status: draft, sent, paid, overdue, cancelled, credited\n' +
' - limit (number, optional): Max results, 1–100 (default 50)\n\n' +
'Returns JSON:\n' +
' { invoices: [{ id, invoice_number, status, customer_name, total, currency, invoice_date, due_date }],\n' +
' count: number, total_count: number }\n\n' +
'Examples:\n' +
' - "Show unpaid invoices" → status="sent"\n' +
' - "Show overdue invoices" → status="overdue"',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited'],
description: 'Filter by invoice status',
},
limit: { type: 'number', description: 'Max results (default 50, max 100)' },
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
const status = args.status as string | undefined
let query = supabase
.from('invoices')
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' })
.eq('user_id', userId)
if (status) {
query = query.eq('status', status)
}
const { data, error, count } = await query
.order('invoice_date', { ascending: false })
.limit(limit)
if (error) throw new Error(`Database error: ${error.message}`)
const invoices = (data ?? []).map((inv: Record<string, unknown>) => ({
id: inv.id,
invoice_number: inv.invoice_number,
status: inv.status,
customer_name: (inv.customers as Record<string, unknown>)?.name ?? null,
total: inv.total,
currency: inv.currency,
invoice_date: inv.invoice_date,
due_date: inv.due_date,
document_type: inv.document_type,
}))
return {
invoices,
count: invoices.length,
total_count: count ?? invoices.length,
}
},
},
{
name: 'gnubok_create_invoice',
description:
'Create a new invoice for a customer. Automatically calculates VAT based on customer type.\n\n' +
'Args:\n' +
' - customer_id (string, required): UUID from gnubok_list_customers\n' +
' - items (array, required): Line items, each with:\n' +
' - description (string): What was sold/delivered\n' +
' - quantity (number): How many\n' +
' - unit (string): Unit of measure (st, tim, dag, mån)\n' +
' - unit_price (number): Price per unit excl. VAT\n' +
' - vat_rate (number, optional): Override VAT rate (0–100)\n' +
' - invoice_date (string, optional): YYYY-MM-DD (default today)\n' +
' - due_date (string, optional): YYYY-MM-DD (default based on payment terms)\n' +
' - currency (string, optional): SEK, EUR, USD, GBP, NOK, DKK (default SEK)\n' +
' - our_reference (string, optional)\n' +
' - your_reference (string, optional)\n' +
' - notes (string, optional): Notes printed on invoice\n\n' +
'Returns JSON: the created invoice with id, invoice_number, total, vat_amount.\n\n' +
'Examples:\n' +
' - "Invoice Acme for 15000 kr consulting" → items=[{description:"Konsulttjänster",quantity:1,unit:"st",unit_price:15000}]\n' +
' - "Invoice 10 hours at 1500/h" → items=[{description:"Konsulttjänster",quantity:10,unit:"tim",unit_price:1500}]',
inputSchema: {
type: 'object',
properties: {
customer_id: { type: 'string', description: 'Customer UUID' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
quantity: { type: 'number' },
unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
vat_rate: { type: 'number', description: 'VAT rate 0–100 (optional override)' },
},
required: ['description', 'quantity', 'unit', 'unit_price'],
},
description: 'Invoice line items',
},
invoice_date: { type: 'string', description: 'YYYY-MM-DD (default today)' },
due_date: { type: 'string', description: 'YYYY-MM-DD (default from payment terms)' },
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
our_reference: { type: 'string' },
your_reference: { type: 'string' },
notes: { type: 'string' },
},
required: ['customer_id', 'items'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
const customerId = args.customer_id as string
const items = args.items as Array<{
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
}>
if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.')
if (!items?.length) throw new Error('At least one item is required.')
for (const [i, item] of items.entries()) {
if (!item.description?.trim()) throw new Error(`Item ${i + 1}: description is required`)
if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${i + 1}: quantity must be positive`)
if (!item.unit?.trim()) throw new Error(`Item ${i + 1}: unit is required (st, tim, dag)`)
if (item.unit_price == null) throw new Error(`Item ${i + 1}: unit_price is required`)
}
const today = new Date().toISOString().split('T')[0]
const currency = ((args.currency as string) || 'SEK') as Currency
const invoiceDate = (args.invoice_date as string) || today
// Fetch customer (full row for VAT rules)
const { data: customer, error: custError } = await supabase
.from('customers')
.select('*')
.eq('id', customerId)
.eq('user_id', userId)
.single()
if (custError || !customer) {
throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.')
}
// VAT rules from customer type (same logic as web UI)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
// Calculate per-item VAT
const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0)
let vatAmount = 0
for (const item of items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
throw new Error(
`VAT rate ${itemRate}% is not allowed for customer type "${customer.customer_type}". ` +
`Allowed rates: ${availableRates.map((r) => r.rate + '%').join(', ')}`
)
}
const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
const total = subtotal + vatAmount
// Mixed-rate detection
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
// Currency exchange (Riksbanken)
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (currency !== 'SEK') {
const rateData = await fetchExchangeRate(currency)
if (rateData) {
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
}
// Due date from payment terms if not provided
let dueDate = args.due_date as string | undefined
if (!dueDate) {
const d = new Date(invoiceDate)
d.setDate(d.getDate() + (customer.default_payment_terms || 30))
dueDate = d.toISOString().split('T')[0]
}
// Generate invoice number via DB RPC (sequential, same as web UI)
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: userId,
})
const invoiceNumber = baseNumber as string
// Create invoice
const { data: invoice, error: insertError } = await supabase
.from('invoices')
.insert({
user_id: userId,
customer_id: customerId,
invoice_number: invoiceNumber,
invoice_date: invoiceDate,
due_date: dueDate,
status: 'draft',
currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
vat_treatment: vatRules.treatment,
vat_rate: uniqueRates.size > 1 ? null : (uniqueRates.values().next().value ?? vatRules.rate),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
document_type: 'invoice',
our_reference: (args.our_reference as string) || null,
your_reference: (args.your_reference as string) || null,
notes: (args.notes as string) || null,
})
.select()
.single()
if (insertError || !invoice) {
throw new Error(`Failed to create invoice: ${insertError?.message || 'Unknown error'}`)
}
// Insert items
const invoiceItems = items.map((item, idx) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
sort_order: idx,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
}
})
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(invoiceItems)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
throw new Error(`Failed to create invoice items: ${itemsError.message}`)
}
// Emit event (triggers journal entry creation via event handler)
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId },
})
}
return {
invoice: {
id: invoice.id,
invoice_number: invoiceNumber,
status: 'draft',
customer_name: customer.name,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round(total * 100) / 100,
currency,
vat_treatment: vatRules.treatment,
invoice_date: invoiceDate,
due_date: dueDate,
item_count: invoiceItems.length,
...(exchangeRate ? { exchange_rate: exchangeRate, total_sek: totalSek } : {}),
},
note: 'Invoice created as draft. Use the web UI to send it.',
}
},
},
// ── Report tools ─────────────────────────────────────────────
{
name: 'gnubok_get_trial_balance',
description:
'Get the trial balance (huvudbok) for a fiscal period. Shows all account balances.\n\n' +
'Args:\n' +
' - period_id (string, optional): Fiscal period UUID. If omitted, uses the most recent period.\n\n' +
'Returns JSON:\n' +
' { rows: [{ account_number, account_name, period_debit, period_credit, closing_debit, closing_credit }],\n' +
' total_debit: number, total_credit: number, is_balanced: boolean, period_name: string }\n\n' +
'Examples:\n' +
' - "What are my account balances?" → call with no args\n' +
' - "Trial balance for last year" → provide the period_id',
inputSchema: {
type: 'object',
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
let periodId = args.period_id as string | undefined
// If no period specified, find the most recent one
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('user_id', userId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) {
throw new Error('No fiscal periods found. Categorize some transactions first to auto-create a period.')
}
periodId = periods.id
}
// Get period info
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) throw new Error('Fiscal period not found.')
// Aggregate journal entry lines
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(status, user_id, fiscal_period_id)')
.eq('journal_entries.user_id', userId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
if (error) throw new Error(`Database error: ${error.message}`)
// Get account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accountMap = new Map((accounts ?? []).map((a: { account_number: string; account_name: string }) => [a.account_number, a.account_name]))
// Aggregate by account
const totals = new Map<string, { debit: number; credit: number }>()
for (const line of lines ?? []) {
const acc = line.account_number
const existing = totals.get(acc) ?? { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
totals.set(acc, existing)
}
const rows = Array.from(totals.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([accNum, t]) => {
const net = Math.round((t.debit - t.credit) * 100) / 100
return {
account_number: accNum,
account_name: accountMap.get(accNum) ?? accNum,
period_debit: Math.round(t.debit * 100) / 100,
period_credit: Math.round(t.credit * 100) / 100,
closing_debit: net > 0 ? net : 0,
closing_credit: net < 0 ? Math.abs(net) : 0,
}
})
const totalDebit = Math.round(rows.reduce((s, r) => s + r.closing_debit, 0) * 100) / 100
const totalCredit = Math.round(rows.reduce((s, r) => s + r.closing_credit, 0) * 100) / 100
return {
rows,
total_debit: totalDebit,
total_credit: totalCredit,
is_balanced: Math.abs(totalDebit - totalCredit) < 0.01,
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
account_count: rows.length,
}
},
},
{
name: 'gnubok_get_vat_report',
description:
'Get the VAT declaration (momsdeklaration) for a period. Shows all rutor (boxes) for SKV 4700.\n\n' +
'Args:\n' +
' - period_type (string, required): monthly, quarterly, yearly\n' +
' - year (number, required): e.g. 2025\n' +
' - period (number, required): 1–12 for monthly, 1–4 for quarterly, 1 for yearly\n\n' +
'Returns JSON: VAT declaration with all rutor (05, 10, 11, 12, 48, 49, etc.)\n' +
' ruta49 = VAT to pay (positive) or refund (negative)\n\n' +
'Examples:\n' +
' - "VAT for Q1 2025" → period_type="quarterly", year=2025, period=1\n' +
' - "VAT for March 2025" → period_type="monthly", year=2025, period=3',
inputSchema: {
type: 'object',
properties: {
period_type: {
type: 'string',
enum: ['monthly', 'quarterly', 'yearly'],
description: 'Period type',
},
year: { type: 'number', description: 'Year (e.g. 2025)' },
period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' },
},
required: ['period_type', 'year', 'period'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
const periodType = args.period_type as string
const year = Number(args.year)
const period = Number(args.period)
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
throw new Error('period_type must be: monthly, quarterly, yearly')
}
if (!year || year < 2000 || year > 2100) throw new Error('year must be between 2000 and 2100')
if (periodType === 'monthly' && (period < 1 || period > 12)) throw new Error('period must be 1–12 for monthly')
if (periodType === 'quarterly' && (period < 1 || period > 4)) throw new Error('period must be 1–4 for quarterly')
// Calculate date range
let startDate: string
let endDate: string
if (periodType === 'monthly') {
startDate = `${year}-${String(period).padStart(2, '0')}-01`
const lastDay = new Date(year, period, 0).getDate()
endDate = `${year}-${String(period).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
} else if (periodType === 'quarterly') {
const startMonth = (period - 1) * 3 + 1
const endMonth = period * 3
startDate = `${year}-${String(startMonth).padStart(2, '0')}-01`
const lastDay = new Date(year, endMonth, 0).getDate()
endDate = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
} else {
startDate = `${year}-01-01`
endDate = `${year}-12-31`
}
// Get all posted journal entry lines in the date range
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
.eq('journal_entries.user_id', userId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', startDate)
.lte('journal_entries.entry_date', endDate)
if (error) throw new Error(`Database error: ${error.message}`)
// Aggregate by account
const accountTotals = new Map<string, { debit: number; credit: number }>()
for (const line of lines ?? []) {
const acc = line.account_number
const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
accountTotals.set(acc, existing)
}
function creditBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0
}
function debitBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0
}
// Map accounts to rutor
const ruta05 = creditBalance('3001') + creditBalance('3002') + creditBalance('3003')
const ruta10 = creditBalance('2611')
const ruta11 = creditBalance('2621')
const ruta12 = creditBalance('2631')
const ruta39 = creditBalance('3308')
const ruta40 = creditBalance('3305')
const ruta48 = debitBalance('2641') + debitBalance('2645')
const ruta49 = Math.round((ruta10 + ruta11 + ruta12 - ruta48) * 100) / 100
const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December']
let periodLabel: string
if (periodType === 'monthly') periodLabel = `${monthNames[period - 1]} ${year}`
else if (periodType === 'quarterly') periodLabel = `Q${period} ${year}`
else periodLabel = `${year}`
return {
period: { type: periodType, year, period, start: startDate, end: endDate },
period_label: periodLabel,
rutor: {
ruta05: Math.abs(ruta05),
ruta10: Math.abs(ruta10),
ruta11: Math.abs(ruta11),
ruta12: Math.abs(ruta12),
ruta39: Math.abs(ruta39),
ruta40: Math.abs(ruta40),
ruta48: Math.abs(ruta48),
ruta49,
},
summary: ruta49 > 0
? `Moms att betala: ${Math.abs(ruta49).toFixed(2)} kr`
: ruta49 < 0
? `Moms att få tillbaka: ${Math.abs(ruta49).toFixed(2)} kr`
: 'Noll i moms',
}
},
},
// ── KPI & Income Statement tools ─────────────────────────────
{
name: 'gnubok_get_kpi_report',
description:
'Get key performance indicators for the business. Returns gross margin, net result, cash position, ' +
'receivables, expense ratio, average payment days, VAT liability, and monthly trend data.\n\n' +
'Args:\n' +
' - period_id (string, optional): Fiscal period UUID. If omitted, uses the most recent period.\n\n' +
'Returns JSON:\n' +
' { gross_margin: %|null, net_result: SEK, cash_position: SEK, outstanding_receivables: SEK,\n' +
' overdue_receivables: SEK, expense_ratio: %|null, avg_payment_days: days|null,\n' +
' vat_liability: SEK, total_revenue: SEK, total_expenses: SEK,\n' +
' months: [{ label, income, expenses, net }] }\n\n' +
'Examples:\n' +
' - "How is my business doing?" → call with no args\n' +
' - "What are my KPIs?" → call with no args\n' +
' - "Show me the numbers" → call with no args',
inputSchema: {
type: 'object',
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) {
throw new Error('No fiscal periods found. Categorize some transactions first.')
}
periodId = periods.id
}
// Verify period belongs to user
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) throw new Error('Fiscal period not found.')
// Run queries in parallel (same as the KPI API route)
const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] =
await Promise.all([
generateIncomeStatement(supabase, userId, periodId!),
generateTrialBalance(supabase, userId, periodId!),
generateARLedger(supabase, userId),
generateMonthlyBreakdown(supabase, userId, periodId!),
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('user_id', userId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
])
const grossMargin = calculateGrossMargin(incomeStatement)
const cashPosition = calculateCashPosition(trialBalance.rows)
const expenseRatio = calculateExpenseRatio(incomeStatement)
const avgPaymentDays = calculateAvgPaymentDays(
(paidInvoices.data ?? []) as { invoice_date: string; paid_at: string }[]
)
// AR ledger uses entries, each with invoices that have outstanding amounts
const outstandingReceivables = arLedger.total_outstanding
const overdueReceivables = arLedger.total_overdue
// VAT liability from trial balance
const getClosing = (accNum: string) => {
const row = trialBalance.rows.find((r) => r.account_number === accNum)
if (!row) return 0
return row.closing_credit - row.closing_debit
}
const vatLiability = Math.round(
(getClosing('2611') + getClosing('2621') + getClosing('2631') -
getClosing('2641') - getClosing('2645')) * 100
) / 100
return {
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
gross_margin: grossMargin,
net_result: incomeStatement.net_result,
cash_position: cashPosition,
outstanding_receivables: Math.round(outstandingReceivables * 100) / 100,
overdue_receivables: Math.round(overdueReceivables * 100) / 100,
expense_ratio: expenseRatio,
avg_payment_days: avgPaymentDays,
paid_invoice_count: paidInvoices.data?.length ?? 0,
vat_liability: vatLiability,
total_revenue: incomeStatement.total_revenue,
total_expenses: incomeStatement.total_expenses,
months: monthlyBreakdown.months,
}
},
},
{
name: 'gnubok_get_income_statement',
description:
'Get the income statement (resultaträkning) for a fiscal period. Shows revenue, expenses, ' +
'and net result broken down by account category.\n\n' +
'Args:\n' +
' - period_id (string, optional): Fiscal period UUID. If omitted, uses the most recent period.\n\n' +
'Returns JSON:\n' +
' { revenue_sections, total_revenue, expense_sections, total_expenses, net_result,\n' +
' period: { start, end } }\n\n' +
'Examples:\n' +
' - "What is my profit this year?" → call with no args\n' +
' - "Show my income statement" → call with no args',
inputSchema: {
type: 'object',
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) {
throw new Error('No fiscal periods found. Categorize some transactions first.')
}
periodId = periods.id
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) throw new Error('Fiscal period not found.')
const result = await generateIncomeStatement(supabase, userId, periodId!)
result.period = { start: period.period_start, end: period.period_end }
return {
period_name: period.name,
...result,
}
},
},
]
// ── MCP Protocol Handler ─────────────────────────────────────
const SERVER_INFO = {
name: 'gnubok',
version: '1.0.0',
}
const PROTOCOL_VERSION = '2025-03-26'
function jsonRpc(id: string | number | null, result: unknown): JsonRpcResponse {
return { jsonrpc: '2.0', id, result }
}
function jsonRpcError(
id: string | number | null,
code: number,
message: string,
data?: unknown
): JsonRpcResponse {
return { jsonrpc: '2.0', id, error: { code, message, data } }
}
/**
* Handle an MCP JSON-RPC request.
* Auth is done via Bearer API key (extension route has skipAuth: true).
*/
export async function handleMcpRequest(request: Request): Promise<Response> {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const wwwAuth = `Bearer resource_metadata="${appUrl}/.well-known/oauth-protected-resource"`
// ── Pre-auth: handle fire-and-forget notifications before auth check ──
// MCP notifications have no id and don't expect error responses.
// Checking auth on them would return 401 which confuses clients.
const clonedRequest = request.clone()
try {
const peek = await clonedRequest.json()
if (peek.method === 'notifications/initialized') {
return new Response(null, { status: 202 })
}
} catch {
// Not valid JSON — fall through to auth + parse below
}
// ── Auth ──
const token = extractBearerToken(request)
if (!token) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': wwwAuth },
})
}
const authResult = await validateApiKey(token)
if ('error' in authResult) {
const status = authResult.status
if (status === 429) {
return new Response(authResult.error, {
status: 429,
headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' },
})
}
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': wwwAuth },
})
}
const { userId } = authResult
const supabase = createServiceClientNoCookies()
// ── Parse JSON-RPC ──
let body: JsonRpcRequest
try {
body = await request.json()
} catch {
return NextResponse.json(
jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'),
{ status: 400 }
)
}
if (body.jsonrpc !== '2.0' || !body.method) {
return NextResponse.json(
jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'),
{ status: 400 }
)
}
// ── Dispatch ──
const { method, id, params } = body
switch (method) {
case 'initialize': {
const SUPPORTED_VERSIONS = new Set(['2025-03-26', '2024-11-05'])
const clientVersion = (params as Record<string, unknown>)?.protocolVersion as string | undefined
const negotiatedVersion =
clientVersion && SUPPORTED_VERSIONS.has(clientVersion) ? clientVersion : PROTOCOL_VERSION
return NextResponse.json(
jsonRpc(id ?? null, {
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.',
})
)
}
case 'notifications/initialized':
// Handled pre-auth above, but if it somehow reaches here, still return 202
return new Response(null, { status: 202 })
case 'ping':
return NextResponse.json(jsonRpc(id ?? null, {}))
case 'tools/list':
return NextResponse.json(
jsonRpc(id ?? null, {
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
annotations: t.annotations,
...(t._meta ? { _meta: t._meta } : {}),
})),
})
)
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
string,
unknown
>
const tool = tools.find((t) => t.name === toolName)
if (!tool) {
const available = tools.map((t) => t.name).join(', ')
return NextResponse.json(
jsonRpcError(id ?? null, -32602, `Unknown tool: "${toolName}". Available tools: ${available}`)
)
}
try {
const result = await tool.execute(toolArgs, userId, supabase)
const response: Record<string, unknown> = {
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(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
isError: true,
})
)
}
}
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<string, unknown>)?.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}"`)
)
}
}