diff --git a/DECISIONS.md b/DECISIONS.md index 4436c50f..7c2cd06d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -661,3 +661,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-29] Retired the generic design skills now that emilkowalski/skills is installed globally (animation-vocabulary, apple-design, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations in ~/.claude/skills). Deleted .claude/skills/mobile-ux-core (52 lines of universal mobile UX whose file triggers are *.dart/*.swift/*Activity.kt, paths that do not exist in this repo; superseded by design.md's accessibility section plus apple-design) and .claude/skills/scout-design (a design scan that filed Linear tickets via mcp__claude_ai_Linear__save_issue, while this project files GitHub issues and loop-design-scan is the same scan with the right output; loop-design-scan's sibling reference updated). Kept web-design-guidelines: it is a Vercel-plugin symlink, cheap to keep, and may regenerate anyway. Also removed the global ui-ux-pro-max skill, a 67-style/96-palette catalogue that pulls against a locked editorial-monochrome system. [2026-07-29] Consent-expiry follow-up sent from invoiceservice@arcim.io, not a new sender: matching the address the original batch came from lets the two mails corroborate each other; RESEND_FROM_EMAIL alignment to accounted.se stays a separate ops task. +[2026-07-29] Approval-queue MCP App widget (render_ui on list_pending_operations): high-risk confirmed=true now comes from a human click in-widget instead of agent-asserted; payload ceiling 58K->58.5K per the in-test trim-first convention. diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 6c5948e9..d0673b05 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -145,9 +145,16 @@ describe('tools/list payload size guard', () => { // trimmed to the floor first (agi_status, lock_period, list_employees // gave back ~100 tokens); the ~90-token remainder is the contract // agents read the filing state through. + // * 58K → 58.5K with the approval-queue widget: render_ui on + // gnubok_list_pending_operations opens the MCP Apps queue where + // approve/reject (and the high-risk BFL acknowledgment) are first-party + // human clicks instead of agent-asserted confirmed=true. The property + + // hint prose was trimmed to the floor first (~30 tokens recovered); + // headroom before the change was ~14 tokens, so even the trimmed wire + // contract crossed. // Long-term answer to growth is leaning harder on gnubok_search_tools: if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(58_000) + expect(approxTokens).toBeLessThan(58_500) }) }) diff --git a/extensions/general/mcp-server/__tests__/pending-operations-widget.test.ts b/extensions/general/mcp-server/__tests__/pending-operations-widget.test.ts new file mode 100644 index 00000000..5cc1e450 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/pending-operations-widget.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for the pending-operations approval-queue widget: registration, + * resource serving, tool wiring, and namespace projection. Does NOT re-test + * approve/reject semantics (covered by pending-operations-tools tests); + * only the widget plumbing. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { uiWidgets, 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: ['pending_operations:read'], + }), + // Fully-chainable, awaitable proxy resolving to empty data: satisfies + // both loadAtomsAsSkills and the pending_operations list query without + // hand-enumerating each chain. + 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() + }, + }, + ) + const membershipChain: unknown = new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve({ + data: { + company_id: '11111111-1111-4111-8111-111111111111', + role: 'owner', + }, + error: null, + }) + } + return () => membershipChain + }, + } + ) + return { + from: (table: string) => (table === 'company_members' ? membershipChain : 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('Pending operations widget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('widget registration', () => { + it('registers the pending-operations widget in uiWidgets', () => { + const widget = findUiWidget('ui://pending-operations/app.html') + expect(widget).toBeDefined() + expect(widget?.name).toBe('Pending Operations') + expect(widget?.html).toContain('') + expect(widget?.html).toContain('Att godkänna') + }) + + it('uiWidgets contains all three widgets', () => { + const uris = uiWidgets.map((w) => w.uri) + expect(uris).toContain('ui://receipt-matcher/app.html') + expect(uris).toContain('ui://vat-review/app.html') + expect(uris).toContain('ui://pending-operations/app.html') + }) + + it('times out stranded RPCs so a silent host cannot freeze a row', () => { + const widget = findUiWidget('ui://pending-operations/app.html')! + expect(widget.html).toContain('RPC_TIMEOUT_MS') + expect(widget.html).toContain('clearTimeout(timer)') + }) + + it('the widget calls the approve and reject tools and arms confirmed=true for high risk', () => { + const widget = findUiWidget('ui://pending-operations/app.html')! + expect(widget.html).toContain('gnubok_approve_pending_operation') + expect(widget.html).toContain('gnubok_reject_pending_operation') + // High-risk approvals send confirmed=true only from the armed second + // click: the human acknowledgment, never a default. + expect(widget.html).toContain('args.confirmed = true') + expect(widget.html).toContain("risk_level === 'high'") + }) + }) + + describe('gnubok_list_pending_operations wiring', () => { + it('declares render_ui and points at the pending-operations widget', () => { + const tool = tools.find((t) => t.name === 'gnubok_list_pending_operations')! + expect((tool as { uiResourceUri?: string }).uiResourceUri).toBe( + 'ui://pending-operations/app.html' + ) + const props = (tool.inputSchema as { properties: Record }).properties + expect(props.render_ui).toMatchObject({ type: 'boolean' }) + expect(tool.annotations.readOnlyHint).toBe(true) + }) + + it('emits result-level _meta only when render_ui=true', async () => { + const withUi = await ( + await handleMcpRequest( + mcpRequest('tools/call', { + name: 'gnubok_list_pending_operations', + arguments: { render_ui: true }, + }), + ) + ).json() + expect(withUi.result.isError).toBeUndefined() + expect(withUi.result._meta).toEqual({ + ui: { resourceUri: 'ui://pending-operations/app.html' }, + }) + + const withoutUi = await ( + await handleMcpRequest( + mcpRequest('tools/call', { + name: 'gnubok_list_pending_operations', + arguments: {}, + }), + ) + ).json() + expect(withoutUi.result.isError).toBeUndefined() + expect(withoutUi.result._meta).toBeUndefined() + }) + }) + + 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://pending-operations/app.html' + ) + expect(widget).toMatchObject({ + uri: 'ui://pending-operations/app.html', + name: 'Pending Operations', + mimeType: 'text/html;profile=mcp-app', + }) + }) + + it('returns the widget HTML on resources/read', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'ui://pending-operations/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('Att godkänna') + }) + + it('projects the tool names inside the widget HTML for the accounted namespace', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'ui://pending-operations/app.html' }, 'accounted') + ) + const result = await parseResult(res) + const html = result.contents[0].text as string + expect(html).toContain('accounted_approve_pending_operation') + expect(html).toContain('accounted_reject_pending_operation') + expect(html).not.toContain('gnubok_approve_pending_operation') + }) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 219ba98a..a3a398ee 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -14417,7 +14417,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_pending_operations', title: 'List Pending Operations', - description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Use to review the queue before calling gnubok_approve_pending_operation or gnubok_reject_pending_operation.', + description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Approve via gnubok_approve_pending_operation, discard via gnubok_reject_pending_operation. render_ui=true opens the approval widget.', inputSchema: { type: 'object', additionalProperties: false, @@ -14427,11 +14427,19 @@ export const tools: McpTool[] = [ operation_type: { type: 'string', description: 'Filter to a single operation_type (e.g. "create_invoice")' }, limit: { type: 'number', minimum: 1, maximum: 200, description: 'Default 50' }, offset: { type: 'number', minimum: 0, description: 'Default 0' }, + render_ui: { + type: 'boolean', + description: 'Render the interactive approval widget (claude.ai / Desktop): approve/reject by click; the click supplies the high-risk BFL acknowledgment. Data returned either way. Default false.', + }, }, required: [], }, outputSchema: paginatedSchema('operations'), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + // Renders the approval-queue widget only when the caller passes + // render_ui=true (the dispatcher emits result-level _meta in that case), + // keeping the tool data-only by default. + uiResourceUri: 'ui://pending-operations/app.html', async execute(args, companyId, _userId, supabase) { const status = (args.status as string) ?? 'pending' const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50)) @@ -15949,7 +15957,7 @@ export async function handleMcpRequest(request: Request): Promise { '• Suppliers: gnubok_list_suppliers (or gnubok_create_supplier) → gnubok_create_supplier_invoice_from_inbox → gnubok_approve_supplier_invoice. Refund via gnubok_credit_supplier_invoice.', '• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to open the momsdeklaration review widget (claude.ai / Desktop). gnubok_vat_close_check reports filing-readiness blockers.', '• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger: all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.', - '• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget and gnubok_receipt_matcher opens the receipt↔transaction matcher. Both also return structured data; other clients ignore the UI and use the data.', + '• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget, gnubok_receipt_matcher opens the receipt↔transaction matcher, and gnubok_list_pending_operations(render_ui=true) opens the approval queue where the user approves/rejects with a click. All also return structured data; other clients ignore the UI and use the data.', '• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.', '• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → gnubok_book_salary_run → gnubok_generate_agi.', '• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.', diff --git a/extensions/general/mcp-server/widgets/index.ts b/extensions/general/mcp-server/widgets/index.ts index a70b4ed3..5ebe784c 100644 --- a/extensions/general/mcp-server/widgets/index.ts +++ b/extensions/general/mcp-server/widgets/index.ts @@ -1,10 +1,12 @@ import type { UiWidget } from './types' import { receiptMatcherWidget } from './receipt-matcher' import { vatReviewWidget } from './vat-review' +import { pendingOperationsWidget } from './pending-operations' export const uiWidgets: UiWidget[] = [ receiptMatcherWidget, vatReviewWidget, + pendingOperationsWidget, ] export function findUiWidget(uri: string): UiWidget | null { diff --git a/extensions/general/mcp-server/widgets/pending-operations.ts b/extensions/general/mcp-server/widgets/pending-operations.ts new file mode 100644 index 00000000..bc1d8a30 --- /dev/null +++ b/extensions/general/mcp-server/widgets/pending-operations.ts @@ -0,0 +1,379 @@ +import type { UiWidget } from './types' + +/** + * Pending Operations Widget: MCP Apps inline HTML. + * The approval queue for staged operations, rendered in the conversation. + * Approve/reject are human CLICKS inside the widget, so the positive + * acknowledgment for high-risk operations (BFL 5 kap 5§) is first-party + * instead of agent-asserted: the widget arms the approve button and the + * second click sends confirmed=true. + * Triggered by gnubok_list_pending_operations with render_ui=true. + */ + +export const PENDING_OPERATIONS_HTML = ` + + + + +Att godkänna - Accounted + + + +
+

Att godkänna

+ +
+
Laddar väntande operationer…
+ + + +` + +export const pendingOperationsWidget: UiWidget = { + uri: 'ui://pending-operations/app.html', + name: 'Pending Operations', + description: 'Interactive approval queue for staged operations: approve or reject with a human click', + html: PENDING_OPERATIONS_HTML, +}