diff --git a/extensions/general/mcp-server/__tests__/capability-gate.test.ts b/extensions/general/mcp-server/__tests__/capability-gate.test.ts new file mode 100644 index 00000000..41262bd4 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/capability-gate.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the capability paywall gate in the MCP dispatcher. + * + * The paid external-service tools (send_invoice → email_send, the two + * Skatteverket submissions → skatteverket) must be blocked server-side when the + * company isn't entitled — BEFORE tool.execute() runs, so no pending op is + * staged. The gate sits right after the API-key scope check and mirrors its + * shape, so the test key holds the required SCOPE but the company may lack the + * CAPABILITY. Self-hosted short-circuits hasCapability to all-on (covered in + * lib/entitlements/__tests__/has-capability.test.ts), so here we drive the + * gate directly via a mocked hasCapability. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: vi.fn(), +})) + +vi.mock('@/lib/auth/api-keys', async (importOriginal) => { + const actual = await importOriginal() + // A minimal chainable Supabase stub — only reached if the gate lets a call + // through to execute(); resolves everything to null so execute fails with a + // plain execution error (never capability_blocked). + const chain: unknown = new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve({ data: null, error: null }) + } + return () => chain + }, + }, + ) + return { + ...actual, + extractBearerToken: vi.fn().mockReturnValue('test-token'), + validateApiKey: vi.fn().mockResolvedValue({ + userId: 'user-1', + companyId: '11111111-1111-4111-8111-111111111111', + // Holds the SCOPES for all three paid tools so the scope gate passes and + // the CAPABILITY gate is what we exercise. + scopes: ['invoices:write', 'skatteverket:write', 'reports:read'], + apiKeyId: 'key-1', + apiKeyName: 'Test Key', + }), + createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })), + } +}) + +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, hasCapability: vi.fn() } +}) + +import { handleMcpRequest } from '../server' +import { hasCapability } from '@/lib/entitlements/has-capability' + +const mockHasCapability = vi.mocked(hasCapability) + +function mcpToolCall(name: string, args: Record = {}): 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: 1, method: 'tools/call', params: { name, arguments: args } }), + }) +} + +interface ToolCalledEvent { + tool: string + success: boolean + isError: boolean + errorKind: string | null + errorCode: string | null + latencyMs: number +} + +function captureNextToolCalled(): Promise { + return new Promise((resolve) => { + const off = eventBus.on('mcp.tool_called', (payload) => { + off() + resolve(payload as unknown as ToolCalledEvent) + }) + }) +} + +async function parsedToolResult(response: Response): Promise<{ isError: boolean; payload: Record }> { + const json = await response.json() + const result = json.result as { isError?: boolean; content: { text: string }[] } + return { isError: result.isError === true, payload: JSON.parse(result.content[0].text) } +} + +describe('MCP capability gate', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + it('blocks gnubok_send_invoice when email_send is not entitled — before execute()', async () => { + mockHasCapability.mockResolvedValue(false) + const eventPromise = captureNextToolCalled() + + const response = await handleMcpRequest(mcpToolCall('gnubok_send_invoice', { invoice_id: 'inv-1' })) + const { isError, payload } = await parsedToolResult(response) + + expect(isError).toBe(true) + expect((payload.error as Record).capability_blocked).toBe(true) + expect((payload.error as Record).capability).toBe('email_send') + expect(mockHasCapability).toHaveBeenCalledWith(expect.anything(), '11111111-1111-4111-8111-111111111111', 'email_send') + + const event = await eventPromise + expect(event.errorKind).toBe('capability_denied') + expect(event.errorCode).toBe('capability_blocked') + expect(event.success).toBe(false) + // The gate exits before tool.execute(), exactly like scope denial. + expect(event.latencyMs).toBe(0) + }) + + it('blocks gnubok_agi_submit when skatteverket is not entitled', async () => { + mockHasCapability.mockResolvedValue(false) + + const response = await handleMcpRequest(mcpToolCall('gnubok_agi_submit', { salary_run_id: 'sr-1' })) + const { isError, payload } = await parsedToolResult(response) + + expect(isError).toBe(true) + expect((payload.error as Record).capability).toBe('skatteverket') + expect(mockHasCapability).toHaveBeenCalledWith(expect.anything(), '11111111-1111-4111-8111-111111111111', 'skatteverket') + }) + + it('lets a free tool through without consulting the capability gate', async () => { + mockHasCapability.mockResolvedValue(false) + + await handleMcpRequest(mcpToolCall('gnubok_list_skills', {})) + + // gnubok_list_skills has no MCP_TOOL_CAPABILITY_MAP entry — the gate is skipped entirely. + expect(mockHasCapability).not.toHaveBeenCalled() + }) + + it('proceeds to execute() when the company IS entitled (no capability_blocked)', async () => { + mockHasCapability.mockResolvedValue(true) + const eventPromise = captureNextToolCalled() + + const response = await handleMcpRequest(mcpToolCall('gnubok_send_invoice', { invoice_id: 'inv-1' })) + const { payload } = await parsedToolResult(response) + + // Gate passed → execute() runs (and fails for an unrelated reason: email not + // configured / invoice not found). The point is it is NOT capability_blocked. + expect((payload.error as Record | undefined)?.capability_blocked).toBeUndefined() + expect(mockHasCapability).toHaveBeenCalledWith(expect.anything(), '11111111-1111-4111-8111-111111111111', 'email_send') + + const event = await eventPromise + expect(event.errorKind).not.toBe('capability_denied') + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index a537ba3a..cb68f68a 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -71,6 +71,8 @@ import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplica import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { getEmailService } from '@/lib/email/service' +import { hasCapability, capabilityBlockedError } from '@/lib/entitlements/has-capability' +import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys' import { generateInvoiceEmailHtml, generateInvoiceEmailText, @@ -9982,7 +9984,7 @@ function emitToolCallTelemetry(payload: { success: boolean isError: boolean errorCode: string | null - errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null + errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | null errorMessage: string | null requestId: string | number | null userId: string @@ -10437,6 +10439,35 @@ export async function handleMcpRequest(request: Request): Promise { ) } + // Enforce the capability paywall — the MCP/agent path is a paid chokepoint + // just like the HTTP routes (send_invoice → email_send, the two SKV + // submissions → skatteverket). Fail-closed; self-hosted short-circuits to + // all-on inside hasCapability. Blocks before any pending op is staged. + const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName] + if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) { + const capError = { error: capabilityBlockedError(requiredCapability) } + emitToolCallTelemetry({ + tool: toolName, + requiredScope, + actor, + latencyMs: 0, + success: false, + isError: true, + errorCode: capError.error.code, + errorKind: 'capability_denied', + errorMessage: capError.error.message_sv, + requestId: id ?? null, + userId, + companyId, + }) + return NextResponse.json( + jsonRpc(id ?? null, { + content: [{ type: 'text', text: JSON.stringify(capError, null, 2) }], + isError: true, + }) + ) + } + // Detect if THIS call follows the previous call's `next` hint — must // run before execute() so we don't double-store on this call. Emits // mcp.next_hint_followed when the agent's behaviour matches the hint. diff --git a/lib/entitlements/__tests__/capability-maps.test.ts b/lib/entitlements/__tests__/capability-maps.test.ts new file mode 100644 index 00000000..fcbcda5b --- /dev/null +++ b/lib/entitlements/__tests__/capability-maps.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { + MCP_TOOL_CAPABILITY_MAP, + PAID_OPERATION_CAPABILITY_MAP, + PAID_CAPABILITIES, + CAPABILITY, +} from '../keys' + +/** + * These maps are the contract that gates the paid MCP/agent path (dispatch + + * commit). Locking the exact entries is the guard against a future paid + * external-service tool silently bypassing the paywall — mirrors the + * TOOL_SCOPE_MAP assertions in the mcp-server tests. + */ +describe('MCP_TOOL_CAPABILITY_MAP', () => { + it('gates exactly the three paid external-service MCP tools', () => { + expect(MCP_TOOL_CAPABILITY_MAP).toEqual({ + gnubok_send_invoice: CAPABILITY.email_send, + gnubok_vat_declaration_submit: CAPABILITY.skatteverket, + gnubok_agi_submit: CAPABILITY.skatteverket, + }) + }) + + it('only maps tools to PAID capabilities', () => { + for (const key of Object.values(MCP_TOOL_CAPABILITY_MAP)) { + expect(PAID_CAPABILITIES).toContain(key) + } + }) +}) + +describe('PAID_OPERATION_CAPABILITY_MAP', () => { + it('gates exactly the three paid pending-operation types', () => { + expect(PAID_OPERATION_CAPABILITY_MAP).toEqual({ + send_invoice: CAPABILITY.email_send, + submit_vat_declaration: CAPABILITY.skatteverket, + submit_agi: CAPABILITY.skatteverket, + }) + }) + + it('only maps operations to PAID capabilities', () => { + for (const key of Object.values(PAID_OPERATION_CAPABILITY_MAP)) { + expect(PAID_CAPABILITIES).toContain(key) + } + }) + + it('covers the same set of capabilities as the MCP tool map (dispatch ↔ commit parity)', () => { + expect(new Set(Object.values(PAID_OPERATION_CAPABILITY_MAP))).toEqual( + new Set(Object.values(MCP_TOOL_CAPABILITY_MAP)), + ) + }) +}) diff --git a/lib/entitlements/has-capability.ts b/lib/entitlements/has-capability.ts index b980de26..08f74609 100644 --- a/lib/entitlements/has-capability.ts +++ b/lib/entitlements/has-capability.ts @@ -80,6 +80,12 @@ export async function hasCapability( return true } +/** Bilingual paywall copy, shared by every transport (HTTP route, MCP tool, commit executor). */ +export const CAPABILITY_BLOCKED_MESSAGE_SV = + 'Den här funktionen kräver en betald prenumeration. Uppgradera för att fortsätta använda externa tjänster.' +export const CAPABILITY_BLOCKED_MESSAGE_EN = + 'This feature requires a paid subscription. Upgrade to keep using external services.' + /** * Standard bilingual 403 for a capability-blocked endpoint. Matches the * sandbox/guard envelope so the UI surfaces the upsell consistently. @@ -87,10 +93,8 @@ export async function hasCapability( export function capabilityBlockedResponse(key: CapabilityKey): NextResponse { return NextResponse.json( { - error: - 'Den här funktionen kräver en betald prenumeration. Uppgradera för att fortsätta använda externa tjänster.', - error_en: - 'This feature requires a paid subscription. Upgrade to keep using external services.', + error: CAPABILITY_BLOCKED_MESSAGE_SV, + error_en: CAPABILITY_BLOCKED_MESSAGE_EN, capability_blocked: true, capability: key, }, @@ -98,6 +102,30 @@ export function capabilityBlockedResponse(key: CapabilityKey): NextResponse { ) } +export interface CapabilityBlockedError { + code: 'capability_blocked' + capability_blocked: true + capability: CapabilityKey + message_sv: string + message_en: string +} + +/** + * Transport-free counterpart to capabilityBlockedResponse, for call sites that + * don't return a NextResponse — the MCP dispatcher (folded into the JSON-RPC + * `isError` envelope) and the pending-operation commit executor. Same copy and + * the same `capability_blocked: true` marker so every surface upsells alike. + */ +export function capabilityBlockedError(key: CapabilityKey): CapabilityBlockedError { + return { + code: 'capability_blocked', + capability_blocked: true, + capability: key, + message_sv: CAPABILITY_BLOCKED_MESSAGE_SV, + message_en: CAPABILITY_BLOCKED_MESSAGE_EN, + } +} + /** * Convenience wrapper: check + return the 403 in one call. Returns the * NextResponse to return from the route, or null when the company has the diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index d4fd0c03..c5493b12 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -53,3 +53,34 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [ CAPABILITY.skatteverket, CAPABILITY.email_send, ] as const + +/** + * Paid MCP tools → required capability. The MCP/agent path is a paid chokepoint + * just like the HTTP routes, so the dispatcher gates these the same way it gates + * API-key scope (see mcp-server `tools/call`). Only external-service WRITE tools + * appear here: send_invoice (email) and the two Skatteverket submissions. The + * read/local SKV tools (generate_agi, vat_declaration_validate/status, agi_status) + * stay free — the §4 carve-out forbids blocking a statutory filing obligation. + * + * No MCP tool invokes AI or triggers bank sync, so those PAID capabilities have + * no entry here — they are reachable only via already-gated HTTP routes/handlers. + */ +export const MCP_TOOL_CAPABILITY_MAP: Readonly>> = { + gnubok_send_invoice: CAPABILITY.email_send, + gnubok_vat_declaration_submit: CAPABILITY.skatteverket, + gnubok_agi_submit: CAPABILITY.skatteverket, +} as const + +/** + * Paid pending-operation types → required capability. Keyed by + * `pending_operations.operation_type`. This is the commit-time twin of + * MCP_TOOL_CAPABILITY_MAP: it gates the actual external-service call inside + * commitPendingOperation, so an operation staged during the trial cannot be + * committed once the grant has expired — regardless of caller (MCP approve tool + * or the UI approval path). Keep the values in sync with MCP_TOOL_CAPABILITY_MAP. + */ +export const PAID_OPERATION_CAPABILITY_MAP: Readonly>> = { + send_invoice: CAPABILITY.email_send, + submit_vat_declaration: CAPABILITY.skatteverket, + submit_agi: CAPABILITY.skatteverket, +} as const diff --git a/lib/events/types.ts b/lib/events/types.ts index 6742eca8..c8ceba91 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -168,7 +168,7 @@ export type CoreEvent = 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 + errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | null errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success. // Raw material for clustering real agent failures into curated gotchas — // errorCode alone can't distinguish "period locked" from "unbalanced". diff --git a/lib/pending-operations/__tests__/commit-capability-gate.test.ts b/lib/pending-operations/__tests__/commit-capability-gate.test.ts new file mode 100644 index 00000000..554b847d --- /dev/null +++ b/lib/pending-operations/__tests__/commit-capability-gate.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for the commit-time capability gate in commitPendingOperation. + * + * This is the twin of the MCP dispatch gate and the true external-service + * chokepoint: it runs BEFORE the atomic claim, so a blocked op stays 'pending' + * (re-approvable once the company subscribes) and an op staged DURING the trial + * cannot be committed once the grant expires — regardless of caller (MCP approve + * tool or the UI approval path). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, hasCapability: vi.fn() } +}) + +import { commitPendingOperation } from '../commit' +import { hasCapability } from '@/lib/entitlements/has-capability' + +const mockHasCapability = vi.mocked(hasCapability) + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'send_invoice', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'high', + created_at: '2026-06-01T00:00:00Z', + resolved_at: null, + updated_at: '2026-06-01T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: capability gate', () => { + it('blocks send_invoice when email_send is not entitled — 403, op left pending', async () => { + mockHasCapability.mockResolvedValue(false) + const { supabase } = createQueuedMockSupabase() // no responses enqueued: the claim must never run + + const op = makePendingOp({ operation_type: 'send_invoice', params: { invoice_id: 'inv-1' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(403) + expect(result.code).toBe('capability_blocked') + expect(mockHasCapability).toHaveBeenCalledWith(supabase, 'company-1', 'email_send') + }) + + it('blocks submit_vat_declaration when skatteverket is not entitled', async () => { + mockHasCapability.mockResolvedValue(false) + const { supabase } = createQueuedMockSupabase() + + const op = makePendingOp({ operation_type: 'submit_vat_declaration', params: { period_type: 'monthly', year: 2025, period: 3 } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(403) + expect(result.code).toBe('capability_blocked') + expect(mockHasCapability).toHaveBeenCalledWith(supabase, 'company-1', 'skatteverket') + }) + + it('does NOT consult the gate for a free operation type', async () => { + mockHasCapability.mockResolvedValue(false) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: null }) // claim resolves to "already claimed" → early 409, before any executor + + const op = makePendingOp({ operation_type: 'create_customer', params: { name: 'x' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + // create_customer is not in PAID_OPERATION_CAPABILITY_MAP — the gate is skipped. + expect(mockHasCapability).not.toHaveBeenCalled() + expect(result.status).toBe('failed') // claim returned no row (409); proves we got past the gate + }) +}) diff --git a/lib/pending-operations/__tests__/skatteverket-executors.test.ts b/lib/pending-operations/__tests__/skatteverket-executors.test.ts index c3f9fe2b..32b4c767 100644 --- a/lib/pending-operations/__tests__/skatteverket-executors.test.ts +++ b/lib/pending-operations/__tests__/skatteverket-executors.test.ts @@ -21,6 +21,15 @@ import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-comm import type { PendingOperation } from '@/types' import { commitPendingOperation } from '../commit' +// The commit-time capability gate (PR: gate paid MCP tools) runs hasCapability +// before the atomic claim for submit_vat_declaration/submit_agi. These tests +// isolate the registry/lifecycle wiring, so make the gate transparent here; +// its enforcement is covered by commit-capability-gate.test.ts. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) } +}) + function makePendingOp(overrides: Partial): PendingOperation { return { id: 'op-1', diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index b68cb2d5..3e0dc8af 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -60,6 +60,8 @@ import { type SkvSubmitResult, } from '@/lib/pending-operations/skatteverket-commit' import { getEmailService } from '@/lib/email/service' +import { hasCapability, CAPABILITY_BLOCKED_MESSAGE_SV } from '@/lib/entitlements/has-capability' +import { PAID_OPERATION_CAPABILITY_MAP } from '@/lib/entitlements/keys' import { generateInvoiceEmailHtml, generateInvoiceEmailText, @@ -3665,6 +3667,23 @@ async function commitPendingOperationInner( pendingOp: PendingOperation, opts: CommitOptions = {} ): Promise { + // ── Capability gate (commit-time twin of the MCP dispatch gate). The actual + // external-service call (email / Skatteverket submit) happens below, so + // this is the true paid chokepoint — it also catches an op STAGED during + // the trial then approved AFTER the grant expired, regardless of caller + // (MCP approve tool or the UI approval path). Checked BEFORE the atomic + // claim so a blocked op stays 'pending' and is re-approvable once the + // company subscribes. Self-hosted short-circuits to all-on in hasCapability. + const requiredCapability = PAID_OPERATION_CAPABILITY_MAP[pendingOp.operation_type] + if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) { + return { + status: 'failed', + error: CAPABILITY_BLOCKED_MESSAGE_SV, + http_status: 403, + code: 'capability_blocked', + } + } + // ── Atomic claim: flip status pending → committing in a single conditional // update. If 0 rows are affected, another caller (auto-commit ↔ human // approval, or two parallel approvals) already claimed this op and we