From 5c52f24a493427839acd4ff0e426488ae0410c56 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 5 May 2026 20:04:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20agent-native=20improvements=20?= =?UTF-8?q?=E2=80=94=20progressive=20discovery,=20widgets,=20skills,=20tel?= =?UTF-8?q?emetry=20(#393)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): agent-native improvements — progressive discovery, widgets, skills, telemetry Four coordinated streams of MCP server improvements that move gnubok toward agent-first design, grounded in Anthropic's Nov 2025 "Code execution with MCP" article and the May 2026 MCP conference talk. Context budget — minimize tools/list payload - New gnubok_search_tools: progressive discovery with name|summary|full detail levels and scope filtering. Agents pull only the schemas they need. - Trimmed all 50 tool descriptions from multi-paragraph blocks (avg ~500-1000 chars) to one-sentence summaries (avg ~120 chars). Args/Returns/Examples blocks dropped — they duplicated inputSchema. - outputSchema declared on every tool; structuredContent emitted on every successful tools/call (was previously only widget-tagged tools). - protocolVersion bumped to 2025-06-18 (negotiates back to 2024-11-05). - Workflow examples consolidated into initialize.instructions. - Net effect: tools/list payload ~43 KB / ~10.8K tokens for 51+ tools, with headroom guard at 20K tokens. MCP applications — server-shipped UI widgets - New widgets/ directory with typed UiWidget contract; receipt-matcher moved out of widget-html.ts (which was deleted) into widgets/receipt-matcher.ts. - New gnubok_vat_review_widget tool + interactive momsdeklaration widget (all 8 rutor with summary card, theme-aware light/dark, copy buttons). - resources/list and resources/read iterate uiWidgets dynamically — adding the next widget is a single file drop. Skills over MCP — domain-knowledge primitive - 5 user-facing SKILL.md-style workflow guides authored from existing .claude/skills/swedish-* development skills: • month-end-close — book → reconcile → VAT (monthly filers) → lock • quarterly-vat-review — ruta-by-ruta map, deadlines, common errors • year-end-close — bokslut, bokslutstransaktioner, lock → year-end → opening balances → close (irreversible) • invoicing-rules — ML 17 kap. 24 §, customer types, ROT/RUT, Peppol • payroll-monthly — salary run → calculate → review → AGI XML - gnubok_list_skills (with optional tag filter) + gnubok_load_skill(slug). Both unscoped — available to any authenticated key. - Each skill also exposed as MCP resource at gnubok://skill/ (text/markdown) for forward compatibility with a future native skills/list primitive. Tool-call telemetry — measure before optimizing further - Three new CoreEvent types (mcp.tool_called, mcp.tools_list_called, mcp.resource_read), all persisted to event_log (30-day TTL, RLS-scoped). - Fire-and-forget emission from the dispatcher — never blocks JSON-RPC response, double-guarded against handler failures. - tools/call instrumented at all four exit points (success, execution error, scope denied, unknown tool). Latency measured tightly around tool.execute() — excludes dispatcher overhead. - tools/list logs returned tool count (informs progressive-discovery adoption); resources/read logs URI + kind discriminator (widget / skill / data / unknown). - No PII or secret material in payloads — only metadata. Out of scope (explicitly deferred): - Code-mode SDK (no production code-mode hosts to consume it yet). - Elicitations (require streamable HTTP transport — bigger architectural lift). - DB lockdown / RPC funnel (foundational; should follow once telemetry tells us where writes actually flow). - CRUD → intent endpoints (frontend coupling — multi-PR effort). Tests: +37 new unit tests across search-tools, output-schema, payload-size, vat-review-widget, skills, telemetry. Existing receipt-matcher test updated for the new structuredContent contract. 2,615 unit tests passing. Production build green. No new lint warnings or errors in changed files. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): address PR #393 review findings Greptile (P1 + P2) and the Swedish accounting compliance bot flagged 9 issues across security, data correctness, and skill content. All addressed: Security (P1) - gnubok_search_tools: scope filter now fails closed when __keyScopes is absent. The earlier permissive default leaked the full tool inventory if the dispatcher's hard-coded name check ever silently broke. Marker presence is part of the contract — explicitly empty array also hides scoped tools. Two new test cases pin the fail-closed behaviour. Compliance — VAT (data correctness) - get_vat_report now aggregates 2614/2624/2634 (reverse-charge output VAT) and exposes them as ruta30/ruta31/ruta32 per SKV 4700. ruta48 also picks up 2647 (missing before). ruta49 formula corrected to (10+11+12+30+31+32) − 48. The widget renders the new rutor between the Utgående and Ingående sections. - Widget ruta 05 sub-label updated from "3001+3002+3003" to "all momspliktig försäljning oavsett skattesats" — ruta 05 covers all domestic taxable supplies, not just direct-rate sales. - Refactored: extracted computeVatReport() helper used by both gnubok_get_vat_report and gnubok_vat_review_widget. Removes the rename-fragile tools.find() lookup at runtime. Compliance — payroll - payroll-monthly skill: replaced "born 1958 or earlier = 10.21%" (the 2024 formulation) with the statutory rule "age 66+ on 1 January of the income year (67+ from income year 2026)". Removed the unsourced "age 16–18: 11.78%" row in favour of a current växa-stöd description with explicit Prop. 2025/26:34 reference and a "verify against current Skatteverket tables" caveat. Compliance — skills text - invoicing-rules: added explicit footnote on the 1 April 2026 livsmedel rate change. Restaurang/servering stays at 12 %; livsmedel sold in other forms drops to 6 %. Per Prop. 2025/26:55. - year-end-close: clarified periodiseringsfond cap as "25 % of överskott before this year's avsättning" (IL 30 kap.), removing the ambiguous "skattemässigt resultat" phrasing that could be misread as a circular after-fond computation. Schema correctness - STAGED_OPERATION_SCHEMA.required gains "staged" — every path through stagePendingOperation returns the field, so the schema now matches the contract that MCP clients validate against. Tests: 2,617 passing (+2 for the search-tools fail-closed cases). Production build green. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): address PR #393 round-2 compliance review Round 2 of the swedish-compliance bot ran after the previous fix-push and flagged four substantive items + a recommendation. All addressed: VAT computation (data correctness) - ruta05 expanded beyond 3001/3002/3003 to cover the common BAS taxable- revenue accounts (3001-3008, 3041-3048, 3051-3058, 3071-3078). Companies that book to 30xx alternates were previously under-reporting taxable turnover; now all standard BAS taxable-revenue numbering contributes. - One-sided reverse-charge warning: when output VAT is booked on 2614/2624/2634 (rutor 30/31/32 > 0) but the matching calculated input VAT (2645) is zero, computeVatReport now returns a Swedish-language warning string. ruta49 is inflated in this case — the warning surfaces the most common reverse-charge error per the swedish-vat skill. The widget renders warnings in a terracotta panel above the summary card. - computeVatReport exported and a focused unit test added — exercises 2647 inclusion in ruta48, reverse-charge balanced/unbalanced cases, and the expanded ruta05 mapping. Fills the gap that prior tools/call integration tests couldn't reach. Skill content - payroll-monthly Step 6: BAS journal-entry example no longer hard-codes 31.42 % on the 7510/2730 lines. The avgift line is now described as "avgift_base × applicable_rate per employee" with explicit aggregation semantics for runs that mix full-rate and reduced-rate employees. Aligns with the Step-4 reduced-rate caveats already in place. - invoicing-rules ROT/RUT block: replaced the bare "30 % / max 50 000 SEK" text with the full year-by-year picture — RUT 50 % / 75 000 max, ROT baseline 30 % / 50 000 max, 2024 H2 doubled ceiling, 2025 May–Dec enhanced 50 % rate. Defaults to "verify against current Skatteverket table" rather than a single hard-coded rate. False positives in the round-2 review (no fix needed; documented for the record): - "Old vat_report path still computes ruta48 without 2647" — the old path was replaced by computeVatReport in the previous push; the bot was reading the diff hunk and conflating it with current behaviour. - "Widget prose says ruta49 = (10+11+12) - 48" — no such prose exists in widgets/vat-review.ts. The skill body has the correct (10+11+12+30+31+32)-48 formula. - "Including 'reversed' status entries in VAT aggregation may over-count cross-period storno" — current behaviour is correct per Skatteverket period-aligned filing: the reversed original stays in its own period, the matching storno (status 'posted') lands in the reversal period, and they net to zero across the full year. Adding code comment to document. Tests: 2,623 passing (+6 for computeVatReport unit tests). Production build green. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): address PR #393 round-3 compliance review Compliance bot re-ran after the round-2 push and flagged three items: VAT computation - One-sided reverse-charge warning previously only checked 2645 (EU acquisitions). For domestic reverse charge per ML 16:13 (byggtjänster, electronics > 100k SEK, etc.) the matching input lands on 2647 — a correctly-balanced 2614+2647 booking would have falsely fired the warning. Fixed: warning now triggers only when *both* 2645 and 2647 are zero. Updated message text mentions both accounts. Added a test case asserting the no-warning path for 2647-only-input. Skill documentation drift - quarterly-vat-review skill body still showed ruta05 source as "3001 + 3002 + 3003" while the runtime computeVatReport sums 32 BAS taxable-revenue accounts. Updated the skill table to read "3001–3008, 3041–3048, 3051–3058, 3071–3078" so the auditor-facing docs match the implementation. outputSchema upgrade - gnubok_get_vat_report and gnubok_vat_review_widget previously declared outputSchema as the bare { type: 'object' }. Replaced with a shared VAT_REPORT_OUTPUT_SCHEMA constant declaring period, period_label, all 11 rutor (with descriptions referencing source accounts), summary, and warnings. Modern MCP clients that validate structuredContent against outputSchema now have an accurate contract. Added a test asserting the schema is non-trivial and declares every ruta the runtime returns. Tests: 2,625 passing (+2 for the 2647 warning path and the outputSchema shape assertion). Production build green. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): address PR #393 round-4 compliance review The compliance bot re-ran after the round-3 push with a fresh batch. Real findings fixed; false positives documented. VAT computation - Removed 3004 (Försäljning inom Sverige, momsfri / VAT-exempt) from RUTA_05_ACCOUNTS — round-2's expansion accidentally included it. Ruta 05 is the *taxable* base; exempt sales must NOT contribute. New test pins the exclusion. - Added 3106 (taxable EU goods supply, momspliktig) to RUTA_05_ACCOUNTS. Used when EU buyer's VAT number is invalid or buyer is private. - Added ruta35 — EU intra-community goods supplies, momsfri (account 3108). Previously omitted entirely from the rutor schema; SKV 4700 has it as a distinct box separate from ruta 39 (services) and ruta 40 (export outside EU). VatReportResult, VAT_REPORT_OUTPUT_SCHEMA, the widget table, the copy-summary block, and the quarterly-vat-review skill table all updated. New test covers 3108 → ruta35 mapping. - Strengthened the comment on the posted+reversed status filter to document why current behavior is correct per ML 2023:200 and faktureringsmetoden (the bot's cross-period storno concern is a false positive — see commit message rationale below). Skill content - invoicing-rules: added explicit BFL 5 kap. 6–7 § / ML 17 kap. 22–23 § note that the kreditfaktura itself consumes a sequential number from the same (or dedicated KR-) fakturaserie. The KR- prefix is a display convention; the underlying löpnummer must be unbroken just like the regular series. - payroll-monthly: added the missing "born 1937 or earlier → 0 %" cohort to the rate breakdown. Previously could lead a payroll run to over-pay avgifter on the oldest cohort. False positives in the round-4 review (verified, not changed) - 2644/2648 as reverse-charge inputs: verified against gnubok's actual BAS chart (lib/bookkeeping/bas-data/class-2-equity-liabilities.ts). 2644 does not exist; 2648 is "Vilande ingående moms" (dormant input VAT for cash method), not RC at 6 %. Canonical RC inputs are 2645 (EU) and 2647 (domestic) — both already covered. - Cross-period storno over-count: per ML 2023:200 + Skatteverket faktureringsmetoden, the original sale's VAT belongs to the invoice- date period; the kreditfaktura's reduction belongs to the storno-date period. Including 'reversed' status entries (which still have their original date) is therefore correct. *Excluding* them would under-report the original period and over-credit the reversal period. Added a multi-line comment in computeVatReport documenting this. Tests: 2,627 passing (+2 for ruta35 mapping and 3004 exclusion). Production build green. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/output-schema.test.ts | 29 + .../__tests__/payload-size.bench.test.ts | 21 + .../__tests__/receipt-matcher.test.ts | 6 +- .../mcp-server/__tests__/search-tools.test.ts | 151 ++ .../mcp-server/__tests__/skills.test.ts | 205 +++ .../mcp-server/__tests__/telemetry.test.ts | 329 ++++ .../__tests__/vat-report-compute.test.ts | 243 +++ .../__tests__/vat-review-widget.test.ts | 130 ++ extensions/general/mcp-server/server.ts | 1625 +++++++++++------ extensions/general/mcp-server/skills/index.ts | 21 + .../mcp-server/skills/invoicing-rules.ts | 148 ++ .../mcp-server/skills/month-end-close.ts | 82 + .../mcp-server/skills/payroll-monthly.ts | 118 ++ .../mcp-server/skills/quarterly-vat-review.ts | 100 + extensions/general/mcp-server/skills/types.ts | 38 + .../mcp-server/skills/year-end-close.ts | 103 ++ .../general/mcp-server/widgets/index.ts | 15 + .../receipt-matcher.ts} | 19 +- .../general/mcp-server/widgets/types.ts | 19 + .../general/mcp-server/widgets/vat-review.ts | 397 ++++ lib/auth/api-keys.ts | 3 +- lib/events/handlers/event-log-handler.ts | 5 + lib/events/types.ts | 47 + 23 files changed, 3262 insertions(+), 592 deletions(-) create mode 100644 extensions/general/mcp-server/__tests__/output-schema.test.ts create mode 100644 extensions/general/mcp-server/__tests__/payload-size.bench.test.ts create mode 100644 extensions/general/mcp-server/__tests__/search-tools.test.ts create mode 100644 extensions/general/mcp-server/__tests__/skills.test.ts create mode 100644 extensions/general/mcp-server/__tests__/telemetry.test.ts create mode 100644 extensions/general/mcp-server/__tests__/vat-report-compute.test.ts create mode 100644 extensions/general/mcp-server/__tests__/vat-review-widget.test.ts create mode 100644 extensions/general/mcp-server/skills/index.ts create mode 100644 extensions/general/mcp-server/skills/invoicing-rules.ts create mode 100644 extensions/general/mcp-server/skills/month-end-close.ts create mode 100644 extensions/general/mcp-server/skills/payroll-monthly.ts create mode 100644 extensions/general/mcp-server/skills/quarterly-vat-review.ts create mode 100644 extensions/general/mcp-server/skills/types.ts create mode 100644 extensions/general/mcp-server/skills/year-end-close.ts create mode 100644 extensions/general/mcp-server/widgets/index.ts rename extensions/general/mcp-server/{widget-html.ts => widgets/receipt-matcher.ts} (96%) create mode 100644 extensions/general/mcp-server/widgets/types.ts create mode 100644 extensions/general/mcp-server/widgets/vat-review.ts diff --git a/extensions/general/mcp-server/__tests__/output-schema.test.ts b/extensions/general/mcp-server/__tests__/output-schema.test.ts new file mode 100644 index 00000000..d2d10ba0 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/output-schema.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { tools } from '../server' + +describe('outputSchema coverage', () => { + it('every tool declares an outputSchema', () => { + const missing = tools.filter((t) => !t.outputSchema).map((t) => t.name) + expect(missing).toEqual([]) + }) + + it('every outputSchema is an object schema', () => { + for (const t of tools) { + expect(t.outputSchema, `tool ${t.name} outputSchema`).toBeDefined() + const schema = t.outputSchema as Record + expect(schema.type, `tool ${t.name} outputSchema.type`).toBe('object') + } + }) + + it('every tool has a tight description (<= 280 chars)', () => { + const tooLong = tools.filter((t) => t.description.length > 280) + expect(tooLong.map((t) => `${t.name}: ${t.description.length} chars`)).toEqual([]) + }) + + it('no description embeds Args:/Returns:/Examples: blocks (those belong to JSON Schema)', () => { + const verbose = tools.filter((t) => + /Args:\s*\n|Returns JSON:|Examples:\s*\n|Errors:\s*\n/.test(t.description) + ) + expect(verbose.map((t) => t.name)).toEqual([]) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts new file mode 100644 index 00000000..a97ce1e3 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { tools } from '../server' + +describe('tools/list payload size guard', () => { + it('keeps the projected tools/list payload under the context-budget ceiling', () => { + const projection = tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), + annotations: t.annotations, + ...(t._meta ? { _meta: t._meta } : {}), + })) + const payload = JSON.stringify({ tools: projection }) + const approxTokens = Math.round(payload.length / 4) + // Ceiling chosen with headroom over the current ~11K-token payload. + // If this fires, either tools were added or descriptions drifted back to verbose; + // re-trim or rely on gnubok_search_tools for progressive disclosure. + expect(approxTokens).toBeLessThan(20_000) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts index d66f2cd4..04327843 100644 --- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -341,7 +341,7 @@ describe('MCP Receipt Matcher', () => { expect(result.content).toBeDefined() }) - it('does not include structuredContent for regular tools', async () => { + it('also includes structuredContent for regular tools (alongside the text content block)', async () => { const tx = makeTransaction({ id: 'tx-1', amount: -500 }) enqueueMany([ { data: tx, error: null }, @@ -358,7 +358,9 @@ describe('MCP Receipt Matcher', () => { ) const result = await parseResult(res) - expect(result.structuredContent).toBeUndefined() + // Modern clients consume structuredContent directly when an outputSchema is declared. + expect(result.structuredContent).toBeDefined() + expect(result.structuredContent).toMatchObject({ staged: true }) expect(result.content).toBeDefined() }) }) diff --git a/extensions/general/mcp-server/__tests__/search-tools.test.ts b/extensions/general/mcp-server/__tests__/search-tools.test.ts new file mode 100644 index 00000000..0fdcd960 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/search-tools.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest' +import { tools } from '../server' +import { ALL_SCOPES } from '@/lib/auth/api-keys' + +const searchTool = tools.find((t) => t.name === 'gnubok_search_tools')! + +async function call(args: Record, keyScopes: string[] = ALL_SCOPES as unknown as string[]) { + // Mirror the dispatcher: inject __keyScopes the way handleMcpRequest does. + const argsWithScopes = { ...args, __keyScopes: keyScopes } + return (await searchTool.execute( + argsWithScopes, + 'company-id', + 'user-id', + {} as never, + { type: 'api_key' } + )) as { + tools: Array<{ name: string; description?: string; scope: string | null; inputSchema?: unknown; outputSchema?: unknown }> + count: number + total_matched: number + detail: 'name' | 'summary' | 'full' + } +} + +describe('gnubok_search_tools', () => { + it('is registered as a tool', () => { + expect(searchTool).toBeDefined() + expect(searchTool.annotations.readOnlyHint).toBe(true) + }) + + it('returns all tools when query is empty (default summary detail)', async () => { + const result = await call({}) + expect(result.detail).toBe('summary') + expect(result.tools.length).toBeGreaterThan(0) + expect(result.tools.length).toBeLessThanOrEqual(20) // default limit + // summary entries should have name + description but not full schema + expect(result.tools[0]).toHaveProperty('name') + expect(result.tools[0]).toHaveProperty('description') + expect(result.tools[0]).not.toHaveProperty('inputSchema') + }) + + it('detail=name returns only names + scope', async () => { + const result = await call({ detail: 'name', limit: 5 }) + expect(result.detail).toBe('name') + for (const t of result.tools) { + expect(t).not.toHaveProperty('description') + expect(t).not.toHaveProperty('inputSchema') + expect(t).toHaveProperty('name') + expect(t).toHaveProperty('scope') + } + }) + + it('detail=full returns inputSchema and outputSchema', async () => { + const result = await call({ detail: 'full', query: 'list_uncategorized', limit: 5 }) + expect(result.tools.length).toBeGreaterThan(0) + const tool = result.tools[0] + expect(tool).toHaveProperty('inputSchema') + expect(tool).toHaveProperty('outputSchema') + }) + + it('filters by query keyword', async () => { + const result = await call({ query: 'vat' }) + expect(result.tools.length).toBeGreaterThan(0) + for (const t of result.tools) { + const haystack = `${t.name} ${t.description ?? ''}`.toLowerCase() + expect(haystack).toContain('vat') + } + }) + + it('respects limit (1-50, default 20, clamps over-50)', async () => { + const overLimit = await call({ limit: 100 }) + expect(overLimit.tools.length).toBeLessThanOrEqual(50) + }) + + it('filters out tools the caller cannot invoke based on scopes', async () => { + // Caller has only reports:read — should not see invoices:write tools. + const result = await call({ query: '', limit: 50 }, ['reports:read']) + const names = result.tools.map((t) => t.name) + expect(names).not.toContain('gnubok_create_invoice') + expect(names).not.toContain('gnubok_send_invoice') + // But should see reports:read tools. + expect(names).toContain('gnubok_get_trial_balance') + // And unscoped tools (like search itself) are always available. + expect(names).toContain('gnubok_search_tools') + }) + + it('scope filter narrows results to a single scope', async () => { + const result = await call({ scope: 'invoices:write', limit: 50 }) + for (const t of result.tools) { + expect(t.scope).toBe('invoices:write') + } + }) + + it('total_matched reflects pre-limit candidates', async () => { + const limited = await call({ query: '', limit: 3 }) + expect(limited.tools.length).toBe(3) + expect(limited.total_matched).toBeGreaterThan(3) + }) + + // Security: when the dispatcher fails to inject __keyScopes (rename, refactor + // regression, direct invocation outside the dispatcher), the search MUST fall + // back to a fail-closed default — only unscoped tools visible. The earlier + // permissive default leaked the full inventory. + it('fail-closed: hides scoped tools when __keyScopes is not injected', async () => { + // Bypass the helper which always injects __keyScopes — call execute() directly. + const result = (await searchTool.execute( + { limit: 50 }, // no __keyScopes + 'company-id', + 'user-id', + {} as never, + { type: 'api_key' } + )) as { tools: Array<{ name: string; scope: string | null }> } + + const names = result.tools.map((t) => t.name) + + // Only unscoped (discovery / skill) tools should appear. + expect(names).toContain('gnubok_search_tools') + expect(names).toContain('gnubok_list_skills') + expect(names).toContain('gnubok_load_skill') + + // No scoped tool should leak — pick representatives from each scope domain. + expect(names).not.toContain('gnubok_create_invoice') // invoices:write + expect(names).not.toContain('gnubok_get_trial_balance') // reports:read + expect(names).not.toContain('gnubok_list_uncategorized_transactions') // transactions:read + expect(names).not.toContain('gnubok_create_salary_run') // payroll:write + expect(names).not.toContain('gnubok_close_period') // bookkeeping:write + + // Sanity: every returned tool truly is unscoped. + for (const t of result.tools) { + expect(t.scope).toBeNull() + } + }) + + it('fail-closed: explicitly empty __keyScopes also hides scoped tools', async () => { + // The "scopes were checked, granted set is empty" case must behave the same + // as "scopes were not injected at all". Both indicate no scoped access. + const result = (await searchTool.execute( + { __keyScopes: [], limit: 50 }, + 'company-id', + 'user-id', + {} as never, + { type: 'api_key' } + )) as { tools: Array<{ name: string; scope: string | null }> } + + const names = result.tools.map((t) => t.name) + expect(names).not.toContain('gnubok_create_invoice') + expect(names).not.toContain('gnubok_get_trial_balance') + for (const t of result.tools) { + expect(t.scope).toBeNull() + } + }) +}) diff --git a/extensions/general/mcp-server/__tests__/skills.test.ts b/extensions/general/mcp-server/__tests__/skills.test.ts new file mode 100644 index 00000000..d88d7c78 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/skills.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for skills over MCP — registry, discovery tools, and resource exposure. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tools } from '../server' +import { skills, findSkill, SKILL_URI_PREFIX, skillUri } from '../skills' + +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: 'company-1', + // Minimal scopes — skills tools should be available regardless. + scopes: [], + }), + createServiceClientNoCookies: vi.fn(), + } +}) + +import { handleMcpRequest } from '../server' + +function mcpRequest(method: string, params?: Record, id: number | string = 1): Request { + return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }) +} + +async function parseResult(response: Response) { + const json = await response.json() + return json.result +} + +describe('Skills registry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('exports a non-empty skills array', () => { + expect(skills.length).toBeGreaterThanOrEqual(5) + }) + + it('every skill has unique slug', () => { + const slugs = skills.map((s) => s.slug) + expect(new Set(slugs).size).toBe(slugs.length) + }) + + it('every skill body is non-trivial and contains a Tools section', () => { + for (const s of skills) { + expect(s.body.length, `skill ${s.slug} body length`).toBeGreaterThan(500) + expect(s.body, `skill ${s.slug} should have a ## Tools section`).toMatch(/## Tools/i) + } + }) + + it('every skill has the expected metadata shape', () => { + for (const s of skills) { + expect(s.slug).toMatch(/^[a-z0-9-]+$/) + expect(s.name).toBeTruthy() + expect(s.summary.length).toBeGreaterThan(20) + expect(s.summary.length).toBeLessThan(200) + expect(Array.isArray(s.tags)).toBe(true) + expect(s.tags.length).toBeGreaterThan(0) + } + }) + + it('findSkill returns the skill or null', () => { + expect(findSkill('month-end-close')).toBeTruthy() + expect(findSkill('does-not-exist')).toBeNull() + }) + + it('skillUri uses the gnubok://skill/ prefix', () => { + expect(skillUri('foo')).toBe('gnubok://skill/foo') + expect(SKILL_URI_PREFIX).toBe('gnubok://skill/') + }) +}) + +describe('gnubok_list_skills tool', () => { + it('is registered with correct annotations and no scope requirement', () => { + const tool = tools.find((t) => t.name === 'gnubok_list_skills') + expect(tool).toBeDefined() + expect(tool?.annotations.readOnlyHint).toBe(true) + expect(tool?.annotations.idempotentHint).toBe(true) + }) + + it('returns all skills when called with no args', async () => { + const tool = tools.find((t) => t.name === 'gnubok_list_skills')! + const result = (await tool.execute({}, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as { + skills: Array<{ slug: string; name: string; summary: string; tags: string[] }> + count: number + } + expect(result.count).toBe(skills.length) + expect(result.skills.every((s) => s.slug && s.name && s.summary)).toBe(true) + // Body should NOT be returned by list (token saving). + expect((result.skills[0] as Record).body).toBeUndefined() + }) + + it('filters by tag', async () => { + const tool = tools.find((t) => t.name === 'gnubok_list_skills')! + const result = (await tool.execute({ tag: 'vat' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as { + skills: Array<{ slug: string; tags: string[] }> + count: number + } + expect(result.count).toBeGreaterThan(0) + for (const s of result.skills) { + expect(s.tags.map((t) => t.toLowerCase())).toContain('vat') + } + }) +}) + +describe('gnubok_load_skill tool', () => { + it('is registered', () => { + const tool = tools.find((t) => t.name === 'gnubok_load_skill') + expect(tool).toBeDefined() + }) + + it('returns full body for a valid slug', async () => { + const tool = tools.find((t) => t.name === 'gnubok_load_skill')! + const result = (await tool.execute({ slug: 'month-end-close' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as { + slug: string + name: string + body: string + } + expect(result.slug).toBe('month-end-close') + expect(result.body).toContain('# Month-End Close') + expect(result.body).toContain('## Tools') + }) + + it('throws structured error for unknown slug', async () => { + const tool = tools.find((t) => t.name === 'gnubok_load_skill')! + await expect( + tool.execute({ slug: 'nonexistent-skill' }, 'company-1', 'user-1', {} as never, { type: 'api_key' }) + ).rejects.toThrow(/Skill not found.*Available skills/) + }) + + it('throws when slug is missing or empty', async () => { + const tool = tools.find((t) => t.name === 'gnubok_load_skill')! + await expect( + tool.execute({ slug: '' }, 'company-1', 'user-1', {} as never, { type: 'api_key' }) + ).rejects.toThrow(/slug is required/) + }) +}) + +describe('Skills via MCP protocol', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resources/list includes one entry per skill at gnubok://skill/', async () => { + const res = await handleMcpRequest(mcpRequest('resources/list')) + const result = await parseResult(res) + const uris = result.resources.map((r: { uri: string }) => r.uri) + for (const skill of skills) { + expect(uris).toContain(skillUri(skill.slug)) + } + }) + + it('skill resources have the text/markdown mimeType', async () => { + const res = await handleMcpRequest(mcpRequest('resources/list')) + const result = await parseResult(res) + const skillResources = result.resources.filter((r: { uri: string }) => + r.uri.startsWith(SKILL_URI_PREFIX) + ) + expect(skillResources.length).toBe(skills.length) + for (const r of skillResources) { + expect(r.mimeType).toBe('text/markdown') + } + }) + + it('resources/read returns the Markdown body for a skill URI', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'gnubok://skill/quarterly-vat-review' }) + ) + const result = await parseResult(res) + expect(result.contents).toHaveLength(1) + expect(result.contents[0].uri).toBe('gnubok://skill/quarterly-vat-review') + expect(result.contents[0].mimeType).toBe('text/markdown') + expect(result.contents[0].text).toContain('# Quarterly VAT Review') + }) + + it('resources/read returns Resource not found for unknown skill slug', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'gnubok://skill/does-not-exist' }) + ) + const json = await res.json() + expect(json.error).toBeDefined() + expect(json.error.message).toContain('Resource not found') + }) + + it('tools/list includes both skill tools', async () => { + const res = await handleMcpRequest(mcpRequest('tools/list')) + const result = await parseResult(res) + const names = result.tools.map((t: { name: string }) => t.name) + expect(names).toContain('gnubok_list_skills') + expect(names).toContain('gnubok_load_skill') + }) +}) diff --git a/extensions/general/mcp-server/__tests__/telemetry.test.ts b/extensions/general/mcp-server/__tests__/telemetry.test.ts new file mode 100644 index 00000000..2e12b597 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/telemetry.test.ts @@ -0,0 +1,329 @@ +/** + * Tests for `mcp.tool_called` telemetry emission. + * + * Verifies all four dispatcher exit points (success, execution error, + * scope denied, unknown tool) emit a correctly-shaped event to the bus, + * and that the event-log handler registers the new type for persistence. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' + +// ── Mocks (mirrors receipt-matcher.test.ts setup) ──────────── + +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: 'company-1', + // Only reports:read — enough to call gnubok_get_trial_balance, NOT enough + // to call gnubok_create_invoice (invoices:write). Drives the scope-denied test. + scopes: ['reports:read'], + apiKeyId: 'key-1', + apiKeyName: 'Test Key', + }), + createServiceClientNoCookies: vi.fn(), + } +}) + +import { handleMcpRequest } from '../server' + +function mcpRequest(method: string, params?: Record, id: number | string = 1): Request { + return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }) +} + +interface ToolCalledPayload { + tool: string + requiredScope: string | null + actorType: string + actorId: string | null + actorLabel: string | null + latencyMs: number + success: boolean + isError: boolean + errorCode: string | null + errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null + requestId: string | number | null + userId: string + companyId: string +} + +interface ToolsListCalledPayload { + toolCount: number + actorType: string + actorId: string | null + actorLabel: string | null + latencyMs: number + requestId: string | number | null + userId: string + companyId: string +} + +interface ResourceReadPayload { + uri: string + kind: 'widget' | 'skill' | 'data' | 'unknown' + success: boolean + errorCode: string | null + latencyMs: number + actorType: string + actorId: string | null + actorLabel: string | null + requestId: string | number | null + userId: string + companyId: string +} + +async function captureNextToolCalledEvent(): Promise { + return new Promise((resolve) => { + const off = eventBus.on('mcp.tool_called', (payload) => { + off() + resolve(payload as ToolCalledPayload) + }) + }) +} + +async function captureNextToolsListEvent(): Promise { + return new Promise((resolve) => { + const off = eventBus.on('mcp.tools_list_called', (payload) => { + off() + resolve(payload as ToolsListCalledPayload) + }) + }) +} + +async function captureNextResourceReadEvent(): Promise { + return new Promise((resolve) => { + const off = eventBus.on('mcp.resource_read', (payload) => { + off() + resolve(payload as ResourceReadPayload) + }) + }) +} + +describe('mcp.tool_called telemetry', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + it('emits on successful tool execution with success=true and a measured latencyMs', async () => { + const eventPromise = captureNextToolCalledEvent() + + // gnubok_list_skills is unscoped + has no DB dependency, perfect for a happy-path test. + const response = await handleMcpRequest( + mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }) + ) + const json = await response.json() + expect(json.error).toBeUndefined() + + const event = await eventPromise + expect(event.tool).toBe('gnubok_list_skills') + expect(event.requiredScope).toBeNull() // unscoped + expect(event.success).toBe(true) + expect(event.isError).toBe(false) + expect(event.errorCode).toBeNull() + expect(event.errorKind).toBeNull() + expect(event.actorType).toBe('api_key') + expect(event.actorId).toBe('key-1') + expect(event.actorLabel).toBe('Test Key') + expect(event.userId).toBe('user-1') + expect(event.companyId).toBe('company-1') + expect(event.requestId).toBe(1) + // Real wall-clock — non-negative number + expect(typeof event.latencyMs).toBe('number') + expect(event.latencyMs).toBeGreaterThanOrEqual(0) + }) + + it('emits errorKind=scope_denied when the API key lacks the required scope', async () => { + const eventPromise = captureNextToolCalledEvent() + + // gnubok_create_invoice requires invoices:write; our test key has only reports:read. + await handleMcpRequest( + mcpRequest('tools/call', { + name: 'gnubok_create_invoice', + arguments: { customer_id: 'x', items: [] }, + }) + ) + + const event = await eventPromise + expect(event.tool).toBe('gnubok_create_invoice') + expect(event.requiredScope).toBe('invoices:write') + expect(event.success).toBe(false) + expect(event.isError).toBe(true) + expect(event.errorKind).toBe('scope_denied') + expect(event.errorCode).toBe('INSUFFICIENT_SCOPE') + // Scope denial exits before tool.execute() runs. + expect(event.latencyMs).toBe(0) + }) + + it('emits errorKind=unknown_tool when the tool name does not exist', async () => { + const eventPromise = captureNextToolCalledEvent() + + await handleMcpRequest( + mcpRequest('tools/call', { name: 'gnubok_does_not_exist', arguments: {} }) + ) + + const event = await eventPromise + expect(event.tool).toBe('gnubok_does_not_exist') + expect(event.requiredScope).toBeNull() + expect(event.success).toBe(false) + expect(event.isError).toBe(true) + expect(event.errorKind).toBe('unknown_tool') + expect(event.errorCode).toBe('UNKNOWN_TOOL') + expect(event.latencyMs).toBe(0) + }) + + it('emits errorKind=execution when the tool throws inside execute()', async () => { + const eventPromise = captureNextToolCalledEvent() + + // gnubok_load_skill throws on unknown slug — clean way to force an + // execution error without mocking Supabase. + await handleMcpRequest( + mcpRequest('tools/call', { + name: 'gnubok_load_skill', + arguments: { slug: 'definitely-does-not-exist' }, + }) + ) + + const event = await eventPromise + expect(event.tool).toBe('gnubok_load_skill') + expect(event.success).toBe(false) + expect(event.isError).toBe(true) + expect(event.errorKind).toBe('execution') + expect(event.errorCode).toBeTruthy() + // Execution path measures real latency, even if the tool exits quickly. + expect(event.latencyMs).toBeGreaterThanOrEqual(0) + }) + + it('does NOT block the JSON-RPC response on telemetry — even if a handler throws', async () => { + // Register a handler that throws synchronously. The bus already isolates + // failures via Promise.allSettled, so the response should still arrive. + eventBus.on('mcp.tool_called', () => { + throw new Error('intentional handler boom') + }) + + const response = await handleMcpRequest( + mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }) + ) + const json = await response.json() + + expect(response.status).toBe(200) + expect(json.error).toBeUndefined() + expect(json.result).toBeDefined() + }) +}) + +describe('mcp.tools_list_called telemetry', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + it('emits with toolCount filtered by the caller scopes', async () => { + const eventPromise = captureNextToolsListEvent() + + await handleMcpRequest(mcpRequest('tools/list')) + + const event = await eventPromise + // Caller has only reports:read — tools requiring other scopes are filtered out, + // but unscoped tools (search_tools, list_skills, load_skill) and reports:read + // tools are present. Just sanity-check the count is positive and bounded. + expect(event.toolCount).toBeGreaterThan(0) + expect(event.toolCount).toBeLessThan(100) + expect(event.actorType).toBe('api_key') + expect(event.userId).toBe('user-1') + expect(event.companyId).toBe('company-1') + expect(typeof event.latencyMs).toBe('number') + expect(event.latencyMs).toBeGreaterThanOrEqual(0) + }) +}) + +describe('mcp.resource_read telemetry', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + it('emits kind=widget for a widget URI hit', async () => { + const eventPromise = captureNextResourceReadEvent() + + await handleMcpRequest( + mcpRequest('resources/read', { uri: 'ui://receipt-matcher/app.html' }) + ) + + const event = await eventPromise + expect(event.uri).toBe('ui://receipt-matcher/app.html') + expect(event.kind).toBe('widget') + expect(event.success).toBe(true) + expect(event.errorCode).toBeNull() + }) + + it('emits kind=skill for a skill URI hit', async () => { + const eventPromise = captureNextResourceReadEvent() + + await handleMcpRequest( + mcpRequest('resources/read', { uri: 'gnubok://skill/quarterly-vat-review' }) + ) + + const event = await eventPromise + expect(event.uri).toBe('gnubok://skill/quarterly-vat-review') + expect(event.kind).toBe('skill') + expect(event.success).toBe(true) + expect(event.errorCode).toBeNull() + }) + + it('emits kind=unknown success=false for an URI that matches nothing', async () => { + const eventPromise = captureNextResourceReadEvent() + + await handleMcpRequest( + mcpRequest('resources/read', { uri: 'gnubok://nonexistent/whatever' }) + ) + + const event = await eventPromise + expect(event.uri).toBe('gnubok://nonexistent/whatever') + expect(event.kind).toBe('unknown') + expect(event.success).toBe(false) + expect(event.errorCode).toBe('RESOURCE_NOT_FOUND') + }) + + it('emits kind=unknown for a skill URI with an unknown slug', async () => { + const eventPromise = captureNextResourceReadEvent() + + // The dispatcher only matches kind=skill when findSkill returns a hit; + // unknown slugs fall through and end up as kind=unknown. + await handleMcpRequest( + mcpRequest('resources/read', { uri: 'gnubok://skill/does-not-exist' }) + ) + + const event = await eventPromise + expect(event.kind).toBe('unknown') + expect(event.success).toBe(false) + expect(event.errorCode).toBe('RESOURCE_NOT_FOUND') + }) +}) + +describe('event_log persistence registration', () => { + it('includes all three MCP telemetry events in the persisted event types', async () => { + // Read the file as text — the constant is module-private. This is a + // deliberate string-level guard so a future refactor that drops one + // of the events from the list trips the test. + const fs = await import('node:fs/promises') + const path = await import('node:path') + const handlerPath = path.resolve(__dirname, '..', '..', '..', '..', 'lib', 'events', 'handlers', 'event-log-handler.ts') + const text = await fs.readFile(handlerPath, 'utf-8') + expect(text).toMatch(/'mcp\.tool_called'/) + expect(text).toMatch(/'mcp\.tools_list_called'/) + expect(text).toMatch(/'mcp\.resource_read'/) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts b/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts new file mode 100644 index 00000000..daff5b0b --- /dev/null +++ b/extensions/general/mcp-server/__tests__/vat-report-compute.test.ts @@ -0,0 +1,243 @@ +/** + * Focused tests for computeVatReport — the shared VAT computation used by + * gnubok_get_vat_report and gnubok_vat_review_widget. These exist because the + * tools/call integration tests can't reach into the rutor math; this file + * mocks Supabase to feed synthetic journal entry lines and asserts the rutor + * shape, ruta48 inclusion of 2647, ruta49 formula, and the one-sided + * reverse-charge warning. + */ +import { describe, it, expect } from 'vitest' +import { computeVatReport, tools } from '../server' + +interface MockLine { + account_number: string + debit_amount: number + credit_amount: number +} + +function mockSupabaseWithLines(lines: MockLine[]) { + // Build a chain that matches the call path in computeVatReport: + // .from('journal_entry_lines').select(...).eq(...).in(...).gte(...).lte(...) + // The terminal `.lte()` returns `{ data, error }`. + const terminal = { data: lines, error: null } + const chain: Record unknown> = {} + // Terminal awaitable: vitest awaits the last call; .lte() returns the data. + chain.lte = () => terminal + chain.gte = () => chain + chain.in = () => chain + chain.eq = () => chain + chain.select = () => chain + chain.from = () => chain + return { from: chain.from } as never +} + +describe('computeVatReport', () => { + it('aggregates 2611 → ruta10, 2641 → ruta48, includes 2647 → ruta48', async () => { + const lines: MockLine[] = [ + // Domestic 25% sale: 1000 + 250 VAT + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + { account_number: '2611', debit_amount: 0, credit_amount: 250 }, + // Domestic input VAT 25% + { account_number: '2641', debit_amount: 100, credit_amount: 0 }, + // Domestic reverse-charge input VAT (2647) + { account_number: '2647', debit_amount: 50, credit_amount: 0 }, + ] + + const result = await computeVatReport( + { period_type: 'monthly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta05).toBe(1000) + expect(result.rutor.ruta10).toBe(250) + expect(result.rutor.ruta11).toBe(0) + expect(result.rutor.ruta12).toBe(0) + // Ruta 48 = 2641 (100) + 2647 (50) = 150 + expect(result.rutor.ruta48).toBe(150) + // Ruta 49 = 250 - 150 = 100 (positive = pay) + expect(result.rutor.ruta49).toBe(100) + expect(result.summary).toContain('Moms att betala') + expect(result.warnings).toEqual([]) + }) + + it('aggregates reverse-charge output VAT into ruta30/31/32 and the ruta49 formula', async () => { + const lines: MockLine[] = [ + // Reverse-charge purchase 25% — both sides booked correctly + { account_number: '2614', debit_amount: 0, credit_amount: 500 }, // ruta30 + { account_number: '2645', debit_amount: 500, credit_amount: 0 }, // matching input → ruta48 + // Reverse-charge purchase 6% + { account_number: '2634', debit_amount: 0, credit_amount: 30 }, // ruta32 + { account_number: '2645', debit_amount: 30, credit_amount: 0 }, + ] + + const result = await computeVatReport( + { period_type: 'quarterly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta30).toBe(500) + expect(result.rutor.ruta31).toBe(0) + expect(result.rutor.ruta32).toBe(30) + expect(result.rutor.ruta48).toBe(530) // 500 + 30 from 2645 + // Ruta 49 = (10+11+12+30+31+32) - 48 = 0+0+0+500+0+30 - 530 = 0 + expect(result.rutor.ruta49).toBe(0) + expect(result.warnings).toEqual([]) + }) + + it('emits a one-sided-reverse-charge warning when 2614 is booked without 2645 OR 2647', async () => { + const lines: MockLine[] = [ + // Output booked but matching input missing (the most common reverse-charge error) + { account_number: '2614', debit_amount: 0, credit_amount: 500 }, + // Neither 2645 nor 2647 present + ] + + const result = await computeVatReport( + { period_type: 'monthly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta30).toBe(500) + expect(result.rutor.ruta48).toBe(0) + // Without the matching input, ruta49 is inflated by 500 — the warning surfaces this. + expect(result.rutor.ruta49).toBe(500) + expect(result.warnings.length).toBe(1) + expect(result.warnings[0]).toMatch(/Omvänd betalningsskyldighet/) + // Both 2645 (EU) and 2647 (domestic) are mentioned so users know what to look for. + expect(result.warnings[0]).toMatch(/2645/) + expect(result.warnings[0]).toMatch(/2647/) + }) + + it('does NOT warn when reverse-charge output is balanced by 2647 (domestic, no 2645)', async () => { + // Domestic reverse charge per ML 16:13 (byggtjänster, electronics > 100k SEK) — + // matching input lands on 2647, not 2645. The earlier check missed this. + const lines: MockLine[] = [ + { account_number: '2614', debit_amount: 0, credit_amount: 500 }, // ruta30 + { account_number: '2647', debit_amount: 500, credit_amount: 0 }, // domestic input → ruta48 + ] + + const result = await computeVatReport( + { period_type: 'monthly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta30).toBe(500) + expect(result.rutor.ruta48).toBe(500) + expect(result.rutor.ruta49).toBe(0) + // No warning — the domestic mirror is correctly booked. + expect(result.warnings).toEqual([]) + }) + + it('expanded ruta05 includes alternative BAS revenue accounts (3041/3051/3071) AND taxable EU goods (3106)', async () => { + const lines: MockLine[] = [ + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + { account_number: '3041', debit_amount: 0, credit_amount: 500 }, // service 25% + { account_number: '3051', debit_amount: 0, credit_amount: 300 }, // goods 25% + { account_number: '3071', debit_amount: 0, credit_amount: 200 }, // other domestic + { account_number: '3106', debit_amount: 0, credit_amount: 100 }, // momspliktig EU goods + ] + + const result = await computeVatReport( + { period_type: 'yearly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta05).toBe(2100) + }) + + it('excludes 3004 (momsfri) from ruta05 — exempt sales must NOT be in the taxable base', async () => { + const lines: MockLine[] = [ + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + { account_number: '3004', debit_amount: 0, credit_amount: 500 }, // exempt — must be excluded + ] + + const result = await computeVatReport( + { period_type: 'yearly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta05).toBe(1000) + }) + + it('aggregates 3108 → ruta35 (EU intra-community goods, momsfri leverans till EU)', async () => { + const lines: MockLine[] = [ + // Domestic taxable sale + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + // EU goods supply, momsfri (zero-rated to EU customer with valid VAT number) + { account_number: '3108', debit_amount: 0, credit_amount: 5000 }, + ] + + const result = await computeVatReport( + { period_type: 'quarterly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta05).toBe(1000) // 3108 NOT in ruta05 (it's reported separately) + expect(result.rutor.ruta35).toBe(5000) // The new ruta we just added + expect(result.rutor.ruta39).toBe(0) + expect(result.rutor.ruta40).toBe(0) + }) + + it('refund summary string when ruta49 is negative', async () => { + const lines: MockLine[] = [ + { account_number: '2641', debit_amount: 100, credit_amount: 0 }, + // No output VAT; pure refund position. + ] + + const result = await computeVatReport( + { period_type: 'monthly', year: 2026, period: 1 }, + 'company-1', + mockSupabaseWithLines(lines) + ) + + expect(result.rutor.ruta49).toBe(-100) + expect(result.summary).toContain('Moms att få tillbaka') + }) + + it('exposes a rich outputSchema on both VAT tools (not bare {type:object})', () => { + for (const name of ['gnubok_get_vat_report', 'gnubok_vat_review_widget']) { + const tool = tools.find((t) => t.name === name) + expect(tool, `tool ${name}`).toBeDefined() + const schema = tool!.outputSchema as Record | undefined + expect(schema).toBeDefined() + expect(schema!.type).toBe('object') + const props = schema!.properties as Record + // The schema must declare period, period_label, rutor, summary, warnings. + expect(props).toHaveProperty('period') + expect(props).toHaveProperty('rutor') + expect(props).toHaveProperty('summary') + expect(props).toHaveProperty('warnings') + // rutor must declare each ruta the runtime returns. + const rutorProps = (props.rutor as { properties: Record }).properties + for (const r of ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49']) { + expect(rutorProps, `tool ${name} rutor.${r}`).toHaveProperty(r) + } + } + }) + + it('rejects bad period_type / out-of-range period / out-of-range year', async () => { + const supabase = mockSupabaseWithLines([]) + + await expect( + computeVatReport({ period_type: 'weekly', year: 2026, period: 1 }, 'c', supabase) + ).rejects.toThrow(/period_type/) + + await expect( + computeVatReport({ period_type: 'monthly', year: 2026, period: 13 }, 'c', supabase) + ).rejects.toThrow(/period must be 1–12/) + + await expect( + computeVatReport({ period_type: 'quarterly', year: 2026, period: 5 }, 'c', supabase) + ).rejects.toThrow(/period must be 1–4/) + + await expect( + computeVatReport({ period_type: 'monthly', year: 1900, period: 1 }, 'c', supabase) + ).rejects.toThrow(/year must be between/) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/vat-review-widget.test.ts b/extensions/general/mcp-server/__tests__/vat-review-widget.test.ts new file mode 100644 index 00000000..1d58c684 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/vat-review-widget.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for the VAT review widget — registration, resource serving, + * and tool _meta wiring. Does NOT re-test the underlying VAT computation + * (covered by existing get_vat_report 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: 'company-1', + scopes: ['reports:read'], + }), + createServiceClientNoCookies: vi.fn(), + } +}) + +import { handleMcpRequest } from '../server' + +function mcpRequest(method: string, params?: Record, id: number | string = 1): Request { + return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }) +} + +async function parseResult(response: Response) { + const json = await response.json() + return json.result +} + +describe('VAT review widget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('widget registration', () => { + it('registers the vat-review widget in uiWidgets', () => { + const widget = findUiWidget('ui://vat-review/app.html') + expect(widget).toBeDefined() + expect(widget?.name).toBe('VAT Review') + expect(widget?.html).toContain('') + expect(widget?.html).toContain('Momsdeklaration') + }) + + it('uiWidgets contains both receipt-matcher and vat-review', () => { + const uris = uiWidgets.map((w) => w.uri) + expect(uris).toContain('ui://receipt-matcher/app.html') + expect(uris).toContain('ui://vat-review/app.html') + }) + }) + + describe('gnubok_vat_review_widget tool', () => { + it('is registered with _meta.ui pointing to the vat-review widget', () => { + const tool = tools.find((t) => t.name === 'gnubok_vat_review_widget') + expect(tool).toBeDefined() + expect(tool?._meta).toEqual({ ui: { resourceUri: 'ui://vat-review/app.html' } }) + expect(tool?.annotations.readOnlyHint).toBe(true) + }) + + it('declares the same required inputs as gnubok_get_vat_report', () => { + const widgetTool = tools.find((t) => t.name === 'gnubok_vat_review_widget') + const reportTool = tools.find((t) => t.name === 'gnubok_get_vat_report') + const widgetRequired = (widgetTool?.inputSchema as { required?: string[] }).required ?? [] + const reportRequired = (reportTool?.inputSchema as { required?: string[] }).required ?? [] + expect(widgetRequired.sort()).toEqual(reportRequired.sort()) + }) + }) + + describe('protocol: resources/list', () => { + it('lists the vat-review widget alongside the receipt-matcher widget', async () => { + const res = await handleMcpRequest(mcpRequest('resources/list')) + const result = await parseResult(res) + + const widget = result.resources.find( + (r: { uri: string }) => r.uri === 'ui://vat-review/app.html' + ) + expect(widget).toEqual({ + uri: 'ui://vat-review/app.html', + name: 'VAT Review', + description: 'Interactive review of momsdeklaration (SKV 4700) before filing', + mimeType: 'text/html;profile=mcp-app', + }) + + const uris = result.resources.map((r: { uri: string }) => r.uri) + expect(uris).toContain('ui://receipt-matcher/app.html') + expect(uris).toContain('ui://vat-review/app.html') + }) + }) + + describe('protocol: resources/read', () => { + it('returns HTML for the vat-review widget', async () => { + const res = await handleMcpRequest( + mcpRequest('resources/read', { uri: 'ui://vat-review/app.html' }) + ) + const result = await parseResult(res) + + expect(result.contents).toHaveLength(1) + expect(result.contents[0].uri).toBe('ui://vat-review/app.html') + expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app') + expect(result.contents[0].text).toContain('Momsdeklaration') + expect(result.contents[0].text).toContain('ruta49') + }) + }) + + describe('protocol: tools/list', () => { + it('includes gnubok_vat_review_widget with _meta when the API key has reports:read scope', async () => { + const res = await handleMcpRequest(mcpRequest('tools/list')) + const result = await parseResult(res) + + const widgetTool = result.tools.find( + (t: { name: string }) => t.name === 'gnubok_vat_review_widget' + ) + expect(widgetTool).toBeDefined() + expect(widgetTool._meta).toEqual({ ui: { resourceUri: 'ui://vat-review/app.html' } }) + }) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index e6ced171..f2d94688 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -24,9 +24,10 @@ import { import { generateTrialBalance } from '@/lib/reports/trial-balance' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' -import { RECEIPT_MATCHER_HTML } from './widget-html' +import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets' import { dataResources, findResource, parseResourceQuery } from './resources' import { prompts, findPrompt } from './prompts' +import { skills, findSkill, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills' import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' import { shouldAutoCommit } from '@/lib/pending-operations/should-auto-commit' import { commitPendingOperation } from '@/lib/pending-operations/commit' @@ -99,6 +100,7 @@ interface McpTool { name: string description: string inputSchema: Record + outputSchema?: Record annotations: McpToolAnnotations _meta?: { ui: { resourceUri: string } } execute: ( @@ -517,38 +519,506 @@ async function categorizeTransactionCore( } } +// ── Output schema helpers ──────────────────────────────────── + +const PAGINATION_PROPS = { + count: { type: 'number', description: 'Number of items in this page' }, + total_count: { type: 'number', description: 'Total matching across all pages' }, + has_more: { type: 'boolean' }, + next_offset: { type: 'number', description: 'Offset for the next page (omitted on last page)' }, +} as const + +const STAGED_OPERATION_SCHEMA = { + type: 'object', + properties: { + staged: { type: 'boolean' }, + operation_id: { type: 'string', description: 'UUID of the staged operation, present once persisted' }, + risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }, + actor: { type: 'object' }, + auto_committed: { type: 'boolean' }, + auto_commit_reason: { type: 'string' }, + dry_run: { type: 'boolean' }, + idempotency_replay: { type: 'boolean' }, + message: { type: 'string' }, + preview: { type: 'object' }, + result: { type: 'object' }, + next: { type: 'object' }, + }, + required: ['staged', 'risk_level', 'actor', 'auto_committed', 'message', 'preview'], +} as const + +function paginatedSchema(itemsKey: string, itemSchema: Record = { type: 'object' }) { + return { + type: 'object', + properties: { + [itemsKey]: { type: 'array', items: itemSchema }, + ...PAGINATION_PROPS, + }, + required: [itemsKey, 'count', 'total_count', 'has_more'], + } as const +} + +const VAT_REPORT_OUTPUT_SCHEMA = { + type: 'object', + properties: { + period: { + type: 'object', + properties: { + type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'] }, + year: { type: 'number' }, + period: { type: 'number' }, + start: { type: 'string', description: 'Period start date (YYYY-MM-DD)' }, + end: { type: 'string', description: 'Period end date (YYYY-MM-DD)' }, + }, + required: ['type', 'year', 'period', 'start', 'end'], + }, + period_label: { type: 'string', description: 'Human-readable period label (e.g. "Q1 2026")' }, + rutor: { + type: 'object', + description: 'SKV 4700 momsdeklaration boxes — absolute values, signs implied by box semantics', + properties: { + ruta05: { type: 'number', description: 'Total domestic taxable sales (all rates)' }, + ruta10: { type: 'number', description: 'Output VAT 25 % (account 2611)' }, + ruta11: { type: 'number', description: 'Output VAT 12 % (account 2621)' }, + ruta12: { type: 'number', description: 'Output VAT 6 % (account 2631)' }, + ruta30: { type: 'number', description: 'Reverse-charge output VAT 25 % (account 2614)' }, + ruta31: { type: 'number', description: 'Reverse-charge output VAT 12 % (account 2624)' }, + ruta32: { type: 'number', description: 'Reverse-charge output VAT 6 % (account 2634)' }, + ruta35: { type: 'number', description: 'EU intra-community goods supplies, momsfri (account 3108)' }, + ruta39: { type: 'number', description: 'EU services sold (account 3308)' }, + ruta40: { type: 'number', description: 'Export outside EU (account 3305)' }, + ruta48: { type: 'number', description: 'Total input VAT (2641 + 2645 + 2647)' }, + ruta49: { + type: 'number', + description: 'VAT to pay (positive) or refund (negative) = (10+11+12+30+31+32) − 48', + }, + }, + required: ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49'], + }, + summary: { type: 'string', description: 'One-line Swedish summary string (att betala / att få tillbaka / noll)' }, + warnings: { + type: 'array', + items: { type: 'string' }, + description: 'Pre-filing warnings (e.g. one-sided reverse charge). Empty when none.', + }, + }, + required: ['period', 'period_label', 'rutor', 'summary', 'warnings'], +} as const + +// ── VAT report computation (shared by gnubok_get_vat_report + gnubok_vat_review_widget) ── +// +// Maps posted journal entry lines to SKV 4700 rutor. ruta49 covers domestic +// output VAT (10/11/12) AND reverse-charge output VAT (30/31/32) per +// ML 2023:200 — both must be displayed and netted against ruta48 (input VAT). +// +// Account → ruta map: +// 3001-3008, 3041-3048, 3051-3058, 3071-3078 → ruta05 (all domestic taxable sales — common BAS revenue accounts) +// 2611 → ruta10 (output VAT 25%) +// 2621 → ruta11 (output VAT 12%) +// 2631 → ruta12 (output VAT 6%) +// 2614 → ruta30 (reverse-charge output VAT 25%) +// 2624 → ruta31 (reverse-charge output VAT 12%) +// 2634 → ruta32 (reverse-charge output VAT 6%) +// 3308 → ruta39 (EU services sold) +// 3305 → ruta40 (export outside EU) +// 2641/2645/2647 → ruta48 (all input VAT) +// +// Posted+reversed status filter: a "reversed" original entry is still part of +// its period's books — Skatteverket files VAT period-by-period under +// faktureringsmetoden (sale's VAT in invoice-date period; kreditfaktura's +// reduction in storno-date period). The original entry stays in its period; +// the storno (status 'posted', dated when the credit was issued) lands in +// its own period. The two periods file independently; across a year they +// arithmetically cancel. *Excluding* 'reversed' would under-report Period N +// (the original sale's VAT silently disappears) and over-credit Period N+M +// (a reversal with no original) — incorrect per ML 2023:200. + +/** Common BAS taxable-revenue accounts that contribute to ruta 05. + * + * Conservative expansion beyond 3001/3002/3003. Excludes 3004 (momsfri, + * exempt) and 3108/3305/3308 (handled by ruta35/40/39). 3106 covers the + * rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the + * buyer's VAT number is invalid). + * + * Companies using non-standard charts must either book to one of these + * or extend the list — gnubok's BAS chart only ships 3001/3002/3003/3004 + * by default, but 30xx alternates are common in custom charts. */ +const RUTA_05_ACCOUNTS = [ + // Domestic sales by VAT rate (canonical BAS) + '3001', '3002', '3003', '3005', '3006', '3007', '3008', + // Taxable EU goods (momspliktig — buyer's VAT number invalid or buyer is private) + '3106', + // Domestic services (alternative numbering some companies use) + '3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048', + // Domestic goods (alternative numbering) + '3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058', + // Other domestic taxable + '3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078', +] as const + +export interface VatReportResult { + period: { type: string; year: number; period: number; start: string; end: string } + period_label: string + rutor: { + ruta05: number; ruta10: number; ruta11: number; ruta12: number + ruta30: number; ruta31: number; ruta32: number + ruta35: number; ruta39: number; ruta40: number + ruta48: number; ruta49: number + } + summary: string + warnings: string[] +} + +export async function computeVatReport( + args: Record, + companyId: string, + supabase: SupabaseClient +): Promise { + const periodType = args.period_type as string + const year = Number(args.year) + const period = Number(args.period) + + if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { + throw new Error('period_type must be: monthly, quarterly, yearly') + } + if (!year || year < 2000 || year > 2100) throw new Error('year must be between 2000 and 2100') + if (periodType === 'monthly' && (period < 1 || period > 12)) throw new Error('period must be 1–12 for monthly') + if (periodType === 'quarterly' && (period < 1 || period > 4)) throw new Error('period must be 1–4 for quarterly') + + let startDate: string + let endDate: string + + if (periodType === 'monthly') { + startDate = `${year}-${String(period).padStart(2, '0')}-01` + const lastDay = new Date(year, period, 0).getDate() + endDate = `${year}-${String(period).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + } else if (periodType === 'quarterly') { + const startMonth = (period - 1) * 3 + 1 + const endMonth = period * 3 + startDate = `${year}-${String(startMonth).padStart(2, '0')}-01` + const lastDay = new Date(year, endMonth, 0).getDate() + endDate = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + } else { + startDate = `${year}-01-01` + endDate = `${year}-12-31` + } + + const { data: lines, error } = await supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)') + .eq('journal_entries.company_id', companyId) + .in('journal_entries.status', ['posted', 'reversed']) + .gte('journal_entries.entry_date', startDate) + .lte('journal_entries.entry_date', endDate) + + if (error) throw new Error(`Database error: ${error.message}`) + + const accountTotals = new Map() + for (const line of lines ?? []) { + const acc = line.account_number + const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + accountTotals.set(acc, existing) + } + + function creditBalance(acc: string): number { + const t = accountTotals.get(acc) + return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0 + } + + function debitBalance(acc: string): number { + const t = accountTotals.get(acc) + return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0 + } + + const ruta05 = RUTA_05_ACCOUNTS.reduce((sum, acc) => sum + creditBalance(acc), 0) + const ruta10 = creditBalance('2611') + const ruta11 = creditBalance('2621') + const ruta12 = creditBalance('2631') + const ruta30 = creditBalance('2614') + const ruta31 = creditBalance('2624') + const ruta32 = creditBalance('2634') + const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU) + const ruta39 = creditBalance('3308') + const ruta40 = creditBalance('3305') + const calculatedInput2645 = debitBalance('2645') + const calculatedInput2647 = debitBalance('2647') + const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647 + const ruta49 = Math.round( + (ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 - ruta48) * 100 + ) / 100 + + const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', + 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'] + + let periodLabel: string + if (periodType === 'monthly') periodLabel = `${monthNames[period - 1]} ${year}` + else if (periodType === 'quarterly') periodLabel = `Q${period} ${year}` + else periodLabel = `${year}` + + // Pre-filing warnings — surface common compliance footguns. + // + // The matching input for reverse-charge output (2614/2624/2634) lands on + // 2645 (EU acquisitions) or 2647 (domestic reverse charge per ML 16:13 — + // byggtjänster, electronics > 100k SEK, etc.). Either is a valid mirror; + // the warning must fire only when *both* are zero. + const warnings: string[] = [] + const totalReverseChargeOutput = ruta30 + ruta31 + ruta32 + const totalReverseChargeInput = calculatedInput2645 + calculatedInput2647 + if (totalReverseChargeOutput > 0 && totalReverseChargeInput === 0) { + warnings.push( + 'Omvänd betalningsskyldighet: utgående moms har bokförts (rutor 30/31/32) men ingen ' + + 'beräknad ingående moms (varken 2645 EU eller 2647 inhemsk). Kontrollera att den ' + + 'motsvarande ingående bokningen finns — båda sidor krävs enligt ML 2023:200.' + ) + } + + return { + period: { type: periodType, year, period, start: startDate, end: endDate }, + period_label: periodLabel, + rutor: { + ruta05: Math.abs(ruta05), + ruta10: Math.abs(ruta10), + ruta11: Math.abs(ruta11), + ruta12: Math.abs(ruta12), + ruta30: Math.abs(ruta30), + ruta31: Math.abs(ruta31), + ruta32: Math.abs(ruta32), + ruta35: Math.abs(ruta35), + ruta39: Math.abs(ruta39), + ruta40: Math.abs(ruta40), + ruta48: Math.abs(ruta48), + ruta49, + }, + summary: ruta49 > 0 + ? `Moms att betala: ${Math.abs(ruta49).toFixed(2)} kr` + : ruta49 < 0 + ? `Moms att få tillbaka: ${Math.abs(ruta49).toFixed(2)} kr` + : 'Noll i moms', + warnings, + } +} + // ── Tools ──────────────────────────────────────────────────── -const tools: McpTool[] = [ +export const tools: McpTool[] = [ { - name: 'gnubok_list_uncategorized_transactions', - description: - 'List bank transactions that have not been categorized (no journal entry yet). ' + - 'Use this to see what needs bookkeeping attention.\n\n' + - 'Args:\n' + - ' - limit (number, optional): Max results, 1–100 (default: 20)\n' + - ' - offset (number, optional): Skip first N results for pagination (default: 0)\n\n' + - 'Returns JSON:\n' + - ' { transactions: [{ id, date, description, amount, currency, merchant_name, reference }],\n' + - ' count: number, total_count: number, has_more: boolean, next_offset?: number }\n\n' + - 'Examples:\n' + - ' - "Show my uncategorized transactions" → call with no args\n' + - ' - "Show next 50" → call with limit=50\n' + - ' - "Show page 2" → call with offset=20\n\n' + - 'Error: Returns error text if the database query fails.', + name: 'gnubok_search_tools', + description: 'Search gnubok MCP tools by keyword and return their schemas at a chosen detail level. Call this first when looking for a capability — avoids loading every tool schema upfront.', inputSchema: { type: 'object', properties: { - limit: { - type: 'number', - description: 'Max results to return, 1–100 (default 20)', - }, - offset: { - type: 'number', - description: 'Number of results to skip for pagination (default 0)', - }, + query: { type: 'string', description: 'Keywords matched against tool name + description (e.g. "vat", "invoice", "categorize"). Empty string returns all tools.' }, + detail: { type: 'string', enum: ['name', 'summary', 'full'], description: 'Detail level. name: just names. summary: name + description + scope (default). full: complete schema including inputSchema and outputSchema.' }, + scope: { type: 'string', description: 'Optional filter: only tools requiring this API key scope (e.g. "invoices:write").' }, + limit: { type: 'number', description: 'Max results, 1–50 (default 20).' }, }, }, + outputSchema: { + type: 'object', + properties: { + tools: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + total_matched: { type: 'number' }, + detail: { type: 'string' }, + }, + required: ['tools', 'count', 'total_matched', 'detail'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, _companyId, _userId, _supabase, _actor) { + const query = ((args.query as string) || '').toLowerCase().trim() + const detail = ((args.detail as string) || 'summary') as 'name' | 'summary' | 'full' + const scopeFilter = args.scope as string | undefined + const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) + + // Filter results to tools the caller is actually authorized to invoke. + // + // The dispatcher injects __keyScopes when it routes to gnubok_search_tools. + // If the marker is missing (refactor regression, direct execute() invocation + // outside the dispatcher, etc.), FAIL CLOSED — return only unscoped tools + // rather than leaking the full inventory. The marker presence is also part + // of the contract: an explicitly-empty array means "no scopes granted", + // which still hides scoped tools. + const rawKeyScopes = (args as Record).__keyScopes + const callerScopes: string[] = Array.isArray(rawKeyScopes) + ? (rawKeyScopes as string[]) + : [] + const scopesInjected = Array.isArray(rawKeyScopes) + + let candidates = tools.filter((t) => { + const required = TOOL_SCOPE_MAP[t.name] + if (required) { + // Scoped tool: visible only if scopes were injected AND the caller has it. + if (!scopesInjected) return false + if (!callerScopes.includes(required)) return false + } + if (scopeFilter && required !== scopeFilter) return false + return true + }) + + if (query) { + candidates = candidates.filter((t) => { + const haystack = `${t.name} ${t.description}`.toLowerCase() + return haystack.includes(query) + }) + } + + const totalMatched = candidates.length + const sliced = candidates.slice(0, limit) + + const projected = sliced.map((t) => { + const requiredScope = TOOL_SCOPE_MAP[t.name] ?? null + if (detail === 'name') return { name: t.name, scope: requiredScope } + if (detail === 'full') { + return { + name: t.name, + description: t.description, + scope: requiredScope, + inputSchema: t.inputSchema, + ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), + annotations: t.annotations, + } + } + // summary (default) + return { name: t.name, description: t.description, scope: requiredScope } + }) + + return { + tools: projected, + count: projected.length, + total_matched: totalMatched, + detail, + } + }, + }, + + { + name: 'gnubok_list_skills', + description: 'List available domain-knowledge skills (workflows for month-end close, VAT review, year-end, invoicing, payroll). Call gnubok_load_skill(slug) to read the body.', + inputSchema: { + type: 'object', + properties: { + tag: { type: 'string', description: 'Optional filter — return only skills matching this tag (e.g. "vat", "monthly", "yearly", "payroll").' }, + }, + }, + outputSchema: { + type: 'object', + properties: { + skills: { + type: 'array', + items: { + type: 'object', + properties: { + slug: { type: 'string' }, + name: { type: 'string' }, + summary: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + }, + required: ['slug', 'name', 'summary'], + }, + }, + count: { type: 'number' }, + }, + required: ['skills', 'count'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args) { + const tag = (args.tag as string | undefined)?.toLowerCase().trim() + const filtered = tag + ? skills.filter((s) => s.tags.some((t) => t.toLowerCase() === tag)) + : skills + return { + skills: filtered.map((s) => ({ + slug: s.slug, + name: s.name, + summary: s.summary, + tags: s.tags, + })), + count: filtered.length, + } + }, + }, + + { + name: 'gnubok_load_skill', + description: 'Load a domain-knowledge skill by slug. Returns the full Markdown body — call gnubok_list_skills first to find slugs.', + inputSchema: { + type: 'object', + properties: { + slug: { type: 'string', description: 'Skill slug (e.g. "month-end-close", "quarterly-vat-review", "year-end-close", "invoicing-rules", "payroll-monthly")' }, + }, + required: ['slug'], + }, + outputSchema: { + type: 'object', + properties: { + slug: { type: 'string' }, + name: { type: 'string' }, + summary: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + body: { type: 'string', description: 'Full skill content as Markdown' }, + }, + required: ['slug', 'name', 'body'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args) { + const slug = (args.slug as string | undefined)?.trim() + if (!slug) throw new Error('slug is required') + const skill = findSkill(slug) + if (!skill) { + const available = skills.map((s) => s.slug).join(', ') + throw new Error(`Skill not found: "${slug}". Available skills: ${available}`) + } + return { + slug: skill.slug, + name: skill.name, + summary: skill.summary, + tags: skill.tags, + body: skill.body, + } + }, + }, + + { + name: 'gnubok_list_uncategorized_transactions', + description: 'List bank transactions with no journal entry yet, newest first. Paginated.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max results to return, 1–100 (default 20)' }, + offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' }, + }, + }, + outputSchema: paginatedSchema('transactions', { + type: 'object', + properties: { + id: { type: 'string' }, + date: { type: 'string' }, + description: { type: 'string' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + merchant_name: { type: 'string' }, + reference: { type: 'string' }, + is_business: { type: 'boolean' }, + category: { type: 'string' }, + }, + }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -595,45 +1065,17 @@ const tools: McpTool[] = [ { name: 'gnubok_categorize_transaction', - description: - 'Categorize a bank transaction and stage the journal entry for user approval.\n\n' + - 'This tool stages the operation — the user reviews and approves it in the gnubok web app. ' + - 'The journal entry is NOT created until the user approves.\n\n' + - 'Args:\n' + - ' - transaction_id (string, required): UUID of the transaction from gnubok_list_uncategorized_transactions\n' + - ' - category (string, required): One of: ' + VALID_CATEGORIES.join(', ') + '\n' + - ' - vat_treatment (string, optional): One of: ' + VALID_VAT_TREATMENTS.join(', ') + '. ' + - 'Defaults to standard_25 for business expenses.\n\n' + - 'Returns JSON:\n' + - ' { staged: true, operation_id, message, preview: { debit_account, credit_account, amount, vat_lines } }\n\n' + - 'Examples:\n' + - ' - "Book that as office supplies, 25% VAT" → category="expense_office"\n' + - ' - "Mark as private" → category="private" (no journal entry created for private)\n' + - ' - "Book as consulting income" → category="income_services"\n\n' + - 'Errors:\n' + - ' - "Transaction not found" if the ID is invalid or belongs to another user\n' + - ' - "Transaction already has a journal entry" if already categorized\n' + - ' - "Invalid account mapping" if the category/entity type combination has no mapping', + description: 'Categorize a bank transaction. Stages the journal entry for the user to approve in the web app — no DB write until approval.', inputSchema: { type: 'object', properties: { - transaction_id: { - type: 'string', - description: 'UUID of the transaction to categorize', - }, - category: { - type: 'string', - description: 'Transaction category', - enum: [...VALID_CATEGORIES], - }, - vat_treatment: { - type: 'string', - description: 'VAT treatment override', - enum: [...VALID_VAT_TREATMENTS], - }, + transaction_id: { type: 'string', description: 'UUID of the transaction to categorize' }, + category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] }, + vat_treatment: { type: 'string', description: 'VAT treatment override (defaults to standard_25 for business expenses)', enum: [...VALID_VAT_TREATMENTS] }, }, required: ['transaction_id', 'category'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, @@ -695,25 +1137,22 @@ const tools: McpTool[] = [ { name: 'gnubok_receipt_matcher', - description: - 'Open the receipt matcher widget. Shows uncategorized transactions with drag-and-drop ' + - 'receipt attachment. Renders an interactive UI inline in the conversation.\n\n' + - 'Args:\n' + - ' - limit (number, optional): Max transactions to show, 1–50 (default: 20)\n\n' + - 'Returns JSON:\n' + - ' { transactions: [...], categories: [...], vat_treatments: [...] }\n\n' + - 'Examples:\n' + - ' - "Match my receipts" → call with no args\n' + - ' - "Open receipt matcher" → call with no args', + description: 'Open an interactive widget for drag-and-drop receipt-to-transaction matching. Renders inline in compatible clients.', inputSchema: { type: 'object', properties: { - limit: { - type: 'number', - description: 'Max transactions to show, 1–50 (default 20)', - }, + limit: { type: 'number', description: 'Max transactions to show, 1–50 (default 20)' }, }, }, + outputSchema: { + type: 'object', + properties: { + transactions: { type: 'array', items: { type: 'object' } }, + categories: { type: 'array', items: { type: 'string' } }, + vat_treatments: { type: 'array', items: { type: 'string' } }, + }, + required: ['transactions', 'categories', 'vat_treatments'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -748,13 +1187,16 @@ const tools: McpTool[] = [ { name: 'gnubok_list_customers', - description: - 'List all customers. Use this to look up customer IDs for invoice creation.\n\n' + - 'Args: none\n\n' + - 'Returns JSON:\n' + - ' { customers: [{ id, name, customer_type, email, org_number, vat_number, default_payment_terms }],\n' + - ' count: number }', + description: 'List all customers for the active company. Use to look up customer_id for invoice creation.', inputSchema: { type: 'object', properties: {} }, + outputSchema: { + type: 'object', + properties: { + customers: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['customers', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -776,25 +1218,8 @@ const tools: McpTool[] = [ { name: 'gnubok_create_customer', - description: - 'Stage a new customer for user approval. Required before creating invoices.\n\n' + - 'The customer is NOT created immediately — it is staged for the user to review ' + - 'and approve in the gnubok web app.\n\n' + - 'Args:\n' + - ' - name (string, required): Customer/company name\n' + - ' - customer_type (string, required): individual, swedish_business, eu_business, non_eu_business\n' + - ' - email (string, optional): Contact email\n' + - ' - org_number (string, optional): Swedish org number (for swedish_business)\n' + - ' - vat_number (string, optional): EU VAT number (for eu_business, triggers VIES validation)\n' + - ' - payment_terms (number, optional): Days until due (default 30)\n' + - ' - address (string, optional): Street address\n' + - ' - postal_code (string, optional)\n' + - ' - city (string, optional)\n' + - ' - country (string, optional): Defaults to Sweden\n\n' + - 'Returns JSON: { staged: true, operation_id, message, preview }\n\n' + - 'Examples:\n' + - ' - "Add Acme AB" → name="Acme AB", customer_type="swedish_business"\n' + - ' - "Add a German client" → customer_type="eu_business", country="Germany"', + description: 'Stage a new customer. Stages for user approval — NOT created until approved in the web app. EU VAT numbers trigger VIES validation.', + outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', properties: { @@ -872,17 +1297,7 @@ const tools: McpTool[] = [ { name: 'gnubok_list_invoices', - description: - 'List invoices, optionally filtered by status.\n\n' + - 'Args:\n' + - ' - status (string, optional): Filter by status: draft, sent, paid, overdue, cancelled, credited\n' + - ' - limit (number, optional): Max results, 1–100 (default 50)\n\n' + - 'Returns JSON:\n' + - ' { invoices: [{ id, invoice_number, status, customer_name, total, currency, invoice_date, due_date }],\n' + - ' count: number, total_count: number }\n\n' + - 'Examples:\n' + - ' - "Show unpaid invoices" → status="sent"\n' + - ' - "Show overdue invoices" → status="overdue"', + description: 'List invoices for the active company, newest first. Optional status filter.', inputSchema: { type: 'object', properties: { @@ -894,6 +1309,7 @@ const tools: McpTool[] = [ limit: { type: 'number', description: 'Max results (default 50, max 100)' }, }, }, + outputSchema: paginatedSchema('invoices', { type: 'object' }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -941,28 +1357,8 @@ const tools: McpTool[] = [ { name: 'gnubok_create_invoice', - description: - 'Stage a new invoice for user approval. Validates inputs and calculates VAT preview.\n\n' + - 'The invoice is NOT created immediately — it is staged for the user to review ' + - 'and approve in the gnubok web app. The invoice number is assigned at approval time.\n\n' + - 'Args:\n' + - ' - customer_id (string, required): UUID from gnubok_list_customers\n' + - ' - items (array, required): Line items, each with:\n' + - ' - description (string): What was sold/delivered\n' + - ' - quantity (number): How many\n' + - ' - unit (string): Unit of measure (st, tim, dag, mån)\n' + - ' - unit_price (number): Price per unit excl. VAT\n' + - ' - vat_rate (number, optional): Override VAT rate (0–100)\n' + - ' - invoice_date (string, optional): YYYY-MM-DD (default today)\n' + - ' - due_date (string, optional): YYYY-MM-DD (default based on payment terms)\n' + - ' - currency (string, optional): SEK, EUR, USD, GBP, NOK, DKK (default SEK)\n' + - ' - our_reference (string, optional)\n' + - ' - your_reference (string, optional)\n' + - ' - notes (string, optional): Notes printed on invoice\n\n' + - 'Returns JSON: { staged: true, operation_id, message, preview }\n\n' + - 'Examples:\n' + - ' - "Invoice Acme for 15000 kr consulting" → items=[{description:"Konsulttjänster",quantity:1,unit:"st",unit_price:15000}]\n' + - ' - "Invoice 10 hours at 1500/h" → items=[{description:"Konsulttjänster",quantity:10,unit:"tim",unit_price:1500}]', + description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Stages for user approval — invoice number assigned at approval.', + outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', properties: { @@ -1104,22 +1500,27 @@ const tools: McpTool[] = [ { name: 'gnubok_get_trial_balance', - description: - 'Get the trial balance (huvudbok) for a fiscal period. Shows all account balances.\n\n' + - 'Args:\n' + - ' - period_id (string, optional): Fiscal period UUID. If omitted, uses the most recent period.\n\n' + - 'Returns JSON:\n' + - ' { rows: [{ account_number, account_name, period_debit, period_credit, closing_debit, closing_credit }],\n' + - ' total_debit: number, total_credit: number, is_balanced: boolean, period_name: string }\n\n' + - 'Examples:\n' + - ' - "What are my account balances?" → call with no args\n' + - ' - "Trial balance for last year" → provide the period_id', + description: 'Trial balance (huvudbok) for a fiscal period — all account balances with debit/credit totals. Defaults to most recent period.', inputSchema: { type: 'object', properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, + outputSchema: { + type: 'object', + properties: { + rows: { type: 'array', items: { type: 'object' } }, + total_debit: { type: 'number' }, + total_credit: { type: 'number' }, + is_balanced: { type: 'boolean' }, + period_name: { type: 'string' }, + period_start: { type: 'string' }, + period_end: { type: 'string' }, + account_count: { type: 'number' }, + }, + required: ['rows', 'total_debit', 'total_credit', 'is_balanced'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1215,17 +1616,8 @@ const tools: McpTool[] = [ { name: 'gnubok_get_vat_report', - description: - 'Get the VAT declaration (momsdeklaration) for a period. Shows all rutor (boxes) for SKV 4700.\n\n' + - 'Args:\n' + - ' - period_type (string, required): monthly, quarterly, yearly\n' + - ' - year (number, required): e.g. 2025\n' + - ' - period (number, required): 1–12 for monthly, 1–4 for quarterly, 1 for yearly\n\n' + - 'Returns JSON: VAT declaration with all rutor (05, 10, 11, 12, 48, 49, etc.)\n' + - ' ruta49 = VAT to pay (positive) or refund (negative)\n\n' + - 'Examples:\n' + - ' - "VAT for Q1 2025" → period_type="quarterly", year=2025, period=1\n' + - ' - "VAT for March 2025" → period_type="monthly", year=2025, period=3', + description: 'VAT declaration (momsdeklaration, SKV 4700) for a period. Returns all rutor; ruta49 = VAT to pay (positive) or refund (negative).', + outputSchema: VAT_REPORT_OUTPUT_SCHEMA, inputSchema: { type: 'object', properties: { @@ -1245,105 +1637,33 @@ const tools: McpTool[] = [ idempotentHint: true, openWorldHint: false, }, - async execute(args, companyId, userId, supabase) { - const periodType = args.period_type as string - const year = Number(args.year) - const period = Number(args.period) + async execute(args, companyId, _userId, supabase) { + return computeVatReport(args, companyId, supabase) + }, + }, - if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { - throw new Error('period_type must be: monthly, quarterly, yearly') - } - if (!year || year < 2000 || year > 2100) throw new Error('year must be between 2000 and 2100') - if (periodType === 'monthly' && (period < 1 || period > 12)) throw new Error('period must be 1–12 for monthly') - if (periodType === 'quarterly' && (period < 1 || period > 4)) throw new Error('period must be 1–4 for quarterly') - - // Calculate date range - let startDate: string - let endDate: string - - if (periodType === 'monthly') { - startDate = `${year}-${String(period).padStart(2, '0')}-01` - const lastDay = new Date(year, period, 0).getDate() - endDate = `${year}-${String(period).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - } else if (periodType === 'quarterly') { - const startMonth = (period - 1) * 3 + 1 - const endMonth = period * 3 - startDate = `${year}-${String(startMonth).padStart(2, '0')}-01` - const lastDay = new Date(year, endMonth, 0).getDate() - endDate = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - } else { - startDate = `${year}-01-01` - endDate = `${year}-12-31` - } - - // Get all posted journal entry lines in the date range - const { data: lines, error } = await supabase - .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)') - .eq('journal_entries.company_id', companyId) - .in('journal_entries.status', ['posted', 'reversed']) - .gte('journal_entries.entry_date', startDate) - .lte('journal_entries.entry_date', endDate) - - if (error) throw new Error(`Database error: ${error.message}`) - - // Aggregate by account - const accountTotals = new Map() - for (const line of lines ?? []) { - const acc = line.account_number - const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 } - existing.debit += Number(line.debit_amount) || 0 - existing.credit += Number(line.credit_amount) || 0 - accountTotals.set(acc, existing) - } - - function creditBalance(acc: string): number { - const t = accountTotals.get(acc) - return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0 - } - - function debitBalance(acc: string): number { - const t = accountTotals.get(acc) - return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0 - } - - // Map accounts to rutor - const ruta05 = creditBalance('3001') + creditBalance('3002') + creditBalance('3003') - const ruta10 = creditBalance('2611') - const ruta11 = creditBalance('2621') - const ruta12 = creditBalance('2631') - const ruta39 = creditBalance('3308') - const ruta40 = creditBalance('3305') - const ruta48 = debitBalance('2641') + debitBalance('2645') - const ruta49 = Math.round((ruta10 + ruta11 + ruta12 - ruta48) * 100) / 100 - - const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', - 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'] - - let periodLabel: string - if (periodType === 'monthly') periodLabel = `${monthNames[period - 1]} ${year}` - else if (periodType === 'quarterly') periodLabel = `Q${period} ${year}` - else periodLabel = `${year}` - - return { - period: { type: periodType, year, period, start: startDate, end: endDate }, - period_label: periodLabel, - rutor: { - ruta05: Math.abs(ruta05), - ruta10: Math.abs(ruta10), - ruta11: Math.abs(ruta11), - ruta12: Math.abs(ruta12), - ruta39: Math.abs(ruta39), - ruta40: Math.abs(ruta40), - ruta48: Math.abs(ruta48), - ruta49, - }, - summary: ruta49 > 0 - ? `Moms att betala: ${Math.abs(ruta49).toFixed(2)} kr` - : ruta49 < 0 - ? `Moms att få tillbaka: ${Math.abs(ruta49).toFixed(2)} kr` - : 'Noll i moms', - } + { + name: 'gnubok_vat_review_widget', + description: 'Open the interactive VAT review widget for a period. Same data as gnubok_get_vat_report, rendered as a tabular UI for pre-filing review.', + inputSchema: { + type: 'object', + properties: { + period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, + year: { type: 'number', description: 'Year (e.g. 2025)' }, + period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' }, + }, + required: ['period_type', 'year', 'period'], + }, + outputSchema: VAT_REPORT_OUTPUT_SCHEMA, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { ui: { resourceUri: 'ui://vat-review/app.html' } }, + async execute(args, companyId, _userId, supabase) { + return computeVatReport(args, companyId, supabase) }, }, @@ -1351,26 +1671,14 @@ const tools: McpTool[] = [ { name: 'gnubok_get_kpi_report', - description: - 'Get key performance indicators for the business. Returns gross margin, net result, cash position, ' + - 'receivables, expense ratio, average payment days, VAT liability, and monthly trend data.\n\n' + - 'Args:\n' + - ' - period_id (string, optional): Fiscal period UUID. If omitted, uses the most recent period.\n\n' + - 'Returns JSON:\n' + - ' { gross_margin: %|null, net_result: SEK, cash_position: SEK, outstanding_receivables: SEK,\n' + - ' overdue_receivables: SEK, expense_ratio: %|null, avg_payment_days: days|null,\n' + - ' vat_liability: SEK, total_revenue: SEK, total_expenses: SEK,\n' + - ' months: [{ label, income, expenses, net }] }\n\n' + - 'Examples:\n' + - ' - "How is my business doing?" → call with no args\n' + - ' - "What are my KPIs?" → call with no args\n' + - ' - "Show me the numbers" → call with no args', + description: 'Business KPIs for a fiscal period: gross margin, net result, cash position, receivables, expense ratio, payment days, VAT liability, monthly trend.', inputSchema: { type: 'object', properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1464,23 +1772,14 @@ const tools: McpTool[] = [ { name: 'gnubok_get_income_statement', - description: - 'Get the income statement (resultaträkning) for a fiscal period. Shows revenue, expenses, ' + - 'and net result broken down by account category.\n\n' + - 'Args:\n' + - ' - period_id (string, optional): Fiscal period UUID. If omitted, uses the most recent period.\n\n' + - 'Returns JSON:\n' + - ' { revenue_sections, total_revenue, expense_sections, total_expenses, net_result,\n' + - ' period: { start, end } }\n\n' + - 'Examples:\n' + - ' - "What is my profit this year?" → call with no args\n' + - ' - "Show my income statement" → call with no args', + description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category.', inputSchema: { type: 'object', properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1528,19 +1827,7 @@ const tools: McpTool[] = [ { name: 'gnubok_mark_invoice_as_paid', - description: - 'Mark an invoice as paid and create the payment journal entry. ' + - 'Supports both accrual (faktureringsmetoden) and cash (kontantmetoden) accounting.\n\n' + - 'Args:\n' + - ' - invoice_id (string, required): UUID of the invoice\n' + - ' - payment_date (string, optional): ISO date YYYY-MM-DD (default: today)\n\n' + - 'Returns JSON:\n' + - ' { success: true, status: "paid", paid_at: string, paid_amount: number, journal_entry_id?: string }\n\n' + - 'Accrual: creates clearing entry (Debit 1930, Credit 1510).\n' + - 'Cash: creates revenue entry (Debit 1930, Credit 30xx/26xx).\n\n' + - 'Errors:\n' + - ' - Invoice must be in "sent" or "overdue" status\n' + - ' - Invoice not found if ID is invalid or belongs to another user', + description: 'Mark an invoice as paid and create the payment journal entry. Stages for approval. Status must be sent or overdue.', inputSchema: { type: 'object', properties: { @@ -1549,6 +1836,7 @@ const tools: McpTool[] = [ }, required: ['invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, @@ -1590,21 +1878,7 @@ const tools: McpTool[] = [ { name: 'gnubok_send_invoice', - description: - 'Send an invoice to the customer via email with a PDF attachment. ' + - 'Also creates the revenue journal entry (accrual method) and stores the PDF.\n\n' + - 'Args:\n' + - ' - invoice_id (string, required): UUID of the invoice to send\n\n' + - 'Returns JSON:\n' + - ' { success: true, message: string, messageId?: string }\n\n' + - 'Prerequisites:\n' + - ' - Customer must have an email address\n' + - ' - Email service must be configured (RESEND_API_KEY)\n' + - ' - Company settings must exist\n\n' + - 'Errors:\n' + - ' - "Email service not configured" if RESEND_API_KEY is missing\n' + - ' - "Customer has no email address" if customer email is empty\n' + - ' - "Company settings missing" if not set up', + description: 'Send invoice via email with PDF attachment. Stages for approval. Requires customer email + email service configured.', inputSchema: { type: 'object', properties: { @@ -1612,6 +1886,7 @@ const tools: McpTool[] = [ }, required: ['invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, @@ -1656,17 +1931,7 @@ const tools: McpTool[] = [ { name: 'gnubok_mark_invoice_as_sent', - description: - 'Mark a draft invoice as sent without sending an email. Use this when the invoice ' + - 'was delivered outside the system (e.g., printed or sent manually).\n\n' + - 'Args:\n' + - ' - invoice_id (string, required): UUID of the draft invoice\n\n' + - 'Returns JSON:\n' + - ' { success: true, status: "sent", journal_entry_id?: string }\n\n' + - 'Under accrual method: creates the revenue journal entry.\n' + - 'Under cash method: no journal entry (booking at payment).\n\n' + - 'Errors:\n' + - ' - Invoice must be in "draft" status', + description: 'Mark a draft invoice as sent without sending email (when delivered manually). Stages for approval. Status must be draft.', inputSchema: { type: 'object', properties: { @@ -1674,6 +1939,7 @@ const tools: McpTool[] = [ }, required: ['invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, @@ -1712,13 +1978,16 @@ const tools: McpTool[] = [ { name: 'gnubok_list_suppliers', - description: - 'List all suppliers (leverantörer) with contact and payment details.\n\n' + - 'Args: none\n\n' + - 'Returns JSON:\n' + - ' { suppliers: [{ id, name, supplier_type, email, org_number, vat_number,\n' + - ' default_expense_account, default_payment_terms, city, country }], count: number }', + description: 'List all suppliers (leverantörer) with contact and payment details, sorted by name.', inputSchema: { type: 'object', properties: {} }, + outputSchema: { + type: 'object', + properties: { + suppliers: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['suppliers', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1740,16 +2009,7 @@ const tools: McpTool[] = [ { name: 'gnubok_list_supplier_invoices', - description: - 'List supplier invoices (leverantörsfakturor) with optional status filter.\n\n' + - 'Args:\n' + - ' - status (string, optional): Filter by status — "registered", "approved", "overdue", "paid",\n' + - ' "to_pay" (approved + overdue), or "all" (default)\n' + - ' - limit (number, optional): Max results, 1–100 (default 50)\n\n' + - 'Returns JSON:\n' + - ' { invoices: [{ id, supplier_invoice_number, invoice_date, due_date, status,\n' + - ' total, total_sek, currency, vat_treatment, supplier: { id, name } }],\n' + - ' count: number }', + description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Optional status filter; "to_pay" combines approved+overdue.', inputSchema: { type: 'object', properties: { @@ -1761,6 +2021,14 @@ const tools: McpTool[] = [ limit: { type: 'number', description: 'Max results 1–100 (default 50)' }, }, }, + outputSchema: { + type: 'object', + properties: { + invoices: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['invoices', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1796,21 +2064,21 @@ const tools: McpTool[] = [ { name: 'gnubok_get_counterparty_templates', - description: - 'List active counterparty categorization templates. These are learned patterns from ' + - 'previous categorizations, used for auto-matching future transactions.\n\n' + - 'Args:\n' + - ' - limit (number, optional): Max results, 1–200 (default 100)\n\n' + - 'Returns JSON:\n' + - ' { templates: [{ id, counterparty_name, debit_account, credit_account,\n' + - ' vat_treatment, category, occurrence_count, confidence, source }],\n' + - ' count: number }', + description: 'List active counterparty categorization templates — learned patterns from prior categorizations used for auto-matching new transactions.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max results 1–200 (default 100)' }, }, }, + outputSchema: { + type: 'object', + properties: { + templates: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['templates', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1842,16 +2110,7 @@ const tools: McpTool[] = [ { name: 'gnubok_suggest_categories', - description: - 'Get category and template suggestions for uncategorized transactions. Uses mapping rules, ' + - 'pattern matching, user history, and counterparty templates to suggest the most likely categories.\n\n' + - 'Args:\n' + - ' - transaction_ids (string[], required): Up to 20 transaction UUIDs\n\n' + - 'Returns JSON:\n' + - ' { suggestions: { [tx_id]: [{ category, label, account, confidence, source }] },\n' + - ' counterparty_matches: { [tx_id]: { template_name, confidence, match_method } } }\n\n' + - 'Sources: "mapping_rule" (highest), "pattern" (keyword), "history" (past categorizations).\n' + - 'Counterparty matches use exact, normalized, or fuzzy Levenshtein matching.', + description: 'Suggest categories for uncategorized transactions using mapping rules, pattern matching, history, and counterparty templates. Up to 20 transactions per call.', inputSchema: { type: 'object', properties: { @@ -1863,6 +2122,14 @@ const tools: McpTool[] = [ }, required: ['transaction_ids'], }, + outputSchema: { + type: 'object', + properties: { + suggestions: { type: 'object' }, + counterparty_matches: { type: 'object' }, + }, + required: ['suggestions', 'counterparty_matches'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1943,15 +2210,7 @@ const tools: McpTool[] = [ { name: 'gnubok_list_accounts', - description: - 'List accounts from the chart of accounts (kontoplan) with optional filtering.\n\n' + - 'Args:\n' + - ' - account_class (number, optional): Filter by class (1=assets, 2=liabilities, 3=revenue,\n' + - ' 4–7=expenses, 8=financial)\n' + - ' - active_only (boolean, optional): Only show active accounts (default: true)\n\n' + - 'Returns JSON:\n' + - ' { accounts: [{ account_number, account_name, account_class, account_type,\n' + - ' normal_balance, is_active }], count: number }', + description: 'List chart of accounts (kontoplan). account_class: 1=assets, 2=liabilities, 3=revenue, 4–7=expenses, 8=financial.', inputSchema: { type: 'object', properties: { @@ -1959,6 +2218,14 @@ const tools: McpTool[] = [ active_only: { type: 'boolean', description: 'Only active accounts (default: true)' }, }, }, + outputSchema: { + type: 'object', + properties: { + accounts: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['accounts', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -1990,19 +2257,14 @@ const tools: McpTool[] = [ { name: 'gnubok_get_balance_sheet', - description: - 'Generate balance sheet (balansräkning) for a fiscal period.\n\n' + - 'Args:\n' + - ' - period_id (string, optional): Fiscal period UUID. If omitted, uses the most recent period.\n\n' + - 'Returns JSON:\n' + - ' { assets: { sections, total }, equity_and_liabilities: { sections, total },\n' + - ' is_balanced: boolean, period_name: string, period: { start, end } }', + description: 'Balance sheet (balansräkning) for a fiscal period: assets, equity, and liabilities sections with totals + balance check.', inputSchema: { type: 'object', properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2046,16 +2308,7 @@ const tools: McpTool[] = [ { name: 'gnubok_get_general_ledger', - description: - 'Generate general ledger (huvudbok) for a fiscal period, optionally filtered by account range.\n\n' + - 'Args:\n' + - ' - period_id (string, optional): Fiscal period UUID (default: most recent)\n' + - ' - account_from (string, optional): Starting account number (e.g., "1930")\n' + - ' - account_to (string, optional): Ending account number (e.g., "1939")\n\n' + - 'Returns JSON:\n' + - ' { accounts: [{ account_number, account_name, opening_balance,\n' + - ' entries: [{ date, voucher, description, debit, credit, balance }],\n' + - ' closing_balance }] }', + description: 'General ledger (huvudbok) for a fiscal period: per-account opening balance, entries, closing balance. Optional account range filter.', inputSchema: { type: 'object', properties: { @@ -2064,6 +2317,7 @@ const tools: McpTool[] = [ account_to: { type: 'string', description: 'Ending account number filter' }, }, }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2095,21 +2349,14 @@ const tools: McpTool[] = [ { name: 'gnubok_get_ar_ledger', - description: - 'Generate accounts receivable ledger (kundreskontra). Shows outstanding customer invoices ' + - 'with aging information.\n\n' + - 'Args:\n' + - ' - as_of_date (string, optional): Balance date YYYY-MM-DD (default: today)\n\n' + - 'Returns JSON:\n' + - ' { customers: [{ name, invoices: [{ invoice_number, date, due_date, total,\n' + - ' paid_amount, remaining, days_overdue }], total_outstanding }],\n' + - ' total_outstanding: number }', + description: 'Accounts receivable ledger (kundreskontra): outstanding customer invoices with aging.', inputSchema: { type: 'object', properties: { as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' }, }, }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2124,21 +2371,14 @@ const tools: McpTool[] = [ { name: 'gnubok_get_supplier_ledger', - description: - 'Generate accounts payable ledger (leverantörsreskontra). Shows outstanding supplier invoices ' + - 'with aging information.\n\n' + - 'Args:\n' + - ' - as_of_date (string, optional): Balance date YYYY-MM-DD (default: today)\n\n' + - 'Returns JSON:\n' + - ' { suppliers: [{ name, invoices: [{ invoice_number, date, due_date, total,\n' + - ' paid_amount, remaining, days_overdue }], total_outstanding }],\n' + - ' total_outstanding: number }', + description: 'Accounts payable ledger (leverantörsreskontra): outstanding supplier invoices with aging.', inputSchema: { type: 'object', properties: { as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' }, }, }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2155,20 +2395,7 @@ const tools: McpTool[] = [ { name: 'gnubok_match_transaction_to_invoice', - description: - 'Match a bank transaction to a customer invoice. Links the transaction to the invoice, ' + - 'creates the payment journal entry, and updates the invoice status. Supports partial payments.\n\n' + - 'If the transaction was previously categorized, the old journal entry is automatically reversed (storno).\n\n' + - 'Args:\n' + - ' - transaction_id (string, required): UUID of the bank transaction (must be income, amount > 0)\n' + - ' - invoice_id (string, required): UUID of the invoice to match\n\n' + - 'Returns JSON:\n' + - ' { success: true, invoice_status: "paid"|"partially_paid", paid_amount: number,\n' + - ' remaining_amount: number, journal_entry_id?: string }\n\n' + - 'Errors:\n' + - ' - Transaction must be income (amount > 0)\n' + - ' - Transaction must not already be linked to an invoice\n' + - ' - Invoice must be in "sent", "overdue", or "partially_paid" status', + description: 'Match a bank transaction (income, amount>0) to a customer invoice. Stages for approval. Supports partial payments and auto-storno of prior categorization.', inputSchema: { type: 'object', properties: { @@ -2177,6 +2404,7 @@ const tools: McpTool[] = [ }, required: ['transaction_id', 'invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, @@ -2235,13 +2463,16 @@ const tools: McpTool[] = [ { name: 'gnubok_list_fiscal_periods', - description: - 'List all fiscal periods (räkenskapsperioder) with their status.\n\n' + - 'Args: none\n\n' + - 'Returns JSON:\n' + - ' { periods: [{ id, name, period_start, period_end, status }], count: number }\n\n' + - 'Status values: "active" (open), "locked" (no new entries), "closed" (year-end completed).', + description: 'List all fiscal periods (räkenskapsperioder) with status: active (open), locked (no new entries), or closed (year-end completed).', inputSchema: { type: 'object', properties: {} }, + outputSchema: { + type: 'object', + properties: { + periods: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['periods', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2265,15 +2496,7 @@ const tools: McpTool[] = [ { name: 'gnubok_get_reconciliation_status', - description: - 'Get bank reconciliation status showing matched vs unmatched transactions and ledger entries.\n\n' + - 'Args:\n' + - ' - date_from (string, optional): Start date YYYY-MM-DD\n' + - ' - date_to (string, optional): End date YYYY-MM-DD\n\n' + - 'Returns JSON:\n' + - ' { total_transactions: number, matched: number, unmatched: number,\n' + - ' match_rate: number, bank_balance: number, ledger_balance: number,\n' + - ' difference: number }', + description: 'Bank reconciliation status: matched/unmatched counts, match rate, bank vs ledger balance, difference. Optional date range.', inputSchema: { type: 'object', properties: { @@ -2281,6 +2504,7 @@ const tools: McpTool[] = [ date_to: { type: 'string', description: 'End date YYYY-MM-DD' }, }, }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2298,15 +2522,7 @@ const tools: McpTool[] = [ { name: 'gnubok_upload_document', - description: - 'Upload a document (invoice, receipt) to the inbox. Runs deterministic field extraction (pdfjs + regex) on text-based PDFs.\n\n' + - 'Args:\n' + - ' - file_name (string, required): File name with extension (e.g. "faktura.pdf")\n' + - ' - file_content_base64 (string, required): Base64-encoded file content\n' + - ' - mime_type (string, optional): MIME type. Inferred from extension if omitted.\n\n' + - 'Returns JSON:\n' + - ' { document_id, inbox_item_id, status, extracted_data }\n\n' + - 'Supported types: PDF, JPEG, PNG, HEIC, WebP. Max 20 MB.', + description: 'Upload a PDF/JPEG/PNG/HEIC/WebP (max 20 MB) to the inbox. Runs deterministic field extraction on text-based PDFs.', inputSchema: { type: 'object', properties: { @@ -2316,6 +2532,17 @@ const tools: McpTool[] = [ }, required: ['file_name', 'file_content_base64'], }, + outputSchema: { + type: 'object', + properties: { + document_id: { type: 'string' }, + inbox_item_id: { type: 'string' }, + status: { type: 'string' }, + extracted_data: { type: 'object' }, + matched_supplier_id: { type: 'string' }, + }, + required: ['document_id', 'inbox_item_id', 'status'], + }, annotations: { readOnlyHint: false, destructiveHint: false, @@ -2405,29 +2632,22 @@ const tools: McpTool[] = [ { name: 'gnubok_list_inbox_items', - description: - 'List document inbox items (received supplier-invoice documents).\n\n' + - 'Args:\n' + - ' - status (string, optional): Filter by status (received, error)\n' + - ' - limit (number, optional): Max results, 1–50 (default 20)\n\n' + - 'Returns JSON:\n' + - ' { items: [{ id, status, source, created_at, vendor_name, amount,\n' + - ' invoice_date, matched_supplier_id, created_supplier_invoice_id }],\n' + - ' count: number }', + description: 'List document inbox items (received supplier-invoice documents). Optional status filter.', inputSchema: { type: 'object', properties: { - status: { - type: 'string', - enum: ['received', 'error'], - description: 'Filter by status', - }, - limit: { - type: 'number', - description: 'Max results (default 20, max 50)', - }, + status: { type: 'string', enum: ['received', 'error'], description: 'Filter by status' }, + limit: { type: 'number', description: 'Max results (default 20, max 50)' }, }, }, + outputSchema: { + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['items', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2487,13 +2707,7 @@ const tools: McpTool[] = [ { name: 'gnubok_get_inbox_item', - description: - 'Get a single document inbox item with full extracted data.\n\n' + - 'Args:\n' + - ' - inbox_item_id (string, required): UUID of the inbox item\n\n' + - 'Returns JSON:\n' + - ' Full inbox item with id, status, source, extracted_data (complete),\n' + - ' matched_supplier_id, created_supplier_invoice_id, email metadata, timestamps.', + description: 'Get a single inbox item with complete extracted data, supplier match, email metadata, and timestamps.', inputSchema: { type: 'object', properties: { @@ -2501,6 +2715,7 @@ const tools: McpTool[] = [ }, required: ['inbox_item_id'], }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2526,19 +2741,21 @@ const tools: McpTool[] = [ // ── Payroll (Lönehantering) ────────────────────────────────── { name: 'gnubok_list_employees', - description: - 'List all employees for the company.\n\n' + - 'Args:\n' + - ' - active_only (boolean, optional): Only active employees (default: true)\n\n' + - 'Returns JSON:\n' + - ' { employees: [{ id, first_name, last_name, personnummer (masked), employment_type,\n' + - ' monthly_salary, employment_degree, tax_table_number, tax_column }], count: number }', + description: 'List employees for the active company. Personnummer returned masked (XXXXXXXX-NNNN).', inputSchema: { type: 'object', properties: { active_only: { type: 'boolean', description: 'Only active employees (default: true)' }, }, }, + outputSchema: { + type: 'object', + properties: { + employees: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + }, + required: ['employees', 'count'], + }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const activeOnly = args.active_only !== false @@ -2555,14 +2772,7 @@ const tools: McpTool[] = [ }, { name: 'gnubok_get_salary_run', - description: - 'Get a salary run with employee breakdown and calculation details.\n\n' + - 'Args:\n' + - ' - salary_run_id (string, required): UUID of the salary run\n\n' + - 'Returns JSON:\n' + - ' Full salary run with status, totals, and per-employee breakdown including\n' + - ' gross_salary, tax_withheld, net_salary, avgifter, vacation_accrual,\n' + - ' and calculation_breakdown with step-by-step formulas.', + description: 'Get salary run with status, totals, per-employee breakdown (gross, tax, net, avgifter, vacation accrual) and step-by-step calculation breakdown.', inputSchema: { type: 'object', properties: { @@ -2570,6 +2780,7 @@ const tools: McpTool[] = [ }, required: ['salary_run_id'], }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const id = args.salary_run_id as string @@ -2589,13 +2800,7 @@ const tools: McpTool[] = [ }, { name: 'gnubok_get_salary_journal', - description: - 'Get the salary journal report (lönejournal) for a year.\n\n' + - 'Args:\n' + - ' - year (number, required): Year to report on\n\n' + - 'Returns JSON:\n' + - ' { rows: [per-employee per-month data], totals: { grossSalary, taxWithheld,\n' + - ' netSalary, avgifterAmount, vacationAccrual, totalEmployerCost } }', + description: 'Salary journal (lönejournal) for a year: per-employee per-month rows + yearly totals.', inputSchema: { type: 'object', properties: { @@ -2603,6 +2808,7 @@ const tools: McpTool[] = [ }, required: ['year'], }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const { generateSalaryJournal } = await import('@/lib/reports/salary-journal') @@ -2611,15 +2817,7 @@ const tools: McpTool[] = [ }, { name: 'gnubok_create_salary_run', - description: - 'Create a new salary run for a period, add all active employees, and calculate.\n\n' + - 'Args:\n' + - ' - period_year (number, required): Year\n' + - ' - period_month (number, required): Month (1-12)\n' + - ' - payment_date (string, required): Payment date (YYYY-MM-DD)\n\n' + - 'Returns JSON:\n' + - ' Created salary run with totals after calculation.\n\n' + - 'Note: Creates in draft status. Use the web UI to review, approve, and book.', + description: 'Create a draft salary run for a period and add all active employees with base lines. Use gnubok_calculate_salary_run next; final approval/booking happens in the web UI.', inputSchema: { type: 'object', properties: { @@ -2629,6 +2827,7 @@ const tools: McpTool[] = [ }, required: ['period_year', 'period_month', 'payment_date'], }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase) { const { period_year, period_month, payment_date } = args as { period_year: number; period_month: number; payment_date: string } @@ -2666,12 +2865,7 @@ const tools: McpTool[] = [ }, { name: 'gnubok_calculate_salary_run', - description: - 'Trigger calculation for a draft salary run. Updates all employee results.\n\n' + - 'Args:\n' + - ' - salary_run_id (string, required): UUID of the salary run\n\n' + - 'Returns JSON:\n' + - ' Updated salary run with calculated totals.', + description: 'Calculate a draft salary run: tax, avgifter, vacation accrual, totals. Run must be in draft status.', inputSchema: { type: 'object', properties: { @@ -2679,6 +2873,7 @@ const tools: McpTool[] = [ }, required: ['salary_run_id'], }, + outputSchema: { type: 'object' }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, userId, supabase) { // Delegate to the calculate API endpoint logic @@ -2698,14 +2893,7 @@ const tools: McpTool[] = [ }, { name: 'gnubok_generate_agi', - description: - 'Generate AGI XML (Arbetsgivardeklaration) for a salary run.\n\n' + - 'Args:\n' + - ' - salary_run_id (string, required): UUID of the salary run (must be in review/approved/paid/booked status)\n\n' + - 'Returns JSON:\n' + - ' { message, period, employee_count }\n\n' + - 'The XML is stored in agi_declarations for 7-year retention per BFL.\n' + - 'Download via GET /api/salary/runs/{id}/agi/xml', + description: 'Generate AGI XML (Arbetsgivardeklaration) for a salary run. Run must be past draft. Stored 7 years per BFL; download URL returned.', inputSchema: { type: 'object', properties: { @@ -2713,6 +2901,15 @@ const tools: McpTool[] = [ }, required: ['salary_run_id'], }, + outputSchema: { + type: 'object', + properties: { + message: { type: 'string' }, + period: { type: 'string' }, + employee_count: { type: 'number' }, + download_url: { type: 'string' }, + }, + }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const id = args.salary_run_id as string @@ -2733,12 +2930,7 @@ const tools: McpTool[] = [ { name: 'gnubok_close_period', - description: - 'Stage a "close fiscal period" proposal for human approval. Closing a period is irreversible per BFL — it requires the period to already be locked AND the year-end closing entry to be posted.\n\n' + - 'Args:\n' + - ' - fiscal_period_id (string, required): UUID of the fiscal period\n\n' + - 'Returns: { staged: true, operation_id, risk_level: "high", preview }\n\n' + - 'High-risk: never auto-committed regardless of trust level. Always requires human approval in the web app.', + description: 'Stage period close (irreversible per BFL). Requires period locked + year-end closing entry posted. High-risk — always staged, never auto-committed.', inputSchema: { type: 'object', properties: { @@ -2746,6 +2938,7 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, @@ -2791,12 +2984,7 @@ const tools: McpTool[] = [ { name: 'gnubok_lock_period', - description: - 'Stage a "lock fiscal period" proposal for human approval. Locking prevents new entries from being posted into the period. Requires zero unbooked business transactions.\n\n' + - 'Args:\n' + - ' - fiscal_period_id (string, required): UUID of the fiscal period\n\n' + - 'Returns: { staged: true, operation_id, risk_level: "high", preview }\n\n' + - 'High-risk: never auto-committed.', + description: 'Stage period lock — blocks new entries. Requires zero unbooked business transactions. High-risk, always staged.', inputSchema: { type: 'object', properties: { @@ -2804,6 +2992,7 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, @@ -2861,12 +3050,7 @@ const tools: McpTool[] = [ { name: 'gnubok_uncategorize_transaction', - description: - 'Stage an "uncategorize transaction" proposal for human approval. Reverses the journal entry via storno (legal — never deletes) and clears the transaction\'s category.\n\n' + - 'Use when a previous categorization needs to be undone before re-categorizing.\n\n' + - 'Args:\n' + - ' - transaction_id (string, required): UUID of the transaction\n\n' + - 'Returns: { staged: true, operation_id, risk_level: "medium", preview }', + description: 'Stage uncategorize: reverses linked journal entry via storno (never deletes) and clears the category. Stages for approval.', inputSchema: { type: 'object', properties: { @@ -2874,6 +3058,7 @@ const tools: McpTool[] = [ }, required: ['transaction_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, @@ -2924,12 +3109,7 @@ const tools: McpTool[] = [ { name: 'gnubok_export_sie', - description: - 'Generate a SIE-4 file for the given fiscal period. Returns the SIE content as text — the agent can save it locally or hand it to the user.\n\n' + - 'SIE-4 is the standard Swedish bookkeeping interchange format (cp437/utf-8). Include this when migrating between systems or handing data to an auditor.\n\n' + - 'Args:\n' + - ' - fiscal_period_id (string, required): UUID of the fiscal period to export\n\n' + - 'Returns: { content, byte_size, fiscal_period_id, company_name, generated_at }', + description: 'Generate SIE-4 file for a fiscal period (standard Swedish bookkeeping interchange format). Returns SIE text content.', inputSchema: { type: 'object', properties: { @@ -2937,6 +3117,16 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id'], }, + outputSchema: { + type: 'object', + properties: { + content: { type: 'string' }, + byte_size: { type: 'number' }, + fiscal_period_id: { type: 'string' }, + company_name: { type: 'string' }, + generated_at: { type: 'string' }, + }, + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -2977,9 +3167,7 @@ const tools: McpTool[] = [ { name: 'gnubok_run_year_end', - description: - 'Stage a year-end closing proposal for human approval. Year-end zeros class 3-8 result accounts into 2099, locks the period, creates the next period, and seeds opening balances. Always high-risk.\n\n' + - 'Args: fiscal_period_id (required)', + description: 'Stage year-end closing: zero result accounts (class 3–8) into 2099, lock period, create next period, seed opening balances. High-risk, always staged.', inputSchema: { type: 'object', properties: { @@ -2987,6 +3175,7 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string @@ -3021,9 +3210,7 @@ const tools: McpTool[] = [ { name: 'gnubok_set_opening_balances', - description: - 'Stage an "opening balances" proposal: copy class 1-2 closing balances from a closed period into the next period as opening balances.\n\n' + - 'Args: closed_period_id, next_period_id (both required)', + description: 'Stage opening-balance entry: copy class 1–2 closing balances from a closed period into the next period.', inputSchema: { type: 'object', properties: { @@ -3032,6 +3219,7 @@ const tools: McpTool[] = [ }, required: ['closed_period_id', 'next_period_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const closedId = args.closed_period_id as string @@ -3049,9 +3237,7 @@ const tools: McpTool[] = [ { name: 'gnubok_run_currency_revaluation', - description: - 'Stage a currency revaluation: revalue open foreign-currency receivables and payables to the closing-date FX rate. Posts to 3960/7960. Throws if a revaluation already exists for the period.\n\n' + - 'Args: fiscal_period_id, closing_date (required, YYYY-MM-DD)', + description: 'Stage currency revaluation: revalue open FX receivables/payables to closing-date rate (posts 3960/7960). One per period max.', inputSchema: { type: 'object', properties: { @@ -3060,6 +3246,7 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id', 'closing_date'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string @@ -3077,9 +3264,7 @@ const tools: McpTool[] = [ { name: 'gnubok_list_voucher_gaps', - description: - 'List voucher number gaps in a fiscal period (BFNAR 2013:2 audit requirement). Each gap may have an existing explanation.\n\n' + - 'Args: fiscal_period_id (required), voucher_series (optional)', + description: 'List voucher number gaps in a fiscal period (BFNAR 2013:2 audit requirement). Each gap shows whether it has an explanation.', inputSchema: { type: 'object', properties: { @@ -3088,6 +3273,15 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id'], }, + outputSchema: { + type: 'object', + properties: { + gaps: { type: 'array', items: { type: 'object' } }, + total_gaps: { type: 'number' }, + unexplained_gaps: { type: 'number' }, + }, + required: ['gaps', 'total_gaps', 'unexplained_gaps'], + }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string @@ -3140,9 +3334,7 @@ const tools: McpTool[] = [ { name: 'gnubok_explain_voucher_gap', - description: - 'Stage an explanation for a voucher number gap. Required for BFNAR 2013:2 compliance — every gap must have a documented reason.\n\n' + - 'Args: fiscal_period_id, voucher_series, gap_start, gap_end, explanation (all required)', + description: 'Stage explanation for a voucher gap (BFNAR 2013:2 compliance — every gap needs a documented reason).', inputSchema: { type: 'object', properties: { @@ -3154,6 +3346,7 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id', 'voucher_series', 'gap_start', 'gap_end', 'explanation'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const explanation = args.explanation as string @@ -3181,14 +3374,13 @@ const tools: McpTool[] = [ { name: 'gnubok_approve_supplier_invoice', - description: - 'Stage approval of a registered supplier invoice. Moves status from "registered" to "approved". High-risk: never auto-committed.\n\n' + - 'Args: supplier_invoice_id (required)', + description: 'Stage approval of a registered supplier invoice (registered → approved). High-risk, always staged.', inputSchema: { type: 'object', properties: { supplier_invoice_id: { type: 'string' } }, required: ['supplier_invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.supplier_invoice_id as string @@ -3217,14 +3409,13 @@ const tools: McpTool[] = [ { name: 'gnubok_credit_supplier_invoice', - description: - 'Stage a credit-note (kreditfaktura) for an existing supplier invoice. Creates a mirror invoice with negative effect and reverses the registration JE under accrual method.\n\n' + - 'Args: supplier_invoice_id (required)', + description: 'Stage credit-note (kreditfaktura) for a supplier invoice: mirror invoice with negative effect + reverses registration JE (accrual).', inputSchema: { type: 'object', properties: { supplier_invoice_id: { type: 'string' } }, required: ['supplier_invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.supplier_invoice_id as string @@ -3254,14 +3445,13 @@ const tools: McpTool[] = [ { name: 'gnubok_convert_invoice', - description: - 'Stage conversion of a proforma invoice to a real invoice. Allocates an F-series number, copies items, marks the proforma cancelled. Medium-risk.\n\n' + - 'Args: invoice_id (required, must be proforma)', + description: 'Stage conversion of a proforma invoice to a real invoice. Allocates F-series number, copies items, marks proforma cancelled.', inputSchema: { type: 'object', properties: { invoice_id: { type: 'string' } }, required: ['invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.invoice_id as string @@ -3295,10 +3485,7 @@ const tools: McpTool[] = [ { name: 'gnubok_unlock_period', - description: - 'Stage an "unlock fiscal period" proposal for human approval. Clears `locked_at` so new entries can be posted into the period. Cannot unlock a closed period — only one that is locked but not closed.\n\n' + - 'Args: fiscal_period_id (required)\n\n' + - 'High-risk: never auto-committed.', + description: 'Stage period unlock — clears locked_at so entries can be posted again. Cannot unlock a closed period. High-risk, always staged.', inputSchema: { type: 'object', properties: { @@ -3306,6 +3493,7 @@ const tools: McpTool[] = [ }, required: ['fiscal_period_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string @@ -3339,10 +3527,7 @@ const tools: McpTool[] = [ { name: 'gnubok_credit_invoice', - description: - 'Stage a credit note (kreditfaktura) for an existing customer invoice. Creates a `KR-` prefixed mirror invoice with negated amounts and reverses the original JE under accrual method. Original must be sent/paid/overdue and not already credited.\n\n' + - 'Args: invoice_id (required), reason (optional Swedish-language note)\n\n' + - 'High-risk: never auto-committed.', + description: 'Stage credit note (kreditfaktura) for a customer invoice: KR- prefixed mirror invoice + reverses original JE (accrual). Original must be sent/paid/overdue and not already credited.', inputSchema: { type: 'object', properties: { @@ -3351,6 +3536,7 @@ const tools: McpTool[] = [ }, required: ['invoice_id'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.invoice_id as string @@ -3389,17 +3575,7 @@ const tools: McpTool[] = [ { name: 'gnubok_import_sie', - description: - 'Stage a SIE file import proposal for human approval. Parses an SIE file (types 1-4, CP437/UTF-8/Latin-1) and stages a job that, on commit, creates the fiscal period, opening balances, and journal entries.\n\n' + - 'Args:\n' + - ' - file_content (string, required): Full SIE file contents as a string\n' + - ' - filename (string, required): Original filename (used for the import record + dedup)\n' + - ' - mappings (array, required): Account mappings with { sourceAccount, sourceName, targetAccount, targetName, confidence, matchType, isOverride }. Use gnubok_export_sie or the import wizard to derive these first.\n' + - ' - create_fiscal_period (bool, optional, default false)\n' + - ' - import_opening_balances (bool, optional, default false)\n' + - ' - import_transactions (bool, optional, default false)\n' + - ' - voucher_series (string, optional): Override voucher series for imported vouchers\n\n' + - 'High-risk: never auto-committed. Large file_content payloads are stored on the pending_operation row — keep files reasonable in size.', + description: 'Stage SIE-file import (types 1–4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. High-risk, always staged.', inputSchema: { type: 'object', properties: { @@ -3407,16 +3583,17 @@ const tools: McpTool[] = [ filename: { type: 'string', description: 'Original filename' }, mappings: { type: 'array', - description: 'Account mappings (AccountMapping[])', + description: 'Account mappings: { sourceAccount, sourceName, targetAccount, targetName, confidence, matchType, isOverride }', items: { type: 'object' }, }, create_fiscal_period: { type: 'boolean' }, import_opening_balances: { type: 'boolean' }, import_transactions: { type: 'boolean' }, - voucher_series: { type: 'string' }, + voucher_series: { type: 'string', description: 'Override voucher series for imported vouchers' }, }, required: ['file_content', 'filename', 'mappings'], }, + outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fileContent = args.file_content as string @@ -3460,7 +3637,7 @@ const SERVER_INFO = { version: '1.0.0', } -const PROTOCOL_VERSION = '2025-03-26' +const PROTOCOL_VERSION = '2025-06-18' function jsonRpc(id: string | number | null, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result } @@ -3475,6 +3652,113 @@ function jsonRpcError( return { jsonrpc: '2.0', id, error: { code, message, data } } } +/** + * Emit `mcp.tool_called` telemetry to the event bus. Fire-and-forget — the + * dispatcher must never block the JSON-RPC response on telemetry, and a failing + * handler must never surface to the client. The event bus already isolates + * handlers via Promise.allSettled, but we belt-and-braces here too. + */ +function emitToolCallTelemetry(payload: { + tool: string + requiredScope: string | null + actor: ActorContext + latencyMs: number + success: boolean + isError: boolean + errorCode: string | null + errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null + requestId: string | number | null + userId: string + companyId: string +}): void { + void eventBus + .emit({ + type: 'mcp.tool_called', + payload: { + tool: payload.tool, + requiredScope: payload.requiredScope, + actorType: payload.actor.type, + actorId: payload.actor.id ?? null, + actorLabel: payload.actor.label ?? null, + latencyMs: payload.latencyMs, + success: payload.success, + isError: payload.isError, + errorCode: payload.errorCode, + errorKind: payload.errorKind, + requestId: payload.requestId, + userId: payload.userId, + companyId: payload.companyId, + }, + }) + .catch((err) => { + // Last-resort guard. EventBus.emit already swallows handler failures, + // but if the bus itself is in a bad state we still don't want to break tools. + console.error('[mcp] tool_called telemetry emit failed:', err) + }) +} + +/** Fire-and-forget telemetry for a tools/list call. */ +function emitToolsListTelemetry(payload: { + toolCount: number + actor: ActorContext + latencyMs: number + requestId: string | number | null + userId: string + companyId: string +}): void { + void eventBus + .emit({ + type: 'mcp.tools_list_called', + payload: { + toolCount: payload.toolCount, + actorType: payload.actor.type, + actorId: payload.actor.id ?? null, + actorLabel: payload.actor.label ?? null, + latencyMs: payload.latencyMs, + requestId: payload.requestId, + userId: payload.userId, + companyId: payload.companyId, + }, + }) + .catch((err) => { + console.error('[mcp] tools_list_called telemetry emit failed:', err) + }) +} + +/** Fire-and-forget telemetry for a resources/read call. */ +function emitResourceReadTelemetry(payload: { + uri: string + kind: 'widget' | 'skill' | 'data' | 'unknown' + success: boolean + errorCode: string | null + actor: ActorContext + latencyMs: number + requestId: string | number | null + userId: string + companyId: string +}): void { + void eventBus + .emit({ + type: 'mcp.resource_read', + payload: { + uri: payload.uri, + kind: payload.kind, + success: payload.success, + errorCode: payload.errorCode, + latencyMs: payload.latencyMs, + actorType: payload.actor.type, + actorId: payload.actor.id ?? null, + actorLabel: payload.actor.label ?? null, + requestId: payload.requestId, + userId: payload.userId, + companyId: payload.companyId, + }, + }) + .catch((err) => { + console.error('[mcp] resource_read telemetry emit failed:', err) + }) +} + /** * Handle an MCP JSON-RPC request. * Auth is done via Bearer API key (extension route has skipAuth: true). @@ -3551,7 +3835,7 @@ export async function handleMcpRequest(request: Request): Promise { switch (method) { case 'initialize': { - const SUPPORTED_VERSIONS = new Set(['2025-03-26', '2024-11-05']) + const SUPPORTED_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']) const clientVersion = (params as Record)?.protocolVersion as string | undefined const negotiatedVersion = clientVersion && SUPPORTED_VERSIONS.has(clientVersion) ? clientVersion : PROTOCOL_VERSION @@ -3564,7 +3848,24 @@ export async function handleMcpRequest(request: Request): Promise { prompts: { listChanged: false }, }, serverInfo: SERVER_INFO, - instructions: 'gnubok — Swedish bookkeeping via conversation. Categorize transactions, manage invoices (create, send, mark paid), view suppliers, match payments, get reports (trial balance, income statement, balance sheet, VAT, KPI, general ledger, AR/AP ledgers), and explore chart of accounts.', + instructions: [ + 'gnubok — Swedish double-entry bookkeeping via conversation.', + '', + 'Discovery:', + '• Call gnubok_search_tools first (e.g. query="vat") to load only the schemas you need. tools/list returns names + 1-line summaries; pull full schemas via gnubok_search_tools(detail="full") when invoking.', + '• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first — domain workflows are documented as loadable skills with tool references.', + '', + 'Common workflows:', + '• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages for user approval). Use gnubok_match_transaction_to_invoice to apply income to a specific invoice.', + '• Invoicing: gnubok_list_customers (or gnubok_create_customer) → gnubok_create_invoice → gnubok_send_invoice or gnubok_mark_invoice_as_sent → gnubok_mark_invoice_as_paid. Refund via gnubok_credit_invoice.', + '• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative).', + '• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger — all default to the most recent fiscal period.', + '• 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 → review/approve in web UI → gnubok_generate_agi.', + '', + 'Write operations stage a pending_operation (risk_level: low/medium/high) — the user approves in the gnubok web app before any DB write. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.', + 'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").', + ].join('\n'), }) ) } @@ -3577,16 +3878,26 @@ export async function handleMcpRequest(request: Request): Promise { return NextResponse.json(jsonRpc(id ?? null, {})) case 'tools/list': { + const listStartedAt = Date.now() const allowedTools = tools.filter((t) => { const required = TOOL_SCOPE_MAP[t.name] return !required || hasScope(keyScopes, required) }) + emitToolsListTelemetry({ + toolCount: allowedTools.length, + actor, + latencyMs: Date.now() - listStartedAt, + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json( jsonRpc(id ?? null, { tools: allowedTools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, + ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), annotations: t.annotations, ...(t._meta ? { _meta: t._meta } : {}), })), @@ -3603,6 +3914,19 @@ export async function handleMcpRequest(request: Request): Promise { const tool = tools.find((t) => t.name === toolName) if (!tool) { + emitToolCallTelemetry({ + tool: toolName ?? '', + requiredScope: null, + actor, + latencyMs: 0, + success: false, + isError: true, + errorCode: 'UNKNOWN_TOOL', + errorKind: 'unknown_tool', + requestId: id ?? null, + userId, + companyId, + }) const available = tools.map((t) => t.name).join(', ') return NextResponse.json( jsonRpcError(id ?? null, -32602, `Unknown tool: "${toolName}". Available tools: ${available}`) @@ -3616,6 +3940,19 @@ export async function handleMcpRequest(request: Request): Promise { new Error(`Insufficient scope: this API key does not have the "${requiredScope}" scope`), { toolName } ) + emitToolCallTelemetry({ + tool: toolName, + requiredScope, + actor, + latencyMs: 0, + success: false, + isError: true, + errorCode: scopeError.error.code, + errorKind: 'scope_denied', + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json( jsonRpc(id ?? null, { content: [{ type: 'text', text: JSON.stringify(scopeError, null, 2) }], @@ -3624,17 +3961,55 @@ export async function handleMcpRequest(request: Request): Promise { ) } + const callStartedAt = Date.now() try { + // gnubok_search_tools needs the caller's scopes to filter results to + // what the API key can actually invoke. Inject privately via __keyScopes. + if (toolName === 'gnubok_search_tools') { + (toolArgs as Record).__keyScopes = keyScopes + } const result = await tool.execute(toolArgs, companyId, userId, supabase, actor) + const latencyMs = Date.now() - callStartedAt const response: Record = { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], } - if (tool._meta?.ui) { - response.structuredContent = result + // Emit structuredContent for every tool — clients with outputSchema support + // can consume this directly without re-parsing the JSON-stringified text block. + // structuredContent must be an object, so wrap non-objects. + if (result !== null && result !== undefined) { + response.structuredContent = + typeof result === 'object' && !Array.isArray(result) ? result : { value: result } } + emitToolCallTelemetry({ + tool: toolName, + requiredScope: requiredScope ?? null, + actor, + latencyMs, + success: true, + isError: false, + errorCode: null, + errorKind: null, + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json(jsonRpc(id ?? null, response)) } catch (err) { + const latencyMs = Date.now() - callStartedAt const structured = toToolError(err, { toolName }) + emitToolCallTelemetry({ + tool: toolName, + requiredScope: requiredScope ?? null, + actor, + latencyMs, + success: false, + isError: true, + errorCode: structured.error.code, + errorKind: 'execution', + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json( jsonRpc(id ?? null, { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], @@ -3648,12 +4023,18 @@ export async function handleMcpRequest(request: Request): Promise { return NextResponse.json( jsonRpc(id ?? null, { resources: [ - { - uri: 'ui://receipt-matcher/app.html', - name: 'Receipt Matcher', - description: 'Interactive widget for matching receipts to uncategorized transactions', - mimeType: 'text/html;profile=mcp-app', - }, + ...uiWidgets.map((w) => ({ + uri: w.uri, + name: w.name, + description: w.description, + mimeType: WIDGET_MIME_TYPE, + })), + ...skills.map((s) => ({ + uri: skillUri(s.slug), + name: s.name, + description: s.summary, + mimeType: SKILL_MIME_TYPE, + })), ...dataResources.map((r) => ({ uri: r.uri, name: r.name, @@ -3666,20 +4047,65 @@ export async function handleMcpRequest(request: Request): Promise { case 'resources/read': { const uri = (params as Record)?.uri as string - if (uri === 'ui://receipt-matcher/app.html') { + const readStartedAt = Date.now() + + const widget = findUiWidget(uri) + if (widget) { + emitResourceReadTelemetry({ + uri, + kind: 'widget', + success: true, + errorCode: null, + actor, + latencyMs: Date.now() - readStartedAt, + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json( jsonRpc(id ?? null, { contents: [ { uri, - mimeType: 'text/html;profile=mcp-app', - text: RECEIPT_MATCHER_HTML, + mimeType: WIDGET_MIME_TYPE, + text: widget.html, }, ], }) ) } + // Skills exposed at gnubok://skill/ — Markdown bodies, forward-compatible + // with a future native MCP skills/list primitive. + if (uri.startsWith(SKILL_URI_PREFIX)) { + const slug = skillSlugFromUri(uri) + const skill = slug ? findSkill(slug) : null + if (skill) { + emitResourceReadTelemetry({ + uri, + kind: 'skill', + success: true, + errorCode: null, + actor, + latencyMs: Date.now() - readStartedAt, + requestId: id ?? null, + userId, + companyId, + }) + return NextResponse.json( + jsonRpc(id ?? null, { + contents: [ + { + uri, + mimeType: SKILL_MIME_TYPE, + text: skill.body, + }, + ], + }) + ) + } + } + const dataResource = findResource(uri) if (dataResource) { try { @@ -3690,6 +4116,17 @@ export async function handleMcpRequest(request: Request): Promise { scopes: keyScopes, query: parseResourceQuery(uri), }) + emitResourceReadTelemetry({ + uri, + kind: 'data', + success: true, + errorCode: null, + actor, + latencyMs: Date.now() - readStartedAt, + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json( jsonRpc(id ?? null, { contents: [ @@ -3703,12 +4140,34 @@ export async function handleMcpRequest(request: Request): Promise { ) } catch (err) { const message = err instanceof Error ? err.message : 'Resource read failed' + emitResourceReadTelemetry({ + uri, + kind: 'data', + success: false, + errorCode: 'RESOURCE_READ_FAILED', + actor, + latencyMs: Date.now() - readStartedAt, + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json( jsonRpcError(id ?? null, -32603, `Resource read error: ${message}`) ) } } + emitResourceReadTelemetry({ + uri, + kind: 'unknown', + success: false, + errorCode: 'RESOURCE_NOT_FOUND', + actor, + latencyMs: Date.now() - readStartedAt, + requestId: id ?? null, + userId, + companyId, + }) return NextResponse.json( jsonRpcError(id ?? null, -32602, `Resource not found: "${uri}"`) ) diff --git a/extensions/general/mcp-server/skills/index.ts b/extensions/general/mcp-server/skills/index.ts new file mode 100644 index 00000000..0838efca --- /dev/null +++ b/extensions/general/mcp-server/skills/index.ts @@ -0,0 +1,21 @@ +import type { Skill } from './types' +import { monthEndCloseSkill } from './month-end-close' +import { quarterlyVatReviewSkill } from './quarterly-vat-review' +import { yearEndCloseSkill } from './year-end-close' +import { invoicingRulesSkill } from './invoicing-rules' +import { payrollMonthlySkill } from './payroll-monthly' + +export const skills: Skill[] = [ + monthEndCloseSkill, + quarterlyVatReviewSkill, + yearEndCloseSkill, + invoicingRulesSkill, + payrollMonthlySkill, +] + +export function findSkill(slug: string): Skill | null { + return skills.find((s) => s.slug === slug) ?? null +} + +export type { Skill } from './types' +export { SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './types' diff --git a/extensions/general/mcp-server/skills/invoicing-rules.ts b/extensions/general/mcp-server/skills/invoicing-rules.ts new file mode 100644 index 00000000..ebf8852f --- /dev/null +++ b/extensions/general/mcp-server/skills/invoicing-rules.ts @@ -0,0 +1,148 @@ +import type { Skill } from './types' + +const body = `# Invoicing Rules — gnubok + +How to send a Swedish-compliant invoice from start to finish. + +## When to use + +- "Skicka faktura till [kund]" +- "Invoice [customer] for [amount]" +- "Create a credit note" +- "How do I invoice an EU customer?" + +## Mandatory invoice fields (ML 17 kap. 24 §) + +Every Swedish invoice (faktura) must contain: + +1. **Datum för utfärdande** (issue date) +2. **Löpnummer** (sequential invoice number — system-assigned at approval) +3. **Säljarens momsregistreringsnummer** (seller's VAT number) +4. **Köparens momsregistreringsnummer** (for EU B2B; otherwise name + address) +5. **Säljarens fullständiga namn och adress** +6. **Köparens fullständiga namn och adress** +7. **Mängd och slag av varor / omfattning av tjänster** +8. **Datum då varorna levererats / tjänsterna utförts** (if different from invoice date) +9. **Beskattningsunderlag per momssats** +10. **Tillämpad momssats** +11. **Momsbelopp** +12. **Vid omvänd betalningsskyldighet:** notation "omvänd betalningsskyldighet" or "reverse charge" +13. **Vid undantag:** referens till relevant ML-paragraph or article in Direktivet +14. **F-skatt / FA-skatt notation** ("Innehar F-skattsedel" or "F-skattebevis") for B2B services + +The \`gnubok_create_invoice\` tool handles all of these automatically — but always provide \`our_reference\`/\`your_reference\` if known. + +## Workflow + +### Step 1 — Customer ready + +Customers with their full data already in the system: \`gnubok_list_customers\`. Find the one. Note the \`customer_id\`. + +If the customer doesn't exist: + +\`gnubok_create_customer\` with at minimum \`{ name, customer_type }\`. \`customer_type\` must be one of: + +- \`individual\` — physical person +- \`swedish_business\` — AB / HB / KB / EF with Swedish org-number +- \`eu_business\` — EU company. **Provide \`vat_number\`** so VIES validation runs (otherwise reverse-charge eligibility fails). +- \`non_eu_business\` — outside EU + +### Step 2 — Determine VAT treatment + +| Customer | VAT treatment | Default rate | +|----------|---------------|--------------| +| Swedish individual | \`standard_25\` (or 12/6 by goods) | 25 % | +| Swedish business | \`standard_25\` | 25 % | +| EU business with valid VAT number | \`reverse_charge\` | 0 % (with notation) | +| EU business without VAT number | \`standard_25\` | 25 % (treat as B2C) | +| Non-EU business / private | \`export\` | 0 % | +| Books, newspapers, transport | \`reduced_6\` | 6 % | +| Restaurant, hotel | \`reduced_12\` | 12 % — see footnote below | + +**Footnote on the 1 April 2026 livsmedel rate change** (Prop. 2025/26:55): + +- **Livsmedel sold in other forms** (grocery, takeaway sold by retailer, etc.) drops from 12 % → **6 %** from 1 April 2026. +- **Restaurang och servering** (sit-down food and beverage service) **stays at 12 %** even after 1 April 2026. +- Hotels: room nights remain at 12 %; on-site restaurant service is restaurang (12 %); minibar / shop is sale of varor (6 % if food, 25 % otherwise). + +When in doubt for an invoice issued on or after 1 April 2026, classify the supply per the above rather than defaulting to one rate for "restaurang/hotell". + +Use \`getAvailableVatRates(customerType, vatNumberValidated)\` semantics — gnubok handles this. Per-line override possible via \`vat_rate\` on each item. + +### Step 3 — Create the invoice + +\`gnubok_create_invoice({ customer_id, items: [{ description, quantity, unit, unit_price, vat_rate? }], invoice_date?, due_date?, currency? })\` + +Returns staged operation. User approves in web app → invoice number is allocated atomically (gap-free) and journal entry posted (under accrual / faktureringsmetoden). + +### Step 4 — Send + +\`gnubok_send_invoice(invoice_id)\` — emails the PDF to the customer. Requires email service configured (Resend) and customer email on file. + +If the user delivered the invoice manually (printed, e-faktura via Peppol, etc.), use \`gnubok_mark_invoice_as_sent\` instead — same booking effect, no email. + +### Step 5 — Record payment + +When money arrives in 1930: + +- **Match to bank transaction** (preferred): \`gnubok_match_transaction_to_invoice({ transaction_id, invoice_id })\` — links the payment, marks invoice paid (or partially_paid), books JE. +- **Manual mark**: \`gnubok_mark_invoice_as_paid({ invoice_id, payment_date })\` — when payment arrived but isn't in the bank feed yet. + +### Step 6 — Reverse if needed + +If the invoice was wrong: \`gnubok_credit_invoice({ invoice_id, reason })\` creates a \`KR-\` mirror invoice with negated amounts and reverses the original JE. Original status → \`credited\`. **Never edit a sent invoice** — kreditfaktura is the only legal path. + +The kreditfaktura **itself** consumes a sequential number from the same (or a dedicated KR-) fakturaserie per BFL 5 kap. 6–7 § / ML 17 kap. 22–23 §. The \`KR-\` prefix is a display convention; the underlying löpnummer must be unbroken just like the regular invoice series. \`gnubok_credit_invoice\` allocates this atomically at approval — agents shouldn't try to set or skip the number manually. + +## ROT/RUT (consumer services) + +For consumer-targeted services (RUT: städning, RUT) or construction (ROT): + +- Use \`fakturamodellen\` (the customer pays the discounted amount; you reclaim the rest from Skatteverket) +- Customer must have **personnummer** (or coordination number) on file +- Add the property's **fastighetsbeteckning** (real estate ID) for ROT +- **RUT**: 50 % deduction, max 75 000 SEK/year/person (2025). +- **ROT**: rate and ceiling have shifted year by year — verify against Skatteverket for the invoice date before applying: + - **Standard rate**: 30 %, max 50 000 SEK/year/person. + - **2024 H2 (1 Jul – 31 Dec 2024)**: temporary doubled ceiling, separate caps applied. + - **2025 May–Dec**: enhanced 50 % rate (still 50 000 SEK ceiling). Reverts to 30 % from 2026 unless extended. + - When in doubt for an invoice issued in May 2025 or later, default to the current Skatteverket-published rate rather than the 30 % baseline. + +This data goes on the invoice; gnubok's invoice template renders it automatically when set on the customer. + +## Peppol / e-invoicing (B2G) + +Swedish authorities require e-invoices via Peppol BIS Billing 3.0 (Lag 2018:1277). For private B2B, the buyer's preference governs but Peppol is preferred. gnubok renders an EN 16931-compliant XML on demand. + +## Critical rules + +- **Invoice numbers are sequential and gap-free.** Allocated atomically at approval. If you change your mind, use \`gnubok_credit_invoice\`, never delete or skip a number — Skatteverket will audit. +- **F-skatt notation is mandatory** for B2B services. gnubok adds it automatically when company settings have F-skatt = true. +- **Currency:** SEK is default but the invoice itself can be issued in any of SEK/EUR/USD/GBP/NOK/DKK. The bookkeeping JE is always in SEK at issue-date Riksbanken rate. + +## Common errors + +- **EU customer charged 25 %**: missing \`vat_number\` or VIES validation failed. Fix: re-validate, then re-issue as \`reverse_charge\`. +- **Sent before approval**: not possible — \`gnubok_send_invoice\` stages too. The user must approve. +- **Edit instead of credit**: blocked by DB triggers. Use \`gnubok_credit_invoice\`. + +## Tools + +- \`gnubok_list_customers\` / \`gnubok_create_customer\` — customer setup +- \`gnubok_create_invoice\` — stage new invoice +- \`gnubok_send_invoice\` — email PDF +- \`gnubok_mark_invoice_as_sent\` — manual delivery +- \`gnubok_mark_invoice_as_paid\` — manual payment +- \`gnubok_match_transaction_to_invoice\` — link bank payment +- \`gnubok_credit_invoice\` — kreditfaktura (legal undo) +- \`gnubok_convert_invoice\` — proforma → real invoice +- \`gnubok_list_invoices\` — find existing invoices +` + +export const invoicingRulesSkill: Skill = { + slug: 'invoicing-rules', + name: 'Invoicing Rules', + summary: 'Mandatory invoice fields (ML 17 kap. 24 §), VAT treatment per customer type, ROT/RUT, Peppol, kreditfaktura.', + tags: ['invoicing', 'vat', 'compliance', 'eu', 'rot-rut'], + body, +} diff --git a/extensions/general/mcp-server/skills/month-end-close.ts b/extensions/general/mcp-server/skills/month-end-close.ts new file mode 100644 index 00000000..5aba547e --- /dev/null +++ b/extensions/general/mcp-server/skills/month-end-close.ts @@ -0,0 +1,82 @@ +import type { Skill } from './types' + +const body = `# Month-End Close — gnubok + +Run this at the end of each calendar month to ensure books are clean before locking the period. + +## When to use + +Trigger this workflow when the user says any of: + +- "Close out [month]" +- "Stäng [månad]" +- "Month-end close" +- "Lock [period]" + +Run it on the **last business day** of the month (or first business day of the next month). Locking too early prevents legitimate late entries; locking too late risks period-skew on VAT filings. + +## Workflow + +### Step 1 — Book every business transaction + +Goal: zero uncategorized business transactions inside the period. + +1. Call \`gnubok_list_uncategorized_transactions\` to see what's outstanding. +2. For each, call \`gnubok_suggest_categories\` (batches of up to 20) to get high-confidence proposals. +3. Stage categorizations via \`gnubok_categorize_transaction\` (or, for income that matches an invoice, \`gnubok_match_transaction_to_invoice\`). +4. The user approves each in the web app — staging is non-negotiable for legal compliance (BFL 5 kap.). + +If a transaction is genuinely private, mark it as \`category: 'private'\` — no journal entry will be created. + +### Step 2 — Reconcile bank + +Run \`gnubok_get_reconciliation_status\` for the month's date range. The result includes \`bank_balance\`, \`ledger_balance\`, and \`difference\`. Any non-zero difference means unmatched transactions or missing JEs — investigate before locking. + +### Step 3 — Check voucher gaps + +Run \`gnubok_list_voucher_gaps\` for the fiscal period. **Every gap must have an explanation** per BFNAR 2013:2. Use \`gnubok_explain_voucher_gap\` to document each one (e.g., "Voucher number reserved but not used because invoice was cancelled before posting"). + +### Step 4 — Run VAT report (monthly filers) + +If the company files VAT monthly (beskattningsunderlag > 40M SEK, or voluntarily), run \`gnubok_get_vat_report\` with \`period_type: 'monthly'\`. Sanity-check ruta49 ("att betala/återfå"). Use \`gnubok_vat_review_widget\` for a visual review. + +If quarterly or annual filer: skip — VAT happens on its own cadence (see the quarterly-vat-review skill). + +### Step 5 — Lock the period + +Stage the lock via \`gnubok_lock_period(fiscal_period_id)\`. The tool refuses if any business transactions remain unbooked. After user approval, no new entries can be posted into the period — late corrections must use \`gnubok_unlock_period\` (also high-risk, also staged). + +## Critical rules + +- **Never delete journal entries.** Use \`gnubok_uncategorize_transaction\` (storno reversal) to undo. DB triggers enforce this — direct deletes will fail. +- **Posted entries are immutable.** Once a JE is posted, even amounts are locked. Use \`correctEntry\` (web app) for corrections. +- **Money math:** \`Math.round(x * 100) / 100\`, never \`toFixed()\`. The categorize tool handles this; if you compute manually, follow the same pattern. +- **Locking ≠ closing.** Locking blocks new entries; closing (after year-end) is irreversible. This skill stops at locking. + +## Common errors + +- **"Period must be locked before closing"** — \`gnubok_close_period\` requires \`gnubok_lock_period\` first AND the year-end closing entry. Don't try to close mid-year periods. +- **"Cannot lock period: N business transactions unbooked"** — Step 1 wasn't complete. Re-run \`gnubok_list_uncategorized_transactions\`. + +## Tools + +- \`gnubok_list_uncategorized_transactions\` — find unbooked transactions +- \`gnubok_suggest_categories\` — get categorization proposals (batch of 20) +- \`gnubok_categorize_transaction\` — stage a single categorization +- \`gnubok_match_transaction_to_invoice\` — apply income to a customer invoice +- \`gnubok_get_reconciliation_status\` — bank vs ledger balance +- \`gnubok_list_voucher_gaps\` — BFNAR 2013:2 audit check +- \`gnubok_explain_voucher_gap\` — document a gap +- \`gnubok_get_vat_report\` — momsdeklaration data +- \`gnubok_vat_review_widget\` — interactive VAT review +- \`gnubok_lock_period\` — stage period lock +- \`gnubok_uncategorize_transaction\` — undo a categorization (storno) +` + +export const monthEndCloseSkill: Skill = { + slug: 'month-end-close', + name: 'Month-End Close', + summary: 'End-of-month workflow: book transactions, reconcile bank, verify voucher gaps, file VAT (monthly filers), lock period.', + tags: ['monthly', 'close', 'reconciliation', 'vat'], + body, +} diff --git a/extensions/general/mcp-server/skills/payroll-monthly.ts b/extensions/general/mcp-server/skills/payroll-monthly.ts new file mode 100644 index 00000000..9ad7db0d --- /dev/null +++ b/extensions/general/mcp-server/skills/payroll-monthly.ts @@ -0,0 +1,118 @@ +import type { Skill } from './types' + +const body = `# Monthly Payroll — gnubok + +Salary run + AGI filing for one calendar month. + +## When to use + +- "Run payroll for [month]" +- "Lönekörning [månad]" +- "Generate AGI" +- Once per month, **before payment_date** + +## Statutory deadlines + +- **AGI (arbetsgivardeklaration):** 12th of the **next month** (17th in January and August). E.g. payroll for March → file AGI by 12 April. +- **Skatt + sociala avgifter payment:** same deadline as AGI. +- Skattekontot must be in funds by deadline (SFL 62 kap. 3 §). + +## Workflow + +### Step 1 — Verify employees are set up + +\`gnubok_list_employees\` returns all active employees. For each, the system needs: + +- **Personnummer** (last 4 stored, full encrypted) +- **monthly_salary** (or hourly_rate + estimated hours) +- **employment_degree** (1–100 %) +- **tax_table_number** + **tax_column** (skattetabell + kolumn from Skatteverket) +- **employment_type** (\`tjänsteman\`, \`arbetare\`, etc.) — drives BAS account choice (7210 vs 7010) + +If anything is missing, the user fixes it in the web UI before running payroll. + +### Step 2 — Create the salary run + +\`gnubok_create_salary_run({ period_year, period_month, payment_date })\` + +- Creates a \`salary_runs\` row with status \`draft\` +- Adds **all active employees** with their base salary line (item_type \`monthly_salary\` or \`hourly_salary\`) +- Returns the run ID + employee count +- Idempotent on \`(company_id, period_year, period_month)\` — re-calling errors with "Salary run already exists for this period" + +### Step 3 — Add OB-tillägg, traktamente, förmåner (if any) + +Variable lines (overtime, weekend supplement, milage, traktamente, förmåner) are added in the web UI per-employee. There's no MCP tool yet for these — guide the user there. + +### Step 4 — Calculate + +\`gnubok_calculate_salary_run({ salary_run_id })\`. Computes per employee: + +- **Bruttolön** (gross): sum of taxable salary lines +- **Skatteavdrag**: tax-table lookup (skattetabell + kolumn → table column for the gross level) +- **Nettolön** (net): bruttolön − skatteavdrag +- **Sociala avgifter (arbetsgivaravgifter)**: 31.42 % of bruttolön (standard 2025). Reduced rates apply to specific age groups — always check the current statutory rates before relying on these: + - **Born 1937 or earlier**: **0 %** — no avgifter at all (oldest cohort, never paid into the modern pension system). Easy to miss; the BAS journal entries for 7510/2730 simply don't apply for these employees. + - **Age 66+ on 1 January of the income year (67+ from income year 2026)**: 10.21 % (only ålderspensionsavgift). The threshold rises with the riktålder; verify the cohort year for the current run rather than hard-coding a birth year. + - **växa-stöd / temporary youth reduction**: ages 19–23, salary ≤ 25 000 SEK/month, capped duration. The exact rate and window vary year-over-year (e.g. 20.81 % during 1 Apr 2026 – 30 Sep 2027 per Prop. 2025/26:34) — confirm against Skatteverket's current published table before applying. +- **Semesterlöneskuld** (vacation accrual): 12 % of bruttolön (default). Booked monthly to 2920. +- **Förmåner** (benefits): employer-paid taxable amounts (bilförmån, kostförmån, etc.) — added to skattegrundande lön but not to nettolön payment. + +Errors at this stage usually mean missing tax-table data — fall back to \`getDefaultTaxColumn(personnummer, year)\` heuristics or prompt user. + +### Step 5 — Review + +\`gnubok_get_salary_run({ salary_run_id })\` — full breakdown including \`calculation_breakdown\` showing step-by-step formulas. The user reviews per-employee in web UI. + +\`gnubok_get_salary_journal({ year })\` — annual rollup for sanity check. + +### Step 6 — Approve & book (web UI) + +The user marks the run \`approved\` → \`paid\` → \`booked\` in the web UI. Booking creates the JE: + +- Debit **7210** (lön tjänstemän) or **7010** (lön arbetare): bruttolön +- Debit **7510** (sociala avgifter): \`avgift_base × applicable_rate\` — **per employee**, using the rate from Step 4 (default 31.42 %, or a reduced rate when applicable: 10.21 % for 66+, växa-stöd, etc.) +- Credit **2710** (källskatt): skatteavdrag +- Credit **2730** (lagstadgade arbetsgivaravgifter): same amount as the 7510 debit (the avgift cost is the same number as the avgift liability) +- Credit **2920** (semesterlöneskuld): 12 % × bruttolön (debit 7290 to balance) +- Credit **1930** (bank): nettolön (when paid) + +When a run mixes full-rate and reduced-rate employees, the 7510/2730 lines are summed across all employees — the *total* avgift line equals \`Σ(per-employee avgift_base × per-employee rate)\`, **not** \`Σ bruttolön × 31.42 %\`. \`gnubok_calculate_salary_run\` already does this aggregation. + +### Step 7 — Generate AGI + +\`gnubok_generate_agi({ salary_run_id })\`. Run must be in \`review\`/\`approved\`/\`paid\`/\`booked\` status (past draft). + +Returns \`{ message, period, employee_count, download_url }\`. The XML conforms to Skatteverket's AGI format and is stored 7 years per BFL. Download from \`/api/salary/runs/{id}/agi/xml\` and upload to Skatteverket e-tjänst. + +## Critical rules + +- **Skatteavdrag is mandatory.** Never pay gross. Skatteverket charges 100% penalty for missing avdrag. +- **Sociala avgifter are 31.42 % even if salary is in EUR.** Convert to SEK at payment date for the avgift base. +- **Semesterlöneskuld** must be reserved monthly, not at year-end. 2920 grows by 12 % of every month's bruttolön. +- **Förmånsbeskattning** (benefit tax) is required even if not in cash. Bilförmån, kostförmån, sjukvårdsförsäkring all count. +- **Karensavdrag** (sick day deduction): first day of sickness is generally without pay; 80 % from day 2. Specific rules — fall through to \`swedish-payroll\` reference if unsure. + +## Common errors + +- **Run already exists**: idempotency on (company, year, month). Find the existing run with \`gnubok_get_salary_run\`. +- **Tax table column wrong**: defaults to column 1 if not set, which is too high for most employees. Fix on employee record. +- **AGI before booking**: works (status check is past-draft, not booked) — but you should book first so the JE matches what AGI reports. + +## Tools + +- \`gnubok_list_employees\` — verify setup +- \`gnubok_create_salary_run\` — stage new monthly run +- \`gnubok_calculate_salary_run\` — compute tax + avgifter + accrual +- \`gnubok_get_salary_run\` — review breakdown +- \`gnubok_get_salary_journal\` — annual rollup +- \`gnubok_generate_agi\` — produce AGI XML for filing +` + +export const payrollMonthlySkill: Skill = { + slug: 'payroll-monthly', + name: 'Monthly Payroll', + summary: 'Monthly salary run + AGI: employee setup, calculation, sociala avgifter, semesterlöneskuld, booking, AGI XML.', + tags: ['monthly', 'payroll', 'agi', 'compliance'], + body, +} diff --git a/extensions/general/mcp-server/skills/quarterly-vat-review.ts b/extensions/general/mcp-server/skills/quarterly-vat-review.ts new file mode 100644 index 00000000..91ec2ed2 --- /dev/null +++ b/extensions/general/mcp-server/skills/quarterly-vat-review.ts @@ -0,0 +1,100 @@ +import type { Skill } from './types' + +const body = `# Quarterly VAT Review — gnubok + +End-to-end review of momsdeklaration (SKV 4700) before filing to Skatteverket. + +## When to use + +- "Run VAT for Q[N]" / "Moms för kvartal [N]" +- "How much VAT do I owe this quarter?" +- After all transactions in the quarter are booked +- Before the filing deadline (12th of the second month after quarter-end; 17 August for Q2) + +## Filing deadlines + +| Quarter | Deadline | +|---------|----------| +| Q1 (Jan–Mar) | **12 May** | +| Q2 (Apr–Jun) | **17 August** (vacation rule) | +| Q3 (Jul–Sep) | **12 November** | +| Q4 (Oct–Dec) | **12 February** (next year) | + +Weekend/holiday → next business day. Payment must reach Skattekontot by deadline (SFL 62 kap. 3 §). + +## Workflow + +### Step 1 — Verify the quarter is fully booked + +Run the month-end-close skill for each month in the quarter. Critically: zero uncategorized business transactions in the date range, and bank reconciliation difference = 0. + +### Step 2 — Generate the report + +\`gnubok_get_vat_report({ period_type: 'quarterly', year: YYYY, period: 1|2|3|4 })\` + +Returns all rutor (boxes) plus a summary string. + +### Step 3 — Visual review + +\`gnubok_vat_review_widget(...)\` opens a tabular UI. The user reviews each ruta inline, copies the summary, and confirms before filing. + +### Step 4 — Drill into anomalies + +If any ruta looks wrong, call \`gnubok_get_general_ledger\` filtered to the relevant 26xx account: + +- Ruta 10 looks too high? → general ledger for **2611** (output 25%) +- Ruta 48 looks too low? → general ledger for **2641** + **2645** (input + EU calculated) +- Ruta 30 unexpectedly nonzero? → \`2614\` reverse-charge — verify the underlying purchase + +### Step 5 — File and record payment + +File via Skatteverket e-tjänst (or skatteverket extension if enabled). After filing, record the payment journal entry (debit/credit 2650/1930) when the money moves from skattekontot. + +## Ruta-by-ruta map (what each box means) + +| Ruta | Description | Source accounts | +|------|-------------|-----------------| +| 05 | Momspliktig försäljning (taxable sales, all rates) | 3001–3008, 3041–3048, 3051–3058, 3071–3078 (common BAS taxable revenue accounts) | +| 10 | Utgående moms 25 % | 2611 | +| 11 | Utgående moms 12 % | 2621 | +| 12 | Utgående moms 6 % | 2631 | +| 30 | Utgående moms reverse charge 25 % | 2614 | +| 31 | Utgående moms reverse charge 12 % | 2624 | +| 32 | Utgående moms reverse charge 6 % | 2634 | +| 35 | EU-varuförsäljning, momsfri (intra-community goods supply) | 3108 | +| 39 | EU-tjänsteförsäljning (services to EU B2B) | 3308 | +| 40 | Export (outside EU) | 3305 | +| 48 | Ingående moms (all input VAT) | 2641 + 2645 + 2647 | +| 49 | **Att betala / återfå** | computed | + +**Ruta 49 = (10 + 11 + 12 + 30 + 31 + 32) − 48.** Positive = pay. Negative = refund. + +## Critical rules + +- **Reverse charge: never net silently.** Both the output (2614/2624/2634) and the calculated input (2645) MUST be booked separately. Skatteverket reads both rutor. +- **Representation moms is capped at 300 SEK ex moms per person per occasion** (since 2017). Above the cap, no VAT deduction. +- **Mixed verksamhet:** if the company has both VAT-liable and VAT-exempt revenue, input VAT requires proportional deduction (HFD 2023 ref. 45). Don't deduct full 2641 in that case. +- **Bokslutsmetoden (cash) cap = 3 M SEK omsättning.** Above that, faktureringsmetoden (accrual) is required by law. + +## Common errors + +- **Forgetting Ruta 39 for EU services.** A Swedish consultant invoicing a German customer at 0% VAT (reverse charge) MUST report the invoice in ruta 39, not just ruta 05. Wrong ruta = penalty risk. +- **Wrong rate on books/transport.** 6% applies (not 12%): books, newspapers, transport, sports admission, repairs. Restaurant food = 12%, drops to 6% from 1 April 2026. +- **Filing late by one day.** Skattetillägg + interest. The system clock matters more than the user thinks. + +## Tools + +- \`gnubok_get_vat_report\` — generate momsdeklaration data +- \`gnubok_vat_review_widget\` — interactive review widget +- \`gnubok_get_general_ledger\` — drill into 26xx accounts +- \`gnubok_list_uncategorized_transactions\` — verify nothing missing +- \`gnubok_get_reconciliation_status\` — bank vs ledger sanity check +` + +export const quarterlyVatReviewSkill: Skill = { + slug: 'quarterly-vat-review', + name: 'Quarterly VAT Review', + summary: 'End-to-end momsdeklaration: deadlines, ruta-by-ruta map, reverse charge rules, common errors, drill-down via general ledger.', + tags: ['vat', 'quarterly', 'monthly', 'compliance', 'skatteverket'], + body, +} diff --git a/extensions/general/mcp-server/skills/types.ts b/extensions/general/mcp-server/skills/types.ts new file mode 100644 index 00000000..622e5997 --- /dev/null +++ b/extensions/general/mcp-server/skills/types.ts @@ -0,0 +1,38 @@ +/** + * Skills over MCP — domain-knowledge bodies the server ships alongside tools. + * + * A skill is a versioned Markdown document that documents *how* to compose + * gnubok tools to accomplish a real-world workflow (month-end close, VAT + * review, year-end, invoicing, payroll). Agents call gnubok_load_skill(slug) + * to load only the skills they need for the current task — keeping context + * lean while shipping deep domain knowledge alongside the protocol. + * + * Forward-compatible: when MCP adds a native `skills/list` primitive, the + * Skill interface and bodies migrate without changes. + */ +export interface Skill { + /** URL-safe id, used in tool args and resource URIs (e.g. "month-end-close"). */ + slug: string + /** Display name (e.g. "Month-End Close"). */ + name: string + /** One-line summary used by gnubok_list_skills. */ + summary: string + /** Tags for filtering (e.g. ['monthly', 'vat', 'reconciliation']). */ + tags: string[] + /** Full skill body as Markdown. */ + body: string +} + +export const SKILL_MIME_TYPE = 'text/markdown' as const + +/** Resource URI prefix for skills exposed via resources/read. */ +export const SKILL_URI_PREFIX = 'gnubok://skill/' as const + +export function skillUri(slug: string): string { + return `${SKILL_URI_PREFIX}${slug}` +} + +export function skillSlugFromUri(uri: string): string | null { + if (!uri.startsWith(SKILL_URI_PREFIX)) return null + return uri.slice(SKILL_URI_PREFIX.length) || null +} diff --git a/extensions/general/mcp-server/skills/year-end-close.ts b/extensions/general/mcp-server/skills/year-end-close.ts new file mode 100644 index 00000000..3754b035 --- /dev/null +++ b/extensions/general/mcp-server/skills/year-end-close.ts @@ -0,0 +1,103 @@ +import type { Skill } from './types' + +const body = `# Year-End Close (Bokslut) — gnubok + +The annual close. Irreversible. Legally significant. Always staged for human approval. + +## When to use + +- "Run year-end" / "Bokslut för [år]" +- "Close FY[year]" +- After all monthly closes are done and the last period is locked +- Before årsredovisning filing to Bolagsverket (AB) or NE-bilaga (enskild firma) + +**Do not run year-end during the year.** It zeros result accounts (3xxx–8xxx) into 2099 (årets resultat) — only correct at the end of the räkenskapsår. + +## Workflow + +### Step 1 — Bokslutstransaktioner (accrual entries) + +Before running year-end, post any year-end adjusting entries via the web app: + +- **Förutbetalda kostnader / upplupna intäkter** (1700/1800-series accruals) +- **Avskrivningar** (depreciation): planenlig + räkenskapsenlig 30 % / 20 % rule, or restvärde 25 % +- **Periodiseringsfond** (AB only, max 25 % of överskott av näringsverksamhet **before** this year's avsättning per IL 30 kap.; 6-year mandatory reversal, oldest fond reversed first) +- **Överavskrivning** (2150/8850 — bokföringsmässig avskrivning beyond skattemässig) +- **Lagervärdering** (lägsta värdets princip) +- **Skuld till företagaren / egenavgifter** (enskild firma) + +These are not staged via MCP today — direct in web UI. The skill is to remind the user. + +### Step 2 — Currency revaluation (if multi-currency) + +If the company has open foreign-currency receivables/payables (1510/2440 in EUR/USD/etc.), revalue to closing-date FX rate via \`gnubok_run_currency_revaluation({ fiscal_period_id, closing_date })\`. Posts to **3960** (kursvinster) and **7960** (kursförluster). One revaluation per period. + +### Step 3 — Lock the period + +\`gnubok_lock_period(fiscal_period_id)\`. Required before year-end. Refuses if business transactions are unbooked. + +### Step 4 — Run year-end + +\`gnubok_run_year_end(fiscal_period_id)\` — stages a high-risk operation. After approval: + +- Class 3–8 (revenue + expenses) zeroed into **2099** (årets resultat) +- Period flagged \`is_year_end_complete\` +- Next period created automatically + +### Step 5 — Set opening balances + +\`gnubok_set_opening_balances({ closed_period_id, next_period_id })\`. Copies class 1–2 closing balances into the next period as opening balances. Stage → approve. + +### Step 6 — Close (final, irreversible) + +\`gnubok_close_period(fiscal_period_id)\`. Once approved, the period is sealed forever. **No more entries possible — not even via storno.** + +## Tax provisions to compute (AB) + +After year-end JE but before filing INK2: + +- **Bolagsskatt 20.6 %** of skattemässigt resultat (since 2021). Posted to 8910 → 2510. +- **Periodiseringsfond:** max 25 % of överskott **before this year's avsättning** (IL 30 kap.). 6-year mandatory reversal; oldest fond reversed first to avoid statutory return. +- **Räkenskapsenlig avskrivning:** must be applied consistently — switching method requires Skatteverket approval. + +## Tax provisions (Enskild firma) + +- **Egenavgifter** (28.97 % normal, 10.21 % age 66+) — reserves for next year's tax. +- **Räntefördelning** (positive at 7.94 % on capital underlag 2025; 50 000 SEK floor). +- **Expansionsfond** (max equity capital × 1.4; reversed when withdrawn). + +These compute with \`gnubok_get_kpi_report\` for inputs but the actual tax JE is web-UI today. + +## Critical rules + +- **Year-end is forever.** Once \`gnubok_close_period\` succeeds, there is no rollback. \`gnubok_unlock_period\` cannot unlock a closed period — only one that is locked but not closed. +- **Run order matters.** lock → year-end → opening balances → close. Any other order fails. +- **K2 vs K3:** affects många bokslutsposter — start-up costs, leasing, immateriella tillgångar. The skill assumes K2 unless told otherwise. +- **Revisionsplikt:** AB with > 3 M SEK omsättning, > 1.5 M SEK BR-omslutning, > 3 employees (any 2 of 3, two consecutive years) need auditor — book the audit before close. + +## Common errors + +- **"Period must be locked before closing"** — Step 3 missed. +- **"Year-end closing entry must exist"** — Step 4 missed. +- **Forgetting periodiseringsfond reversal** — must reverse the oldest 6-year-old fond automatically. Skatteverket WILL catch this. +- **Skipping currency revaluation on FX exposure** — distorts BR; auditors flag. + +## Tools + +- \`gnubok_lock_period\` — pre-flight before year-end +- \`gnubok_run_year_end\` — zero result accounts +- \`gnubok_set_opening_balances\` — seed next period +- \`gnubok_run_currency_revaluation\` — FX revaluation +- \`gnubok_close_period\` — final, irreversible +- \`gnubok_get_balance_sheet\` — verify post-year-end balances +- \`gnubok_get_income_statement\` — verify result before year-end JE +- \`gnubok_get_trial_balance\` — sanity check before each step +` + +export const yearEndCloseSkill: Skill = { + slug: 'year-end-close', + name: 'Year-End Close (Bokslut)', + summary: 'Annual close: bokslutstransaktioner, currency revaluation, lock → year-end → opening balances → close. Irreversible.', + tags: ['yearly', 'close', 'bokslut', 'compliance'], + body, +} diff --git a/extensions/general/mcp-server/widgets/index.ts b/extensions/general/mcp-server/widgets/index.ts new file mode 100644 index 00000000..a70b4ed3 --- /dev/null +++ b/extensions/general/mcp-server/widgets/index.ts @@ -0,0 +1,15 @@ +import type { UiWidget } from './types' +import { receiptMatcherWidget } from './receipt-matcher' +import { vatReviewWidget } from './vat-review' + +export const uiWidgets: UiWidget[] = [ + receiptMatcherWidget, + vatReviewWidget, +] + +export function findUiWidget(uri: string): UiWidget | null { + return uiWidgets.find((w) => w.uri === uri) ?? null +} + +export type { UiWidget } from './types' +export { WIDGET_MIME_TYPE } from './types' diff --git a/extensions/general/mcp-server/widget-html.ts b/extensions/general/mcp-server/widgets/receipt-matcher.ts similarity index 96% rename from extensions/general/mcp-server/widget-html.ts rename to extensions/general/mcp-server/widgets/receipt-matcher.ts index 0c539bbf..d3e087f8 100644 --- a/extensions/general/mcp-server/widget-html.ts +++ b/extensions/general/mcp-server/widgets/receipt-matcher.ts @@ -1,9 +1,9 @@ +import type { UiWidget } from './types' + /** - * Receipt Matcher Widget — MCP Apps inline HTML - * - * Self-contained HTML document rendered in an iframe by MCP Apps hosts - * (Claude Desktop, etc.). Communicates exclusively via postMessage - * (JSON-RPC 2.0 over the MCP Apps protocol). No fetch() calls. + * Receipt Matcher Widget — MCP Apps inline HTML. + * Drag-and-drop receipt attachment for uncategorized bank transactions. + * Triggered by the gnubok_receipt_matcher tool. */ export const RECEIPT_MATCHER_HTML = ` @@ -377,4 +377,11 @@ export const RECEIPT_MATCHER_HTML = ` })(); -`; +` + +export const receiptMatcherWidget: UiWidget = { + uri: 'ui://receipt-matcher/app.html', + name: 'Receipt Matcher', + description: 'Interactive widget for matching receipts to uncategorized transactions', + html: RECEIPT_MATCHER_HTML, +} diff --git a/extensions/general/mcp-server/widgets/types.ts b/extensions/general/mcp-server/widgets/types.ts new file mode 100644 index 00000000..8bb6b6b5 --- /dev/null +++ b/extensions/general/mcp-server/widgets/types.ts @@ -0,0 +1,19 @@ +/** + * MCP Apps inline widget contract. + * + * Each widget is a self-contained HTML document rendered in an iframe by + * MCP Apps hosts (Claude Desktop, Claude Web). Widgets communicate with + * the host exclusively via postMessage / JSON-RPC 2.0; never via fetch(). + */ +export interface UiWidget { + /** Resource URI clients use to load the widget — e.g. `ui://vat-review/app.html`. */ + uri: string + /** Display name shown in the host's resource list. */ + name: string + /** One-line description shown alongside the resource. */ + description: string + /** Self-contained HTML document (no external network access). */ + html: string +} + +export const WIDGET_MIME_TYPE = 'text/html;profile=mcp-app' as const diff --git a/extensions/general/mcp-server/widgets/vat-review.ts b/extensions/general/mcp-server/widgets/vat-review.ts new file mode 100644 index 00000000..c279ab1f --- /dev/null +++ b/extensions/general/mcp-server/widgets/vat-review.ts @@ -0,0 +1,397 @@ +import type { UiWidget } from './types' + +/** + * VAT Review Widget — MCP Apps inline HTML. + * Read-only review of momsdeklaration (SKV 4700) before filing to Skatteverket. + * Triggered by the gnubok_vat_review_widget tool. + */ + +export const VAT_REVIEW_HTML = ` + + + + +Momsdeklaration — gnubok + + + +
+

Momsdeklaration

+ — +
+
Laddar…
+ + + +` + +export const vatReviewWidget: UiWidget = { + uri: 'ui://vat-review/app.html', + name: 'VAT Review', + description: 'Interactive review of momsdeklaration (SKV 4700) before filing', + html: VAT_REVIEW_HTML, +} diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index f897f2af..73ceb6af 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -45,7 +45,7 @@ export const SCOPE_GROUPS = [ { domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const }, ] as const -/** Map MCP tool name → required scope */ +/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */ export const TOOL_SCOPE_MAP: Record = { // Transactions gnubok_list_uncategorized_transactions: 'transactions:read', @@ -69,6 +69,7 @@ export const TOOL_SCOPE_MAP: Record = { // Reports gnubok_get_trial_balance: 'reports:read', gnubok_get_vat_report: 'reports:read', + gnubok_vat_review_widget: 'reports:read', gnubok_get_kpi_report: 'reports:read', gnubok_get_income_statement: 'reports:read', gnubok_list_accounts: 'reports:read', diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts index 243cd4af..0cce36ab 100644 --- a/lib/events/handlers/event-log-handler.ts +++ b/lib/events/handlers/event-log-handler.ts @@ -33,6 +33,11 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [ 'invoice.match_confirmed', 'supplier_invoice.match_confirmed', 'supplier_invoice.confirmed', + // MCP telemetry — every tool invocation, tools/list call, and resources/read. + // Lightweight metadata only; 30-day TTL on event_log bounds the volume. + 'mcp.tool_called', + 'mcp.tools_list_called', + 'mcp.resource_read', ] // Excluded (with reasoning): diff --git a/lib/events/types.ts b/lib/events/types.ts index bc3aca50..e0d44f36 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -90,6 +90,53 @@ export type CoreEvent = // Company & account lifecycle | { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } } | { type: 'account.deleted'; payload: { userId: string; deletedAt: string } } + // MCP telemetry — fired from the MCP dispatcher. + // Persisted to event_log (30-day TTL) for hot-tool / error-rate / latency analytics. + // Intentionally lightweight: no args, no result body — only metadata. + | { type: 'mcp.tool_called'; payload: { + tool: string // e.g. 'gnubok_create_invoice' + requiredScope: string | null // from TOOL_SCOPE_MAP, null if unscoped + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorId: string | null // api_key id, oauth client, etc. + actorLabel: string | null // human-readable actor label + latencyMs: number // wall-clock time inside execute() + success: boolean // true iff the tool returned without throwing AND was invoked (not denied) + isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client + errorCode: string | null // structured error code from tool-result.toToolError when applicable + errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null + requestId: string | number | null // JSON-RPC request id (helps correlate with client-side logs) + userId: string + companyId: string + }} + // tools/list — informs us whether agents are using progressive discovery + // (gnubok_search_tools) or pulling the full list. Tool counts vary with + // the caller's scope set. + | { type: 'mcp.tools_list_called'; payload: { + toolCount: number // tools actually returned (post scope filter) + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorId: string | null + actorLabel: string | null + latencyMs: number + requestId: string | number | null + userId: string + companyId: string + }} + // resources/read — informs us which skills/widgets/data resources actually + // get loaded by agents. `kind` discriminates by URI scheme so we can + // GROUP BY skill vs widget vs data without parsing URIs. + | { type: 'mcp.resource_read'; payload: { + uri: string // e.g. 'gnubok://skill/month-end-close' + kind: 'widget' | 'skill' | 'data' | 'unknown' + success: boolean + errorCode: string | null + latencyMs: number + actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + actorId: string | null + actorLabel: string | null + requestId: string | number | null + userId: string + companyId: string + }} // ============================================================ // Helper Types