@@ -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" ;
5251import { queryPreviewWarnings , queryShape } from "../query/preview.js" ;
5352import { ensureQuerySessionStore } from "../query/sessions.js" ;
54- import { generateQueryTemplates , type QueryTemplateScenario } from "../query-generation.js" ;
5553import { selectRuntimeProfile , withQuerySlot , type GatewayRuntime } from "../runtime.js" ;
5654import { extractSqlSnippetsFromContent , type SqlScanSnippet } from "../sql-scan.js" ;
5755import {
@@ -61,8 +59,6 @@ import {
6159 visibleDefaultProfile
6260} from "./access.js" ;
6361import {
64- normalizeGlossaryText ,
65- searchBusinessGlossary ,
6662 visibleGlossaryTargets
6763} from "./business-glossary.js" ;
6864import type { McpRequestContext } from "./context.js" ;
@@ -78,16 +74,6 @@ import {
7874 getBuildColumn
7975} from "./query-builder.js" ;
8076import { 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" ;
9177import {
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" ;
146137import { registerSqlRewriteTools } from "./tools/sql-rewrite.js" ;
147138import { registerSqlUsageTools } from "./tools/sql-usages.js" ;
148139import { registerTableContextTools } from "./tools/table-context.js" ;
@@ -169,30 +160,6 @@ export { effectiveRowLimit, shouldMaskColumnValue } from "../query/output.js";
169160export { isToolAllowed } from "./access.js" ;
170161export 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-
196163export 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 ( / ( c o u n t | t o t a l | s u m m a r y | g r o u p | a g g r e g a t e | 统 计 | 数 量 | 汇 总 | 聚 合 ) / i. test ( text ) ) {
3111- scenarios . push ( "aggregation" ) ;
3112- }
3113- if ( / ( r e c e n t | l a t e s t | t i m e | d a t e | r a n g e | 最 近 | 最 新 | 时 间 | 日 期 ) / i. test ( text ) ) {
3114- scenarios . push ( "time_range" ) ;
3115- }
3116- if ( / ( i d | p r i m a r y k e y | 主 键 | 编 号 ) / i. test ( text ) ) {
3117- scenarios . push ( "by_primary_key" ) ;
3118- }
3119- if ( / ( j o i n | r e l a t e d | d e t a i l | 关 联 | 联 表 | 明 细 ) / 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 - Z a - z 0 - 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-
31902869function 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