feat(mcp): connect card widget: one-click open-in-browser button for bank and Skatteverket links (#1939)
The gnubok_connect_bank and gnubok_connect_skatteverket tools now carry definition-level _meta.ui.resourceUri pointing at a new connect-card MCP Apps widget. On claude.ai/Claude Desktop the tool result renders as a card with an "Öppna i webbläsaren" button that sends the host a ui/open-link request from the click handler (the sanctioned new-tab mechanism; custom connectors always get Claude's confirmation modal, so the destination URL is shown in the card). Clients that do not render MCP Apps (Claude Code) keep the connect_url in the structured result as before. 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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
a97943d00d
commit
151fb1384c
@@ -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<typeof import('@/lib/auth/api-keys')>()
|
||||
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<string, unknown>, 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('<!DOCTYPE html>')
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 = `<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Anslut - Accounted</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--border: rgba(0,0,0,0.1);
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #6b6b6b;
|
||||
--success: #5a7a5a;
|
||||
--success-bg: rgba(90,122,90,0.08);
|
||||
--accent: #1a1a1a;
|
||||
--accent-text: #ffffff;
|
||||
--url-bg: rgba(0,0,0,0.04);
|
||||
}
|
||||
.dark {
|
||||
--bg: #161616;
|
||||
--surface: #1e1e1e;
|
||||
--border: rgba(255,255,255,0.1);
|
||||
--text: #e5e5e5;
|
||||
--text-muted: #999;
|
||||
--success: #7aab7a;
|
||||
--success-bg: rgba(122,171,122,0.1);
|
||||
--accent: #e5e5e5;
|
||||
--accent-text: #161616;
|
||||
--url-bg: rgba(255,255,255,0.06);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
max-width: 480px;
|
||||
}
|
||||
.card h1 { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||||
.lede { color: var(--text-muted); margin-bottom: 12px; }
|
||||
.status {
|
||||
display: inline-block; font-size: 12px; font-weight: 500;
|
||||
color: var(--success); background: var(--success-bg);
|
||||
border-radius: 999px; padding: 2px 10px; margin-bottom: 10px;
|
||||
}
|
||||
.url {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
background: var(--url-bg); border-radius: 4px;
|
||||
padding: 6px 8px; margin-bottom: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; align-items: center; }
|
||||
button {
|
||||
font: inherit; font-weight: 500; cursor: pointer;
|
||||
border-radius: 6px; padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text);
|
||||
}
|
||||
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
button:focus-visible { outline: 2px solid var(--success); outline-offset: 2px; }
|
||||
.note { margin-top: 10px; color: var(--text-muted); font-size: 12px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 id="title">Anslut</h1>
|
||||
<span class="status hidden" id="status">Ansluten</span>
|
||||
<p class="lede" id="lede">Laddar…</p>
|
||||
<div class="url hidden" id="url"></div>
|
||||
<div class="actions hidden" id="actions">
|
||||
<button class="primary" id="open">Öppna i webbläsaren</button>
|
||||
<button id="copy">Kopiera länk</button>
|
||||
</div>
|
||||
<p class="note hidden" id="note"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// ── MCP Apps Bridge ──
|
||||
let rpcId = 1;
|
||||
const pending = new Map();
|
||||
let connectUrl = null;
|
||||
|
||||
function sendRequest(method, params) {
|
||||
const id = rpcId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
|
||||
});
|
||||
}
|
||||
|
||||
function sendNotification(method, params) {
|
||||
window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
const msg = e.data;
|
||||
if (!msg || msg.jsonrpc !== '2.0') return;
|
||||
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) reject(msg.error);
|
||||
else resolve(msg.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'ui/notifications/tool-result') {
|
||||
const sc = msg.params?.structuredContent;
|
||||
if (sc) render(sc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'ui/notifications/tool-input') return;
|
||||
if (msg.method === 'ui/notifications/host-context-changed') {
|
||||
applyTheme(msg.params);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function applyTheme(ctx) {
|
||||
if (!ctx) return;
|
||||
if (ctx.theme === 'dark') document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// ── Initialize ──
|
||||
sendRequest('ui/initialize', {
|
||||
name: 'gnubok-connect-card',
|
||||
version: '1.0.0'
|
||||
}).then(function(res) {
|
||||
if (res && res.hostContext) applyTheme(res.hostContext);
|
||||
sendNotification('ui/notifications/initialized');
|
||||
}).catch(function() {
|
||||
sendNotification('ui/notifications/initialized');
|
||||
});
|
||||
|
||||
// ── Render ──
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function show(id) { el(id).classList.remove('hidden'); }
|
||||
function hide(id) { el(id).classList.add('hidden'); }
|
||||
|
||||
function render(sc) {
|
||||
const isSkv = 'available' in sc;
|
||||
connectUrl = sc.connect_url || null;
|
||||
el('title').textContent = isSkv ? 'Anslut Skatteverket' : 'Anslut din bank';
|
||||
|
||||
if (isSkv && !sc.available) {
|
||||
el('lede').textContent = 'Skatteverket-kopplingen är inte aktiverad på den här installationen. Deklarationer kan fortfarande laddas ned som filer.';
|
||||
hide('url'); hide('actions'); hide('status');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sc.connected) {
|
||||
show('status');
|
||||
if (isSkv) {
|
||||
el('lede').textContent = 'Skatteverket är anslutet. Skattekontot synkas automatiskt och deklarationer kan lämnas in härifrån.';
|
||||
hide('url'); hide('actions');
|
||||
return;
|
||||
}
|
||||
const banks = (sc.connections || []).filter(function(c) { return c.status === 'active'; })
|
||||
.map(function(c) { return c.bank; }).filter(Boolean).join(', ');
|
||||
el('lede').textContent = (banks ? banks + ' är ansluten. ' : 'Minst en bank är ansluten. ') + 'Vill du lägga till ytterligare en bank?';
|
||||
el('open').textContent = 'Lägg till bank';
|
||||
} else {
|
||||
el('lede').textContent = isSkv
|
||||
? 'Godkänn åtkomsten hos Skatteverket med BankID som firmatecknare. Du behöver vara inloggad i Accounted i webbläsaren.'
|
||||
: 'Välj din bank och godkänn med BankID (PSD2-samtycke, upp till 180 dagar). Du behöver vara inloggad i Accounted i webbläsaren.';
|
||||
}
|
||||
|
||||
if (connectUrl) {
|
||||
el('url').textContent = connectUrl;
|
||||
show('url');
|
||||
show('actions');
|
||||
}
|
||||
}
|
||||
|
||||
el('open').addEventListener('click', function() {
|
||||
if (!connectUrl) return;
|
||||
sendRequest('ui/open-link', { url: connectUrl }).then(function() {
|
||||
el('note').textContent = 'Länken öppnas i en ny flik. Kom tillbaka hit när du är klar.';
|
||||
show('note');
|
||||
}).catch(function() {
|
||||
el('note').textContent = 'Kunde inte öppna automatiskt. Kopiera länken och öppna den själv i webbläsaren.';
|
||||
show('note');
|
||||
});
|
||||
});
|
||||
|
||||
el('copy').addEventListener('click', function() {
|
||||
if (!connectUrl) return;
|
||||
navigator.clipboard.writeText(connectUrl).then(function() {
|
||||
el('copy').textContent = 'Kopierad';
|
||||
setTimeout(function() { el('copy').textContent = 'Kopiera länk'; }, 1500);
|
||||
}).catch(function() {});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user