feat(api): expose bank-connection freshness in MCP and v1 REST (#2124)
* feat(api): expose bank-connection freshness in MCP and v1 REST
gnubok_connect_bank now returns last_synced_at, consent_expires and
error_message per connection, and its instructions tell the agent to
flag stale or expiring connections. New read-only endpoint
GET /api/v1/companies/{companyId}/bank-connections exposes the same
fields to API-key integrations (scope companies:read).
Background: a user's PSD2 feed died silently in July; bookkeeping
looked complete while three weeks stale, and nothing on the API/MCP
surface could reveal it. Sync stays cron-driven; an agent-triggerable
sync was considered and deferred (see DECISIONS.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix
* fix(api): address skeptic findings on bank-connection freshness
- Map the bank-connections group into skills/accounted-api (apiskill:check
crashed on the unmapped group; regenerated skill files included).
- Gate the v1 route on the bank_sync capability, mirroring the MCP twin:
a lapsed entitlement now answers with a capability error instead of
status=active with a frozen last_synced_at.
- Reword MCP instructions + v1 pitfalls: null last_synced_at right after
connecting is normal, staleness threshold aligned to the UI's 36 hours,
and re-authorisation is only advised for expired/error/consent-out, not
for stale-but-active connections (lapsed subscription or deselected
accounts are the usual causes there).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix
* fix(mcp): keep gnubok_connect_bank schema under the tools/list token ceiling
The enriched outputSchema plus the worked examples that landed on main
(#2100) pushed the projected tools/list payload 20 tokens over the
61.6K context-budget ceiling. Drop the per-property descriptions from
the new freshness fields; the instructions string (runtime output, not
catalog payload) already explains them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a08bf51ced
commit
b56da5d6c5
@@ -86,15 +86,52 @@ describe('onboarding connect-link tools', () => {
|
||||
expect(named.instructions).not.toContain('BETTER LINK AVAILABLE')
|
||||
})
|
||||
|
||||
it('bank: reports an active connection', async () => {
|
||||
it('bank: reports an active connection with freshness fields', async () => {
|
||||
const { from } = listClient([
|
||||
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
|
||||
{
|
||||
id: 'c1',
|
||||
bank_name: 'Swedbank',
|
||||
status: 'active',
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
last_synced_at: '2026-08-30T05:00:00Z',
|
||||
consent_expires: '2026-11-01T00:00:00Z',
|
||||
error_message: null,
|
||||
},
|
||||
])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(true)
|
||||
expect(result.connections).toEqual([
|
||||
{ connection_id: 'c1', bank: 'Swedbank', status: 'active', since: '2026-08-01T00:00:00Z' },
|
||||
{
|
||||
connection_id: 'c1',
|
||||
bank: 'Swedbank',
|
||||
status: 'active',
|
||||
since: '2026-08-01T00:00:00Z',
|
||||
last_synced_at: '2026-08-30T05:00:00Z',
|
||||
consent_expires: '2026-11-01T00:00:00Z',
|
||||
error_message: null,
|
||||
},
|
||||
])
|
||||
expect(result.instructions).toContain('last_synced_at')
|
||||
})
|
||||
|
||||
it('bank: surfaces the error_message and null sync stamp on a dead connection', async () => {
|
||||
const { from } = listClient([
|
||||
{
|
||||
id: 'c2',
|
||||
bank_name: 'SEB',
|
||||
status: 'expired',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_synced_at: null,
|
||||
consent_expires: '2026-07-15T00:00:00Z',
|
||||
error_message: 'Bankkopplingen behöver förnyas.',
|
||||
},
|
||||
])
|
||||
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
||||
expect(result.connected).toBe(false)
|
||||
const rows = result.connections as Array<Record<string, unknown>>
|
||||
expect(rows[0].last_synced_at).toBeNull()
|
||||
expect(rows[0].consent_expires).toBe('2026-07-15T00:00:00Z')
|
||||
expect(rows[0].error_message).toBe('Bankkopplingen behöver förnyas.')
|
||||
})
|
||||
|
||||
it('skatteverket: hands out the authorize link when enabled and not connected', async () => {
|
||||
|
||||
@@ -3616,7 +3616,24 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
properties: {
|
||||
connected: { type: 'boolean' },
|
||||
connections: { type: 'array', items: { type: 'object' } },
|
||||
connections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connection_id: { type: 'string' },
|
||||
bank: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
since: { type: 'string' },
|
||||
// Semantics live in the instructions string (runtime output), not
|
||||
// here: every byte of this schema is charged against the
|
||||
// tools/list context-budget ceiling (payload-size.bench.test.ts).
|
||||
last_synced_at: { type: ['string', 'null'] },
|
||||
consent_expires: { type: ['string', 'null'] },
|
||||
error_message: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
connect_url: { type: 'string' },
|
||||
instructions: { type: 'string' },
|
||||
},
|
||||
@@ -3632,12 +3649,20 @@ export const tools: McpTool[] = [
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, status, created_at')
|
||||
.select('id, bank_name, status, created_at, last_synced_at, consent_expires, error_message')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['pending', 'pending_selection', 'active', 'expired', 'error'])
|
||||
.order('created_at', { ascending: false })
|
||||
if (error) throw error
|
||||
const connections = (data ?? []) as Array<{ id: string; bank_name: string | null; status: string; created_at: string }>
|
||||
const connections = (data ?? []) as Array<{
|
||||
id: string
|
||||
bank_name: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
last_synced_at: string | null
|
||||
consent_expires: string | null
|
||||
error_message: string | null
|
||||
}>
|
||||
const active = connections.filter((c) => c.status === 'active')
|
||||
// A named bank deep-links straight into that bank's consent (the page
|
||||
// auto-starts it; unknown names fall back to the prefilled picker).
|
||||
@@ -3652,11 +3677,14 @@ export const tools: McpTool[] = [
|
||||
bank: c.bank_name,
|
||||
status: c.status,
|
||||
since: c.created_at,
|
||||
last_synced_at: c.last_synced_at,
|
||||
consent_expires: c.consent_expires,
|
||||
error_message: c.error_message,
|
||||
})),
|
||||
connect_url: connectUrl,
|
||||
instructions:
|
||||
active.length > 0
|
||||
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
|
||||
? 'At least one bank is connected and syncing. Check last_synced_at on each connection: older than 36 hours means transactions and balances may be STALE; warn the user before building on them. A null last_synced_at right after connecting is normal (the first sync lands within a minute). Match the remedy to the cause: status expired/error or consent_expires near or past needs BankID re-authorisation via the connect_url; a stale last_synced_at while status is active usually means the subscription lapsed or every account is deselected, so point the user to Installningar -> Bank instead of re-authorising. To add another bank, give the user the connect_url.'
|
||||
: (requestedBank
|
||||
? ''
|
||||
: 'BETTER LINK AVAILABLE: if you know (or can ask) which bank the company uses, call this tool again with bank=<name>; the link then opens that bank\'s consent directly instead of a picker. ') +
|
||||
|
||||
Reference in New Issue
Block a user