diff --git a/extensions/general/mcp-server/__tests__/connect-links.test.ts b/extensions/general/mcp-server/__tests__/connect-links.test.ts index 36d2900e..f46308ea 100644 --- a/extensions/general/mcp-server/__tests__/connect-links.test.ts +++ b/extensions/general/mcp-server/__tests__/connect-links.test.ts @@ -132,3 +132,47 @@ describe('onboarding connect-link tools', () => { expect(result.connect_url).toBeNull() }) }) + +describe('gnubok_connect_migration', () => { + const migrationTool = tools.find((t) => t.name === 'gnubok_connect_migration')! + + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test') + }) + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('renders the connect card and deep-links the wizard for an API provider', async () => { + expect( + (migrationTool as { _meta?: { ui: { resourceUri: string } } })._meta + ).toEqual({ ui: { resourceUri: 'ui://connect-card/app.html' } }) + + const result = (await migrationTool.execute( + { provider: 'fortnox' }, + COMPANY_ID, + 'user-1', + {} as never + )) as Record + expect(result.connect_url).toBe('https://app.example.test/import?mode=migration&provider=fortnox') + expect(result.api_connected).toBe(true) + expect(result.provider_name).toBe('Fortnox') + }) + + it('tells the agent SIE comes first for a file-only provider', async () => { + const result = (await migrationTool.execute( + { provider: 'bokio' }, + COMPANY_ID, + 'user-1', + {} as never + )) as Record + expect(result.api_connected).toBe(false) + expect(result.instructions).toContain('gnubok_create_sie_upload') + }) + + it('rejects an unknown provider', async () => { + await expect( + migrationTool.execute({ provider: 'monopol' }, COMPANY_ID, 'user-1', {} as never) + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index b3e6d513..c9fbf758 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -234,9 +234,13 @@ describe('tools/list payload size guard', () => { // 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. + // * 62.4K to 63K with gnubok_connect_migration (the previous-system + // connect card, same one-click feel as bank/Skatteverket): the skill + // instructs calling it when the user names Fortnox/BL/Briox/Wint, so + // default catalog for the standing Claude.ai reason. // 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_400) + expect(approxTokens).toBeLessThan(63_000) }) }) diff --git a/extensions/general/mcp-server/__tests__/protocol-2026-07-28.test.ts b/extensions/general/mcp-server/__tests__/protocol-2026-07-28.test.ts index d3fbe0a8..d2312299 100644 --- a/extensions/general/mcp-server/__tests__/protocol-2026-07-28.test.ts +++ b/extensions/general/mcp-server/__tests__/protocol-2026-07-28.test.ts @@ -124,7 +124,9 @@ describe('MCP spec revision 2026-07-28', () => { it('decorates results for stateless clients: resultType, serverInfo, freshness', async () => { const { result } = await readBody(mcpRequest('tools/list', { _meta: STATELESS_META })) expect(result?.resultType).toBe('complete') - expect(result?.ttlMs).toBe(3_600_000) + // 5 min, not 1 h: the catalog and widgets change with every deploy and a + // long client cache made freshly shipped tools flap in and out (E2E #7/#8). + expect(result?.ttlMs).toBe(300_000) expect(result?.cacheScope).toBe('private') const meta = result?._meta as Record> expect(meta['io.modelcontextprotocol/serverInfo'].name).toBe('gnubok') @@ -291,7 +293,9 @@ describe('MCP spec revision 2026-07-28', () => { it('adds freshness hints to prompts/list for stateless clients', async () => { const { result } = await readBody(mcpRequest('prompts/list', { _meta: STATELESS_META })) expect(result?.resultType).toBe('complete') - expect(result?.ttlMs).toBe(3_600_000) + // 5 min, not 1 h: the catalog and widgets change with every deploy and a + // long client cache made freshly shipped tools flap in and out (E2E #7/#8). + expect(result?.ttlMs).toBe(300_000) }) it('keeps resource-not-found on -32602 (invalid params)', async () => { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3e87b151..da6d28cb 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -3557,6 +3557,72 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_connect_migration', + title: 'Connect Previous System', + description: + 'Connect card into the migration wizard for a NAMED previous system. API systems (fortnox/bjornlunden/briox/wint) fetch all fiscal years plus invoices, customers and documents; visma/bokio complement AFTER a SIE import. Same one-click feel as the bank/Skatteverket cards.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + provider: { + type: 'string', + enum: ['fortnox', 'bjornlunden', 'briox', 'wint', 'visma', 'bokio'], + description: 'The previous system the user named', + }, + }, + required: ['provider'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + provider: { type: 'string' }, + provider_name: { type: 'string' }, + api_connected: { type: 'boolean', description: 'true = the wizard fetches SIE directly from the system; false = a SIE file import comes first' }, + connect_url: { type: 'string' }, + instructions: { type: 'string' }, + }, + required: ['provider', 'provider_name', 'api_connected', 'connect_url', 'instructions'], + }, + _meta: { ui: { resourceUri: 'ui://connect-card/app.html' } }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args) { + const PROVIDERS: Record = { + fortnox: { name: 'Fortnox', api: true }, + bjornlunden: { name: 'Björn Lundén', api: true }, + briox: { name: 'Briox', api: true }, + wint: { name: 'Wint', api: true }, + visma: { name: 'Visma eEkonomi', api: false }, + bokio: { name: 'Bokio', api: false }, + } + const provider = String(args.provider ?? '') + const info = PROVIDERS[provider] + if (!info) { + throw Object.assign( + new Error(`Unknown provider "${provider}". Supported: ${Object.keys(PROVIDERS).join(', ')}.`), + { code: 'VALIDATION_ERROR' } + ) + } + const connectUrl = `${connectLinkBaseUrl()}/import?mode=migration&provider=${provider}` + return { + provider, + provider_name: info.name, + api_connected: info.api, + connect_url: connectUrl, + instructions: info.api + ? `On claude.ai/Claude Desktop a connect card with an open-in-browser button renders with this result; elsewhere give the user the connect_url. The wizard connects to ${info.name} (login there), fetches every fiscal year and imports bookkeeping PLUS invoices, customers, suppliers and documents. The user comes back here when the wizard reports done.` + : `${info.name} has no API export: run the SIE-file import FIRST (gnubok_create_sie_upload drop card). This wizard link then complements with invoices and customers. On claude.ai/Desktop a connect card renders; elsewhere give the user the connect_url.`, + } + }, + }, + { name: 'gnubok_get_company_settings', title: 'Get Company Settings', @@ -18917,7 +18983,13 @@ const JSONRPC_UNSUPPORTED_PROTOCOL_VERSION = -32022 // change only on deploy; skills live in the DB and can change between // deploys; data resources are live ledger state and must never be cached. // Everything is served behind Authorization, so cacheScope stays private. -const CACHE_STATIC = { ttlMs: 3_600_000, cacheScope: 'private' } as const +// "Static" content (tools/list, widget HTML, prompts) is static only within +// one deploy: every deploy can add tools and change widgets. The 1-hour hint +// this used to carry made Claude.ai serve a pre-deploy catalog for up to an +// hour after a release: a freshly shipped tool flapped in and out of the +// connector's tool list depending on which fetch hit the client cache +// (E2E #7/#8, 2026-08-26). 5 minutes bounds that window to one coffee sip. +const CACHE_STATIC = { ttlMs: 300_000, cacheScope: 'private' } as const const CACHE_SKILLS = { ttlMs: 300_000, cacheScope: 'private' } as const const CACHE_LIVE = { ttlMs: 0, cacheScope: 'private' } as const diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts index c385a280..085ee6c9 100644 --- a/extensions/general/mcp-server/skills/onboarding.ts +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -102,18 +102,19 @@ reaches far enough back anyway. 1. Branch on WHICH system they name: - **Fortnox / Björn Lundén / Briox / Wint** (API-connected systems): - offer TWO paths and recommend by need. The FULL migration at - \`/import?mode=migration&provider=\` - connects to the old system directly and fetches every fiscal year - PLUS invoices, customers, suppliers and documents: recommend it when - they have open fakturor or want underlag along. The QUICK path is a - SIE export dropped here (Fortnox: Register → Exportera → SIE 4): - ledger only, fastest. Either way the result lands in the same books. + offer TWO paths and recommend by need. The FULL migration: call + \`gnubok_connect_migration\` with the provider; it renders a connect + card (same feel as bank/Skatteverket) whose button opens the wizard + that logs into the old system and fetches every fiscal year PLUS + invoices, customers, suppliers and documents: recommend it when they + have open fakturor or want underlag along. The QUICK path is a SIE + export dropped here (Fortnox: Register → Exportera → SIE 4): ledger + only, fastest. Either way the result lands in the same books. - **Visma eEkonomi / Bokio**: no API export exists; ask for the SIE file (Visma: Bokföring → Export SIE; Bokio: Inställningar → - Exportera data → SIE) and use the drop card. The wizard at - \`/import?mode=migration&provider=\` can complement with - invoices and customers AFTER the SIE import. + Exportera data → SIE) and use the drop card FIRST. Then + \`gnubok_connect_migration\` with the provider renders the card that + complements with invoices and customers. - **Annat/okänt system**: every Swedish system exports SIE4 (.se/.sie); ask them to export it and drop it here. 2. As soon as SIE import is the next step, call @@ -172,6 +173,7 @@ message instead of making them ask. - \`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 +- \`gnubok_connect_migration\`: connect card into the previous-system wizard - \`gnubok_list_companies\`, \`gnubok_get_agent_briefing\`: state checks - \`gnubok_list_uncategorized_transactions\`: the first real bookkeeping step diff --git a/extensions/general/mcp-server/widgets/connect-card.ts b/extensions/general/mcp-server/widgets/connect-card.ts index eff2dd61..b7d1c60a 100644 --- a/extensions/general/mcp-server/widgets/connect-card.ts +++ b/extensions/general/mcp-server/widgets/connect-card.ts @@ -166,8 +166,25 @@ export const CONNECT_CARD_HTML = ` function render(sc) { const isSkv = 'available' in sc; + const isMigration = 'provider_name' in sc; connectUrl = sc.connect_url || null; - el('title').textContent = isSkv ? 'Anslut Skatteverket' : 'Anslut din bank'; + el('title').textContent = isSkv + ? 'Anslut Skatteverket' + : isMigration + ? 'Hämta från ' + sc.provider_name + : 'Anslut din bank'; + + if (isMigration) { + el('lede').textContent = sc.api_connected + ? 'Guiden loggar in hos ' + sc.provider_name + ' och hämtar bokföring, fakturor, kunder och underlag. Du behöver vara inloggad i Accounted i webbläsaren.' + : sc.provider_name + ' saknar API-export: importera SIE-filen här först; guiden kompletterar sedan med fakturor och kunder.'; + if (connectUrl) { + el('url').textContent = connectUrl; + show('url'); + show('actions'); + } + return; + } if (isSkv && !sc.available) { el('lede').textContent = 'Skatteverket-kopplingen är inte aktiverad på den här installationen. Deklarationer kan fortfarande laddas ned som filer.'; diff --git a/lib/auth/scope-catalog.ts b/lib/auth/scope-catalog.ts index 35728a2e..97fb4c42 100644 --- a/lib/auth/scope-catalog.ts +++ b/lib/auth/scope-catalog.ts @@ -210,6 +210,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_lookup_company: 'companies:read', gnubok_connect_bank: 'companies:read', gnubok_connect_skatteverket: 'companies:read', + gnubok_connect_migration: 'companies:read', gnubok_get_company_settings: 'companies:read', gnubok_update_company_settings: 'companies:write', // Transactions