mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-28 08:49:38 +02:00
fix: classify MCP SQL query errors as expected (#285)
This commit is contained in:
parent
b076431b0a
commit
036a745fc1
6 changed files with 226 additions and 10 deletions
|
|
@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initKtxProject } from '../../../src/context/project/project.js';
|
||||
import { KtxQueryError } from '../../../src/errors.js';
|
||||
import { createKtxConnectorCapabilities, type KtxQueryResult, type KtxScanConnector, type KtxSchemaSnapshot } from '../../../src/context/scan/types.js';
|
||||
import { writeLocalSlSource } from '../../../src/context/sl/local-sl.js';
|
||||
import { createLocalProjectMcpContextPorts } from '../../../src/context/mcp/local-project-ports.js';
|
||||
|
|
@ -325,6 +326,78 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
expect(connector.executeReadOnly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps warehouse execution errors as KtxQueryError while preserving the diagnostic message', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'snowflake',
|
||||
url: 'env:DATABASE_URL',
|
||||
};
|
||||
const driverError = new Error("SQL compilation error:\nsyntax error line 4 at position 14 unexpected 'rows'.");
|
||||
driverError.name = 'OperationFailedError';
|
||||
const connector: KtxScanConnector = {
|
||||
...testConnector(testSnapshot(), { headers: [], rows: [], totalRows: 0, rowCount: 0 }),
|
||||
executeReadOnly: vi.fn(async () => {
|
||||
throw driverError;
|
||||
}),
|
||||
};
|
||||
const sqlAnalysis = {
|
||||
analyzeForFingerprint: vi.fn(),
|
||||
analyzeBatch: vi.fn(),
|
||||
validateReadOnly: vi.fn(async () => ({ ok: true, error: null })),
|
||||
};
|
||||
const ports = createLocalProjectMcpContextPorts(project, {
|
||||
sqlAnalysis,
|
||||
localScan: { createConnector: vi.fn(async () => connector) },
|
||||
embeddingService: null,
|
||||
});
|
||||
|
||||
const execution = ports.sqlExecution?.execute({
|
||||
connectionId: 'warehouse',
|
||||
sql: 'select\n count(*)\nfrom events\nlimit 100 rows',
|
||||
maxRows: 1000,
|
||||
});
|
||||
|
||||
await expect(execution).rejects.toBeInstanceOf(KtxQueryError);
|
||||
await expect(execution).rejects.toThrow("syntax error line 4 at position 14 unexpected 'rows'.");
|
||||
await expect(execution).rejects.toMatchObject({ cause: driverError });
|
||||
expect(connector.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets connector programming faults propagate instead of masking them as query errors', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'snowflake',
|
||||
url: 'env:DATABASE_URL',
|
||||
};
|
||||
const bug = new TypeError("Cannot read properties of undefined (reading 'rows')");
|
||||
const connector: KtxScanConnector = {
|
||||
...testConnector(testSnapshot(), { headers: [], rows: [], totalRows: 0, rowCount: 0 }),
|
||||
executeReadOnly: vi.fn(async () => {
|
||||
throw bug;
|
||||
}),
|
||||
};
|
||||
const sqlAnalysis = {
|
||||
analyzeForFingerprint: vi.fn(),
|
||||
analyzeBatch: vi.fn(),
|
||||
validateReadOnly: vi.fn(async () => ({ ok: true, error: null })),
|
||||
};
|
||||
const ports = createLocalProjectMcpContextPorts(project, {
|
||||
sqlAnalysis,
|
||||
localScan: { createConnector: vi.fn(async () => connector) },
|
||||
embeddingService: null,
|
||||
});
|
||||
|
||||
const execution = ports.sqlExecution?.execute({
|
||||
connectionId: 'warehouse',
|
||||
sql: 'select 1',
|
||||
maxRows: 10,
|
||||
});
|
||||
|
||||
await expect(execution).rejects.toBe(bug);
|
||||
await expect(execution).rejects.toBeInstanceOf(TypeError);
|
||||
expect(connector.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes local scan entity details through MCP ports', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { KtxCliIo } from '../../src/cli-runtime.js';
|
||||
import { KtxExpectedError, KtxQueryError } from '../../src/errors.js';
|
||||
import { __resetTelemetryEmitterForTests } from '../../src/telemetry/emitter.js';
|
||||
import {
|
||||
__resetTelemetryExceptionStateForTests,
|
||||
|
|
@ -150,6 +152,71 @@ describe('reportException', () => {
|
|||
expect((captures[0] as { properties: Record<string, unknown> }).properties.$groups).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips Error Tracking for expected operational errors', async () => {
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new KtxExpectedError('expected operational rejection surfaced to the caller'),
|
||||
context: { source: 'mcp:sql_execution', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
projectDir: join(homeDir, 'project'),
|
||||
});
|
||||
|
||||
expect(captures).toEqual([]);
|
||||
expect(immediateCaptures).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips Error Tracking for warehouse query errors surfaced to the agent', async () => {
|
||||
const { io } = makeIo();
|
||||
const driverError = new Error("SQL compilation error:\nsyntax error line 4 at position 14 unexpected 'rows'.");
|
||||
driverError.name = 'OperationFailedError';
|
||||
|
||||
await reportException({
|
||||
error: new KtxQueryError(driverError.message, { cause: driverError }),
|
||||
context: { source: 'mcp:sql_execution', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
projectDir: join(homeDir, 'project'),
|
||||
});
|
||||
|
||||
expect(captures).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports ZodError faults instead of skipping them globally', async () => {
|
||||
const { io } = makeIo();
|
||||
let zodError: unknown;
|
||||
try {
|
||||
z.object({ maxRows: z.number() }).parse({ maxRows: 'not-a-number' });
|
||||
} catch (error) {
|
||||
zodError = error;
|
||||
}
|
||||
|
||||
await reportException({
|
||||
error: zodError,
|
||||
context: { source: 'uncaughtException', handled: false, fatal: true },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
expect(immediateCaptures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still reports unexpected faults', async () => {
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new TypeError("Cannot read properties of undefined (reading 'rows')"),
|
||||
context: { source: 'mcp:sql_execution', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
projectDir: join(homeDir, 'project'),
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses captureExceptionImmediate for fatal reports', async () => {
|
||||
const { io } = makeIo();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue