Skip to content

Commit 1681297

Browse files
committed
refactor: move data quality tool
1 parent f7b2e2b commit 1681297

2 files changed

Lines changed: 233 additions & 181 deletions

File tree

src/mcp/server.ts

Lines changed: 3 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,7 @@ import { metadataSchema, metadataTableRef } from "./metadata-scope.js";
7070
import { policyContext } from "./policy-context.js";
7171
import {
7272
buildSelectColumnInfos,
73-
buildStructuredSelect,
74-
getBuildColumn
73+
buildStructuredSelect
7574
} from "./query-builder.js";
7675
import { registerMcpResources } from "./resources.js";
7776
import {
@@ -121,6 +120,7 @@ import { registerQuerySessionTools } from "./tools/query-sessions.js";
121120
import { registerQueryTemplateTools } from "./tools/query-templates.js";
122121
import { registerRecentRowsTools } from "./tools/recent-rows.js";
123122
import { registerSearchCatalogTools } from "./tools/search-catalog.js";
123+
import { registerDataQualityTools } from "./tools/data-quality.js";
124124
import { registerProfileColumnTools } from "./tools/profile-columns.js";
125125
import { registerValueDistributionTools } from "./tools/profile-value-distribution.js";
126126
import {
@@ -324,87 +324,7 @@ export function createObMcpServer(
324324

325325
registerValueDistributionTools(registerTool, runtime, context);
326326

327-
registerTool(
328-
"ob_check_data_quality",
329-
{
330-
title: "Check Data Quality",
331-
description: "Run bounded read-only data quality checks for nulls, duplicate key risk, dimensions, and time columns.",
332-
inputSchema: {
333-
...profileInput,
334-
table: z.string().min(1),
335-
schema: z.string().optional(),
336-
columns: z.array(z.string().min(1)).max(50).optional(),
337-
rowLimit: z.number().int().positive().max(10_000).optional(),
338-
maxColumns: z.number().int().positive().max(50).optional(),
339-
nullRatioThreshold: z.number().min(0).max(1).optional()
340-
}
341-
},
342-
async ({ profile, table, schema, columns, rowLimit, maxColumns, nullRatioThreshold }) => {
343-
const selected = selectAuthorizedProfile(runtime, profile, context);
344-
const effectiveSchema = metadataSchema(selected.config, schema);
345-
assertTableAccess(effectiveSchema, table, policyContext(selected.config));
346-
const metadata = await withQuerySlot(selected, () =>
347-
getTableDdlMetadata(selected.pool, selected.config.compatibility, table, effectiveSchema, selected.config.queryTimeoutMs)
348-
);
349-
const visibleColumns = filterColumnMetadata(metadata.columns, selected.config.policy);
350-
const visibleConstraints = filterColumnMetadata(metadata.constraints, selected.config.policy);
351-
const compactColumns = compactSchemaColumns(visibleColumns, visibleConstraints, selected.config, metadata.schema, metadata.table);
352-
const primaryKeys = schemaMapPrimaryKeyColumns(visibleConstraints);
353-
const selectedColumns = selectDataQualityColumns(selected.config, compactColumns, primaryKeys, columns, maxColumns ?? 12);
354-
const profileRowLimit = Math.min(effectiveRowLimit(rowLimit, selected.config), 10_000);
355-
const result = selectedColumns.length > 0
356-
? await withQuerySlot(selected, () =>
357-
profileColumns(
358-
selected.pool,
359-
selected.config.compatibility,
360-
metadata.schema,
361-
metadata.table,
362-
selectedColumns.map((column) => ({ name: column.name, dataType: column.type })),
363-
profileRowLimit,
364-
5,
365-
1,
366-
selected.config.queryTimeoutMs
367-
)
368-
)
369-
: undefined;
370-
const columnProfiles = result
371-
? tableProfileMaskedColumns(result, compactColumns, selected.config, metadata.schema, metadata.table)
372-
: [];
373-
const checks = buildDataQualityChecks(
374-
columnProfiles,
375-
selectedColumns,
376-
primaryKeys,
377-
nullRatioThreshold ?? 0
378-
);
379-
380-
return toLimitedResult(
381-
{
382-
mode: selected.config.compatibility,
383-
schema: metadata.schema,
384-
table: metadata.table,
385-
primaryKeys,
386-
profileScope: {
387-
rowLimit: profileRowLimit,
388-
selectedColumns: selectedColumns.map((column) => column.name),
389-
visibleColumnCount: compactColumns.length,
390-
selectedColumnCount: selectedColumns.length
391-
},
392-
checks,
393-
summary: summarizeDataQualityChecks(checks),
394-
columnProfiles,
395-
warnings: uniqueStrings([
396-
...metadata.warnings,
397-
...(result?.warnings ?? []),
398-
"Data quality checks use bounded samples; counts and ratios are not full-table exact values.",
399-
...(compactColumns.length > selectedColumns.length
400-
? [`Only ${selectedColumns.length} of ${compactColumns.length} visible columns were checked. Pass columns or maxColumns to change scope.`]
401-
: [])
402-
])
403-
},
404-
selected.config
405-
);
406-
}
407-
);
327+
registerDataQualityTools(registerTool, runtime, context);
408328

409329
registerSqlPrepareTools(registerTool, runtime, context);
410330

@@ -2103,104 +2023,6 @@ function tableRefKey(mode: ObConfig["compatibility"], schema: string | undefined
21032023
return `${schema ? normalizeTableName(mode, schema) : ""}.${normalizeTableName(mode, table)}`;
21042024
}
21052025

2106-
function selectDataQualityColumns(
2107-
config: ObConfig,
2108-
columns: Array<{ name: string; type?: string; nullable?: boolean; roles: string[]; masked: boolean }>,
2109-
primaryKeys: string[],
2110-
requestedColumns: string[] | undefined,
2111-
maxColumns: number
2112-
) {
2113-
if (requestedColumns && requestedColumns.length > 0) {
2114-
const available = columns.map((column) => ({
2115-
name: column.name,
2116-
type: column.type,
2117-
nullable: column.nullable,
2118-
masked: column.masked
2119-
}));
2120-
return requestedColumns.map((column) => {
2121-
const match = getBuildColumn(config, available, column);
2122-
const full = columns.find((item) => normalizeTableName(config.compatibility, item.name) === normalizeTableName(config.compatibility, match.name));
2123-
return full ?? { ...match, roles: [] };
2124-
});
2125-
}
2126-
return selectTableProfileColumns(columns, primaryKeys, maxColumns);
2127-
}
2128-
2129-
function buildDataQualityChecks(
2130-
profiles: Array<Record<string, unknown>>,
2131-
metadataColumns: Array<{ name: string; type?: string; nullable?: boolean; roles: string[]; masked: boolean }>,
2132-
primaryKeys: string[],
2133-
nullRatioThreshold: number
2134-
) {
2135-
const checks: Array<Record<string, unknown>> = [];
2136-
const primaryKeySet = new Set(primaryKeys.map((column) => column.toLowerCase()));
2137-
for (const profile of profiles) {
2138-
const column = stringRecordValue(profile, "column", "COLUMN") ?? "";
2139-
if (!column) {
2140-
continue;
2141-
}
2142-
const metadata = metadataColumns.find((item) => item.name.toLowerCase() === column.toLowerCase());
2143-
const sampledRows = numberRecordValue(profile, "sampledRows", "sampled_rows") ?? 0;
2144-
const nulls = profile.nulls && typeof profile.nulls === "object" && !Array.isArray(profile.nulls)
2145-
? profile.nulls as Record<string, unknown>
2146-
: {};
2147-
const nullCount = numberRecordValue(nulls, "count") ?? 0;
2148-
const nullRatio = numberRecordValue(nulls, "ratio") ?? 0;
2149-
if (nullCount > 0 && nullRatio >= nullRatioThreshold) {
2150-
checks.push({
2151-
rule: "null_values",
2152-
severity: nullRatio >= 0.5 ? "high" : "warn",
2153-
column,
2154-
evidence: [`${nullCount} of ${sampledRows} sampled rows are null.`],
2155-
ratio: nullRatio,
2156-
recommendation: "Confirm whether nulls are valid for this field before using it as a required filter or join key."
2157-
});
2158-
}
2159-
const distinct = profile.distinct && typeof profile.distinct === "object" && !Array.isArray(profile.distinct)
2160-
? numberRecordValue(profile.distinct as Record<string, unknown>, "value")
2161-
: undefined;
2162-
if (primaryKeySet.has(column.toLowerCase()) && distinct != null && sampledRows > 0 && distinct < sampledRows - nullCount) {
2163-
checks.push({
2164-
rule: "duplicate_key_risk",
2165-
severity: "high",
2166-
column,
2167-
evidence: [`Primary-key-like column has ${distinct} distinct values in ${sampledRows} sampled rows.`],
2168-
recommendation: "Review key uniqueness with a scoped query before relying on this column as a unique identifier."
2169-
});
2170-
}
2171-
if (metadata?.roles.includes("status") && distinct != null && sampledRows > 0 && distinct / sampledRows > 0.8) {
2172-
checks.push({
2173-
rule: "high_cardinality_dimension",
2174-
severity: "warn",
2175-
column,
2176-
evidence: [`Dimension-like column has ${distinct} distinct values in ${sampledRows} sampled rows.`],
2177-
recommendation: "Check whether this field is suitable for grouping, or use a different business dimension."
2178-
});
2179-
}
2180-
if (metadata?.roles.includes("time") && nullRatio > 0.1) {
2181-
checks.push({
2182-
rule: "time_column_nulls",
2183-
severity: "warn",
2184-
column,
2185-
evidence: [`Time-like column null ratio is ${nullRatio}.`],
2186-
recommendation: "Pass an explicit timeColumn when querying recent rows if this field is incomplete."
2187-
});
2188-
}
2189-
}
2190-
return checks;
2191-
}
2192-
2193-
function summarizeDataQualityChecks(checks: Array<Record<string, unknown>>) {
2194-
const high = checks.filter((check) => check.severity === "high").length;
2195-
const warn = checks.filter((check) => check.severity === "warn").length;
2196-
return {
2197-
high,
2198-
warn,
2199-
total: checks.length,
2200-
status: high > 0 ? "attention_required" : warn > 0 ? "review" : "no_sampled_issues"
2201-
};
2202-
}
2203-
22042026
function uniqueTableRefs(refs: Array<{ schema?: string; table: string }>): Array<{ schema?: string; table: string }> {
22052027
const output: Array<{ schema?: string; table: string }> = [];
22062028
const seen = new Set<string>();

0 commit comments

Comments
 (0)