feat(mcp): connect_migration: one-click card into the previous-system wizard (#1960)

* feat(mcp): gnubok_connect_migration: one-click connect card into the previous-system wizard

'Jag hade Fortnox' now gets the same feel as Skatteverket: the tool
returns the migration-wizard link for the named provider and renders
the connect-card widget (new migration branch: 'Hämta från Fortnox',
button opens the wizard that logs into the old system and fetches all
fiscal years plus invoices, customers, suppliers and documents). For
visma/bokio (no API export) the instructions order the SIE drop card
first and this card as the complement. Scope companies:read; skill
step 3 points at the tool instead of raw wizard links; ceiling 62.4K
to 63K documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): 5-minute freshness hint on tools/list, widgets and prompts: the catalog changes with every deploy

The stateless-client CacheableResult hint on tools/list, resources/read
(widget HTML) and prompts/list was 1 hour. Claude.ai honors it, so for
up to an hour after a deploy the connector served a pre-deploy catalog:
a freshly shipped tool flapped in and out of the tool list depending on
which fetch hit the client cache, and two E2E runs dead-ended on
'tool does not exist' for a tool that was live server-side. These
payloads are static only within one deploy; 5 minutes bounds the stale
window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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 19:19:24 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 5715dd3def
commit d8244ecaff
7 changed files with 159 additions and 15 deletions
@@ -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<string, unknown>
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<string, unknown>
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' })
})
})
@@ -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)
})
})
@@ -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<string, Record<string, unknown>>
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 () => {
+73 -1
View File
@@ -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<string, { name: string; api: boolean }> = {
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
@@ -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=<fortnox|bjornlunden|briox|wint>\`
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=<visma|bokio>\` 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
@@ -166,8 +166,25 @@ export const CONNECT_CARD_HTML = `<!DOCTYPE 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.';
+1
View File
@@ -210,6 +210,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
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