From 0f7625535b5eee854dea2950c80ffd4bf0c1b4c7 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 17:06:30 +0200 Subject: [PATCH] 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 --- .../__tests__/create-company.test.ts | 2 +- .../__tests__/sie-drop-widget.test.ts | 46 +++ .../__tests__/sie-preflight.test.ts | 12 + extensions/general/mcp-server/server.ts | 19 +- .../general/mcp-server/skills/onboarding.ts | 21 +- .../general/mcp-server/widgets/index.ts | 2 + .../general/mcp-server/widgets/sie-drop.ts | 286 ++++++++++++++++++ 7 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 extensions/general/mcp-server/__tests__/sie-drop-widget.test.ts create mode 100644 extensions/general/mcp-server/widgets/sie-drop.ts diff --git a/extensions/general/mcp-server/__tests__/create-company.test.ts b/extensions/general/mcp-server/__tests__/create-company.test.ts index 1a6b3303..e6ee067e 100644 --- a/extensions/general/mcp-server/__tests__/create-company.test.ts +++ b/extensions/general/mcp-server/__tests__/create-company.test.ts @@ -141,7 +141,7 @@ describe('gnubok_create_company', () => { supabase as never )) as Record - 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() diff --git a/extensions/general/mcp-server/__tests__/sie-drop-widget.test.ts b/extensions/general/mcp-server/__tests__/sie-drop-widget.test.ts new file mode 100644 index 00000000..a91fcbbf --- /dev/null +++ b/extensions/general/mcp-server/__tests__/sie-drop-widget.test.ts @@ -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('') + 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' }, + }) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/sie-preflight.test.ts b/extensions/general/mcp-server/__tests__/sie-preflight.test.ts index 9da179ab..425268f0 100644 --- a/extensions/general/mcp-server/__tests__/sie-preflight.test.ts +++ b/extensions/general/mcp-server/__tests__/sie-preflight.test.ts @@ -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') diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index d226e25f..3e87b151 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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, diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts index 6e94296a..6e84d703 100644 --- a/extensions/general/mcp-server/skills/onboarding.ts +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -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 diff --git a/extensions/general/mcp-server/widgets/index.ts b/extensions/general/mcp-server/widgets/index.ts index 59bc4f08..502dea2b 100644 --- a/extensions/general/mcp-server/widgets/index.ts +++ b/extensions/general/mcp-server/widgets/index.ts @@ -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 { diff --git a/extensions/general/mcp-server/widgets/sie-drop.ts b/extensions/general/mcp-server/widgets/sie-drop.ts new file mode 100644 index 00000000..2811f3ef --- /dev/null +++ b/extensions/general/mcp-server/widgets/sie-drop.ts @@ -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 = ` + + + + +SIE-import - Accounted + + + +
+

Importera bokföring (SIE)

+

Släpp SIE-filen här så kontrolleras den innan något bokförs.

+
Släpp .se/.sie-filen här, eller klicka för att välja
+ + + + +
+ + + + +` + +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, +}