fix: classify MCP SQL query errors as expected (#285)

This commit is contained in:
Andrey Avtomonov 2026-06-10 11:42:31 +02:00 committed by GitHub
parent b076431b0a
commit 036a745fc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 226 additions and 10 deletions

View file

@ -1,4 +1,5 @@
import type { KtxSqlQueryExecutorPort } from '../../context/connections/query-executor.js';
import { KtxQueryError, isNativeProgrammingFault } from '../../errors.js';
import { localConnectionInfoFromConfig } from '../../context/connections/local-warehouse-descriptor.js';
import type { KtxEmbeddingPort } from '../../context/core/embedding.js';
import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-layer-compute.js';
@ -110,14 +111,26 @@ async function executeValidatedReadOnlySql(
throw new Error(`Connection "${connectionId}" does not support read-only SQL execution.`);
}
await onProgress?.({ progress: 0.3, message: 'Executing' });
const result = await connector.executeReadOnly(
{
connectionId,
sql: input.sql,
maxRows: input.maxRows,
},
{ runId: 'mcp-sql-execution' },
);
const result = await connector
.executeReadOnly(
{
connectionId,
sql: input.sql,
maxRows: input.maxRows,
},
{ runId: 'mcp-sql-execution' },
)
.catch((error: unknown) => {
// A warehouse/driver rejection (e.g. the agent's SQL failed to compile)
// is a surfaced operational outcome, not a ktx fault: mark it expected
// while preserving the warehouse's own diagnostics. A native JS error
// (TypeError, etc.) signals a bug in connector code — let it propagate
// unchanged so Error Tracking still sees it.
if (isNativeProgrammingFault(error)) {
throw error;
}
throw new KtxQueryError(error instanceof Error ? error.message : String(error), { cause: error });
});
const response = {
headers: result.headers,
...(result.headerTypes ? { headerTypes: result.headerTypes } : {}),

View file

@ -0,0 +1,48 @@
/**
* Marks an error as an expected operational outcome that ktx surfaces to its
* caller (a connected agent or the CLI user) rather than an unexpected ktx
* fault. Examples: invalid agent input, a warehouse rejecting a query, or a
* validation guard rejecting a request.
*
* `reportException` skips PostHog Error Tracking for these so the bug stream
* stays free of routine, caller-driven failures. The failure is still surfaced
* to the caller (as a tool-error result or CLI error) and still recorded by the
* outcome-tagged telemetry events, so no diagnostic signal is lost.
*/
export class KtxExpectedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'KtxExpectedError';
}
}
/**
* A query was rejected at the warehouse/driver boundary the warehouse refused
* to compile or run it, or a read-only guard rejected it. Reuses the underlying
* error's message so the caller still sees the original warehouse diagnostics,
* and keeps the driver error as `cause`.
*/
export class KtxQueryError extends KtxExpectedError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'KtxQueryError';
}
}
/**
* True for the native JavaScript error types that signal a programming fault a
* bug in ktx code rather than an operational outcome. These are universal
* language invariants (a `TypeError` never means "the warehouse rejected the
* query"), so callers can use this to keep genuine faults out of the
* expected-error classification and let them reach Error Tracking unchanged.
*/
export function isNativeProgrammingFault(error: unknown): boolean {
return (
error instanceof TypeError ||
error instanceof RangeError ||
error instanceof ReferenceError ||
error instanceof SyntaxError ||
error instanceof EvalError ||
error instanceof URIError
);
}

View file

@ -1,6 +1,7 @@
import { inspect } from 'node:util';
import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
import { KtxExpectedError } from '../errors.js';
import { buildCommonEnvelope } from './events.js';
import { trackTelemetryException } from './emitter.js';
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
@ -39,6 +40,17 @@ function consumeHandledPrimitive(value: unknown): boolean {
return true;
}
/**
* Expected operational errors are surfaced to the caller and recorded by the
* outcome-tagged telemetry events; they are not ktx faults, so they never reach
* Error Tracking. ktx marks these with KtxExpectedError at the site that knows
* the rejection is expected the classification is never inferred globally from
* a generic error type, which would also swallow internal/fatal faults.
*/
function isExpectedError(error: unknown): boolean {
return error instanceof KtxExpectedError;
}
function shouldSkipAsAlreadyReported(error: unknown, handled: boolean): boolean {
if ((typeof error === 'object' || typeof error === 'function') && error !== null) {
if (reportedObjects.has(error)) {
@ -151,6 +163,9 @@ export async function reportException(input: {
redactionSecrets?: ReadonlyArray<string>;
}): Promise<void> {
try {
if (isExpectedError(input.error)) {
return;
}
if (shouldSkipAsAlreadyReported(input.error, input.context.handled)) {
return;
}