diff --git a/extensions/general/mcp-server/__tests__/undo-sie-import-stage.test.ts b/extensions/general/mcp-server/__tests__/undo-sie-import-stage.test.ts index 6ef1246f..fefaf4fb 100644 --- a/extensions/general/mcp-server/__tests__/undo-sie-import-stage.test.ts +++ b/extensions/general/mcp-server/__tests__/undo-sie-import-stage.test.ts @@ -139,16 +139,25 @@ describe('gnubok_undo_sie_import: stage-time validation', () => { ).rejects.toThrow(/import_id/i) }) - it('rejects reason longer than 500 characters', async () => { + it('rejects reason longer than 500 characters as VALIDATION_ERROR with a Swedish message', async () => { const { supabase } = createQueuedMockSupabase() - await expect( - undoTool.execute( + const { getStructuredError } = await import('@/lib/errors/get-structured-error') + let thrown: unknown + try { + await undoTool.execute( { import_id: 'imp-1', reason: 'x'.repeat(501) }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }, - ), - ).rejects.toThrow(/500/) + ) + } catch (err) { + thrown = err + } + expect(thrown).toBeInstanceOf(Error) + const envelope = getStructuredError(thrown) + expect(envelope.code).toBe('VALIDATION_ERROR') + expect(envelope.message_sv).toBe('Motiveringen får vara högst 500 tecken.') + expect(envelope.message_en).toMatch(/500 characters or fewer/) }) }) diff --git a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts index 18c0f99b..37ef6ee4 100644 --- a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts @@ -749,6 +749,29 @@ describe('gnubok_reverse_journal_entry: staging gates', () => { ).rejects.toThrow(/entry_id is required/i) }) + it('rejects a reason over 500 characters with a VALIDATION_ERROR code and a Swedish message', async () => { + // Customer report: the envelope said "Något gick fel. Försök igen." in + // Swedish while the cause was only in the English field. + const { supabase } = createQueuedMockSupabase() + const { getStructuredError } = await import('@/lib/errors/get-structured-error') + let thrown: unknown + try { + await reverseEntry.execute( + { entry_id: '11111111-1111-1111-1111-111111111111', reason: 'x'.repeat(501) }, + 'company-1', + 'user-1', + supabase as never, + ) + } catch (err) { + thrown = err + } + expect(thrown).toBeInstanceOf(Error) + const envelope = getStructuredError(thrown) + expect(envelope.code).toBe('VALIDATION_ERROR') + expect(envelope.message_sv).toBe('Motiveringen får vara högst 500 tecken.') + expect(envelope.message_en).toMatch(/500 characters or fewer/) + }) + it('rejects when the original entry is not posted', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 2c994049..07fc182e 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -592,6 +592,18 @@ const AUTO_PERIOD_DATE_KEYS = [ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +/** + * `reason` is capped at 500 characters (inputSchema maxLength). Hosts do not + * always enforce inputSchema, so the tools re-check at runtime. The message + * text is what the error layer keys on: getStructuredError infers + * VALIDATION_ERROR from it and getErrorMessage maps it to a specific Swedish + * line (a structured `code` on the error would win over the message and + * collapse the Swedish text to the generic validation fallback). + */ +function reasonTooLongError(): Error { + return new Error('reason must be 500 characters or fewer') +} + /** * Coerce a single account-number argument to a trimmed string (validated * against ACCOUNT_NUMBER_RE from lib/invariants/account-number). Accepts a @@ -15107,7 +15119,7 @@ export const tools: McpTool[] = [ if (!importId) throw new Error('import_id is required') if (reason !== undefined && reason.length > 500) { - throw new Error('reason must be 500 characters or fewer') + throw reasonTooLongError() } // Pre-flight mirrors undoSIEImport: confirm row exists, belongs to @@ -15734,7 +15746,7 @@ export const tools: McpTool[] = [ throw new Error('reversal_date must be ISO yyyy-MM-dd') } if (reason !== undefined && reason.length > 500) { - throw new Error('reason must be 500 characters or fewer') + throw reasonTooLongError() } const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef) diff --git a/lib/errors/__tests__/get-structured-error.test.ts b/lib/errors/__tests__/get-structured-error.test.ts index 0e667466..5be0d1da 100644 --- a/lib/errors/__tests__/get-structured-error.test.ts +++ b/lib/errors/__tests__/get-structured-error.test.ts @@ -35,6 +35,12 @@ describe('getStructuredError', () => { expect(result.remediation?.tool).toBe('gnubok_lock_period') }) + it('maps an over-long reason to VALIDATION_ERROR with a specific Swedish message', () => { + const result = getStructuredError(new Error('reason must be 500 characters or fewer')) + expect(result.code).toBe('VALIDATION_ERROR') + expect(result.message_sv).toBe('Motiveringen får vara högst 500 tecken.') + }) + it('infers PERIOD_HAS_UNBOOKED_TRANSACTIONS from Swedish lock-error message', () => { const result = getStructuredError( new Error('Kan inte låsa period: 3 affärstransaktion(er) saknar bokföring.') diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index e2cee47f..8230a4d6 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -98,6 +98,10 @@ const GENERIC_FALLBACK: Bilingual = { sv: 'Något gick fel. Försök igen.', en: // Known error patterns → user-friendly Swedish messages const ERROR_PATTERN_MAP: [RegExp, string | null][] = [ + [ + /reason must be 500 characters or fewer/i, + 'Motiveringen får vara högst 500 tecken.', + ], [ /locked\/closed fiscal period/i, 'Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.', diff --git a/lib/errors/get-structured-error.ts b/lib/errors/get-structured-error.ts index 479166d7..abdacfa7 100644 --- a/lib/errors/get-structured-error.ts +++ b/lib/errors/get-structured-error.ts @@ -158,6 +158,7 @@ function inferCode(message: string): string | null { if (/Bokföringen är låst/i.test(message)) return 'PERIOD_LOCKED' if (/Transaction not found/i.test(message)) return 'NOT_FOUND' if (/Invoice not found/i.test(message)) return 'NOT_FOUND' + if (/must be \d+ characters or fewer/i.test(message)) return 'VALIDATION_ERROR' return null }