diff --git a/extensions/general/mcp-server/__tests__/connect-card-widget.test.ts b/extensions/general/mcp-server/__tests__/connect-card-widget.test.ts new file mode 100644 index 00000000..c5c37a9d --- /dev/null +++ b/extensions/general/mcp-server/__tests__/connect-card-widget.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for the connect-card widget: registration, tool wiring (definition + * level _meta so the card renders on EVERY connect-tool call), resource + * serving, and the ui/open-link contract. Does NOT re-test the connect tools' + * status logic (covered by connect-links tests); only the widget plumbing. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { findUiWidget } from '../widgets' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: vi.fn(), +})) + +vi.mock('@/lib/auth/api-keys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + extractBearerToken: vi.fn().mockReturnValue('test-token'), + validateApiKey: vi.fn().mockResolvedValue({ + userId: 'user-1', + companyId: '11111111-1111-4111-8111-111111111111', + scopes: ['companies:read'], + }), + createServiceClientNoCookies: vi.fn(() => { + const makeChain = (): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve({ data: [], error: null, count: 0 }) + } + return () => makeChain() + }, + }, + ) + return { from: () => makeChain() } + }), + } +}) + +import { handleMcpRequest } from '../server' + +function mcpRequest(method: string, params?: Record, namespace?: 'accounted'): Request { + const url = new URL('http://localhost:3000/api/extensions/ext/mcp-server/mcp') + if (namespace) url.searchParams.set('tool_namespace', namespace) + return new Request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) +} + +async function parseResult(response: Response) { + const json = await response.json() + return json.result +} + +describe('Connect card widget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('widget registration', () => { + it('registers the connect-card widget in uiWidgets', () => { + const widget = findUiWidget('ui://connect-card/app.html') + expect(widget).toBeDefined() + expect(widget?.name).toBe('Connect Card') + expect(widget?.html).toContain('') + expect(widget?.html).toContain('Anslut') + }) + + it('opens the link via ui/open-link on a real click, never an anchor tag', () => { + const widget = findUiWidget('ui://connect-card/app.html')! + // The sanctioned new-tab mechanism is a ui/open-link request sent from + // the click handler (custom connectors always get Claude's confirmation + // modal, so the URL is also shown in the card for recognition). + expect(widget.html).toContain("sendRequest('ui/open-link'") + expect(widget.html).toContain("addEventListener('click'") + expect(widget.html).not.toContain('target="_blank"') + expect(widget.html).not.toContain('window.open') + }) + + it('performs the ui/initialize handshake (claude.ai keeps the iframe hidden without it)', () => { + const widget = findUiWidget('ui://connect-card/app.html')! + expect(widget.html).toContain("sendRequest('ui/initialize'") + expect(widget.html).toContain("sendNotification('ui/notifications/initialized')") + }) + }) + + describe('connect tool wiring', () => { + it('both connect tools carry definition-level _meta pointing at the card', () => { + for (const name of ['gnubok_connect_bank', 'gnubok_connect_skatteverket']) { + const tool = tools.find((t) => t.name === name)! + expect( + (tool as { _meta?: { ui: { resourceUri: string } } })._meta + ).toEqual({ ui: { resourceUri: 'ui://connect-card/app.html' } }) + } + }) + + it('tools/list surfaces the ui resourceUri on both connect tools', async () => { + const res = await handleMcpRequest(mcpRequest('tools/list')) + const result = await parseResult(res) + for (const name of ['gnubok_connect_bank', 'gnubok_connect_skatteverket']) { + const tool = result.tools.find((t: { name: string }) => t.name === name) + expect(tool).toBeDefined() + expect(tool._meta?.ui).toEqual({ resourceUri: 'ui://connect-card/app.html' }) + } + }) + }) + + describe('protocol: resources/list + resources/read', () => { + it('lists the widget with the MCP Apps mime type', async () => { + const res = await handleMcpRequest(mcpRequest('resources/list')) + const result = await parseResult(res) + const widget = result.resources.find( + (r: { uri: string }) => r.uri === 'ui://connect-card/app.html' + ) + expect(widget).toMatchObject({ + uri: 'ui://connect-card/app.html', + name: 'Connect Card', + mimeType: 'text/html;profile=mcp-app', + }) + }) + + it('returns the widget HTML on resources/read', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'ui://connect-card/app.html' }) + ) + const result = await parseResult(res) + expect(result.contents).toHaveLength(1) + expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app') + expect(result.contents[0].text).toContain('Anslut din bank') + expect(result.contents[0].text).toContain('Anslut Skatteverket') + }) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 68d16e14..a75255f6 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -3121,7 +3121,7 @@ export const tools: McpTool[] = [ }, required: ['connected', 'connections', 'connect_url', 'instructions'], }, - + _meta: { ui: { resourceUri: 'ui://connect-card/app.html' } }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -3151,7 +3151,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.' - : 'Give the user the connect_url to open in their browser (they must be logged in to Accounted there). They pick their bank and approve with BankID; consent lasts up to 180 days and the first transactions arrive within a minute. Tell them to come back here when done, then continue with gnubok_list_uncategorized_transactions.', + : '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 and approve with BankID; consent lasts up to 180 days and the first transactions arrive within a minute. Tell them to come back here when done, then continue with gnubok_list_uncategorized_transactions.', } }, }, @@ -3177,7 +3177,7 @@ export const tools: McpTool[] = [ }, required: ['available', 'connected', 'token_expires_at', 'connect_url', 'instructions'], }, - + _meta: { ui: { resourceUri: 'ui://connect-card/app.html' } }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -3206,7 +3206,7 @@ export const tools: McpTool[] = [ ? 'The Skatteverket integration is not enabled on this installation. Declarations can still be downloaded as files and filed manually at skatteverket.se.' : connected ? 'Skatteverket is connected. Skattekonto syncs automatically; momsdeklaration and AGI can be filed from here (each filing stages for approval).' - : 'Give the user the connect_url to open in their browser (logged in to Accounted). Skatteverket asks them to identify with BankID as firmatecknare and approve the access; they land back in Accounted afterwards. Tell them to come back here when done.', + : '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; Skatteverket asks them to identify with BankID as firmatecknare and approve the access, then they land back in Accounted. Tell them to come back here when done.', } }, }, diff --git a/extensions/general/mcp-server/widgets/connect-card.ts b/extensions/general/mcp-server/widgets/connect-card.ts new file mode 100644 index 00000000..eff2dd61 --- /dev/null +++ b/extensions/general/mcp-server/widgets/connect-card.ts @@ -0,0 +1,232 @@ +import type { UiWidget } from './types' + +/** + * Connect Card Widget: MCP Apps inline HTML. + * One-click "open in browser" card for the connect links returned by + * gnubok_connect_bank and gnubok_connect_skatteverket. The button sends the + * host a `ui/open-link` request (the only sanctioned way to open a new tab + * from a widget); custom connectors always get Claude's confirmation modal, + * so the destination URL is shown in the card for the user to recognize. + * Which tool produced the result is detected from the structuredContent + * shape: only the Skatteverket tool has an `available` field. + */ + +export const CONNECT_CARD_HTML = ` + + + + +Anslut - Accounted + + + +
+

Anslut

+ +

Laddar…

+ + + +
+ + + + +` + +export const connectCardWidget: UiWidget = { + uri: 'ui://connect-card/app.html', + name: 'Connect Card', + description: + 'One-click card for opening the bank or Skatteverket connect link in the browser. Rendered by gnubok_connect_bank and gnubok_connect_skatteverket.', + html: CONNECT_CARD_HTML, +} diff --git a/extensions/general/mcp-server/widgets/index.ts b/extensions/general/mcp-server/widgets/index.ts index 5ebe784c..59bc4f08 100644 --- a/extensions/general/mcp-server/widgets/index.ts +++ b/extensions/general/mcp-server/widgets/index.ts @@ -2,11 +2,13 @@ import type { UiWidget } from './types' import { receiptMatcherWidget } from './receipt-matcher' import { vatReviewWidget } from './vat-review' import { pendingOperationsWidget } from './pending-operations' +import { connectCardWidget } from './connect-card' export const uiWidgets: UiWidget[] = [ receiptMatcherWidget, vatReviewWidget, pendingOperationsWidget, + connectCardWidget, ] export function findUiWidget(uri: string): UiWidget | null {