Skip to content

Commit 8b59df7

Browse files
committed
refactor: move query preparation tool
1 parent 6c3df2d commit 8b59df7

2 files changed

Lines changed: 385 additions & 327 deletions

File tree

src/mcp/server.ts

Lines changed: 6 additions & 327 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
renderStructuredTableDdl,
2525
runReadOnlyQuery,
2626
sampleRows,
27-
searchCatalog,
2827
type TableRef,
2928
type TableRelationship,
3029
type SqlParam
@@ -51,7 +50,6 @@ import {
5150
} from "../query/output.js";
5251
import { queryPreviewWarnings, queryShape } from "../query/preview.js";
5352
import { ensureQuerySessionStore } from "../query/sessions.js";
54-
import { generateQueryTemplates, type QueryTemplateScenario } from "../query-generation.js";
5553
import { selectRuntimeProfile, withQuerySlot, type GatewayRuntime } from "../runtime.js";
5654
import { extractSqlSnippetsFromContent, type SqlScanSnippet } from "../sql-scan.js";
5755
import {
@@ -61,8 +59,6 @@ import {
6159
visibleDefaultProfile
6260
} from "./access.js";
6361
import {
64-
normalizeGlossaryText,
65-
searchBusinessGlossary,
6662
visibleGlossaryTargets
6763
} from "./business-glossary.js";
6864
import type { McpRequestContext } from "./context.js";
@@ -78,16 +74,6 @@ import {
7874
getBuildColumn
7975
} from "./query-builder.js";
8076
import { registerMcpResources } from "./resources.js";
81-
import {
82-
catalogSearchResult,
83-
dedupeSearchResults,
84-
glossarySearchResults,
85-
relatedTableEvidence,
86-
tableCandidatesFromSearchResults,
87-
type BusinessTableCandidate,
88-
type SearchEverythingResult
89-
} from "./search-results.js";
90-
import { explainGeneratedQuery } from "./sql-policy-explain.js";
9177
import {
9278
entityDiagramOutputFormatSchema,
9379
erDocumentFormatSchema,
@@ -143,6 +129,11 @@ import {
143129
registerSqlLintTools,
144130
type LintFinding
145131
} from "./tools/sql-lint.js";
132+
import {
133+
findPrepareQueryCandidates,
134+
prepareQuerySearchTerms,
135+
registerSqlPrepareTools
136+
} from "./tools/sql-prepare.js";
146137
import { registerSqlRewriteTools } from "./tools/sql-rewrite.js";
147138
import { registerSqlUsageTools } from "./tools/sql-usages.js";
148139
import { registerTableContextTools } from "./tools/table-context.js";
@@ -169,30 +160,6 @@ export { effectiveRowLimit, shouldMaskColumnValue } from "../query/output.js";
169160
export { isToolAllowed } from "./access.js";
170161
export type { McpRequestContext } from "./context.js";
171162

172-
const QUERY_TERM_STOPWORDS = new Set([
173-
"show",
174-
"list",
175-
"find",
176-
"get",
177-
"query",
178-
"select",
179-
"data",
180-
"rows",
181-
"table",
182-
"recent",
183-
"latest",
184-
"count",
185-
"查询",
186-
"查看",
187-
"列出",
188-
"数据",
189-
"列表",
190-
"最近",
191-
"最新",
192-
"统计",
193-
"数量"
194-
]);
195-
196163
export function createObMcpServer(
197164
runtime: GatewayRuntime,
198165
httpConfig?: HttpConfig,
@@ -578,70 +545,7 @@ export function createObMcpServer(
578545
}
579546
);
580547

581-
registerTool(
582-
"ob_prepare_query",
583-
{
584-
title: "Prepare Read-only Query",
585-
description: "Prepare candidate tables, fields, read-only SQL drafts, and safety previews from visible metadata without executing the target query.",
586-
inputSchema: {
587-
...profileInput,
588-
question: z.string().min(1),
589-
businessTerm: z.string().optional(),
590-
schema: z.string().optional(),
591-
table: z.string().optional(),
592-
maxCandidates: z.number().int().positive().max(20).optional(),
593-
maxColumns: z.number().int().positive().max(50).optional(),
594-
maxRows: z.number().int().positive().optional()
595-
}
596-
},
597-
async ({ profile, question, businessTerm, schema, table, maxCandidates, maxColumns, maxRows }) => {
598-
const selected = selectAuthorizedProfile(runtime, profile, context);
599-
const effectiveSchema = metadataSchema(selected.config, schema);
600-
if (effectiveSchema && !isSchemaAllowed(effectiveSchema, selected.config.policy)) {
601-
throw new Error(`Schema "${effectiveSchema}" is not allowed by the configured OceanBase MCP access policy.`);
602-
}
603-
const candidateLimit = Math.min(maxCandidates ?? 5, 20);
604-
const searchTerms = prepareQuerySearchTerms(question, businessTerm, table);
605-
const warnings: string[] = [];
606-
const candidates = table
607-
? explicitPrepareQueryCandidate(selected.config, effectiveSchema, table)
608-
: await findPrepareQueryCandidates(selected, searchTerms, effectiveSchema, candidateLimit, warnings);
609-
const preparedCandidates = [];
610-
for (const candidate of candidates.slice(0, candidateLimit)) {
611-
preparedCandidates.push(await buildPreparedQueryCandidate(
612-
selected,
613-
candidate,
614-
question,
615-
maxColumns ?? 12,
616-
effectiveRowLimit(maxRows, selected.config)
617-
));
618-
}
619-
if (preparedCandidates.length === 0) {
620-
warnings.push("No visible candidate table was found from the supplied question or business term. Search metadata first or pass schema and table explicitly.");
621-
}
622-
623-
return toLimitedResult(
624-
{
625-
mode: selected.config.compatibility,
626-
question,
627-
businessTerm: businessTerm ?? null,
628-
schema: effectiveSchema ?? null,
629-
table: table ?? null,
630-
searchTerms,
631-
candidateCount: preparedCandidates.length,
632-
candidates: preparedCandidates,
633-
workflow: [
634-
"Review candidate evidence before using a SQL draft.",
635-
"Run ob_query_preview before executing a query.",
636-
"Use ob_query_page for data inspection when paging is needed."
637-
],
638-
executed: false,
639-
warnings: uniqueStrings(warnings)
640-
},
641-
selected.config
642-
);
643-
}
644-
);
548+
registerSqlPrepareTools(registerTool, runtime, context);
645549

646550
registerTool(
647551
"ob_validate_sql_snippet",
@@ -2962,231 +2866,6 @@ function maskProfileValue(value: unknown): unknown {
29622866
return value == null ? value : "***MASKED***";
29632867
}
29642868

2965-
function explicitPrepareQueryCandidate(
2966-
config: ObConfig,
2967-
schema: string | undefined,
2968-
table: string
2969-
): BusinessTableCandidate[] {
2970-
const normalizedSchema = config.compatibility === "oracle" && schema ? schema.trim().toUpperCase() : schema;
2971-
const normalizedTable = config.compatibility === "oracle" ? table.trim().toUpperCase() : table.trim();
2972-
assertTableAccess(normalizedSchema, normalizedTable, policyContext(config));
2973-
return [{
2974-
schema: normalizedSchema,
2975-
table: normalizedTable,
2976-
confidence: 1,
2977-
sources: ["explicit_input"],
2978-
matchedColumns: [],
2979-
terms: [],
2980-
evidence: [{
2981-
source: "explicit_input",
2982-
type: "table",
2983-
field: "table",
2984-
value: normalizedTable,
2985-
reason: "The caller supplied an explicit table."
2986-
}]
2987-
}];
2988-
}
2989-
2990-
async function findPrepareQueryCandidates(
2991-
selected: ReturnType<typeof selectRuntimeProfile>,
2992-
searchTerms: string[],
2993-
schema: string | undefined,
2994-
limit: number,
2995-
warnings: string[]
2996-
): Promise<BusinessTableCandidate[]> {
2997-
const results: SearchEverythingResult[] = [];
2998-
const warningSet = new Set(warnings);
2999-
for (const term of searchTerms.slice(0, 8)) {
3000-
const rows = filterColumnMetadata(
3001-
await withQuerySlot(selected, () =>
3002-
searchCatalog(
3003-
selected.pool,
3004-
selected.config.compatibility,
3005-
term,
3006-
schema,
3007-
Math.max(limit * 8, 20),
3008-
selected.config.queryTimeoutMs
3009-
)
3010-
),
3011-
selected.config.policy
3012-
);
3013-
results.push(...rows.flatMap((row) => catalogSearchResult(row, term, selected.config)));
3014-
const glossary = searchBusinessGlossary(selected.config, term, limit * 4, schema);
3015-
results.push(...glossary.matches.flatMap((match) => glossarySearchResults(match, term)));
3016-
for (const warning of glossary.warnings) {
3017-
warningSet.add(warning);
3018-
}
3019-
}
3020-
warnings.splice(0, warnings.length, ...warningSet);
3021-
return tableCandidatesFromSearchResults(
3022-
dedupeSearchResults(results),
3023-
selected.config,
3024-
limit
3025-
);
3026-
}
3027-
3028-
async function buildPreparedQueryCandidate(
3029-
selected: ReturnType<typeof selectRuntimeProfile>,
3030-
candidate: BusinessTableCandidate,
3031-
question: string,
3032-
maxColumns: number,
3033-
rowLimit: number
3034-
) {
3035-
const schema = metadataSchema(selected.config, candidate.schema);
3036-
assertTableAccess(schema, candidate.table, policyContext(selected.config));
3037-
const columns = filterColumnMetadata(
3038-
await withQuerySlot(selected, () =>
3039-
describeTable(selected.pool, selected.config.compatibility, candidate.table, schema, selected.config.queryTimeoutMs)
3040-
),
3041-
selected.config.policy
3042-
);
3043-
const constraints = filterColumnMetadata(
3044-
await withQuerySlot(selected, () =>
3045-
listConstraints(selected.pool, selected.config.compatibility, candidate.table, schema, selected.config.queryTimeoutMs)
3046-
),
3047-
selected.config.policy
3048-
);
3049-
const relationshipResult = await withQuerySlot(selected, () =>
3050-
findRelatedTables(selected.pool, selected.config.compatibility, candidate.table, schema, 1, 20, selected.config.queryTimeoutMs)
3051-
);
3052-
const relationships = relationshipResult.relations.filter((relation) =>
3053-
isRelationshipAllowed(relation, selected.config) &&
3054-
relation.sourceColumns.every((column) => !isDeniedColumn(column, selected.config.policy)) &&
3055-
relation.targetColumns.every((column) => !isDeniedColumn(column, selected.config.policy))
3056-
);
3057-
const compactColumns = compactSchemaColumns(columns, constraints, selected.config, schema, candidate.table);
3058-
const primaryKeys = schemaMapPrimaryKeyColumns(constraints);
3059-
const timeFields = compactColumns.filter((column) => column.roles.includes("time")).map((column) => column.name);
3060-
const statusFields = compactColumns.filter((column) => column.roles.includes("status")).map((column) => column.name);
3061-
const generated = generateQueryTemplates({
3062-
mode: selected.config.compatibility,
3063-
schema,
3064-
table: candidate.table,
3065-
columns,
3066-
constraints,
3067-
relationships,
3068-
scenarios: prepareQueryScenarios(question),
3069-
maxColumns,
3070-
rowLimit
3071-
});
3072-
const templates = generated.templates.slice(0, 5).map((template) => ({
3073-
...template,
3074-
policy: explainGeneratedQuery(template.sql, template.parameters.length, selected.config)
3075-
}));
3076-
const matchedColumns = candidate.matchedColumns.filter((column) => !isDeniedColumn(column, selected.config.policy));
3077-
3078-
return {
3079-
schema,
3080-
table: candidate.table,
3081-
confidence: candidate.confidence,
3082-
sources: candidate.sources,
3083-
terms: candidate.terms,
3084-
matchedColumns,
3085-
evidence: candidate.evidence.slice(0, 20),
3086-
fields: compactColumns.slice(0, Math.max(1, Math.min(maxColumns, 50))),
3087-
fieldCount: compactColumns.length,
3088-
primaryKeys,
3089-
timeFields,
3090-
statusFields,
3091-
relatedTables: relationships.slice(0, 8).map((relation) => relatedTableEvidence(candidate, relation, selected.config.compatibility)).filter(Boolean),
3092-
queryTemplates: templates,
3093-
suggestedSql: templates[0] ?? null,
3094-
pagination: preparePaginationSuggestion(rowLimit, primaryKeys, timeFields),
3095-
riskPreview: {
3096-
executed: false,
3097-
generatedSqlCount: templates.length,
3098-
allDraftsPassPolicy: templates.every((template) => template.policy.allowed),
3099-
warnings: [
3100-
...generated.warnings,
3101-
"No target query was executed. Use ob_query_preview before running a draft."
3102-
]
3103-
}
3104-
};
3105-
}
3106-
3107-
function prepareQueryScenarios(question: string): QueryTemplateScenario[] {
3108-
const text = question.toLowerCase();
3109-
const scenarios: QueryTemplateScenario[] = [];
3110-
if (/(count|total|summary|group|aggregate||||)/i.test(text)) {
3111-
scenarios.push("aggregation");
3112-
}
3113-
if (/(recent|latest|time|date|range||||)/i.test(text)) {
3114-
scenarios.push("time_range");
3115-
}
3116-
if (/(id|primary key||)/i.test(text)) {
3117-
scenarios.push("by_primary_key");
3118-
}
3119-
if (/(join|related|detail|||)/i.test(text)) {
3120-
scenarios.push("join");
3121-
}
3122-
scenarios.push("pagination", "by_primary_key", "time_range", "aggregation", "join");
3123-
return uniqueStrings(scenarios) as QueryTemplateScenario[];
3124-
}
3125-
3126-
function preparePaginationSuggestion(
3127-
rowLimit: number,
3128-
primaryKeys: string[],
3129-
timeFields: string[]
3130-
) {
3131-
const orderBy = primaryKeys[0] ?? timeFields[0];
3132-
return {
3133-
tool: "ob_query_page",
3134-
pageSize: Math.min(rowLimit, 50),
3135-
orderBy: orderBy ?? null,
3136-
stableOrder: primaryKeys.length > 0,
3137-
warning: orderBy
3138-
? primaryKeys.length > 0
3139-
? "Primary-key ordering is available for repeatable pages."
3140-
: "A time-like column is available, but ordering may not be unique."
3141-
: "No stable ordering field was detected; pass orderBy before paging."
3142-
};
3143-
}
3144-
3145-
function prepareQuerySearchTerms(
3146-
question: string,
3147-
businessTerm: string | undefined,
3148-
table: string | undefined
3149-
): string[] {
3150-
const terms = [
3151-
businessTerm,
3152-
table,
3153-
question.trim().length <= 80 ? question : undefined,
3154-
...extractAsciiQueryTerms(question),
3155-
...extractHanQueryTerms(question)
3156-
];
3157-
return uniqueStrings(
3158-
terms
3159-
.filter((value): value is string => typeof value === "string")
3160-
.map((value) => value.trim())
3161-
.filter(isUsefulQueryTerm)
3162-
).slice(0, 8);
3163-
}
3164-
3165-
function extractAsciiQueryTerms(value: string): string[] {
3166-
return value.match(/[A-Za-z0-9_]{2,}/g) ?? [];
3167-
}
3168-
3169-
function extractHanQueryTerms(value: string): string[] {
3170-
const output: string[] = [];
3171-
const runs = value.match(/[\u4e00-\u9fff]{2,}/g) ?? [];
3172-
for (const run of runs) {
3173-
for (const size of [2, 3]) {
3174-
for (let index = 0; index <= run.length - size; index += 1) {
3175-
output.push(run.slice(index, index + size));
3176-
if (output.length >= 24) {
3177-
return output;
3178-
}
3179-
}
3180-
}
3181-
}
3182-
return output;
3183-
}
3184-
3185-
function isUsefulQueryTerm(value: string): boolean {
3186-
const normalized = normalizeGlossaryText(value);
3187-
return normalized.length >= 2 && !QUERY_TERM_STOPWORDS.has(normalized);
3188-
}
3189-
31902869
function uniqueTableRefs(refs: Array<{ schema?: string; table: string }>): Array<{ schema?: string; table: string }> {
31912870
const output: Array<{ schema?: string; table: string }> = [];
31922871
const seen = new Set<string>();

0 commit comments

Comments
 (0)