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
@@ -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,
}