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