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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 16:11:11 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent dcb5d329a3
commit cd9fb5b717
7 changed files with 245 additions and 40 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "accounted", "name": "accounted",
"displayName": "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.", "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": { "author": {
"name": "Accounted (erp-mafia)" "name": "Accounted (erp-mafia)"
}, },
+2 -7
View File
@@ -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 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. 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.
> Sätt upp mitt företag i Accounted.
> Organisationsnummer: `<orgnr>`
> Tidigare bokföringssystem: `<t.ex. Fortnox, eller inget>`
> Bank: `<t.ex. Swedbank>`
## How the connection works ## How the connection works
@@ -230,9 +230,13 @@ describe('tools/list payload size guard', () => {
// the scan-before-import step the skill instructs for shared SIE // the scan-before-import step the skill instructs for shared SIE
// files, so default-catalog for the same Claude.ai reason; ~390 // files, so default-catalog for the same Claude.ai reason; ~390
// tokens (schema carries the mappings-passthrough contract). // 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 // 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 // fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further. // before bumping further.
expect(approxTokens).toBeLessThan(62_000) expect(approxTokens).toBeLessThan(62_400)
}) })
}) })
@@ -184,4 +184,45 @@ describe('gnubok_sie_preflight', () => {
it('rejects a call with neither content field', async () => { it('rejects a call with neither content field', async () => {
await expect(run({})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) 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' })
})
}) })
+161 -19
View File
@@ -257,8 +257,11 @@ import {
createPendingDocumentUpload, createPendingDocumentUpload,
uploadDocument, uploadDocument,
MAX_DOCUMENT_SIZE, MAX_DOCUMENT_SIZE,
buildPendingDocumentStoragePath,
DOCUMENTS_BUCKET,
} from '@/lib/core/documents/document-service' } from '@/lib/core/documents/document-service'
import { toSameOriginStorageUrl } from '@/lib/core/documents/storage-proxy' 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 { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction' import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction'
// Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned // 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 * Inline SIE content above this length is refused: a model reproducing tens
* attachment) or base64 (exact bytes, e.g. from a code-execution sandbox). * of thousands of tokens verbatim WILL eventually truncate mid-#VER, and a
* The base64 path runs the same encoding detection as the HTTP upload route, * truncated file that still parses imports silently incomplete bookkeeping.
* so CP437 exports keep their åäö instead of arriving pre-mangled through a * Larger files go through gnubok_create_sie_upload (raw bytes, no model in
* host's UTF-8 read. Returns null when neither field is usable. * the path). ~120k chars ≈ a few thousand verifikat rows.
*/ */
async function decodeSieToolContent(args: Record<string, unknown>): Promise<string | null> { const MAX_INLINE_SIE_CHARS = 120_000
if (typeof args.file_content === 'string' && args.file_content.length > 0) {
return args.file_content /**
* 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<string, unknown>,
companyId: string,
userId: string
): Promise<string | null> {
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 { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser')
const buffer = Buffer.from(args.file_content_base64, 'base64')
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) const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer)) 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 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 <project>.supabase.co. See storage-proxy.ts.
return {
upload_id: reservation.uploadId,
upload_url: toSameOriginStorageUrl(reservation.signedUrl),
expires_at: reservation.expiresAt,
}
},
},
{ {
name: 'gnubok_sie_preflight', name: 'gnubok_sie_preflight',
title: 'SIE Preflight Scan', title: 'SIE Preflight Scan',
@@ -16179,8 +16315,10 @@ export const tools: McpTool[] = [
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
file_content: { type: 'string', description: 'Full SIE file contents as text' }, 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 (preferred when available: preserves CP437 åäö)' }, 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' }, filename: { type: 'string' },
}, },
required: ['filename'], required: ['filename'],
@@ -16205,11 +16343,13 @@ export const tools: McpTool[] = [
idempotentHint: true, idempotentHint: true,
openWorldHint: false, openWorldHint: false,
}, },
async execute(args, companyId, _userId, supabase) { async execute(args, companyId, userId, supabase) {
const content = await decodeSieToolContent(args) const content = await resolveSieToolContent(args, companyId, userId)
if (!content) { if (!content) {
throw Object.assign( 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' } { code: 'VALIDATION_ERROR' }
) )
} }
@@ -16341,8 +16481,10 @@ export const tools: McpTool[] = [
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { 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 åäö)' }, 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' }, filename: { type: 'string', description: 'Original filename' },
mappings: { mappings: {
type: 'array', type: 'array',
@@ -16363,12 +16505,12 @@ export const tools: McpTool[] = [
async execute(args, companyId, userId, supabase, actor) { async execute(args, companyId, userId, supabase, actor) {
// Decoded once here; the staged params carry the decoded text so // Decoded once here; the staged params carry the decoded text so
// commitImportSie re-parses exactly what was previewed. // 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 filename = args.filename as string
const mappings = args.mappings as unknown[] | undefined const mappings = args.mappings as unknown[] | undefined
if (!fileContent || !filename || !Array.isArray(mappings)) { 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 // Parse + validate at stage time so the approver sees real content (which
@@ -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 through their normal approval, but that approval IS the conversation, not an
extra pause around it. 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 ## When to use
- "Sätt upp bokföring för mitt AB / min enskilda firma" - "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. Exportera data → SIE, **Björn Lundén / Briox / Wint** under Export.
Every Swedish system exports SIE4 (.se/.sie); ask them to attach the Every Swedish system exports SIE4 (.se/.sie); ask them to attach the
file here in the chat. file here in the chat.
2. When the file arrives, call \`gnubok_sie_preflight\` with its content 2. When the file arrives, get its BYTES to the server without retyping
(\`file_content\` as read, or \`file_content_base64\` when exact bytes are them. Preferred (and REQUIRED for anything beyond a small file): call
available: that preserves åäö in CP437 exports). Summarize the scan: \`gnubok_create_sie_upload\`, PUT the raw file bytes to the returned
source system, fiscal years, verifikat count, balance status, org-number \`upload_url\` (from your code sandbox when you have one), compute the
match, warnings. This is the "does it look correct" moment: surface file's sha256, then call \`gnubok_sie_preflight\` with \`upload_id\` +
problems BEFORE anything is written. \`sha256\` + the same \`filename\`. Small files may go inline
3. On their go-ahead: \`gnubok_import_sie\` with the same file and the (\`file_content_base64\` + \`sha256\` preferred over plain
preflight's \`mappings\`. It stages for approval; after commit verify with \`file_content\`). NEVER reproduce a large file token by token: the
\`gnubok_get_trial_balance\`. 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 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 chains. The web wizard at \`/import?mode=sie\` is the fallback when no
\`/import?mode=sie\` is the fallback; Fortnox users can also run the full upload path works; Fortnox users can also run the full API migration
API migration (invoices, customers, documents) at (invoices, customers, documents) at
\`/import?mode=migration&provider=fortnox\`. \`/import?mode=migration&provider=fortnox\`.
## Step 4: connect bank and Skatteverket (together, no pause) ## 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_lookup_company\`: registry facts + prefill from the orgnr; call first
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true) - \`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_sie_preflight\`: scan a shared SIE file, nothing written
- \`gnubok_import_sie\`: staged import; use the preflight's mappings - \`gnubok_import_sie\`: staged import; use the preflight's mappings
- \`gnubok_connect_bank\` / \`gnubok_connect_skatteverket\`: status + connect links - \`gnubok_connect_bank\` / \`gnubok_connect_skatteverket\`: status + connect links
+2
View File
@@ -340,6 +340,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_export_sie: 'reports:read', gnubok_export_sie: 'reports:read',
gnubok_audit_package: 'reports:read', gnubok_audit_package: 'reports:read',
gnubok_import_sie: 'bookkeeping:write', 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) // Rot/rut begäran om utbetalning (records a payout request on generate)
gnubok_generate_rot_rut_file: 'invoices:write', gnubok_generate_rot_rut_file: 'invoices:write',
// Supplier CRUD // Supplier CRUD