From 3025cc90490f2e69d5eac925cd4b4bb42d1264e1 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 00:59:20 +0800 Subject: [PATCH 1/9] feat: add connection-failure error classifier (#266) --- src/utils/__tests__/error-classifier.test.ts | 43 ++++++++++ src/utils/error-classifier.ts | 90 ++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/utils/__tests__/error-classifier.test.ts create mode 100644 src/utils/error-classifier.ts diff --git a/src/utils/__tests__/error-classifier.test.ts b/src/utils/__tests__/error-classifier.test.ts new file mode 100644 index 00000000..e4504193 --- /dev/null +++ b/src/utils/__tests__/error-classifier.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { classifyConnectionError, TUNNEL_ERROR_MARKER } from "../error-classifier.js"; + +describe("classifyConnectionError", () => { + it("classifies network socket errors as SOURCE_UNREACHABLE", () => { + for (const code of ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH", "ECONNRESET"]) { + const result = classifyConnectionError({ code }, "postgres", "staging"); + expect(result?.code).toBe("SOURCE_UNREACHABLE"); + expect(result?.message).toContain("staging"); + } + }); + + it("classifies postgres auth errors as AUTH_FAILED", () => { + expect(classifyConnectionError({ code: "28P01" }, "postgres", "prod")?.code).toBe("AUTH_FAILED"); + expect(classifyConnectionError({ code: "28000" }, "postgres", "prod")?.code).toBe("AUTH_FAILED"); + }); + + it("classifies mysql/mariadb auth errors via code or errno", () => { + expect(classifyConnectionError({ code: "ER_ACCESS_DENIED_ERROR" }, "mysql", "m")?.code).toBe("AUTH_FAILED"); + expect(classifyConnectionError({ errno: 1045 }, "mariadb", "m")?.code).toBe("AUTH_FAILED"); + }); + + it("classifies sqlserver login errors as AUTH_FAILED", () => { + expect(classifyConnectionError({ code: "ELOGIN" }, "sqlserver", "s")?.code).toBe("AUTH_FAILED"); + }); + + it("classifies marked SSH tunnel errors as TUNNEL_FAILED, ahead of network code", () => { + const err: any = { code: "ECONNREFUSED" }; + err[TUNNEL_ERROR_MARKER] = true; + expect(classifyConnectionError(err, "postgres", "viaBastion")?.code).toBe("TUNNEL_FAILED"); + }); + + it("returns null for unrecognized errors and non-objects", () => { + expect(classifyConnectionError({ code: "42601" }, "postgres", "x")).toBeNull(); // syntax error + expect(classifyConnectionError(new Error("boom"), "postgres", "x")).toBeNull(); + expect(classifyConnectionError("nope", "postgres", "x")).toBeNull(); + expect(classifyConnectionError(null, "postgres", "x")).toBeNull(); + }); + + it("does not treat a mysql auth code as auth for a postgres source", () => { + expect(classifyConnectionError({ errno: 1045 }, "postgres", "x")).toBeNull(); + }); +}); diff --git a/src/utils/error-classifier.ts b/src/utils/error-classifier.ts new file mode 100644 index 00000000..9ddb58b2 --- /dev/null +++ b/src/utils/error-classifier.ts @@ -0,0 +1,90 @@ +import type { ConnectorType } from "../connectors/interface.js"; + +/** + * Distinct error codes for connection/access failures, so an MCP client can + * tell "the source is down / mis-credentialed" (restore access) from "your + * query is wrong" (fix the SQL). Anything not matched here is left to the + * caller's existing generic error path. + */ +export type ConnectionErrorCode = "SOURCE_UNREACHABLE" | "AUTH_FAILED" | "TUNNEL_FAILED"; + +/** + * Property set on errors thrown while establishing an SSH tunnel, so the + * classifier can distinguish TUNNEL_FAILED from a plain network failure + * without parsing message text. Set in ConnectorManager.connectSource. + */ +export const TUNNEL_ERROR_MARKER = "__dbhubSSHTunnelError"; + +// Node socket-level codes that mean "could not reach / lost the source". +// Timeout (ETIMEDOUT) is folded in here: refused vs timed-out differ at the +// TCP level but call for the same remediation. +const NETWORK_CODES = new Set([ + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", + "EHOSTUNREACH", + "ENETUNREACH", + "ECONNRESET", +]); + +// Per-connector authentication failure signals. Keyed by code or errno. +const AUTH_CODES: Record> = { + postgres: ["28P01", "28000"], + mysql: ["ER_ACCESS_DENIED_ERROR", 1045], + mariadb: ["ER_ACCESS_DENIED_ERROR", 1045], + sqlserver: ["ELOGIN"], + sqlite: [], // no network/auth layer +}; + +function unreachableMessage(sourceId: string): string { + return `Source '${sourceId}' is unreachable (connection refused or timed out). ` + + `Verify the database is running and reachable (host, port, network), then retry.`; +} + +function authMessage(sourceId: string): string { + return `Authentication failed for source '${sourceId}'. ` + + `Verify the credentials/access for this source are valid, then retry.`; +} + +function tunnelMessage(sourceId: string): string { + return `SSH tunnel for source '${sourceId}' failed to establish. ` + + `Verify SSH host/credentials and bastion reachability, then retry.`; +} + +/** + * Classify a thrown error from a connect attempt or query into a connection + * failure category. Returns null when the error is not a recognized + * connection/access failure (caller should fall back to its generic handling). + * Pure; never throws. + */ +export function classifyConnectionError( + error: unknown, + connectorType: ConnectorType, + sourceId: string +): { code: ConnectionErrorCode; message: string } | null { + if (!error || typeof error !== "object") { + return null; + } + const err = error as Record; + + // Tunnel marker wins over the underlying network code. + if (err[TUNNEL_ERROR_MARKER] === true) { + return { code: "TUNNEL_FAILED", message: tunnelMessage(sourceId) }; + } + + const code = err.code; + if (typeof code === "string" && NETWORK_CODES.has(code)) { + return { code: "SOURCE_UNREACHABLE", message: unreachableMessage(sourceId) }; + } + + const authCodes = AUTH_CODES[connectorType] ?? []; + const errno = err.errno; + if ( + (typeof code === "string" && authCodes.includes(code)) || + (typeof errno === "number" && authCodes.includes(errno)) + ) { + return { code: "AUTH_FAILED", message: authMessage(sourceId) }; + } + + return null; +} From 6bd3d951919faa08a381b1a9214e382b35ffd4bc Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:03:51 +0800 Subject: [PATCH 2/9] feat: mark SSH tunnel establishment failures for classification (#266) --- src/connectors/manager.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/connectors/manager.ts b/src/connectors/manager.ts index 763e6973..563bb9ff 100644 --- a/src/connectors/manager.ts +++ b/src/connectors/manager.ts @@ -1,6 +1,6 @@ import { Connector, ConnectorType, ConnectorRegistry, ExecuteOptions, ConnectorConfig } from "./interface.js"; import { SSHTunnel } from "../utils/ssh-tunnel.js"; -import type { SSHTunnelConfig } from "../types/ssh.js"; +import type { SSHTunnelConfig, SSHTunnelInfo } from "../types/ssh.js"; import type { SourceConfig } from "../types/config.js"; import { buildDSNFromSource } from "../config/toml-loader.js"; import { getDatabaseTypeFromDSN, getDefaultPortForType } from "../utils/dsn-obfuscate.js"; @@ -8,6 +8,7 @@ import { redactDSN } from "../config/env.js"; import { SafeURL } from "../utils/safe-url.js"; import { generateRdsAuthToken } from "../utils/aws-rds-signer.js"; import { parseSSHConfig, looksLikeSSHAlias, getDefaultSSHConfigPath } from "../utils/ssh-config-parser.js"; +import { TUNNEL_ERROR_MARKER } from "../utils/error-classifier.js"; // Singleton instance for global access let managerInstance: ConnectorManager | null = null; @@ -191,10 +192,18 @@ export class ConnectorManager { // Create and establish SSH tunnel const tunnel = new SSHTunnel(); - const tunnelInfo = await tunnel.establish(sshConfig, { - targetHost, - targetPort, - }); + let tunnelInfo: SSHTunnelInfo; + try { + tunnelInfo = await tunnel.establish(sshConfig, { + targetHost, + targetPort, + }); + } catch (error) { + if (error && typeof error === "object") { + (error as Record)[TUNNEL_ERROR_MARKER] = true; + } + throw error; + } // Update DSN to use local tunnel endpoint url.hostname = "127.0.0.1"; From 80d587e2eee138887ccbb38b03b825f90a76c984 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:08:04 +0800 Subject: [PATCH 3/9] feat: classify connection failures in execute_sql (#266) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tools/__tests__/execute-sql.test.ts | 20 ++++++++++++++++++++ src/tools/execute-sql.ts | 11 +++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/tools/__tests__/execute-sql.test.ts b/src/tools/__tests__/execute-sql.test.ts index 2ebaeeae..af84d499 100644 --- a/src/tools/__tests__/execute-sql.test.ts +++ b/src/tools/__tests__/execute-sql.test.ts @@ -91,6 +91,26 @@ describe('execute-sql tool', () => { expect(parsedResult.error).toBe('Database error'); expect(parsedResult.code).toBe('EXECUTION_ERROR'); }); + + it('returns SOURCE_UNREACHABLE when the connector throws a network error', async () => { + const econn: any = new Error('connect ECONNREFUSED 127.0.0.1:5432'); + econn.code = 'ECONNREFUSED'; + mockGetCurrentConnector.mockReturnValue({ + id: 'postgres', + getId: () => 'prod', + executeSQL: vi.fn().mockRejectedValue(econn), + } as any); + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ id: 'prod', type: 'postgres' } as any); + vi.mocked(ConnectorManager.ensureConnected).mockResolvedValue(undefined as any); + + const handler = createExecuteSqlToolHandler('prod'); + const res: any = await handler({ sql: 'SELECT 1' }, {}); + const payload = JSON.parse(res.content[0].text); + + expect(res.isError).toBe(true); + expect(payload.code).toBe('SOURCE_UNREACHABLE'); + expect(payload.details.source_id).toBe('prod'); + }); }); describe('read-only mode enforcement', () => { diff --git a/src/tools/execute-sql.ts b/src/tools/execute-sql.ts index c07b73c1..95c60e4d 100644 --- a/src/tools/execute-sql.ts +++ b/src/tools/execute-sql.ts @@ -10,6 +10,7 @@ import { trackToolRequest, } from "../utils/tool-handler-helpers.js"; import { splitSQLStatements } from "../utils/sql-parser.js"; +import { classifyConnectionError } from "../utils/error-classifier.js"; // Schema for execute_sql tool export const executeSqlSchema = { @@ -81,6 +82,16 @@ export function createExecuteSqlToolHandler(sourceId?: string) { } catch (error) { success = false; errorMessage = (error as Error).message; + const connectorType = ConnectorManager.getSourceConfig(sourceId)?.type; + if (connectorType) { + const classified = classifyConnectionError(error, connectorType, effectiveSourceId); + if (classified) { + errorMessage = classified.message; + return createToolErrorResponse(classified.message, classified.code, { + source_id: effectiveSourceId, + }); + } + } return createToolErrorResponse(errorMessage, "EXECUTION_ERROR"); } finally { // Track the request From 0364f296ca2661ecbfe2ed741b4f61550eea523c Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:14:31 +0800 Subject: [PATCH 4/9] refactor: extract tryClassifyConnectionError helper (#266) --- src/tools/__tests__/execute-sql.test.ts | 39 +++++++++++++++++++++++++ src/tools/execute-sql.ts | 14 ++------- src/utils/tool-handler-helpers.ts | 25 ++++++++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/tools/__tests__/execute-sql.test.ts b/src/tools/__tests__/execute-sql.test.ts index af84d499..e16b3343 100644 --- a/src/tools/__tests__/execute-sql.test.ts +++ b/src/tools/__tests__/execute-sql.test.ts @@ -111,6 +111,45 @@ describe('execute-sql tool', () => { expect(payload.code).toBe('SOURCE_UNREACHABLE'); expect(payload.details.source_id).toBe('prod'); }); + + it('falls through to EXECUTION_ERROR when the source config is null', async () => { + const econn: any = new Error('connect ECONNREFUSED 127.0.0.1:5432'); + econn.code = 'ECONNREFUSED'; + mockGetCurrentConnector.mockReturnValue({ + id: 'postgres', + getId: () => 'prod', + executeSQL: vi.fn().mockRejectedValue(econn), + } as any); + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue(null as any); + vi.mocked(ConnectorManager.ensureConnected).mockResolvedValue(undefined as any); + + const handler = createExecuteSqlToolHandler('prod'); + const res: any = await handler({ sql: 'SELECT 1' }, {}); + const payload = JSON.parse(res.content[0].text); + + expect(res.isError).toBe(true); + expect(payload.code).toBe('EXECUTION_ERROR'); + }); + + it('uses the display source id "default" in single-source mode', async () => { + const econn: any = new Error('connect ECONNREFUSED 127.0.0.1:5432'); + econn.code = 'ECONNREFUSED'; + mockGetCurrentConnector.mockReturnValue({ + id: 'postgres', + getId: () => 'default', + executeSQL: vi.fn().mockRejectedValue(econn), + } as any); + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ type: 'postgres' } as any); + vi.mocked(ConnectorManager.ensureConnected).mockResolvedValue(undefined as any); + + const handler = createExecuteSqlToolHandler(); + const res: any = await handler({ sql: 'SELECT 1' }, {}); + const payload = JSON.parse(res.content[0].text); + + expect(res.isError).toBe(true); + expect(payload.code).toBe('SOURCE_UNREACHABLE'); + expect(payload.details.source_id).toBe('default'); + }); }); describe('read-only mode enforcement', () => { diff --git a/src/tools/execute-sql.ts b/src/tools/execute-sql.ts index 95c60e4d..4ee4b351 100644 --- a/src/tools/execute-sql.ts +++ b/src/tools/execute-sql.ts @@ -8,9 +8,9 @@ import { BUILTIN_TOOL_EXECUTE_SQL } from "./builtin-tools.js"; import { getEffectiveSourceId, trackToolRequest, + tryClassifyConnectionError, } from "../utils/tool-handler-helpers.js"; import { splitSQLStatements } from "../utils/sql-parser.js"; -import { classifyConnectionError } from "../utils/error-classifier.js"; // Schema for execute_sql tool export const executeSqlSchema = { @@ -82,16 +82,8 @@ export function createExecuteSqlToolHandler(sourceId?: string) { } catch (error) { success = false; errorMessage = (error as Error).message; - const connectorType = ConnectorManager.getSourceConfig(sourceId)?.type; - if (connectorType) { - const classified = classifyConnectionError(error, connectorType, effectiveSourceId); - if (classified) { - errorMessage = classified.message; - return createToolErrorResponse(classified.message, classified.code, { - source_id: effectiveSourceId, - }); - } - } + const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId); + if (classified) return classified; return createToolErrorResponse(errorMessage, "EXECUTION_ERROR"); } finally { // Track the request diff --git a/src/utils/tool-handler-helpers.ts b/src/utils/tool-handler-helpers.ts index 72fc9b96..157f0868 100644 --- a/src/utils/tool-handler-helpers.ts +++ b/src/utils/tool-handler-helpers.ts @@ -4,9 +4,12 @@ */ import { ConnectorType } from "../connectors/interface.js"; +import { ConnectorManager } from "../connectors/manager.js"; import { isReadOnlySQL, allowedKeywords } from "./allowed-keywords.js"; import { requestStore } from "../requests/index.js"; import { getClientIdentifier } from "./client-identifier.js"; +import { classifyConnectionError } from "./error-classifier.js"; +import { createToolErrorResponse } from "./response-formatter.js"; /** * Request metadata for tracking @@ -75,6 +78,28 @@ export function trackToolRequest( }); } +/** + * If `error` is a recognized connection/access failure for the given source, + * return a classified tool error response; otherwise return null so the caller + * falls back to its generic error handling. + * + * @param rawSourceId config lookup key (undefined => default source) + * @param displaySourceId human-readable id used in the message + details + */ +export function tryClassifyConnectionError( + error: unknown, + rawSourceId: string | undefined, + displaySourceId: string +): ReturnType | null { + const connectorType = ConnectorManager.getSourceConfig(rawSourceId)?.type; + if (!connectorType) return null; + const classified = classifyConnectionError(error, connectorType, displaySourceId); + if (!classified) return null; + return createToolErrorResponse(classified.message, classified.code, { + source_id: displaySourceId, + }); +} + /** * Higher-order function to wrap tool handlers with automatic request tracking * @param handler Core handler logic that performs the actual work From c15665e9aa85c66c7b651163843ee39c780280a1 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:17:24 +0800 Subject: [PATCH 5/9] feat: classify connection failures in search_objects (#266) --- src/tools/__tests__/search-objects.test.ts | 27 ++++++++++++++++++++++ src/tools/search-objects.ts | 3 +++ 2 files changed, 30 insertions(+) diff --git a/src/tools/__tests__/search-objects.test.ts b/src/tools/__tests__/search-objects.test.ts index 0cd9fc78..2dfca456 100644 --- a/src/tools/__tests__/search-objects.test.ts +++ b/src/tools/__tests__/search-objects.test.ts @@ -1208,6 +1208,33 @@ describe('search_database_objects tool', () => { const parsed = parseToolResponse(result); expect(parsed.code).toBe('SEARCH_ERROR'); }); + + it('returns AUTH_FAILED when the connector throws a login error', async () => { + const elogin: any = new Error('Login failed for user'); + elogin.code = 'ELOGIN'; + // make every method the handler might call reject with the auth error + const failing = { + id: 'sqlserver', + getId: () => 'mssql', + getDefaultSchema: vi.fn().mockRejectedValue(elogin), + getSchemas: vi.fn().mockRejectedValue(elogin), + getTables: vi.fn().mockRejectedValue(elogin), + }; + mockGetCurrentConnector.mockReturnValue(failing as any); + vi.mocked(ConnectorManager.ensureConnected).mockResolvedValue(undefined as any); + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ id: 'mssql', type: 'sqlserver' } as any); + + const handler = createSearchDatabaseObjectsToolHandler('mssql'); + const result: any = await handler( + { object_type: 'table', detail_level: 'names', limit: 100 }, + {} + ); + const payload = parseToolResponse(result); + + expect(result.isError).toBe(true); + expect(payload.code).toBe('AUTH_FAILED'); + expect(payload.details.source_id).toBe('mssql'); + }); }); describe('case insensitivity', () => { diff --git a/src/tools/search-objects.ts b/src/tools/search-objects.ts index c1ce6d26..b5d2427a 100644 --- a/src/tools/search-objects.ts +++ b/src/tools/search-objects.ts @@ -6,6 +6,7 @@ import { quoteQualifiedIdentifier } from "../utils/identifier-quoter.js"; import { getEffectiveSourceId, trackToolRequest, + tryClassifyConnectionError, } from "../utils/tool-handler-helpers.js"; /** @@ -714,6 +715,8 @@ export function createSearchDatabaseObjectsToolHandler(sourceId?: string) { } catch (error) { success = false; errorMessage = (error as Error).message; + const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId); + if (classified) return classified; return createToolErrorResponse( `Error searching database objects: ${errorMessage}`, "SEARCH_ERROR" From 67c0e9875e8f08ac2fbf01ccb4736b80353309c3 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:23:26 +0800 Subject: [PATCH 6/9] feat: classify connection failures in custom tools (#266) --- .../__tests__/custom-tool-handler.test.ts | 47 ++++++++++++++++++- src/tools/custom-tool-handler.ts | 6 +++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/tools/__tests__/custom-tool-handler.test.ts b/src/tools/__tests__/custom-tool-handler.test.ts index ec5cdacd..1ecf5866 100644 --- a/src/tools/__tests__/custom-tool-handler.test.ts +++ b/src/tools/__tests__/custom-tool-handler.test.ts @@ -1,10 +1,15 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { z } from "zod"; import { buildZodSchemaFromParameters, buildInputSchema, + createCustomToolHandler, } from "../custom-tool-handler.js"; -import type { ParameterConfig } from "../../types/config.js"; +import { ConnectorManager } from "../../connectors/manager.js"; +import type { ToolConfig, ParameterConfig } from "../../types/config.js"; + +// Auto-mock the connector manager so we control connection/execution behavior +vi.mock("../../connectors/manager.js"); describe("Custom Tool Handler", () => { describe("buildZodSchemaFromParameters", () => { @@ -357,4 +362,42 @@ describe("Custom Tool Handler", () => { expect(schema.required).toBeUndefined(); }); }); + + describe("createCustomToolHandler connection error classification", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns SOURCE_UNREACHABLE (not a SQL error) when the connector throws a network error", async () => { + const econn: any = new Error("connect ECONNREFUSED 127.0.0.1:5432"); + econn.code = "ECONNREFUSED"; + + vi.mocked(ConnectorManager.ensureConnected).mockResolvedValue(undefined as any); + vi.mocked(ConnectorManager.getCurrentConnector).mockReturnValue({ + id: "postgres", + getId: () => "prod", + executeSQL: vi.fn().mockRejectedValue(econn), + } as any); + vi.mocked(ConnectorManager.getSourceConfig).mockReturnValue({ + id: "prod", + type: "postgres", + } as any); + + const toolConfig: ToolConfig = { + name: "get_user", + source: "prod", + statement: "SELECT * FROM users", + } as any; + + const handler = createCustomToolHandler(toolConfig); + const res: any = await handler({}, {}); + const payload = JSON.parse(res.content[0].text); + + expect(res.isError).toBe(true); + expect(payload.code).toBe("SOURCE_UNREACHABLE"); + expect(payload.details.source_id).toBe(toolConfig.source); + // Connection failures must NOT be augmented with SQL-context debugging info + expect(payload.error).not.toContain("SQL:"); + }); + }); }); diff --git a/src/tools/custom-tool-handler.ts b/src/tools/custom-tool-handler.ts index 2c698a63..6a5e732a 100644 --- a/src/tools/custom-tool-handler.ts +++ b/src/tools/custom-tool-handler.ts @@ -15,6 +15,7 @@ import { isAllowedInReadonlyMode, createReadonlyViolationMessage, trackToolRequest, + tryClassifyConnectionError, } from "../utils/tool-handler-helpers.js"; /** @@ -213,6 +214,11 @@ export function createCustomToolHandler(toolConfig: ToolConfig) { success = false; errorMessage = (error as Error).message; + // A connection/access failure is not a SQL problem — classify and return + // it cleanly, ahead of the ZodError / SQL-context augmentation below. + const classified = tryClassifyConnectionError(error, toolConfig.source, toolConfig.source); + if (classified) return classified; + // Provide helpful error messages for common issues if (error instanceof z.ZodError) { const issues = error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); From da809dc75b9dd2d9c62ac84d526e950b81a905de Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:29:13 +0800 Subject: [PATCH 7/9] harden: defensive classify helper + extra mysql/mariadb auth errno (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review hardening from the final code review: - tryClassifyConnectionError wraps getSourceConfig so it never throws from within a caller's catch block (matches classifyConnectionError's totality). - Add errno 1698 (ER_ACCESS_DENIED_NO_PASSWORD_ERROR) to the mysql/mariadb auth table — the most likely auth failure that previously slipped through to EXECUTION_ERROR. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/__tests__/error-classifier.test.ts | 3 +++ src/utils/error-classifier.ts | 4 ++-- src/utils/tool-handler-helpers.ts | 10 +++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/error-classifier.test.ts b/src/utils/__tests__/error-classifier.test.ts index e4504193..3a8c301d 100644 --- a/src/utils/__tests__/error-classifier.test.ts +++ b/src/utils/__tests__/error-classifier.test.ts @@ -18,6 +18,9 @@ describe("classifyConnectionError", () => { it("classifies mysql/mariadb auth errors via code or errno", () => { expect(classifyConnectionError({ code: "ER_ACCESS_DENIED_ERROR" }, "mysql", "m")?.code).toBe("AUTH_FAILED"); expect(classifyConnectionError({ errno: 1045 }, "mariadb", "m")?.code).toBe("AUTH_FAILED"); + // 1698 = ER_ACCESS_DENIED_NO_PASSWORD_ERROR + expect(classifyConnectionError({ errno: 1698 }, "mysql", "m")?.code).toBe("AUTH_FAILED"); + expect(classifyConnectionError({ errno: 1698 }, "mariadb", "m")?.code).toBe("AUTH_FAILED"); }); it("classifies sqlserver login errors as AUTH_FAILED", () => { diff --git a/src/utils/error-classifier.ts b/src/utils/error-classifier.ts index 9ddb58b2..219395c1 100644 --- a/src/utils/error-classifier.ts +++ b/src/utils/error-classifier.ts @@ -30,8 +30,8 @@ const NETWORK_CODES = new Set([ // Per-connector authentication failure signals. Keyed by code or errno. const AUTH_CODES: Record> = { postgres: ["28P01", "28000"], - mysql: ["ER_ACCESS_DENIED_ERROR", 1045], - mariadb: ["ER_ACCESS_DENIED_ERROR", 1045], + mysql: ["ER_ACCESS_DENIED_ERROR", 1045, 1698], + mariadb: ["ER_ACCESS_DENIED_ERROR", 1045, 1698], sqlserver: ["ELOGIN"], sqlite: [], // no network/auth layer }; diff --git a/src/utils/tool-handler-helpers.ts b/src/utils/tool-handler-helpers.ts index 157f0868..04d778e1 100644 --- a/src/utils/tool-handler-helpers.ts +++ b/src/utils/tool-handler-helpers.ts @@ -91,7 +91,15 @@ export function tryClassifyConnectionError( rawSourceId: string | undefined, displaySourceId: string ): ReturnType | null { - const connectorType = ConnectorManager.getSourceConfig(rawSourceId)?.type; + // Defensive: getSourceConfig throws if the manager is uninitialized. Keep + // this helper as total as classifyConnectionError itself — never throw from + // within a caller's catch block. + let connectorType: ConnectorType | undefined; + try { + connectorType = ConnectorManager.getSourceConfig(rawSourceId)?.type; + } catch { + return null; + } if (!connectorType) return null; const classified = classifyConnectionError(error, connectorType, displaySourceId); if (!classified) return null; From 7bdfa79edfe47e76e4134d0b931d18d6463a7310 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:37:56 +0800 Subject: [PATCH 8/9] simplify: drop dead ?? [] fallback on exhaustive AUTH_CODES record (#266) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/error-classifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/error-classifier.ts b/src/utils/error-classifier.ts index 219395c1..48fc9e5d 100644 --- a/src/utils/error-classifier.ts +++ b/src/utils/error-classifier.ts @@ -77,7 +77,7 @@ export function classifyConnectionError( return { code: "SOURCE_UNREACHABLE", message: unreachableMessage(sourceId) }; } - const authCodes = AUTH_CODES[connectorType] ?? []; + const authCodes = AUTH_CODES[connectorType]; const errno = err.errno; if ( (typeof code === "string" && authCodes.includes(code)) || From cfdcccc7f9e33c741c050c70e38f30d92736b638 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Wed, 24 Jun 2026 01:39:43 +0800 Subject: [PATCH 9/9] fix: generalize SOURCE_UNREACHABLE message to match all network codes (#266) The message hard-coded 'connection refused or timed out' but the classifier also matches ENOTFOUND/EHOSTUNREACH/ENETUNREACH/ECONNRESET. Drop the over-specific parenthetical; the remediation already names host/port/network. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/error-classifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/error-classifier.ts b/src/utils/error-classifier.ts index 48fc9e5d..a83aae8b 100644 --- a/src/utils/error-classifier.ts +++ b/src/utils/error-classifier.ts @@ -37,7 +37,7 @@ const AUTH_CODES: Record> = { }; function unreachableMessage(sourceId: string): string { - return `Source '${sourceId}' is unreachable (connection refused or timed out). ` + + return `Source '${sourceId}' is unreachable. ` + `Verify the database is running and reachable (host, port, network), then retry.`; }