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
parent dcb5d329a3
commit cd9fb5b717
7 changed files with 245 additions and 40 deletions
+1 -1
View File
@@ -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)"
},
+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
```
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: `<orgnr>`
> Tidigare bokföringssystem: `<t.ex. Fortnox, eller inget>`
> Bank: `<t.ex. Swedbank>`
> 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
@@ -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)
})
})
@@ -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' })
})
})
+161 -19
View File
@@ -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<string, unknown>): Promise<string | null> {
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<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 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 <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',
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
@@ -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
+2
View File
@@ -340,6 +340,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
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