Skip to content

Commit 2134d79

Browse files
committed
fix(tools): resolve --builtin-tools not registering tools and surface skipped UDTF columns
The --builtin-tools CLI flag was not registering built-in tools due to an ES module evaluation timing bug: allToolDefinitions was computed as a top-level constant at import time, before applyCliOverrides() could set config.ibmi_enableDefaultTools = true. Changed to a function (getAllToolDefinitions) evaluated at registration time. Also fixed executeSql.tool.ts enabled callback to check live config values as fallback, since toolConfig.enabled was captured at load time. Added a `skipped` field to validate_query's column validation output so UDTF/CTE unqualified columns are explicitly reported rather than silently ignored. This surfaces column names like bogus typos that callers should manually verify. Signed-off-by: Adam Shedivy <ajshedivyaj@gmail.com>
1 parent 8a65636 commit 2134d79

5 files changed

Lines changed: 162 additions & 41 deletions

File tree

server/src/ibmi-mcp-server/tools/executeSql.tool.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,5 +477,8 @@ export const executeSqlTool = defineTool({
477477
destructiveHint: !(toolConfig.security?.readOnly ?? true), // Destructive if not read-only
478478
openWorldHint: !(toolConfig.security?.readOnly ?? true), // Open world if not read-only
479479
},
480-
enabled: () => toolConfig.enabled, // Use function to dynamically check enabled state
480+
enabled: () =>
481+
toolConfig.enabled ||
482+
config.ibmi_enableExecuteSql ||
483+
config.ibmi_enableDefaultTools, // Check both toolConfig and live config for CLI override support
481484
});

server/src/ibmi-mcp-server/tools/index.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,37 @@ import { validateQueryTool } from "./validateQuery.tool.js";
1818
import { getRelatedObjectsTool } from "./getRelatedObjects.tool.js";
1919

2020
/**
21-
* Default text-to-SQL toolset, enabled via IBMI_ENABLE_DEFAULT_TOOLS or --builtin-tools CLI flag (default: false).
22-
* These tools provide schema discovery and query validation for LLM workflows:
21+
* Returns all tool definitions for automated registration.
22+
*
23+
* This is a function (not a constant) because the config values it depends on
24+
* (e.g., ibmi_enableDefaultTools) may be set by CLI argument overrides that
25+
* run after ES module evaluation. A top-level constant would capture the
26+
* pre-override defaults and miss CLI flags like --builtin-tools.
27+
*
28+
* The default text-to-SQL toolset is enabled via IBMI_ENABLE_DEFAULT_TOOLS
29+
* or --builtin-tools CLI flag (default: false). These tools provide schema
30+
* discovery and query validation for LLM workflows:
2331
* list_schemas → list_tables_in_schema → get_table_columns → get_related_objects → validate_query → execute_sql
24-
*/
25-
const defaultTools = config.ibmi_enableDefaultTools
26-
? [
27-
listSchemasTool,
28-
listTablesInSchemaTool,
29-
getTableColumnsTool,
30-
getRelatedObjectsTool,
31-
validateQueryTool,
32-
]
33-
: [];
34-
35-
/**
36-
* Array of all tool definitions for automated registration.
3732
*
3833
* To add a new tool:
3934
* 1. Create the tool definition file (e.g., myTool.tool.ts)
4035
* 2. Import it above
41-
* 3. Add it to this array
42-
*
43-
* The ToolRegistry will automatically register all tools in this array.
36+
* 3. Add it to the returned array
4437
*/
45-
export const allToolDefinitions = [
46-
executeSqlTool, // Controlled by IBMI_ENABLE_EXECUTE_SQL or IBMI_ENABLE_DEFAULT_TOOLS
47-
generateSqlTool, // Always available (describe_sql_object)
48-
...defaultTools, // Conditionally included default toolset
49-
];
38+
export function getAllToolDefinitions() {
39+
const defaultTools = config.ibmi_enableDefaultTools
40+
? [
41+
listSchemasTool,
42+
listTablesInSchemaTool,
43+
getTableColumnsTool,
44+
getRelatedObjectsTool,
45+
validateQueryTool,
46+
]
47+
: [];
48+
49+
return [
50+
executeSqlTool, // Controlled by IBMI_ENABLE_EXECUTE_SQL or IBMI_ENABLE_DEFAULT_TOOLS
51+
generateSqlTool, // Always available (describe_sql_object)
52+
...defaultTools, // Conditionally included default toolset
53+
];
54+
}

server/src/ibmi-mcp-server/tools/validateQuery.tool.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ interface RoutineRef {
4040

4141
interface ObjectValidation {
4242
tables: { valid: string[]; invalid: string[] };
43-
columns: { valid: string[]; invalid: string[] };
43+
columns: { valid: string[]; invalid: string[]; skipped?: string[] };
4444
routines: { valid: string[]; invalid: string[] };
4545
}
4646

@@ -83,6 +83,12 @@ const ObjectValidationSchema = z.object({
8383
invalid: z
8484
.array(z.string())
8585
.describe("Columns not found in any referenced table."),
86+
skipped: z
87+
.array(z.string())
88+
.optional()
89+
.describe(
90+
"Columns skipped during validation (e.g., unqualified columns from UDTFs or CTE aliases that cannot be verified against the system catalog).",
91+
),
8692
}),
8793
routines: z.object({
8894
valid: z
@@ -394,16 +400,28 @@ export async function validateQueryLogic(
394400
validateRoutines(refs.routines, appContext),
395401
]);
396402

397-
// CTE aliases and UDTF output columns appear as unqualified COLUMNs.
398-
// When virtual columns are possible, only validate qualified columns
399-
// (where PARSE_STATEMENT resolved SCHEMA + NAME to a physical table).
403+
// CTE aliases and UDTF output columns appear as unqualified COLUMNs
404+
// in PARSE_STATEMENT results. These cannot be verified against
405+
// SYSCOLUMNS, so we skip them but report which columns were skipped.
400406
const hasVirtualColumns =
401407
refs.hasVirtualColumns || CTE_REGEX.test(params.sql_statement);
402408

409+
let skippedColumns: string[] | undefined;
403410
const columnsToValidate = hasVirtualColumns
404411
? refs.columns.filter((c) => c.schema && c.tableName)
405412
: refs.columns;
406413

414+
if (hasVirtualColumns) {
415+
const skipped = refs.columns.filter(
416+
(c) => !c.schema || !c.tableName,
417+
);
418+
if (skipped.length > 0) {
419+
skippedColumns = [
420+
...new Set(skipped.map((c) => c.columnName)),
421+
];
422+
}
423+
}
424+
407425
const columnValidation = await validateColumns(
408426
columnsToValidate,
409427
validTableRefs,
@@ -412,7 +430,10 @@ export async function validateQueryLogic(
412430

413431
objectValidation = {
414432
tables: tableValidation,
415-
columns: columnValidation,
433+
columns: {
434+
...columnValidation,
435+
...(skippedColumns ? { skipped: skippedColumns } : {}),
436+
},
416437
routines: routineValidation,
417438
};
418439
}
@@ -506,6 +527,7 @@ const validateQueryResponseFormatter = (
506527
const invalidTables = ov?.tables.invalid ?? [];
507528
const invalidColumns = ov?.columns.invalid ?? [];
508529
const invalidRoutines = ov?.routines.invalid ?? [];
530+
const skippedColumns = ov?.columns.skipped ?? [];
509531

510532
const parts: string[] = [];
511533

@@ -535,6 +557,13 @@ const validateQueryResponseFormatter = (
535557
parts.push(warnings.join("\n"));
536558
}
537559

560+
// Report columns that were skipped during validation
561+
if (skippedColumns.length > 0) {
562+
parts.push(
563+
`Skipped column validation: ${skippedColumns.join(", ")} (from UDTF output or CTE alias — not present in SYSCOLUMNS). Verify these column names match the function's result set.`,
564+
);
565+
}
566+
538567
parts.push(`Execution time: ${result.executionTime}ms`);
539568

540569
const resultJson = JSON.stringify(result.data, null, 2);

server/src/mcp-server/tools/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11-
import { allToolDefinitions } from "@/ibmi-mcp-server/tools/index.js";
11+
import { getAllToolDefinitions } from "@/ibmi-mcp-server/tools/index.js";
1212
import { registerToolFromDefinition } from "./utils/tool-factory.js";
1313
import { logger, requestContextService } from "../../utils/index.js";
1414
import {
@@ -19,19 +19,23 @@ import { config } from "@/config/index.js";
1919

2020
/**
2121
* Registers all factory pattern tools with the MCP server.
22-
* Tools are defined in the allToolDefinitions array.
22+
* Tools are defined by getAllToolDefinitions().
2323
*/
2424
export async function registerAllTools(server: McpServer): Promise<void> {
2525
const context = requestContextService.createRequestContext({
2626
operation: "RegisterAllTools",
2727
});
2828

29+
// Evaluate tool definitions at registration time (not module load time)
30+
// so that CLI overrides like --builtin-tools are reflected.
31+
const toolDefinitions = getAllToolDefinitions();
32+
2933
logOperationStart(
3034
context,
31-
`Registering ${allToolDefinitions.length} factory pattern tools`,
35+
`Registering ${toolDefinitions.length} factory pattern tools`,
3236
);
3337

34-
for (const toolDef of allToolDefinitions) {
38+
for (const toolDef of toolDefinitions) {
3539
if (
3640
toolDef.name === "execute_sql" &&
3741
!config.ibmi_enableExecuteSql &&
@@ -49,6 +53,6 @@ export async function registerAllTools(server: McpServer): Promise<void> {
4953

5054
logOperationSuccess(
5155
context,
52-
`Successfully registered ${allToolDefinitions.length} factory pattern tools`,
56+
`Successfully registered ${toolDefinitions.length} factory pattern tools`,
5357
);
5458
}

server/tests/unit/tools/defaultTools.test.ts

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,84 @@ describe("Default Tools - validate_query", () => {
12851285
]);
12861286
// Unqualified UDTF output columns should NOT be flagged as invalid
12871287
expect(result.objectValidation!.columns.invalid).toEqual([]);
1288+
// They should be reported as skipped so callers know they weren't verified
1289+
expect(result.objectValidation!.columns.skipped).toEqual([
1290+
"OBJNAME",
1291+
"OBJSIZE",
1292+
]);
1293+
});
1294+
1295+
it("should report bogus UDTF column names as skipped", async () => {
1296+
const { validateQueryTool } =
1297+
await import("../../../src/ibmi-mcp-server/tools/validateQuery.tool.js");
1298+
1299+
// Simulates a query with a typo in a UDTF output column name
1300+
// e.g., ENTRY_TIMESTAMPdsadasdas instead of ENTRY_TIMESTAMP
1301+
const parseData = [
1302+
{
1303+
NAME_TYPE: "FUNCTION",
1304+
NAME: "AUDIT_JOURNAL_CP",
1305+
SCHEMA: "SYSTOOLS",
1306+
COLUMN_NAME: null,
1307+
USAGE_TYPE: "QUERY",
1308+
SQL_STATEMENT_TYPE: "QUERY",
1309+
},
1310+
{
1311+
NAME_TYPE: "COLUMN",
1312+
NAME: null,
1313+
SCHEMA: null,
1314+
COLUMN_NAME: "ENTRY_TIMESTAMPdsadasdas",
1315+
USAGE_TYPE: "QUERY",
1316+
SQL_STATEMENT_TYPE: "QUERY",
1317+
},
1318+
{
1319+
NAME_TYPE: "COLUMN",
1320+
NAME: null,
1321+
SCHEMA: null,
1322+
COLUMN_NAME: "USER_NAME",
1323+
USAGE_TYPE: "QUERY",
1324+
SQL_STATEMENT_TYPE: "QUERY",
1325+
},
1326+
{
1327+
NAME_TYPE: "COLUMN",
1328+
NAME: null,
1329+
SCHEMA: null,
1330+
COLUMN_NAME: "CHANGED_PROFILE",
1331+
USAGE_TYPE: "QUERY",
1332+
SQL_STATEMENT_TYPE: "QUERY",
1333+
},
1334+
];
1335+
const routinesData = [
1336+
{ ROUTINE_SCHEMA: "SYSTOOLS", ROUTINE_NAME: "AUDIT_JOURNAL_CP" },
1337+
];
1338+
1339+
mockExecuteQuery
1340+
.mockResolvedValueOnce(createMockQueryResult(parseData))
1341+
.mockResolvedValueOnce(createMockQueryResult(routinesData));
1342+
1343+
const result = await validateQueryTool.logic(
1344+
{
1345+
sql_statement:
1346+
"SELECT ENTRY_TIMESTAMPdsadasdas, USER_NAME, CHANGED_PROFILE FROM TABLE(SYSTOOLS.AUDIT_JOURNAL_CP(STARTING_TIMESTAMP => CURRENT_TIMESTAMP - 180 DAYS))",
1347+
},
1348+
context,
1349+
mockSdkContext,
1350+
);
1351+
1352+
expect(result.success).toBe(true);
1353+
expect(result.objectValidation!.routines.valid).toEqual([
1354+
"SYSTOOLS.AUDIT_JOURNAL_CP",
1355+
]);
1356+
// No columns should be invalid (they're skipped, not validated)
1357+
expect(result.objectValidation!.columns.invalid).toEqual([]);
1358+
// All unqualified UDTF columns — including the bogus one — should be skipped
1359+
expect(result.objectValidation!.columns.skipped).toContain(
1360+
"ENTRY_TIMESTAMPdsadasdas",
1361+
);
1362+
expect(result.objectValidation!.columns.skipped).toContain("USER_NAME");
1363+
expect(result.objectValidation!.columns.skipped).toContain(
1364+
"CHANGED_PROFILE",
1365+
);
12881366
});
12891367

12901368
it("should skip unqualified column validation for CTE queries", async () => {
@@ -1417,8 +1495,10 @@ describe("Default Tools - validate_query", () => {
14171495
expect(result.objectValidation!.columns.valid).toContain("CUSNUM");
14181496
// Qualified FAKE_COL is still caught as invalid
14191497
expect(result.objectValidation!.columns.invalid).toContain("FAKE_COL");
1420-
// Unqualified OBJNAME (UDTF output) is NOT flagged
1498+
// Unqualified OBJNAME (UDTF output) is NOT flagged as invalid
14211499
expect(result.objectValidation!.columns.invalid).not.toContain("OBJNAME");
1500+
// It should be reported as skipped instead
1501+
expect(result.objectValidation!.columns.skipped).toContain("OBJNAME");
14221502
});
14231503
});
14241504

@@ -1659,10 +1739,10 @@ describe("Default Tools - get_related_objects", () => {
16591739

16601740
describe("Default Tools - Tool Registry", () => {
16611741
it("should include default tools when IBMI_ENABLE_DEFAULT_TOOLS is true", async () => {
1662-
const { allToolDefinitions } =
1742+
const { getAllToolDefinitions } =
16631743
await import("../../../src/ibmi-mcp-server/tools/index.js");
16641744

1665-
const toolNames = allToolDefinitions.map((t) => t.name);
1745+
const toolNames = getAllToolDefinitions().map((t) => t.name);
16661746
expect(toolNames).toContain("list_schemas");
16671747
expect(toolNames).toContain("list_tables_in_schema");
16681748
expect(toolNames).toContain("get_table_columns");
@@ -1673,14 +1753,14 @@ describe("Default Tools - Tool Registry", () => {
16731753
});
16741754

16751755
it("should have 7 total tools when defaults are enabled", async () => {
1676-
const { allToolDefinitions } =
1756+
const { getAllToolDefinitions } =
16771757
await import("../../../src/ibmi-mcp-server/tools/index.js");
16781758

1679-
expect(allToolDefinitions).toHaveLength(7);
1759+
expect(getAllToolDefinitions()).toHaveLength(7);
16801760
});
16811761

16821762
it("should mark all default tools as read-only", async () => {
1683-
const { allToolDefinitions } =
1763+
const { getAllToolDefinitions } =
16841764
await import("../../../src/ibmi-mcp-server/tools/index.js");
16851765

16861766
const defaultToolNames = [
@@ -1692,7 +1772,7 @@ describe("Default Tools - Tool Registry", () => {
16921772
];
16931773

16941774
for (const name of defaultToolNames) {
1695-
const tool = allToolDefinitions.find((t) => t.name === name);
1775+
const tool = getAllToolDefinitions().find((t) => t.name === name);
16961776
expect(tool, `Tool ${name} should exist`).toBeDefined();
16971777
expect(
16981778
tool?.annotations?.readOnlyHint,

0 commit comments

Comments
 (0)