feat(mcp): ask the bank first: connect link deep-starts the named bank's consent (#1951)

* feat(mcp): ask the bank first: connect link deep-starts the named bank's consent

The connect card used to open the generic picker page; the user then chose
the bank there. The agent now asks 'vilken bank har företaget?' among the
opening questions and passes it to gnubok_connect_bank, whose connect_url
becomes /import?mode=psd2&bank=<name>. BankSelector resolves the name
(exact, then unique prefix, then unique substring: ambiguous names fall
back to the prefilled picker rather than guessing an institution) and
auto-starts that bank's consent through the same onConnect handler, so
the duplicate-pending and renew-instead guards stay fully interactive.
The param is stripped via history.replaceState after the one-shot so an
aborted bank flow plus back-navigation does not silently relaunch.

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

* fix(mcp): first-year suggestion keeps the AB vs enskild firma distinction

Compliance-review finding on #1949: the suggestion text collapsed both
forms onto a 31 December end. Only an enskild firma's first year MUST end
31 December; an AB may pick any end within BFL 3 kap 3 §'s 18-month cap,
with 31 December as the common default. The lookup tool's still_to_ask
line and the skill now say so explicitly, and first-year-defaults
documents that fiscalYear-null is a strong-not-perfect filed-report
signal that must only ever feed confirm-question suggestions.

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 14:31:45 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 9a56b7aff9
commit ec9cab24cc
7 changed files with 168 additions and 11 deletions
@@ -0,0 +1,45 @@
/**
* matchBankByName: the deep-link resolver behind /import?mode=psd2&bank=<name>.
* Conservative on purpose: starting a consent at the wrong institution is
* worse than falling back to the prefilled picker.
*/
import { describe, expect, it } from 'vitest'
import { matchBankByName } from '../lib/bank-match'
const BANKS = [
{ name: 'Swedbank' },
{ name: 'SEB' },
{ name: 'Nordea' },
{ name: 'Handelsbanken' },
{ name: 'Danske Bank' },
{ name: 'Länsförsäkringar Bank' },
{ name: 'Länsförsäkringar Skåne' },
{ name: 'ICA Banken' },
]
describe('matchBankByName', () => {
it('matches exact names case-insensitively', () => {
expect(matchBankByName(BANKS, 'swedbank')?.name).toBe('Swedbank')
expect(matchBankByName(BANKS, 'SEB')?.name).toBe('SEB')
expect(matchBankByName(BANKS, ' handelsbanken ')?.name).toBe('Handelsbanken')
})
it('matches a unique prefix', () => {
expect(matchBankByName(BANKS, 'nord')?.name).toBe('Nordea')
expect(matchBankByName(BANKS, 'danske')?.name).toBe('Danske Bank')
})
it('matches a unique substring', () => {
expect(matchBankByName(BANKS, 'ica')?.name).toBe('ICA Banken')
})
it('returns null for an ambiguous name instead of guessing an institution', () => {
expect(matchBankByName(BANKS, 'länsförsäkringar')).toBeNull()
})
it('returns null for unknown or empty input', () => {
expect(matchBankByName(BANKS, 'Monopolbanken')).toBeNull()
expect(matchBankByName(BANKS, '')).toBeNull()
expect(matchBankByName(BANKS, ' ')).toBeNull()
})
})
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import { ChevronRight, Landmark, Loader2, Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { matchBankByName } from '../lib/bank-match'
export interface Bank {
name: string
@@ -128,6 +129,41 @@ export function BankSelector({
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const searchRef = useRef<HTMLInputElement>(null)
const autoStartHandled = useRef(false)
// Deep-link preselect (?bank=<name>, set by the MCP connect card when the
// user already named their bank in chat): auto-start that bank's consent
// the way clicking its row would, so the flow feels like Skatteverket's
// one-click authorize. The param is stripped immediately so an abort at
// the bank followed by back-navigation does not silently re-launch; an
// unknown or ambiguous name just prefills the search instead of guessing.
// Guards further down (duplicate pending, renew-instead-409) stay fully
// interactive because this runs through the same onConnect handler.
useEffect(() => {
if (autoStartHandled.current || isLoading || banks.length === 0 || isConnecting) return
const params = new URLSearchParams(window.location.search)
const requested = params.get('bank')
if (!requested) {
autoStartHandled.current = true
return
}
autoStartHandled.current = true
params.delete('bank')
const query = params.toString()
window.history.replaceState(
null,
'',
`${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`
)
const match = matchBankByName(banks, requested)
if (match) {
onConnect(match)
} else {
setSearchQuery(requested)
searchRef.current?.focus()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot after the bank list arrives
}, [isLoading, banks, isConnecting])
useEffect(() => {
async function fetchBanks() {
@@ -0,0 +1,32 @@
/**
* Resolve a user-stated bank name ("swedbank", "SEB", "handelsbanken") to
* one ASPSP from the Enable Banking list, so a deep link can start that
* bank's consent directly instead of showing the picker.
*
* Matching is deliberately conservative: exact (case-insensitive) first,
* then a UNIQUE prefix match, then a UNIQUE substring match. An ambiguous
* or unknown name returns null and the caller falls back to the picker
* with the query prefilled: guessing between "Länsförsäkringar Bank" and a
* regional "Länsförsäkringar Skåne" would start a consent at the wrong
* institution.
*/
export function matchBankByName<T extends { name: string }>(
banks: T[],
query: string
): T | null {
const q = query.trim().toLowerCase()
if (!q) return null
const exact = banks.filter((b) => b.name.toLowerCase() === q)
if (exact.length === 1) return exact[0]
if (exact.length > 1) return null
const prefix = banks.filter((b) => b.name.toLowerCase().startsWith(q))
if (prefix.length === 1) return prefix[0]
if (prefix.length > 1) return null
const substring = banks.filter((b) => b.name.toLowerCase().includes(q))
if (substring.length === 1) return substring[0]
return null
}
@@ -52,6 +52,28 @@ describe('onboarding connect-link tools', () => {
expect(chain.eq).toHaveBeenCalledWith('company_id', COMPANY_ID)
})
it('bank: a named bank deep-links straight into that bank\'s consent', async () => {
const { from } = listClient([])
const result = (await bankTool.execute(
{ bank: 'Danske Bank' },
COMPANY_ID,
'user-1',
{ from } as never
)) as Record<string, unknown>
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2&bank=Danske%20Bank')
})
it('bank: a blank bank argument falls back to the plain picker link', async () => {
const { from } = listClient([])
const result = (await bankTool.execute(
{ bank: ' ' },
COMPANY_ID,
'user-1',
{ from } as never
)) as Record<string, unknown>
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
})
it('bank: reports an active connection', async () => {
const { from } = listClient([
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
+16 -6
View File
@@ -3144,7 +3144,7 @@ export const tools: McpTool[] = [
)
} else if (firstYear.isFirstFiscalYear) {
stillToAsk.push(
`fiscal year: no closed period in the registry, so this is the FIRST räkenskapsår; suggest first_fiscal_year start ${registrationIso ?? firstYear.firstYearStart} (registration date) and end 31 December (max 18 months from start; an enskild firma's first year must end 31 December), ask only "stämmer det?"`
`fiscal year: no closed period in the registry, so this is likely the FIRST räkenskapsår; suggest first_fiscal_year start ${registrationIso ?? firstYear.firstYearStart} (registration date). End: an enskild firma MUST end 31 December; an AB may pick ANY end within 18 months of start (BFL 3 kap 3 §), 31 December is merely the common default. Ask "stämmer det?" with the choice visible`
)
} else {
stillToAsk.push('fiscal year: calendar year or broken year (no registry data)')
@@ -3307,11 +3307,16 @@ 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). Use after gnubok_create_company or when transactions are missing because no bank is connected.',
'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.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
properties: {
bank: {
type: 'string',
description: "The bank's name as the user said it (e.g. 'Swedbank', 'SEB'); the link auto-starts that bank's consent. Omit to show the picker.",
},
},
},
outputSchema: {
type: 'object',
@@ -3330,7 +3335,7 @@ export const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, _userId, supabase) {
async execute(args, companyId, _userId, supabase) {
const { data, error } = await supabase
.from('bank_connections')
.select('id, bank_name, status, created_at')
@@ -3340,7 +3345,12 @@ export const tools: McpTool[] = [
if (error) throw error
const connections = (data ?? []) as Array<{ id: string; bank_name: string | null; status: string; created_at: string }>
const active = connections.filter((c) => c.status === 'active')
const connectUrl = `${connectLinkBaseUrl()}/import?mode=psd2`
// A named bank deep-links straight into that bank's consent (the page
// auto-starts it; unknown names fall back to the prefilled picker).
const requestedBank = typeof args.bank === 'string' ? args.bank.trim() : ''
const connectUrl = requestedBank
? `${connectLinkBaseUrl()}/import?mode=psd2&bank=${encodeURIComponent(requestedBank)}`
: `${connectLinkBaseUrl()}/import?mode=psd2`
return {
connected: active.length > 0,
connections: connections.map((c) => ({
@@ -3353,7 +3363,7 @@ export const tools: McpTool[] = [
instructions:
active.length > 0
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
: 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there, pick their bank, approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.',
: 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there. With bank passed, the link starts that bank\'s consent directly; otherwise they pick the bank first. They approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.',
}
},
},
@@ -32,14 +32,16 @@ inget konto skapar du det där (BankID eller e-post), det tar en minut." The
call is retried automatically once connected. Do not send the user to the web
app to sign up first.
## Step 1: TWO opening questions, then look up
## Step 1: THREE opening questions, then look up
Open with exactly two questions, together:
Open with exactly three questions, together:
1. **Organisationsnummer?** (10 digits; an enskild firma's org number is the
owner's personnummer, fine to use here)
2. **Har du bokfört i ett annat system tidigare?** (Fortnox, Visma, Bokio,
Björn Lundén, Briox, Wint, annat system, eller helt nytt bolag)
3. **Vilken bank har företaget?** (so the bank connect link later opens that
bank's consent directly instead of a picker)
Then call \`gnubok_lookup_company\` with the org number. The registry answers
most of the form; present the facts as a SHORT summary to confirm ("Jag
@@ -58,8 +60,11 @@ Rules baked into that split (same as the web onboarding):
- **Enskild firma name**: verksamhetsnamnet is freely choosable; suggest the
registered name but let the user pick. An AB's registered name is a fact.
- **Fiscal year**: registry data becomes a confirm question, never an open
one. No closed period in the registry = FIRST räkenskapsår: suggest
registration date to 31 December (up to 18 months, BFL 3 kap 3 §).
one. No closed period in the registry = FIRST räkenskapsår: suggest a
start at the registration date. The end differs by form: an enskild
firma MUST end 31 December; an AB may pick any end within 18 months of
the start (BFL 3 kap 3 §), with 31 December as the common default, so
present the AB's choice rather than assuming it.
\`not_found\`/\`unavailable\`: fall back to asking the \`still_to_ask\` list and
continue. Only \`aktiebolag\` and \`enskild_firma\` are supported today.
@@ -101,7 +106,8 @@ reaches far enough back anyway.
## Step 4: connect bank and Skatteverket (together, no pause)
Call \`gnubok_connect_bank\` AND \`gnubok_connect_skatteverket\` in the same
Call \`gnubok_connect_bank\` (pass \`bank\` from step 1 so the link opens that
bank's consent directly) AND \`gnubok_connect_skatteverket\` in the same
turn; on claude.ai/Desktop both render connect cards with buttons.
- Bank: BankID + PSD2 consent, then an **account selection dialog** in the
+6
View File
@@ -28,6 +28,12 @@ export function parseStartMonthDay(value: string | null | undefined): number | n
* first year to run up to 18 months (no minimum). The 12-month floor alone
* missed exactly the extended-first-year companies the signal exists for
* (Arcim, registered 13 months before onboarding, first year to 31 Dec).
*
* `noClosedPeriod` comes from TIC's `fiscalYear` being null, which is
* derived from `mostRecentFinancialSummary` (the latest filed report): a
* strong but not perfect signal (a filed report can lag in the data).
* Acceptable because every consumer only feeds a confirm-question
* suggestion; never auto-set first_fiscal_year from this.
* Returns both the toggle state and a seeded `first_year_start` (always the
* 1st of the registration month, the format the date inputs expect).
*