* 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>
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
|
|
import { tools } from '../server'
|
|
|
|
// Onboarding connect-link tools (issue #1814 PR 3): status + the browser link
|
|
// the user opens. Both flows need a cookie session and BankID in a browser,
|
|
// so the tools never try to drive them.
|
|
|
|
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
|
const bankTool = tools.find((t) => t.name === 'gnubok_connect_bank')!
|
|
const skvTool = tools.find((t) => t.name === 'gnubok_connect_skatteverket')!
|
|
|
|
function listClient(rows: unknown[] | null, error: unknown = null) {
|
|
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
|
select: vi.fn(() => chain),
|
|
eq: vi.fn(() => chain),
|
|
in: vi.fn(() => chain),
|
|
order: vi.fn(() => chain),
|
|
limit: vi.fn(() => chain),
|
|
maybeSingle: vi.fn().mockResolvedValue({ data: rows?.[0] ?? null, error }),
|
|
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }),
|
|
}
|
|
return { from: vi.fn(() => chain), chain }
|
|
}
|
|
|
|
describe('onboarding connect-link tools', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test')
|
|
})
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('are read-only companies:read tools gated on the capability their link needs', () => {
|
|
expect(TOOL_SCOPE_MAP.gnubok_connect_bank).toBe('companies:read')
|
|
expect(TOOL_SCOPE_MAP.gnubok_connect_skatteverket).toBe('companies:read')
|
|
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_bank).toBe('bank_sync')
|
|
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_skatteverket).toBe('skatteverket')
|
|
expect(bankTool.annotations.readOnlyHint).toBe(true)
|
|
expect(skvTool.annotations.readOnlyHint).toBe(true)
|
|
})
|
|
|
|
it('bank: reports no connection and hands out the PSD2 import link', async () => {
|
|
const { from, chain } = listClient([])
|
|
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
|
expect(result.connected).toBe(false)
|
|
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
|
|
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: nudges the agent to ask for the bank when none was passed, and not when one was', async () => {
|
|
const bare = (await bankTool.execute({}, COMPANY_ID, 'user-1', {
|
|
from: listClient([]).from,
|
|
} as never)) as Record<string, unknown>
|
|
expect(bare.instructions).toContain('BETTER LINK AVAILABLE')
|
|
|
|
const named = (await bankTool.execute({ bank: 'Swedbank' }, COMPANY_ID, 'user-1', {
|
|
from: listClient([]).from,
|
|
} as never)) as Record<string, unknown>
|
|
expect(named.instructions).not.toContain('BETTER LINK AVAILABLE')
|
|
})
|
|
|
|
it('bank: reports an active connection', async () => {
|
|
const { from } = listClient([
|
|
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
|
|
])
|
|
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' },
|
|
])
|
|
})
|
|
|
|
it('skatteverket: hands out the authorize link when enabled and not connected', async () => {
|
|
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
|
|
const { from } = listClient([])
|
|
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
|
expect(result.available).toBe(true)
|
|
expect(result.connected).toBe(false)
|
|
expect(result.connect_url).toBe(
|
|
'https://app.example.test/api/extensions/ext/skatteverket/authorize?return_to=%2F'
|
|
)
|
|
})
|
|
|
|
it('skatteverket: reports connected with the token expiry', async () => {
|
|
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
|
|
const { from } = listClient([{ expires_at: '2026-12-01T00:00:00Z' }])
|
|
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
|
expect(result.connected).toBe(true)
|
|
expect(result.token_expires_at).toBe('2026-12-01T00:00:00Z')
|
|
})
|
|
|
|
it('refuses to hand out a link when NEXT_PUBLIC_APP_URL is not configured', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
|
|
const { from } = listClient([])
|
|
await expect(bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)).rejects.toMatchObject({
|
|
code: 'INTERNAL_ERROR',
|
|
})
|
|
})
|
|
|
|
it('skatteverket: says so when the integration is disabled on the installation', async () => {
|
|
vi.stubEnv('SKATTEVERKET_ENABLED', 'false')
|
|
const { from } = listClient([])
|
|
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
|
|
expect(result.available).toBe(false)
|
|
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' })
|
|
})
|
|
})
|