Closes #2051. errorCauseTag() shipped in #2027 written and tested but wired to nothing. This connects it: the two execution catch paths (sync call and task) now pass errorCause into mcp.tool_called, carrying the SQLSTATE or coded-error code, else the error's class name, capped at 64 chars. The rows this exists for are the UNKNOWN_ERROR residue, whose errorMessage is the constant "Något gick fel. Försök igen." and whose errorDetail is the English constant: 465 such rows in the last 30 days (create_voucher 58 of its 60 failures, query_journal 122) with nothing to cluster on. A five-character SQLSTATE is protocol vocabulary; a raw driver message can quote row values from a constraint violation and belongs in the server log, never in event_log, so the raw message is deliberately not captured. A plain `new Error(...)` tags null rather than 'Error': tagging everything is the same as tagging nothing. Pre-execution denials (scope, capability, validation, unknown tool) pass nothing because their errorCode already is the cause. Self-tested by unwiring one call site and watching the new test name it. Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
726 lines
26 KiB
TypeScript
726 lines
26 KiB
TypeScript
/**
|
|
* 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<typeof import('@/lib/auth/api-keys')>()
|
|
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<string, ReturnType<typeof vi.fn>> = {
|
|
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<string, ReturnType<typeof vi.fn>> = {
|
|
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<string, unknown>,
|
|
id: number | string = 1,
|
|
opts: { url?: string; headers?: Record<string, string> } = {}
|
|
): 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<ToolCalledPayload> {
|
|
return new Promise<ToolCalledPayload>((resolve) => {
|
|
const off = eventBus.on('mcp.tool_called', (payload) => {
|
|
off()
|
|
resolve(payload as ToolCalledPayload)
|
|
})
|
|
})
|
|
}
|
|
|
|
async function captureNextToolsListEvent(): Promise<ToolsListCalledPayload> {
|
|
return new Promise<ToolsListCalledPayload>((resolve) => {
|
|
const off = eventBus.on('mcp.tools_list_called', (payload) => {
|
|
off()
|
|
resolve(payload as ToolsListCalledPayload)
|
|
})
|
|
})
|
|
}
|
|
|
|
async function captureNextResourceReadEvent(): Promise<ResourceReadPayload> {
|
|
return new Promise<ResourceReadPayload>((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!<script>' },
|
|
})
|
|
)
|
|
|
|
const event = await eventPromise
|
|
expect(event.client).toBeNull()
|
|
})
|
|
|
|
it('reports null when no marker is sent (existing clients unchanged)', async () => {
|
|
const eventPromise = captureNextToolCalledEvent()
|
|
|
|
await handleMcpRequest(
|
|
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} })
|
|
)
|
|
|
|
const event = await eventPromise
|
|
expect(event.client).toBeNull()
|
|
})
|
|
})
|
|
|
|
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('11111111-1111-4111-8111-111111111111')
|
|
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: 'Accounted://skill/quarterly-vat-review' })
|
|
)
|
|
|
|
const event = await eventPromise
|
|
expect(event.uri).toBe('Accounted://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: 'Accounted://nonexistent/whatever' })
|
|
)
|
|
|
|
const event = await eventPromise
|
|
expect(event.uri).toBe('Accounted://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: 'Accounted://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('mcp.skill_loaded telemetry', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
it('emits on every successful load: alongside mcp.workflow_started for workflow tier', async () => {
|
|
const skillLoadedPromise = new Promise<Record<string, unknown>>((resolve) => {
|
|
const off = eventBus.on('mcp.skill_loaded', (payload) => {
|
|
off()
|
|
resolve(payload as Record<string, unknown>)
|
|
})
|
|
})
|
|
const workflowStartedPromise = new Promise<Record<string, unknown>>((resolve) => {
|
|
const off = eventBus.on('mcp.workflow_started', (payload) => {
|
|
off()
|
|
resolve(payload as Record<string, unknown>)
|
|
})
|
|
})
|
|
|
|
await handleMcpRequest(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_load_skill',
|
|
arguments: { slug: 'month-end-close' },
|
|
})
|
|
)
|
|
|
|
const event = await skillLoadedPromise
|
|
expect(event.slug).toBe('month-end-close')
|
|
expect(event.tier).toBe('workflow')
|
|
expect(event.actorType).toBe('api_key')
|
|
expect(event.actorId).toBe('key-1')
|
|
expect(event.userId).toBe('user-1')
|
|
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
|
|
|
|
// The pre-existing workflow-funnel event still fires for workflow tier.
|
|
const wf = await workflowStartedPromise
|
|
expect(wf.slug).toBe('month-end-close')
|
|
})
|
|
|
|
it('does not emit when the slug is unknown (load throws before emission)', async () => {
|
|
const seen: unknown[] = []
|
|
eventBus.on('mcp.skill_loaded', (payload) => {
|
|
seen.push(payload)
|
|
})
|
|
|
|
await handleMcpRequest(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_load_skill',
|
|
arguments: { slug: 'nope-not-real' },
|
|
})
|
|
)
|
|
// Flush microtasks: emission is fire-and-forget.
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(seen).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('event_log persistence registration', () => {
|
|
it('includes all 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'/)
|
|
expect(text).toMatch(/'mcp\.skill_loaded'/)
|
|
})
|
|
})
|