fix: classify mcp query failures (#302)

This commit is contained in:
Andrey Avtomonov 2026-06-15 14:48:24 +02:00 committed by GitHub
parent 8a50601582
commit 7e29543398
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 102 additions and 8 deletions

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { KtxExpectedError, KtxQueryError } from '../../../src/errors.js';
import {
assertReadOnlySql,
limitSqlForExecution,
@ -20,6 +21,20 @@ describe('assertReadOnlySql', () => {
);
});
// A guard refusing the agent's SQL is an expected outcome; classifying it as
// KtxQueryError keeps reportException from filing it as a ktx fault.
it('rejects with an expected KtxQueryError, not a bare Error', () => {
expect(() => assertReadOnlySql('delete from orders')).toThrow(KtxQueryError);
expect(() => assertReadOnlySql('describe orders')).toThrow(KtxQueryError);
expect(() => assertReadOnlySql('select 1; drop table orders')).toThrow(KtxQueryError);
try {
assertReadOnlySql('describe orders');
expect.unreachable('expected a throw');
} catch (error) {
expect(error).toBeInstanceOf(KtxExpectedError);
}
});
it('accepts read-only queries that begin with leading comments', () => {
expect(assertReadOnlySql('-- daily widget sales\nselect count(*) from public.widget_sales')).toBe(
'select count(*) from public.widget_sales',

View file

@ -287,6 +287,35 @@ describe('createKtxMcpServer', () => {
expect(io.stderrText()).not.toContain('mcpClientVersion');
});
it('records the failure message as errorDetail when a tool returns an error', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('CI', '');
const fake = makeFakeServer();
const io = makeIo();
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
projectDir: '/tmp/ktx-mcp-error-detail',
io,
contextTools: {
knowledge: {
search: vi.fn<KtxKnowledgeMcpPort['search']>().mockRejectedValue(new Error('wiki search exploded')),
read: vi.fn<KtxKnowledgeMcpPort['read']>().mockResolvedValue(null),
},
},
});
await expect(getTool(fake.tools, 'wiki_search').handler({ query: 'revenue', limit: 5 })).resolves.toMatchObject({
isError: true,
});
expect(io.stderrText()).toContain('"event":"mcp_request_completed"');
expect(io.stderrText()).toContain('"outcome":"error"');
expect(io.stderrText()).toContain('"errorDetail":"wiki search exploded"');
});
it('reports MCP tool exceptions with a tool-derived source', async () => {
reportExceptionMock.mockClear();
vi.stubEnv('ANTHROPIC_API_KEY', 'mcp-anthropic-secret'); // pragma: allowlist secret