From cd9fb5b717efdd34c6d3a46301e4e730edf4e88e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 16:11:11 +0200 Subject: [PATCH] feat(mcp): byte-exact SIE upload path + brevity and memory-first onboarding (#1954) From the fourth E2E run: the agent correctly refused to reproduce a 104 KB SIE file token by token (silent mid-verifikat truncation) and dead-ended to the web wizard, and its replies were walls of compliance prose. 1. gnubok_create_sie_upload: signed same-origin upload URL (reuses the pending-document infra; .se/.sie/.si only, 50 MB HTTP cap). gnubok_sie_preflight and gnubok_import_sie accept upload_id as the byte-exact source, plus optional sha256 (hex of the raw bytes) verified on the upload_id/base64 paths so truncation is DETECTED, never silent. Inline content above 120k chars is refused with a pointer to the upload flow. Scope bookkeeping:write (same intent as import_sie). 2. Skill: brevity rule (max ~8 short lines per reply, one warning per step, no legal essays), memory-first rule (check what is already known before asking the opening questions), the upload-first SIE step, and gnubok_explain_voucher_gap after import for skipped voucher numbers. 3. CONNECTORS.md starter prompt rewritten memory-first so it stays copy-paste ready without the user's own data in it. Plugin v1.2.2. tools/list ceiling 62K to 62.4K documented in the bench. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- claude-plugin/.claude-plugin/plugin.json | 2 +- claude-plugin/CONNECTORS.md | 9 +- .../__tests__/payload-size.bench.test.ts | 6 +- .../__tests__/sie-preflight.test.ts | 41 ++++ extensions/general/mcp-server/server.ts | 180 ++++++++++++++++-- .../general/mcp-server/skills/onboarding.ts | 45 +++-- lib/auth/scope-catalog.ts | 2 + 7 files changed, 245 insertions(+), 40 deletions(-) diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 31b13a37..15881791 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "accounted", "displayName": "Accounted", "description": "Official Accounted plugin: Swedish double-entry bookkeeping flows for Claude. Connects your ledger over MCP and ships short workflow skills (daily bookkeeping, health check, month close, VAT, payroll, year-end) that work from the company's live data and load Swedish accounting knowledge from the product when needed. Every write is staged for your approval; nothing is booked on its own.", - "version": "1.2.1", + "version": "1.2.2", "author": { "name": "Accounted (erp-mafia)" }, diff --git a/claude-plugin/CONNECTORS.md b/claude-plugin/CONNECTORS.md index 510453f4..3770fc30 100644 --- a/claude-plugin/CONNECTORS.md +++ b/claude-plugin/CONNECTORS.md @@ -14,14 +14,9 @@ One-click add on claude.ai (opens the Add custom connector dialog prefilled; the https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted ``` -Pair the link with a starter prompt the user pastes as their first message. The short form works because the server-side onboarding skill carries the whole flow; the long form pre-answers the three opening questions and saves a round-trip: +Pair the link with a starter prompt the user pastes as their first message. It stays copy-paste ready for everyone because it points the agent at what it already knows (Claude memory, earlier chats) instead of containing the user's own data; the server-side onboarding skill carries the rest of the flow: -> Sätt upp mitt företag i Accounted. - -> Sätt upp mitt företag i Accounted. -> Organisationsnummer: `` -> Tidigare bokföringssystem: `` -> Bank: `` +> Sätt upp mitt företag i Accounted. Utgå från det du redan vet om mig och mitt bolag (organisationsnummer, bank, tidigare bokföringssystem) och fråga bara efter det som saknas. Håll det kort. ## How the connection works diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 7f639562..b3e6d513 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -230,9 +230,13 @@ describe('tools/list payload size guard', () => { // the scan-before-import step the skill instructs for shared SIE // files, so default-catalog for the same Claude.ai reason; ~390 // tokens (schema carries the mappings-passthrough contract). + // * 62K to 62.4K with gnubok_create_sie_upload + upload_id/sha256 on the + // two SIE tools: the byte-exact upload path after a real 104 KB file + // dead-ended in chat (a model cannot reproduce 30k tokens verbatim + // without silent-truncation risk); skill-instructed, so default catalog. // Long-term answer to growth is leaning harder on gnubok_search_tools: if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(62_000) + expect(approxTokens).toBeLessThan(62_400) }) }) diff --git a/extensions/general/mcp-server/__tests__/sie-preflight.test.ts b/extensions/general/mcp-server/__tests__/sie-preflight.test.ts index 81c4e158..9da179ab 100644 --- a/extensions/general/mcp-server/__tests__/sie-preflight.test.ts +++ b/extensions/general/mcp-server/__tests__/sie-preflight.test.ts @@ -184,4 +184,45 @@ describe('gnubok_sie_preflight', () => { it('rejects a call with neither content field', async () => { await expect(run({})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) }) + + it('refuses oversized inline content instead of risking silent mid-verifikat truncation', async () => { + const huge = VALID_SIE + '\n' + '#KONTO 9999 "x"\n'.repeat(10_000) + await expect(run({ file_content: huge })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + message: expect.stringContaining('gnubok_create_sie_upload'), + }) + }) + + it('verifies sha256 on the base64 path and rejects a mismatch as truncation', async () => { + const bytes = Buffer.from(VALID_SIE, 'utf8') + const { createHash } = await import('node:crypto') + const good = createHash('sha256').update(bytes).digest('hex') + + const ok = await run({ file_content_base64: bytes.toString('base64'), sha256: good }) + expect(ok.verdict).toBe('ok') + + await expect( + run({ file_content_base64: bytes.toString('base64'), sha256: 'a'.repeat(64) }) + ).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + message: expect.stringContaining('sha256 mismatch'), + }) + }) +}) + +describe('gnubok_create_sie_upload', () => { + const uploadTool = tools.find((t) => t.name === 'gnubok_create_sie_upload')! + + it('is registered with the filename-only contract', () => { + expect(uploadTool).toBeDefined() + expect( + (uploadTool.inputSchema as { required: string[] }).required + ).toEqual(['filename']) + }) + + it('rejects a filename that is not a SIE file', async () => { + await expect( + uploadTool.execute({ filename: 'export.xlsx' }, COMPANY_ID, 'user-1', {} as never) + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) + }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 6357accb..a155c0d9 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -257,8 +257,11 @@ import { createPendingDocumentUpload, uploadDocument, MAX_DOCUMENT_SIZE, + buildPendingDocumentStoragePath, + DOCUMENTS_BUCKET, } from '@/lib/core/documents/document-service' import { toSameOriginStorageUrl } from '@/lib/core/documents/storage-proxy' +import { createHash } from 'node:crypto' import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction' // Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned @@ -1445,22 +1448,101 @@ export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'se } /** - * Resolve SIE file content from tool args: plain text (the model read the - * attachment) or base64 (exact bytes, e.g. from a code-execution sandbox). - * The base64 path runs the same encoding detection as the HTTP upload route, - * so CP437 exports keep their åäö instead of arriving pre-mangled through a - * host's UTF-8 read. Returns null when neither field is usable. + * Inline SIE content above this length is refused: a model reproducing tens + * of thousands of tokens verbatim WILL eventually truncate mid-#VER, and a + * truncated file that still parses imports silently incomplete bookkeeping. + * Larger files go through gnubok_create_sie_upload (raw bytes, no model in + * the path). ~120k chars ≈ a few thousand verifikat rows. */ -async function decodeSieToolContent(args: Record): Promise { - if (typeof args.file_content === 'string' && args.file_content.length > 0) { - return args.file_content +const MAX_INLINE_SIE_CHARS = 120_000 + +/** + * Resolve SIE file content from tool args, three sources in priority order: + * + * - upload_id: raw bytes PUT to a gnubok_create_sie_upload URL. The only + * path with no model in the loop; required for large files. + * - file_content_base64: exact bytes base64-encoded (e.g. from a + * code-execution sandbox). + * - file_content: plain text as the model read the attachment. + * + * Byte paths run the same encoding detection as the HTTP upload route, so + * CP437 exports keep their åäö. An optional sha256 (hex, of the RAW BYTES) + * is verified on the two byte-exact paths and proves the content was not + * truncated or altered in transit; it cannot be checked for plain text + * (the model's read may legitimately differ from the file's bytes). + * Returns null when no source is usable. + */ +async function resolveSieToolContent( + args: Record, + companyId: string, + userId: string +): Promise { + const sha256 = typeof args.sha256 === 'string' ? args.sha256.trim().toLowerCase() : null + const verifyBytes = (buffer: Buffer, source: string) => { + if (!sha256) return + const actual = createHash('sha256').update(buffer).digest('hex') + if (actual !== sha256) { + throw Object.assign( + new Error( + `sha256 mismatch on ${source}: expected ${sha256}, got ${actual}. The content was truncated or altered; re-send it.` + ), + { code: 'VALIDATION_ERROR' } + ) + } } - if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) { - const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser') - const buffer = Buffer.from(args.file_content_base64, 'base64') + + const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser') + + if (typeof args.upload_id === 'string' && args.upload_id.length > 0) { + const fileName = typeof args.filename === 'string' ? args.filename : '' + if (!fileName) { + throw Object.assign(new Error('filename is required together with upload_id'), { + code: 'VALIDATION_ERROR', + }) + } + const service = createServiceClientNoCookies() + const pendingPath = buildPendingDocumentStoragePath(companyId, userId, args.upload_id, fileName) + const { data, error } = await service.storage.from(DOCUMENTS_BUCKET).download(pendingPath) + if (error || !data) { + throw Object.assign( + new Error( + 'No uploaded file found for this upload_id. PUT the raw file bytes to the upload_url from gnubok_create_sie_upload first (same upload_id and filename).' + ), + { code: 'NOT_FOUND' } + ) + } + const buffer = Buffer.from(await data.arrayBuffer()) + verifyBytes(buffer, 'the uploaded file') const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer)) } + + if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) { + if (args.file_content_base64.length > MAX_INLINE_SIE_CHARS) { + throw Object.assign( + new Error( + 'File too large to pass inline safely. Use gnubok_create_sie_upload, PUT the raw bytes to its upload_url, and pass the upload_id here instead.' + ), + { code: 'VALIDATION_ERROR' } + ) + } + const buffer = Buffer.from(args.file_content_base64, 'base64') + verifyBytes(buffer, 'file_content_base64') + const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer)) + } + + if (typeof args.file_content === 'string' && args.file_content.length > 0) { + if (args.file_content.length > MAX_INLINE_SIE_CHARS) { + throw Object.assign( + new Error( + 'File too large to pass inline safely (silent mid-verifikat truncation risk). Use gnubok_create_sie_upload, PUT the raw bytes to its upload_url, and pass the upload_id here instead.' + ), + { code: 'VALIDATION_ERROR' } + ) + } + return args.file_content + } return null } @@ -16170,6 +16252,60 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_create_sie_upload', + title: 'Create SIE Upload', + description: + 'Short-lived URL for a model-free SIE upload: PUT the raw .se/.sie bytes (max 50 MB) to upload_url, then pass upload_id (+ same filename) to gnubok_sie_preflight and gnubok_import_sie. Required for files too large to pass inline; add sha256 of the bytes there to prove integrity.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + filename: { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'File name with extension, for example "export.se"', + }, + }, + required: ['filename'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + upload_id: { type: 'string' }, + upload_url: { type: 'string' }, + expires_at: { type: 'string' }, + }, + required: ['upload_id', 'upload_url', 'expires_at'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase) { + const fileName = args.filename as string + const lower = fileName.toLowerCase() + if (!lower.endsWith('.se') && !lower.endsWith('.sie') && !lower.endsWith('.si')) { + throw Object.assign(new Error('filename must end in .se, .sie or .si'), { + code: 'VALIDATION_ERROR', + }) + } + const uploadId = crypto.randomUUID() + const reservation = await createPendingDocumentUpload(supabase, companyId, userId, uploadId, fileName) + // Served from the app origin: agent sandboxes only reach the MCP host, + // not .supabase.co. See storage-proxy.ts. + return { + upload_id: reservation.uploadId, + upload_url: toSameOriginStorageUrl(reservation.signedUrl), + expires_at: reservation.expiresAt, + } + }, + }, + { name: 'gnubok_sie_preflight', title: 'SIE Preflight Scan', @@ -16179,8 +16315,10 @@ export const tools: McpTool[] = [ type: 'object', additionalProperties: false, properties: { - file_content: { type: 'string', description: 'Full SIE file contents as text' }, - file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preferred when available: preserves CP437 åäö)' }, + file_content: { type: 'string', description: 'Full SIE file contents as text (small files only)' }, + file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preserves CP437 åäö)' }, + upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' }, + sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths to prove nothing was truncated' }, filename: { type: 'string' }, }, required: ['filename'], @@ -16205,11 +16343,13 @@ export const tools: McpTool[] = [ idempotentHint: true, openWorldHint: false, }, - async execute(args, companyId, _userId, supabase) { - const content = await decodeSieToolContent(args) + async execute(args, companyId, userId, supabase) { + const content = await resolveSieToolContent(args, companyId, userId) if (!content) { throw Object.assign( - new Error('Provide the SIE file as file_content (text) or file_content_base64 (exact bytes).'), + new Error( + 'Provide the SIE file as upload_id (from gnubok_create_sie_upload), file_content_base64, or file_content.' + ), { code: 'VALIDATION_ERROR' } ) } @@ -16341,8 +16481,10 @@ export const tools: McpTool[] = [ type: 'object', additionalProperties: false, properties: { - file_content: { type: 'string', description: 'Full SIE file contents' }, + file_content: { type: 'string', description: 'Full SIE file contents (small files only)' }, file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded; alternative to file_content (preserves CP437 åäö)' }, + upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' }, + sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths' }, filename: { type: 'string', description: 'Original filename' }, mappings: { type: 'array', @@ -16363,12 +16505,12 @@ export const tools: McpTool[] = [ async execute(args, companyId, userId, supabase, actor) { // Decoded once here; the staged params carry the decoded text so // commitImportSie re-parses exactly what was previewed. - const fileContent = await decodeSieToolContent(args) + const fileContent = await resolveSieToolContent(args, companyId, userId) const filename = args.filename as string const mappings = args.mappings as unknown[] | undefined if (!fileContent || !filename || !Array.isArray(mappings)) { - throw new Error('file_content (or file_content_base64), filename, and mappings are required') + throw new Error('file content (upload_id, file_content_base64 or file_content), filename, and mappings are required') } // Parse + validate at stage time so the approver sees real content (which diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts index ed359896..6e94296a 100644 --- a/extensions/general/mcp-server/skills/onboarding.ts +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -16,6 +16,17 @@ continue straight into import or categorization. Staged writes still go through their normal approval, but that approval IS the conversation, not an extra pause around it. +**Brevity rule: this is onboarding, not a course.** Keep every reply under +~8 short lines. At most ONE warning per step, one line, and only when it +changes what the user should do right now. No legal essays, no deadline +tables, no cross-checks the user did not ask about (bolagsstämma dates, +EU-moms edge cases, K-regelverk). The user can always ask for depth; the +flow must never make them scroll past it. + +**Memory first.** Before asking the opening questions, check what you +already know about the user (memory, earlier conversation): orgnr, company +name, bank, previous system. Ask only for what is genuinely missing. + ## When to use - "Sätt upp bokföring för mitt AB / min enskilda firma" @@ -94,19 +105,28 @@ reaches far enough back anyway. Exportera data → SIE, **Björn Lundén / Briox / Wint** under Export. Every Swedish system exports SIE4 (.se/.sie); ask them to attach the file here in the chat. -2. When the file arrives, call \`gnubok_sie_preflight\` with its content - (\`file_content\` as read, or \`file_content_base64\` when exact bytes are - available: that preserves åäö in CP437 exports). Summarize the scan: - source system, fiscal years, verifikat count, balance status, org-number - match, warnings. This is the "does it look correct" moment: surface - problems BEFORE anything is written. -3. On their go-ahead: \`gnubok_import_sie\` with the same file and the - preflight's \`mappings\`. It stages for approval; after commit verify with - \`gnubok_get_trial_balance\`. +2. When the file arrives, get its BYTES to the server without retyping + them. Preferred (and REQUIRED for anything beyond a small file): call + \`gnubok_create_sie_upload\`, PUT the raw file bytes to the returned + \`upload_url\` (from your code sandbox when you have one), compute the + file's sha256, then call \`gnubok_sie_preflight\` with \`upload_id\` + + \`sha256\` + the same \`filename\`. Small files may go inline + (\`file_content_base64\` + \`sha256\` preferred over plain + \`file_content\`). NEVER reproduce a large file token by token: the + tools refuse oversized inline content because a mid-verifikat + truncation imports silently incomplete bookkeeping. +3. Summarize the preflight in a few lines: source system, fiscal years, + verifikat count, balance status, org-number match, the one warning that + matters. On the user's go-ahead: \`gnubok_import_sie\` with the same + source (\`upload_id\` or content) and the preflight's \`mappings\`. It + stages for approval; after commit verify with + \`gnubok_get_trial_balance\`, and explain any skipped voucher numbers + with \`gnubok_explain_voucher_gap\` (BFNAR 2013:2; an unexplained gap + blocks year-end). 4. Multiple fiscal years = multiple files: import oldest first so IB/UB - chains. If the file is very large for chat, the web wizard at - \`/import?mode=sie\` is the fallback; Fortnox users can also run the full - API migration (invoices, customers, documents) at + chains. The web wizard at \`/import?mode=sie\` is the fallback when no + upload path works; Fortnox users can also run the full API migration + (invoices, customers, documents) at \`/import?mode=migration&provider=fortnox\`. ## Step 4: connect bank and Skatteverket (together, no pause) @@ -136,6 +156,7 @@ message instead of making them ask. - \`gnubok_lookup_company\`: registry facts + prefill from the orgnr; call first - \`gnubok_create_company\`: preview (no confirm) then create (confirm=true) +- \`gnubok_create_sie_upload\`: byte-exact upload URL for the SIE file - \`gnubok_sie_preflight\`: scan a shared SIE file, nothing written - \`gnubok_import_sie\`: staged import; use the preflight's mappings - \`gnubok_connect_bank\` / \`gnubok_connect_skatteverket\`: status + connect links diff --git a/lib/auth/scope-catalog.ts b/lib/auth/scope-catalog.ts index 4ebc57f2..35728a2e 100644 --- a/lib/auth/scope-catalog.ts +++ b/lib/auth/scope-catalog.ts @@ -340,6 +340,8 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_export_sie: 'reports:read', gnubok_audit_package: 'reports:read', gnubok_import_sie: 'bookkeeping:write', + // Byte-exact SIE upload URL feeding gnubok_import_sie (same write intent). + gnubok_create_sie_upload: 'bookkeeping:write', // Rot/rut begäran om utbetalning (records a payout request on generate) gnubok_generate_rot_rut_file: 'invoices:write', // Supplier CRUD