feat(mcp): drag-and-drop SIE import card: exact bytes via tools/call, no model in the byte path (#1957)

E2E #6: the flow ordered SIE-first correctly, but the agent never
discovered gnubok_create_sie_upload, ran a local preflight, and sent the
user to the web wizard again; it also rendered a duplicate generic bank
card before the Swedbank-specific one.

1. New sie-drop widget (ui://sie-drop/app.html), rendered
   definition-level by gnubok_create_sie_upload: the user drags the
   .se/.sie file onto the card, the widget reads the EXACT bytes
   (FileReader), computes sha256 (WebCrypto), calls
   gnubok_sie_preflight via tools/call with file_content_base64 +
   sha256, shows the verdict, and on Importera stages
   gnubok_import_sie with the preflight's mappings. No network from
   the iframe, no model reproduction: byte path goes through the host
   bridge only, narrated into chat via ui/updateContext.

2. The inline size cap now applies only WITHOUT sha256: a hash-verified
   payload is byte-exact by proof, so the widget's 100 KB+ base64
   passes while unhashed model-retyped content stays refused.

3. Discovery + ordering fixes: create_sie_upload/preflight/import
   descriptions name the card path explicitly; create_company's
   history_note points at the card; connect_bank description says pass
   bank on the FIRST call when the user has named it (the duplicate
   generic card came from a bare call followed by the nudged retry).

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 17:06:30 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent fdb5f6f891
commit 0f7625535b
7 changed files with 370 additions and 18 deletions
@@ -141,7 +141,7 @@ describe('gnubok_create_company', () => {
supabase as never
)) as Record<string, unknown>
expect(result.history_note).toContain('gnubok_sie_preflight')
expect(result.history_note).toContain('gnubok_create_sie_upload')
expect(result.message).toContain('IN ORDER')
} finally {
vi.useRealTimers()
@@ -0,0 +1,46 @@
/**
* SIE drop widget: registration and wiring. The byte path itself (drop →
* preflight → import via tools/call with file_content_base64 + sha256) is
* asserted structurally on the HTML; the sha256/cap semantics live in
* sie-preflight.test.ts.
*/
import { describe, expect, it } from 'vitest'
import { tools } from '../server'
import { findUiWidget } from '../widgets'
describe('SIE drop widget', () => {
const widget = findUiWidget('ui://sie-drop/app.html')
it('is registered and renders the drop zone', () => {
expect(widget).toBeDefined()
expect(widget?.html).toContain('<!DOCTYPE html>')
expect(widget?.html).toContain('Importera bokföring')
expect(widget?.html).toContain("addEventListener('drop'")
})
it('passes exact bytes through tools/call with a sha256, never retyped or fetched', () => {
const html = widget!.html
expect(html).toContain("callTool('gnubok_sie_preflight'")
expect(html).toContain("callTool('gnubok_import_sie'")
expect(html).toContain('file_content_base64')
expect(html).toContain('sha256')
expect(html).toContain("crypto.subtle.digest('SHA-256'")
// No network from the iframe: bytes travel via the host bridge only.
expect(html).not.toContain('fetch(')
expect(html).not.toContain('XMLHttpRequest')
})
it('performs the ui/initialize handshake and narrates via ui/updateContext', () => {
const html = widget!.html
expect(html).toContain("sendRequest('ui/initialize'")
expect(html).toContain("sendNotification('ui/notifications/initialized')")
expect(html).toContain("sendNotification('ui/updateContext'")
})
it('is attached definition-level to gnubok_create_sie_upload', () => {
const tool = tools.find((t) => t.name === 'gnubok_create_sie_upload')!
expect((tool as { _meta?: { ui: { resourceUri: string } } })._meta).toEqual({
ui: { resourceUri: 'ui://sie-drop/app.html' },
})
})
})
@@ -193,6 +193,18 @@ describe('gnubok_sie_preflight', () => {
})
})
it('accepts oversized base64 WHEN a matching sha256 proves the bytes are complete', async () => {
// The drop-card widget path: byte-exact content, hash-verified, so the
// anti-retyping cap does not apply.
const huge = Buffer.from(VALID_SIE + '\n' + '#KONTO 9999 "x"\n'.repeat(10_000), 'utf8')
const { createHash } = await import('node:crypto')
const result = await run({
file_content_base64: huge.toString('base64'),
sha256: createHash('sha256').update(huge).digest('hex'),
})
expect(result.verdict).toBeDefined()
})
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')
+12 -7
View File
@@ -1518,10 +1518,14 @@ async function resolveSieToolContent(
}
if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) {
if (args.file_content_base64.length > MAX_INLINE_SIE_CHARS) {
// The inline cap exists to stop a MODEL from retyping a large file with
// silent truncation. A caller that provides sha256 (the drop-card widget
// always does) has byte-exact content and the hash check below IS the
// truncation guard, so the cap does not apply.
if (!sha256 && 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.'
'File too large to pass inline safely without a sha256. Use gnubok_create_sie_upload (drag-and-drop card / PUT to its upload_url) and pass the upload_id here, or include sha256 of the raw bytes.'
),
{ code: 'VALIDATION_ERROR' }
)
@@ -3415,7 +3419,7 @@ export const tools: McpTool[] = [
trial: 'A 30-day trial with every paid capability (bank sync, Skatteverket, AI, e-mail) is active from now.',
...(historyFirst
? {
history_note: `The fiscal period started ${daysOfHistory} days ago but bank PSD2 history reaches ~90 days: ask which system the bookkeeping lived in and run the SIE import (gnubok_sie_preflight) BEFORE connecting the bank.`,
history_note: `The fiscal period started ${daysOfHistory} days ago but bank PSD2 history reaches ~90 days: ask which system the bookkeeping lived in and run the SIE import BEFORE connecting the bank (call gnubok_create_sie_upload to render the drag-and-drop import card).`,
}
: {}),
message: historyFirst
@@ -3434,7 +3438,7 @@ export const tools: McpTool[] = [
name: 'gnubok_connect_bank',
title: 'Connect Bank',
description:
'Bank connection status plus the browser link where the user connects a bank (PSD2, BankID consent; must be logged in to Accounted there). Ask WHICH bank they use first and pass it as bank: the link then starts that bank\'s consent directly instead of a picker.',
'Bank connection status plus the browser connect link (PSD2, BankID; user must be logged in to Accounted). When the user has NAMED their bank, pass it as bank on the FIRST call (a bare call renders a redundant generic card): the link then starts that bank\'s consent directly.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -16291,7 +16295,7 @@ 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.',
'The SIE-file intake: on claude.ai/Desktop this renders a DRAG-AND-DROP card that reads exact bytes, preflights and imports: call it as soon as an SIE import is next. Elsewhere: PUT raw bytes (max 50 MB) to upload_url, then pass upload_id + sha256 to preflight/import.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -16315,6 +16319,7 @@ export const tools: McpTool[] = [
},
required: ['upload_id', 'upload_url', 'expires_at'],
},
_meta: { ui: { resourceUri: 'ui://sie-drop/app.html' } },
annotations: {
readOnlyHint: false,
destructiveHint: false,
@@ -16345,7 +16350,7 @@ export const tools: McpTool[] = [
name: 'gnubok_sie_preflight',
title: 'SIE Preflight Scan',
description:
'Scan a SIE file BEFORE import: parse, validate (balances, IB, encoding), duplicate check, orgnr match against the company, suggested account mappings. Read-only, stages nothing. Call FIRST when the user shares a SIE file; pass the returned mappings to gnubok_import_sie.',
'Scan a SIE file BEFORE import: parse, validate (balances, IB, encoding), duplicates, orgnr match, suggested mappings. Read-only. Call gnubok_create_sie_upload FIRST: its card/URL carries exact bytes; NEVER retype a large file. Mappings feed gnubok_import_sie.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -16511,7 +16516,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_import_sie',
title: 'Import SIE File',
description: 'Stage SIE-file import (types 1-4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. High-risk, always staged. Run gnubok_sie_preflight first for the scan and the mappings.',
description: 'Stage SIE-file import (types 1-4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. Always staged. Run gnubok_sie_preflight first; large files arrive byte-exact via gnubok_create_sie_upload (card/URL), NEVER retyped inline.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -105,16 +105,17 @@ 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, 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.
2. As soon as SIE import is the next step, call
\`gnubok_create_sie_upload\`. On claude.ai/Desktop it renders a
DRAG-AND-DROP card: the user drops the file on it and the card itself
runs the preflight and stages the import with exact bytes; you only
narrate the verdict and handle the approval. Without the card: PUT the
raw bytes to \`upload_url\` (from your code sandbox), compute sha256,
and call \`gnubok_sie_preflight\` with \`upload_id\` + \`sha256\` +
\`filename\`; smaller files may go inline as \`file_content_base64\` +
\`sha256\`. NEVER reproduce a large file token by token: unhashed
oversized inline content is refused 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
@@ -3,12 +3,14 @@ import { receiptMatcherWidget } from './receipt-matcher'
import { vatReviewWidget } from './vat-review'
import { pendingOperationsWidget } from './pending-operations'
import { connectCardWidget } from './connect-card'
import { sieDropWidget } from './sie-drop'
export const uiWidgets: UiWidget[] = [
receiptMatcherWidget,
vatReviewWidget,
pendingOperationsWidget,
connectCardWidget,
sieDropWidget,
]
export function findUiWidget(uri: string): UiWidget | null {
@@ -0,0 +1,286 @@
import type { UiWidget } from './types'
/**
* SIE Drop Widget: MCP Apps inline HTML rendered by gnubok_create_sie_upload.
* The user drags their .se/.sie export onto the card; the widget reads the
* EXACT bytes itself (FileReader), computes sha256, and passes them through
* `tools/call` as file_content_base64: no model in the byte path, so a
* 100 KB+ file imports without token-by-token reproduction risk. Flow:
* drop → gnubok_sie_preflight (verdict shown in the card) → user clicks
* Importera → gnubok_import_sie with the preflight's mappings (stages for
* approval as always). The tool's upload_url stays available as a fallback
* for hosts without the widget.
*/
export const SIE_DROP_HTML = `<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SIE-import - Accounted</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--border: rgba(0,0,0,0.1);
--text: #1a1a1a;
--text-muted: #6b6b6b;
--success: #5a7a5a;
--success-bg: rgba(90,122,90,0.08);
--error: #b35a3a;
--error-bg: rgba(179,90,58,0.08);
--accent: #1a1a1a;
--accent-text: #ffffff;
--drop-bg: rgba(0,0,0,0.03);
--drop-active: rgba(90,122,90,0.12);
}
.dark {
--bg: #161616;
--surface: #1e1e1e;
--border: rgba(255,255,255,0.1);
--text: #e5e5e5;
--text-muted: #999;
--success: #7aab7a;
--success-bg: rgba(122,171,122,0.1);
--error: #d4816a;
--error-bg: rgba(212,129,106,0.1);
--accent: #e5e5e5;
--accent-text: #161616;
--drop-bg: rgba(255,255,255,0.03);
--drop-active: rgba(122,171,122,0.12);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg); color: var(--text);
font-size: 13px; line-height: 1.5; padding: 12px;
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; max-width: 520px; }
h1 { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.lede { color: var(--text-muted); margin-bottom: 12px; }
.drop {
border: 2px dashed var(--border); border-radius: 8px;
background: var(--drop-bg); padding: 28px 16px; text-align: center;
color: var(--text-muted); cursor: pointer; transition: background 150ms, border-color 150ms;
}
.drop.active { background: var(--drop-active); border-color: var(--success); }
.hidden { display: none; }
.report { border-top: 1px solid var(--border); margin-top: 12px; padding-top: 12px; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
.row .k { color: var(--text-muted); }
.row .v { font-variant-numeric: tabular-nums; text-align: right; }
.status { display: inline-block; font-size: 12px; font-weight: 500; border-radius: 999px; padding: 2px 10px; margin-bottom: 8px; }
.status.ok { color: var(--success); background: var(--success-bg); }
.status.bad { color: var(--error); background: var(--error-bg); }
.warnings { margin-top: 8px; color: var(--error); font-size: 12px; }
.actions { display: flex; gap: 8px; margin-top: 12px; }
button {
font: inherit; font-weight: 500; cursor: pointer; border-radius: 6px;
padding: 8px 14px; border: 1px solid var(--border);
background: var(--surface); color: var(--text);
}
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button:focus-visible { outline: 2px solid var(--success); outline-offset: 2px; }
.note { margin-top: 10px; color: var(--text-muted); font-size: 12px; }
input[type="file"] { display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Importera bokföring (SIE)</h1>
<p class="lede">Släpp SIE-filen här så kontrolleras den innan något bokförs.</p>
<div class="drop" id="drop">Släpp .se/.sie-filen här, eller klicka för att välja</div>
<input type="file" id="picker" accept=".se,.sie,.si" />
<div class="report hidden" id="report"></div>
<div class="actions hidden" id="actions">
<button class="primary" id="import">Importera</button>
</div>
<p class="note hidden" id="note"></p>
</div>
<script>
(function() {
// ── MCP Apps Bridge ──
let rpcId = 1;
const pending = new Map();
let fileState = null; // { name, base64, sha256, preflight }
function sendRequest(method, params) {
const id = rpcId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
});
}
function sendNotification(method, params) {
window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
}
function callTool(name, args) {
return sendRequest('tools/call', { name: name, arguments: args });
}
window.addEventListener('message', function(e) {
const msg = e.data;
if (!msg || msg.jsonrpc !== '2.0') return;
if (msg.id != null && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(msg.error); else resolve(msg.result);
return;
}
if (msg.method === 'ui/notifications/host-context-changed') applyTheme(msg.params);
});
function applyTheme(ctx) {
if (!ctx) return;
if (ctx.theme === 'dark') document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
}
sendRequest('ui/initialize', { name: 'gnubok-sie-drop', version: '1.0.0' })
.then(function(res) {
if (res && res.hostContext) applyTheme(res.hostContext);
sendNotification('ui/notifications/initialized');
})
.catch(function() { sendNotification('ui/notifications/initialized'); });
// ── Helpers ──
function el(id) { return document.getElementById(id); }
function show(id) { el(id).classList.remove('hidden'); }
function hide(id) { el(id).classList.add('hidden'); }
function note(text) { el('note').textContent = text; show('note'); }
function toBase64(buffer) {
const u8 = new Uint8Array(buffer);
let s = '';
const CHUNK = 0x8000;
for (let i = 0; i < u8.length; i += CHUNK) {
s += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));
}
return btoa(s);
}
function toHex(buffer) {
return Array.from(new Uint8Array(buffer)).map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
}
function parseResult(res) {
if (res && res.structuredContent) return res.structuredContent;
try { return JSON.parse(res.content[0].text); } catch (e) { return null; }
}
// ── Drop handling ──
const drop = el('drop');
drop.addEventListener('click', function() { el('picker').click(); });
el('picker').addEventListener('change', function(e) {
if (e.target.files && e.target.files[0]) handleFile(e.target.files[0]);
});
;['dragover', 'dragenter'].forEach(function(ev) {
drop.addEventListener(ev, function(e) { e.preventDefault(); drop.classList.add('active'); });
});
;['dragleave', 'drop'].forEach(function(ev) {
drop.addEventListener(ev, function(e) { e.preventDefault(); drop.classList.remove('active'); });
});
drop.addEventListener('drop', function(e) {
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (file) handleFile(file);
});
function handleFile(file) {
const lower = file.name.toLowerCase();
if (!lower.endsWith('.se') && !lower.endsWith('.sie') && !lower.endsWith('.si')) {
note('Filen måste vara en SIE-export (.se eller .sie).');
return;
}
drop.textContent = 'Kontrollerar ' + file.name + '…';
const reader = new FileReader();
reader.onload = function() {
const buffer = reader.result;
crypto.subtle.digest('SHA-256', buffer).then(function(hash) {
const base64 = toBase64(buffer);
const sha256 = toHex(hash);
fileState = { name: file.name, base64: base64, sha256: sha256, preflight: null };
sendNotification('ui/updateContext', {
content: 'Användaren släppte ' + file.name + ' på importkortet; preflight körs med exakta bytes (sha256 ' + sha256.slice(0, 12) + '…).'
});
callTool('gnubok_sie_preflight', {
filename: file.name,
file_content_base64: base64,
sha256: sha256
}).then(function(res) {
const sc = parseResult(res);
if (!sc) { drop.textContent = 'Kunde inte läsa svaret. Försök igen.'; return; }
fileState.preflight = sc;
renderReport(sc);
}).catch(function(err) {
drop.textContent = 'Preflight misslyckades: ' + (err && err.message ? err.message : 'okänt fel');
});
}).catch(function() {
note('Kunde inte beräkna filens kontrollsumma i denna miljö. Ladda upp via app.accounted.se/import?mode=sie i stället.');
});
};
reader.readAsArrayBuffer(file);
}
function renderReport(sc) {
drop.textContent = fileState.name;
const ok = sc.verdict === 'ok' || sc.verdict === 'ok_with_warnings';
const f = sc.file || {};
const rows = [
['Företag', (f.company_name || '?') + ' (' + (f.org_number || '?') + ')'],
['Räkenskapsår', ((f.fiscal_year || {}).start || '?') + ' – ' + ((f.fiscal_year || {}).end || '?')],
['Verifikat', String(f.voucher_count != null ? f.voucher_count : '?')],
['Konton', String(f.account_count != null ? f.account_count : '?')],
];
let html = '<span class="status ' + (ok ? 'ok' : 'bad') + '">' +
(sc.verdict === 'ok' ? 'Ser korrekt ut'
: sc.verdict === 'ok_with_warnings' ? 'OK med anmärkningar'
: sc.verdict === 'duplicate' ? 'Redan importerad'
: 'Ogiltig fil') + '</span>';
rows.forEach(function(r) {
html += '<div class="row"><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></div>';
});
const warnings = ((sc.validation || {}).warnings || []).concat((sc.validation || {}).errors || []);
if (warnings.length > 0) {
html += '<div class="warnings">' + warnings.slice(0, 3).map(function(w) { return '• ' + w; }).join('<br>') + '</div>';
}
el('report').innerHTML = html;
show('report');
if (ok) { show('actions'); el('import').disabled = false; }
else hide('actions');
}
el('import').addEventListener('click', function() {
if (!fileState || !fileState.preflight) return;
el('import').disabled = true;
el('import').textContent = 'Importerar…';
callTool('gnubok_import_sie', {
filename: fileState.name,
file_content_base64: fileState.base64,
sha256: fileState.sha256,
mappings: fileState.preflight.mappings || [],
create_fiscal_period: true,
import_opening_balances: true,
import_transactions: true
}).then(function(res) {
const sc = parseResult(res);
el('import').textContent = 'Import förberedd';
note('Importen är förberedd och väntar på godkännande: säg till i chatten eller godkänn i Accounted, så bokförs verifikationerna.');
sendNotification('ui/updateContext', {
content: 'SIE-importen av ' + fileState.name + ' är stagead' + (sc && sc.operation_id ? ' (operation ' + sc.operation_id + ')' : '') + ' och väntar på godkännande.'
});
}).catch(function(err) {
el('import').disabled = false;
el('import').textContent = 'Importera';
note('Import misslyckades: ' + (err && err.message ? err.message : 'okänt fel'));
});
});
})();
</script>
</body>
</html>
`
export const sieDropWidget: UiWidget = {
uri: 'ui://sie-drop/app.html',
name: 'SIE Import Drop',
description:
'Drag-and-drop SIE import card: reads the exact file bytes, runs gnubok_sie_preflight, and stages gnubok_import_sie on click. Rendered by gnubok_create_sie_upload.',
html: SIE_DROP_HTML,
}