Skip to content

Commit 315e8de

Browse files
committed
refactor: move sql error explanation tool
1 parent 9b37b5d commit 315e8de

2 files changed

Lines changed: 172 additions & 147 deletions

File tree

src/mcp/server.ts

Lines changed: 3 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import type { HttpConfig, ObConfig } from "../config.js";
1111
import {
1212
describeTable,
1313
estimateErExportPlanStats,
14-
findColumns,
1514
findRelatedTables,
1615
getTableDdlMetadata,
1716
getTableStats,
@@ -87,8 +86,6 @@ import {
8786
import { registerMcpResources } from "./resources.js";
8887
import {
8988
catalogSearchResult,
90-
columnSearchRows,
91-
dedupeColumnSearchResults,
9289
dedupeSearchResults,
9390
glossarySearchResults,
9491
relatedTableEvidence,
@@ -108,7 +105,7 @@ import {
108105
sqlParamSchema,
109106
tableDocumentFormatSchema
110107
} from "./schemas.js";
111-
import { errorHints, toGatewayLimitedResult, toLimitedResult, toResult } from "./tool-runtime.js";
108+
import { toGatewayLimitedResult, toLimitedResult, toResult } from "./tool-runtime.js";
112109
import {
113110
buildErExportCommand,
114111
defaultErHtmlTitle,
@@ -145,6 +142,7 @@ import { registerQueryTemplateTools } from "./tools/query-templates.js";
145142
import { registerRecentRowsTools } from "./tools/recent-rows.js";
146143
import { registerSearchCatalogTools } from "./tools/search-catalog.js";
147144
import { registerSqlConversionTools } from "./tools/sql-conversion.js";
145+
import { registerSqlErrorTools } from "./tools/sql-errors.js";
148146
import { registerSqlUsageTools } from "./tools/sql-usages.js";
149147
import { registerTableContextTools } from "./tools/table-context.js";
150148
import {
@@ -223,6 +221,7 @@ export function createObMcpServer(
223221
registerRecentRowsTools(registerTool, runtime, context);
224222
registerSearchCatalogTools(registerTool, runtime, context);
225223
registerSqlConversionTools(registerTool, runtime, context);
224+
registerSqlErrorTools(registerTool, runtime, context);
226225
registerSqlUsageTools(registerTool, runtime, context);
227226

228227
registerObjectMetadataTools(registerTool, runtime, context);
@@ -641,36 +640,6 @@ export function createObMcpServer(
641640
}
642641
);
643642

644-
registerTool(
645-
"ob_explain_error",
646-
{
647-
title: "Explain SQL Error",
648-
description: "Explain a SQL error with policy context and visible metadata hints without executing the SQL.",
649-
inputSchema: {
650-
...profileInput,
651-
sql: z.string().min(1),
652-
errorMessage: z.string().min(1),
653-
schema: z.string().optional(),
654-
limit: z.number().int().positive().max(100).optional()
655-
}
656-
},
657-
async ({ profile, sql, errorMessage, schema, limit }) => {
658-
const selected = selectAuthorizedProfile(runtime, profile, context);
659-
const effectiveSchema = metadataSchema(selected.config, schema);
660-
if (effectiveSchema && !isSchemaAllowed(effectiveSchema, selected.config.policy)) {
661-
throw new Error(`Schema "${effectiveSchema}" is not allowed by the configured OceanBase MCP access policy.`);
662-
}
663-
const explanation = await explainSqlErrorWithMetadata(
664-
selected,
665-
sql,
666-
errorMessage,
667-
effectiveSchema,
668-
Math.min(limit ?? 20, 100)
669-
);
670-
return toLimitedResult(explanation, selected.config);
671-
}
672-
);
673-
674643
registerTool(
675644
"ob_rewrite_sql",
676645
{
@@ -3278,119 +3247,6 @@ function uniqueTableRefs(refs: Array<{ schema?: string; table: string }>): Array
32783247
return output;
32793248
}
32803249

3281-
async function explainSqlErrorWithMetadata(
3282-
selected: ReturnType<typeof selectRuntimeProfile>,
3283-
sql: string,
3284-
errorMessage: string,
3285-
schema: string | undefined,
3286-
limit: number
3287-
) {
3288-
const classification = classifySqlError(errorMessage);
3289-
const policy = explainSqlPolicy(sql, policyContext(selected.config));
3290-
const shape = queryShape(sql, selected.config.compatibility);
3291-
const baseHints = errorHints(new Error(errorMessage), { sql });
3292-
const candidates = await errorMetadataCandidates(selected, classification, errorMessage, schema, limit);
3293-
return {
3294-
sql,
3295-
errorClass: classification.kind,
3296-
extractedTerm: classification.term ?? null,
3297-
hints: baseHints,
3298-
policy,
3299-
shape,
3300-
candidates,
3301-
warnings: candidates.truncated ? ["Metadata candidates were truncated by the requested limit."] : [],
3302-
executed: false
3303-
};
3304-
}
3305-
3306-
function classifySqlError(errorMessage: string): { kind: string; term?: string } {
3307-
const lower = errorMessage.toLowerCase();
3308-
const column =
3309-
/unknown column ['"`]?([^'"`\s]+)['"`]?/i.exec(errorMessage)?.[1] ??
3310-
/invalid identifier[^'"`]*['"`]([^'"`]+)['"`]/i.exec(errorMessage)?.[1] ??
3311-
/ora-00904:\s*["']?([^"'\s]+)["']?/i.exec(errorMessage)?.[1];
3312-
if (column || /unknown column|invalid identifier|ora-00904|1054/.test(lower)) {
3313-
return { kind: "unknown_column", term: column };
3314-
}
3315-
const table =
3316-
/ora-00942:\s*["']?([^"'\s:]+)["']?/i.exec(errorMessage)?.[1] ??
3317-
/table ['"`]?([^'"`\s]+)['"`]?.*(?:doesn't exist|not exist|unknown)/i.exec(errorMessage)?.[1] ??
3318-
/unknown table ['"`]?([^'"`\s]+)['"`]?/i.exec(errorMessage)?.[1] ??
3319-
/(?:table|view) ['"`]?([^'"`\s]+)['"`]? does not exist/i.exec(errorMessage)?.[1];
3320-
if (table || /unknown table|table .*doesn't exist|ora-00942|1146/.test(lower)) {
3321-
return { kind: "unknown_table", term: table };
3322-
}
3323-
if (/not allowed|blocked|select \*|ob_denied_columns|policy/i.test(errorMessage)) {
3324-
return { kind: "policy" };
3325-
}
3326-
if (/limit|offset|fetch|rownum|syntax|ora-00933|1064/i.test(errorMessage)) {
3327-
return { kind: "dialect_or_pagination" };
3328-
}
3329-
if (/permission|privilege|access denied|ora-01031|1044|1142/i.test(errorMessage)) {
3330-
return { kind: "permission" };
3331-
}
3332-
return { kind: "unknown" };
3333-
}
3334-
3335-
async function errorMetadataCandidates(
3336-
selected: ReturnType<typeof selectRuntimeProfile>,
3337-
classification: { kind: string; term?: string },
3338-
errorMessage: string,
3339-
schema: string | undefined,
3340-
limit: number
3341-
) {
3342-
const term = classification.term ?? fallbackErrorSearchTerm(errorMessage);
3343-
if (!term) {
3344-
return { columns: [], tables: [], truncated: false };
3345-
}
3346-
if (classification.kind === "unknown_column") {
3347-
const rows = filterColumnMetadata(
3348-
await withQuerySlot(selected, () =>
3349-
findColumns(
3350-
selected.pool,
3351-
selected.config.compatibility,
3352-
{ query: term, schema },
3353-
limit,
3354-
selected.config.queryTimeoutMs
3355-
)
3356-
),
3357-
selected.config.policy
3358-
);
3359-
const columns = dedupeColumnSearchResults(rows.flatMap((row) =>
3360-
columnSearchRows(row, term, undefined, selected.config)
3361-
)).slice(0, limit);
3362-
return { columns, tables: [], truncated: rows.length >= limit };
3363-
}
3364-
if (classification.kind === "unknown_table") {
3365-
const rows = filterColumnMetadata(
3366-
await withQuerySlot(selected, () =>
3367-
searchCatalog(
3368-
selected.pool,
3369-
selected.config.compatibility,
3370-
term,
3371-
schema,
3372-
limit,
3373-
selected.config.queryTimeoutMs
3374-
)
3375-
),
3376-
selected.config.policy
3377-
);
3378-
const searchResults = dedupeSearchResults(rows.flatMap((row) =>
3379-
catalogSearchResult(row, term, selected.config)
3380-
));
3381-
return {
3382-
columns: [],
3383-
tables: tableCandidatesFromSearchResults(searchResults, selected.config, limit),
3384-
truncated: rows.length >= limit
3385-
};
3386-
}
3387-
return { columns: [], tables: [], truncated: false };
3388-
}
3389-
3390-
function fallbackErrorSearchTerm(errorMessage: string): string | undefined {
3391-
return /['"`]([A-Za-z_][\w$]*)['"`]/.exec(errorMessage)?.[1];
3392-
}
3393-
33943250
async function rewriteReadOnlySql(
33953251
selected: ReturnType<typeof selectRuntimeProfile>,
33963252
sql: string,

src/mcp/tools/sql-errors.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { z } from "zod";
2+
3+
import { findColumns, searchCatalog } from "../../db.js";
4+
import { explainSqlPolicy, filterColumnMetadata, isSchemaAllowed } from "../../policy.js";
5+
import { queryShape } from "../../query/preview.js";
6+
import { selectRuntimeProfile, withQuerySlot, type GatewayRuntime } from "../../runtime.js";
7+
import { selectAuthorizedProfile } from "../access.js";
8+
import type { McpRequestContext } from "../context.js";
9+
import { metadataSchema } from "../metadata-scope.js";
10+
import { policyContext } from "../policy-context.js";
11+
import {
12+
catalogSearchResult,
13+
columnSearchRows,
14+
dedupeColumnSearchResults,
15+
dedupeSearchResults,
16+
tableCandidatesFromSearchResults
17+
} from "../search-results.js";
18+
import { profileInput } from "../schemas.js";
19+
import { errorHints, toLimitedResult } from "../tool-runtime.js";
20+
import type { ToolRegistrar } from "../tool-registry.js";
21+
22+
export function registerSqlErrorTools(
23+
registerTool: ToolRegistrar,
24+
runtime: GatewayRuntime,
25+
context: McpRequestContext
26+
): void {
27+
registerTool(
28+
"ob_explain_error",
29+
{
30+
title: "Explain SQL Error",
31+
description: "Explain a SQL error with policy context and visible metadata hints without executing the SQL.",
32+
inputSchema: {
33+
...profileInput,
34+
sql: z.string().min(1),
35+
errorMessage: z.string().min(1),
36+
schema: z.string().optional(),
37+
limit: z.number().int().positive().max(100).optional()
38+
}
39+
},
40+
async ({ profile, sql, errorMessage, schema, limit }) => {
41+
const selected = selectAuthorizedProfile(runtime, profile, context);
42+
const effectiveSchema = metadataSchema(selected.config, schema);
43+
if (effectiveSchema && !isSchemaAllowed(effectiveSchema, selected.config.policy)) {
44+
throw new Error(`Schema "${effectiveSchema}" is not allowed by the configured OceanBase MCP access policy.`);
45+
}
46+
const explanation = await explainSqlErrorWithMetadata(
47+
selected,
48+
sql,
49+
errorMessage,
50+
effectiveSchema,
51+
Math.min(limit ?? 20, 100)
52+
);
53+
return toLimitedResult(explanation, selected.config);
54+
}
55+
);
56+
}
57+
58+
async function explainSqlErrorWithMetadata(
59+
selected: ReturnType<typeof selectRuntimeProfile>,
60+
sql: string,
61+
errorMessage: string,
62+
schema: string | undefined,
63+
limit: number
64+
) {
65+
const classification = classifySqlError(errorMessage);
66+
const policy = explainSqlPolicy(sql, policyContext(selected.config));
67+
const shape = queryShape(sql, selected.config.compatibility);
68+
const baseHints = errorHints(new Error(errorMessage), { sql });
69+
const candidates = await errorMetadataCandidates(selected, classification, errorMessage, schema, limit);
70+
return {
71+
sql,
72+
errorClass: classification.kind,
73+
extractedTerm: classification.term ?? null,
74+
hints: baseHints,
75+
policy,
76+
shape,
77+
candidates,
78+
warnings: candidates.truncated ? ["Metadata candidates were truncated by the requested limit."] : [],
79+
executed: false
80+
};
81+
}
82+
83+
function classifySqlError(errorMessage: string): { kind: string; term?: string } {
84+
const lower = errorMessage.toLowerCase();
85+
const column =
86+
/unknown column ['"`]?([^'"`\s]+)['"`]?/i.exec(errorMessage)?.[1] ??
87+
/invalid identifier[^'"`]*['"`]([^'"`]+)['"`]/i.exec(errorMessage)?.[1] ??
88+
/ora-00904:\s*["']?([^"'\s]+)["']?/i.exec(errorMessage)?.[1];
89+
if (column || /unknown column|invalid identifier|ora-00904|1054/.test(lower)) {
90+
return { kind: "unknown_column", term: column };
91+
}
92+
const table =
93+
/ora-00942:\s*["']?([^"'\s:]+)["']?/i.exec(errorMessage)?.[1] ??
94+
/table ['"`]?([^'"`\s]+)['"`]?.*(?:doesn't exist|not exist|unknown)/i.exec(errorMessage)?.[1] ??
95+
/unknown table ['"`]?([^'"`\s]+)['"`]?/i.exec(errorMessage)?.[1] ??
96+
/(?:table|view) ['"`]?([^'"`\s]+)['"`]? does not exist/i.exec(errorMessage)?.[1];
97+
if (table || /unknown table|table .*doesn't exist|ora-00942|1146/.test(lower)) {
98+
return { kind: "unknown_table", term: table };
99+
}
100+
if (/not allowed|blocked|select \*|ob_denied_columns|policy/i.test(errorMessage)) {
101+
return { kind: "policy" };
102+
}
103+
if (/limit|offset|fetch|rownum|syntax|ora-00933|1064/i.test(errorMessage)) {
104+
return { kind: "dialect_or_pagination" };
105+
}
106+
if (/permission|privilege|access denied|ora-01031|1044|1142/i.test(errorMessage)) {
107+
return { kind: "permission" };
108+
}
109+
return { kind: "unknown" };
110+
}
111+
112+
async function errorMetadataCandidates(
113+
selected: ReturnType<typeof selectRuntimeProfile>,
114+
classification: { kind: string; term?: string },
115+
errorMessage: string,
116+
schema: string | undefined,
117+
limit: number
118+
) {
119+
const term = classification.term ?? fallbackErrorSearchTerm(errorMessage);
120+
if (!term) {
121+
return { columns: [], tables: [], truncated: false };
122+
}
123+
if (classification.kind === "unknown_column") {
124+
const rows = filterColumnMetadata(
125+
await withQuerySlot(selected, () =>
126+
findColumns(
127+
selected.pool,
128+
selected.config.compatibility,
129+
{ query: term, schema },
130+
limit,
131+
selected.config.queryTimeoutMs
132+
)
133+
),
134+
selected.config.policy
135+
);
136+
const columns = dedupeColumnSearchResults(rows.flatMap((row) =>
137+
columnSearchRows(row, term, undefined, selected.config)
138+
)).slice(0, limit);
139+
return { columns, tables: [], truncated: rows.length >= limit };
140+
}
141+
if (classification.kind === "unknown_table") {
142+
const rows = filterColumnMetadata(
143+
await withQuerySlot(selected, () =>
144+
searchCatalog(
145+
selected.pool,
146+
selected.config.compatibility,
147+
term,
148+
schema,
149+
limit,
150+
selected.config.queryTimeoutMs
151+
)
152+
),
153+
selected.config.policy
154+
);
155+
const searchResults = dedupeSearchResults(rows.flatMap((row) =>
156+
catalogSearchResult(row, term, selected.config)
157+
));
158+
return {
159+
columns: [],
160+
tables: tableCandidatesFromSearchResults(searchResults, selected.config, limit),
161+
truncated: rows.length >= limit
162+
};
163+
}
164+
return { columns: [], tables: [], truncated: false };
165+
}
166+
167+
function fallbackErrorSearchTerm(errorMessage: string): string | undefined {
168+
return /['"`]([A-Za-z_][\w$]*)['"`]/.exec(errorMessage)?.[1];
169+
}

0 commit comments

Comments
 (0)