/** * 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' import type { EventPayload } from '@/lib/events/types' // ── 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: '11111111-1111-4111-8111-111111111111', // 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', }), // Minimal supabase mock: agent_atom_registry resolves to empty so // gnubok_list_skills happy-path doesn't crash on its registry query. // company_settings + employees are also handled so the applicability // filter has data to work against. createServiceClientNoCookies: vi.fn(() => ({ from: vi.fn((table: string) => { if (table === 'company_members') { return { select: vi.fn(() => { const chain: Record> = { eq: vi.fn(() => chain), is: vi.fn(() => chain), maybeSingle: vi.fn().mockResolvedValue({ data: { company_id: '11111111-1111-4111-8111-111111111111', role: 'owner', }, error: null, }), } return chain }), } } if (table === 'company_settings') { return { select: vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle: vi.fn().mockResolvedValue({ data: { entity_type: 'AB', vat_registered: true }, error: null, }), })), })), } } if (table === 'employees') { return { select: vi.fn(() => ({ eq: vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), })), })), } } return { select: vi.fn(() => { // Chainable: loadAtomsAsSkills filters .eq(is_active).eq(mcp_exposed) // .is(parent_atom_id, null).order(); loadReferenceById uses // .eq(id).not(parent_atom_id,is,null).maybeSingle(). const chain: Record> = { eq: vi.fn(() => chain), is: vi.fn(() => chain), not: vi.fn(() => chain), order: vi.fn().mockResolvedValue({ data: [], error: null }), maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), } return chain }), } }), })), } }) import { handleMcpRequest } from '../server' function mcpRequest( method: string, params?: Record, id: number | string = 1, opts: { url?: string; headers?: Record } = {} ): Request { return new Request(opts.url ?? 'http://localhost:3000/api/extensions/ext/mcp-server/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token', ...opts.headers }, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), }) } /** * The shared event contract, not a local copy. * * This used to be a hand-maintained duplicate interface, which silently * omitted sessionId and half the errorKind union. A new field added to * lib/events/types.ts and to the emitter then type-checked here against the * stale local shape, so the tests passed while the payload type was wrong. * Deriving it removes the drift entirely. */ type ToolCalledPayload = EventPayload<'mcp.tool_called'> interface ToolsListCalledPayload { toolCount: number actorType: string actorId: string | null actorLabel: string | null latencyMs: number requestId: string | number | null userId: string companyId: string client: string | null } 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 client: string | null } 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.errorMessage).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('11111111-1111-4111-8111-111111111111') 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') // The human message rides along for failure clustering. expect(typeof event.errorMessage).toBe('string') expect((event.errorMessage as string).length).toBeGreaterThan(0) // Scope denial exits before tool.execute() runs. expect(event.latencyMs).toBe(0) }) /** * Regression tests for the 2026-08-25 unknown-parameter rejection. * * That guard is correct and stays. What was wrong is what we RECORDED about * it: one integration sent an unknown parameter to gnubok_get_kpi_report and * was refused 604 times over seven days, and every one of those rows logged * errorKind 'company_access_denied' with the message "Förfrågan innehåller * ogiltiga uppgifter." The caller was told exactly which parameter was * wrong; our own telemetry recorded a permissions problem that never existed. */ it('logs an unknown parameter as invalid_arguments, not company_access_denied', async () => { const eventPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_get_trial_balance', arguments: { totally_not_a_parameter: 1 }, }) ) const event = await eventPromise expect(event.errorCode).toBe('VALIDATION_ERROR') expect(event.errorKind).toBe('invalid_arguments') expect(event.errorKind).not.toBe('company_access_denied') }) it('records the specific diagnostic in errorDetail when errorMessage is the generic default', async () => { const eventPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_get_trial_balance', arguments: { totally_not_a_parameter: 1 }, }) ) const event = await eventPromise // Unchanged: the Swedish user-facing message, which for VALIDATION_ERROR // is the registry default and says nothing about the cause. expect(event.errorMessage).toBe('Förfrågan innehåller ogiltiga uppgifter.') // New: what actually went wrong, enough to fix the caller from the log // alone without reading the source. expect(event.errorDetail).toContain('totally_not_a_parameter') expect(event.errorDetail).toContain('gnubok_get_trial_balance') }) it('carries both languages on a scope denial, without duplicating either', async () => { const eventPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_create_invoice', arguments: { customer_id: 'x', items: [] }, }) ) const event = await eventPromise expect(event.errorKind).toBe('scope_denied') // Asserted directly rather than behind an `if`: a conditional assertion // would also pass on a wrong-but-present value, which is no assertion. // And "some other string" is barely stronger, so pin the content that // makes the field worth storing: the scope the caller actually lacks. expect(event.errorDetail).toContain('invoices:write') expect(event.errorDetail).not.toBe(event.errorMessage) }) it('stores null when the call site supplies no diagnostic', async () => { const eventPromise = captureNextToolCalledEvent() // The unknown-tool exit passes errorMessage only. Nothing may be // invented to fill errorDetail. await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_not_a_real_tool', arguments: {} }) ) const event = await eventPromise expect(event.errorKind).toBe('unknown_tool') expect(event.errorDetail).toBeNull() }) it('applies the canonical scope gate to an Accounted alias', async () => { const eventPromise = captureNextToolCalledEvent() const response = await handleMcpRequest( mcpRequest( 'tools/call', { name: 'accounted_create_invoice', arguments: { customer_id: 'x', items: [] }, }, 1, { url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted', } ) ) const body = await response.json() const payload = JSON.parse(body.result.content[0].text) expect(payload.error.code).toBe('INSUFFICIENT_SCOPE') const event = await eventPromise expect(event.tool).toBe('gnubok_create_invoice') expect(event.requiredScope).toBe('invoices:write') expect(event.errorKind).toBe('scope_denied') }) 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') // Short deterministic message: NOT the full available-tools list the // client response carries (that would blow the truncation budget). expect(event.errorMessage).toBe('Unknown tool: "gnubok_does_not_exist"') 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() // The structured error's human message is captured and bounded at 500 // chars: the raw material for clustering execution failures into gotchas. expect(typeof event.errorMessage).toBe('string') expect((event.errorMessage as string).length).toBeGreaterThan(0) expect((event.errorMessage as string).length).toBeLessThanOrEqual(500) // Execution path measures real latency, even if the tool exits quickly. expect(event.latencyMs).toBeGreaterThanOrEqual(0) }) describe('errorCause: machine vocabulary for the unmapped residue (#2051)', () => { /** * 65.1% of real-agent errors in the 30 days before #2027 were * UNKNOWN_ERROR, whose errorMessage is the constant "Något gick fel. * Försök igen." and whose errorDetail is the English constant: nothing to * cluster on. errorCause carries errorCauseTag(err): the SQLSTATE or coded * error code, else the error's class name. Protocol vocabulary only, * because a raw driver message can quote row values from a constraint * violation and belongs in the server log, never in event_log. */ it('records the error class name when execute() dies unmapped', async () => { const eventPromise = captureNextToolCalledEvent() // Valid call for the key's reports:read scope; the harness supabase is a // bare vi.fn() mock, so execute() dies on it. Exactly the shape that // used to log UNKNOWN_ERROR with the constant message and nothing else. await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_get_trial_balance', arguments: {} }) ) const event = await eventPromise expect(event.errorKind).toBe('execution') expect(event.errorCause).toBe('TypeError') }) it('stays null for a plain Error: the class name "Error" is noise, not vocabulary', async () => { const eventPromise = captureNextToolCalledEvent() // gnubok_load_skill throws `new Error("Skill not found: ...")`. await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_load_skill', arguments: { slug: 'definitely-does-not-exist' } }) ) const event = await eventPromise expect(event.errorKind).toBe('execution') expect(event.errorCause).toBeNull() }) it('stays null on success and on pre-execution denials', async () => { const successPromise = captureNextToolCalledEvent() await handleMcpRequest(mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} })) expect((await successPromise).errorCause).toBeNull() const deniedPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_create_invoice', arguments: { customer_id: 'x', items: [] } }) ) const denied = await deniedPromise expect(denied.errorKind).toBe('scope_denied') expect(denied.errorCause).toBeNull() }) }) 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('client marker telemetry', () => { beforeEach(() => { vi.clearAllMocks() eventBus.clear() }) it('records a lowercased X-Gnubok-Client header on mcp.tool_called', async () => { const eventPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, { headers: { 'X-Gnubok-Client': 'OpenClaw' }, }) ) const event = await eventPromise expect(event.client).toBe('openclaw') }) it('records the Accounted client header on the same telemetry field', async () => { const eventPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'accounted_list_skills', arguments: {} }, 1, { url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted', headers: { 'X-Accounted-Client': 'Claude-Desktop' }, }) ) const event = await eventPromise expect(event.client).toBe('claude-desktop') expect(event.tool).toBe('gnubok_list_skills') }) it('falls back to the ?client= query param when no header is present', async () => { const eventPromise = captureNextToolsListEvent() await handleMcpRequest( mcpRequest('tools/list', undefined, 1, { url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?client=openclaw', }) ) const event = await eventPromise expect(event.client).toBe('openclaw') }) it('prefers the header over the query param when both are present', async () => { const eventPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, { url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?client=other', headers: { 'X-Gnubok-Client': 'openclaw' }, }) ) const event = await eventPromise expect(event.client).toBe('openclaw') }) it('runs the allow-list on the percent-decoded query param value', async () => { const eventPromise = captureNextToolCalledEvent() // URLSearchParams.get() percent-decodes before our regex runs, so encoded // payloads can't smuggle disallowed characters past the allow-list. await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, { url: 'http://localhost:3000/api/extensions/ext/mcp-server/mcp?client=open%63law', }) ) const event = await eventPromise expect(event.client).toBe('openclaw') }) it('drops markers that fail the charset/length sanitation and reports null', async () => { const eventPromise = captureNextToolCalledEvent() await handleMcpRequest( mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }, 1, { headers: { 'X-Gnubok-Client': 'bad client!