Skip to content

Commit 0bd5348

Browse files
Copilotdata-douser
andauthored
[UPDATE PRIMITIVE] Normalize camelCase params to kebab-case with actionable error messages for CLI tools (#224)
* Initial plan * feat: improve error messages for unrecognized CLI tool params and accept camelCase aliases Add parameter normalization layer (buildEnhancedToolSchema) that: - Silently normalizes camelCase/snake_case keys to kebab-case equivalents (e.g. sourceRoot → source-root) - Rejects truly unknown properties with the property name in the error and a "did you mean?" suggestion when a close match exists - Applies to all 16+ CLI tools that use kebab-case parameter names Closes #208 (Area 2) Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/5ed1c1a7-10c5-4e53-8454-128d1d6e46ae Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> * [UPDATE PRIMITIVE] Fix ZodEffects schema rejection in CLI tool registration and resolve_queries parameter name (#225) * fix: use registerTool() instead of deprecated tool() to fix ZodEffects schema rejection by MCP SDK The MCP SDK's tool() method argument parsing rejects ZodEffects schemas (from buildEnhancedToolSchema) as 'unrecognized objects' because its isZodRawShapeCompat() check returns false for Zod schema instances. registerTool() passes inputSchema directly to getZodSchemaObject(), which correctly recognises any Zod schema instance, avoiding the error: 'Tool codeql_bqrs_decode expected a Zod schema or ToolAnnotations, but received an unrecognized object' Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/ba14c9e0-173d-49fd-820b-b77bfdd973b0 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> * fix: use correct parameter name 'directory' instead of 'path' for codeql_resolve_queries integration test The codeql_resolve_queries tool schema defines 'directory' as the parameter for specifying the directory to search for queries, but the integration test runner was sending 'path' which was rejected as an unknown property by the parameter normalization layer. Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/ba14c9e0-173d-49fd-820b-b77bfdd973b0 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> * fix: capture suggestion alongside unknownEntries for "did you mean?" hints The hint is now stored at determination time rather than re-computed in the error loop (where it was always undefined). When both kebab-case and camelCase forms are provided (e.g. source-root + sourceRoot), the duplicate is now rejected with a "did you mean?" suggestion instead of being silently ignored. Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/02a09bd4-39e4-4e83-968e-8562405a9f4c Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> * More fixes for PR review feedback --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> Co-authored-by: Nathan Randall <data-douser@github.com>
1 parent 2cf8b49 commit 0bd5348

File tree

8 files changed

+584
-65
lines changed

8 files changed

+584
-65
lines changed

client/src/lib/integration-test-runner.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,8 +1084,8 @@ export class IntegrationTestRunner {
10841084
throw new Error(`Test directory ${beforeDir} not found for ${toolName}/${testCase}`);
10851085
}
10861086
} else if (toolName === "codeql_resolve_queries") {
1087-
// Use the test case directory as the queries path
1088-
params.path = beforeDir;
1087+
// Use the static examples directory which already contains installed QL packs
1088+
params.directory = path.join(staticPath, "src");
10891089
} else if (toolName === "codeql_resolve_tests") {
10901090
// Use the test case directory as the tests path
10911091
params.tests = [beforeDir];

server/dist/codeql-development-mcp-server.js

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188583,6 +188583,55 @@ function getOrCreateLogDirectory(logDir) {
188583188583
return uniqueLogDir;
188584188584
}
188585188585

188586+
// src/lib/param-normalization.ts
188587+
function camelToKebabCase(key) {
188588+
return key.replace(/[A-Z]/g, (ch) => "-" + ch.toLowerCase());
188589+
}
188590+
function kebabToCamelCase(key) {
188591+
return key.replace(/-([a-z])/g, (_match, ch) => ch.toUpperCase());
188592+
}
188593+
function suggestPropertyName(key, knownKeys) {
188594+
const kebab = camelToKebabCase(key);
188595+
if (kebab !== key && knownKeys.has(kebab)) return kebab;
188596+
const snakeToKebab = key.replace(/_/g, "-");
188597+
if (snakeToKebab !== key && knownKeys.has(snakeToKebab)) return snakeToKebab;
188598+
const camel = kebabToCamelCase(key);
188599+
if (camel !== key && knownKeys.has(camel)) return camel;
188600+
return void 0;
188601+
}
188602+
function buildEnhancedToolSchema(shape) {
188603+
const knownKeys = new Set(Object.keys(shape));
188604+
return external_exports.object(shape).passthrough().transform((data, ctx) => {
188605+
const normalized = {};
188606+
const unknownEntries = [];
188607+
for (const [key, value] of Object.entries(data)) {
188608+
if (knownKeys.has(key)) {
188609+
normalized[key] = value;
188610+
} else {
188611+
const suggestion = suggestPropertyName(key, knownKeys);
188612+
if (suggestion && !(suggestion in data) && !(suggestion in normalized)) {
188613+
normalized[suggestion] = value;
188614+
} else {
188615+
const isDuplicate = !!suggestion && (suggestion in data || suggestion in normalized);
188616+
unknownEntries.push({ key, hint: suggestion, isDuplicate });
188617+
}
188618+
}
188619+
}
188620+
for (const { key, hint, isDuplicate } of unknownEntries) {
188621+
const message = isDuplicate && hint ? `duplicate property: both '${key}' and its canonical form '${hint}' were provided; use only '${hint}'` : hint ? `unknown property '${key}' \u2014 did you mean '${hint}'?` : `unknown property '${key}'`;
188622+
ctx.addIssue({
188623+
code: external_exports.ZodIssueCode.custom,
188624+
message,
188625+
path: [key]
188626+
});
188627+
}
188628+
if (unknownEntries.length > 0) {
188629+
return external_exports.NEVER;
188630+
}
188631+
return normalized;
188632+
});
188633+
}
188634+
188586188635
// src/lib/query-resolver.ts
188587188636
init_package_paths();
188588188637
import { basename as basename3 } from "path";
@@ -193491,10 +193540,18 @@ function registerCLITool(server, definition) {
193491193540
inputSchema,
193492193541
resultProcessor = defaultCLIResultProcessor
193493193542
} = definition;
193494-
server.tool(
193543+
const enhancedSchema = buildEnhancedToolSchema(inputSchema);
193544+
server.registerTool(
193495193545
name,
193496-
description,
193497-
inputSchema,
193546+
{
193547+
description,
193548+
// The enhanced schema is a pre-built ZodEffects (passthrough + transform).
193549+
// Using registerTool() instead of tool() because the latter's argument
193550+
// parsing rejects ZodEffects as "unrecognized objects". registerTool()
193551+
// passes inputSchema directly to getZodSchemaObject(), which correctly
193552+
// recognises any Zod schema instance.
193553+
inputSchema: enhancedSchema
193554+
},
193498193555
async (params) => {
193499193556
const tempDirsToCleanup = [];
193500193557
try {

server/dist/codeql-development-mcp-server.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/src/lib/cli-tool-registry.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CLIExecutionResult, executeCodeQLCommand, executeQLTCommand } from './c
88
import { readDatabaseMetadata, resolveDatabasePath } from './database-resolver';
99
import { logger } from '../utils/logger';
1010
import { getOrCreateLogDirectory } from './log-directory-manager';
11+
import { buildEnhancedToolSchema } from './param-normalization';
1112
import { resolveQueryPath } from './query-resolver';
1213
import { cacheDatabaseAnalyzeResults, processQueryRunResults } from './result-processor';
1314
import { getUserWorkspaceDir, packageRootDir } from '../utils/package-paths';
@@ -91,7 +92,14 @@ export const defaultCLIResultProcessor = (
9192
};
9293

9394
/**
94-
* Register a CLI tool with the MCP server
95+
* Register a CLI tool with the MCP server.
96+
*
97+
* The raw `inputSchema` shape is wrapped by {@link buildEnhancedToolSchema}
98+
* so that:
99+
* - camelCase / snake_case variants of kebab-case keys are silently
100+
* normalised (e.g. `sourceRoot` → `source-root`);
101+
* - truly unknown properties are rejected with a helpful error that names
102+
* the unrecognized key and, where possible, suggests the correct name.
95103
*/
96104
export function registerCLITool(server: McpServer, definition: CLIToolDefinition): void {
97105
const {
@@ -103,10 +111,21 @@ export function registerCLITool(server: McpServer, definition: CLIToolDefinition
103111
resultProcessor = defaultCLIResultProcessor
104112
} = definition;
105113

106-
server.tool(
114+
// Build enhanced schema that normalises property-name variants and
115+
// produces actionable error messages for truly unknown keys.
116+
const enhancedSchema = buildEnhancedToolSchema(inputSchema);
117+
118+
server.registerTool(
107119
name,
108-
description,
109-
inputSchema,
120+
{
121+
description,
122+
// The enhanced schema is a pre-built ZodEffects (passthrough + transform).
123+
// Using registerTool() instead of tool() because the latter's argument
124+
// parsing rejects ZodEffects as "unrecognized objects". registerTool()
125+
// passes inputSchema directly to getZodSchemaObject(), which correctly
126+
// recognises any Zod schema instance.
127+
inputSchema: enhancedSchema,
128+
},
110129
async (params: Record<string, unknown>) => {
111130
// Track temporary directories for cleanup
112131
const tempDirsToCleanup: string[] = [];
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Parameter normalization utilities for CLI tool schemas.
3+
*
4+
* Provides camelCase → kebab-case key normalization and
5+
* "did you mean?" suggestions for unrecognized property names.
6+
*/
7+
8+
import { z } from 'zod';
9+
10+
// ─── String-case conversion helpers ──────────────────────────────────────────
11+
12+
/**
13+
* Convert a camelCase string to kebab-case.
14+
* Example: "sourceRoot" → "source-root"
15+
*/
16+
export function camelToKebabCase(key: string): string {
17+
return key.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase());
18+
}
19+
20+
/**
21+
* Convert a kebab-case string to camelCase.
22+
* Example: "source-root" → "sourceRoot"
23+
*/
24+
export function kebabToCamelCase(key: string): string {
25+
return key.replace(/-([a-z])/g, (_match, ch: string) => ch.toUpperCase());
26+
}
27+
28+
// ─── Suggestion logic ────────────────────────────────────────────────────────
29+
30+
/**
31+
* Given an unrecognized property name and the set of known schema keys,
32+
* return the most likely intended key (or `undefined` if no close match).
33+
*
34+
* Resolution order:
35+
* 1. camelCase → kebab-case (e.g. "sourceRoot" → "source-root")
36+
* 2. snake_case → kebab-case (e.g. "source_root" → "source-root")
37+
* 3. kebab-case → camelCase (e.g. "source-root" → "sourceRoot")
38+
*/
39+
export function suggestPropertyName(
40+
key: string,
41+
knownKeys: ReadonlySet<string>,
42+
): string | undefined {
43+
// 1. camelCase → kebab-case
44+
const kebab = camelToKebabCase(key);
45+
if (kebab !== key && knownKeys.has(kebab)) return kebab;
46+
47+
// 2. snake_case → kebab-case
48+
const snakeToKebab = key.replace(/_/g, '-');
49+
if (snakeToKebab !== key && knownKeys.has(snakeToKebab)) return snakeToKebab;
50+
51+
// 3. kebab-case → camelCase
52+
const camel = kebabToCamelCase(key);
53+
if (camel !== key && knownKeys.has(camel)) return camel;
54+
55+
return undefined;
56+
}
57+
58+
// ─── Schema builder ──────────────────────────────────────────────────────────
59+
60+
/**
61+
* Build an enhanced Zod schema from a raw tool input shape.
62+
*
63+
* The returned schema:
64+
* - Accepts additional (unknown) properties without client-side rejection
65+
* (`passthrough` mode → JSON Schema `additionalProperties: true`).
66+
* - Normalizes camelCase / snake_case keys to their kebab-case equivalents
67+
* when a matching schema key exists.
68+
* - Rejects truly unknown properties with a helpful error that names the
69+
* unrecognized key and, when possible, suggests the correct name.
70+
*/
71+
export function buildEnhancedToolSchema(
72+
shape: Record<string, z.ZodTypeAny>,
73+
): z.ZodTypeAny {
74+
const knownKeys = new Set(Object.keys(shape));
75+
76+
return z
77+
.object(shape)
78+
.passthrough()
79+
.transform((data, ctx) => {
80+
const normalized: Record<string, unknown> = {};
81+
const unknownEntries: Array<{ key: string; hint?: string; isDuplicate: boolean }> = [];
82+
83+
for (const [key, value] of Object.entries(data)) {
84+
if (knownKeys.has(key)) {
85+
// Known key — keep as-is
86+
normalized[key] = value;
87+
} else {
88+
// Try to find a kebab-case equivalent
89+
const suggestion = suggestPropertyName(key, knownKeys);
90+
if (suggestion && !(suggestion in data) && !(suggestion in normalized)) {
91+
// Silently normalize to the canonical kebab-case key
92+
normalized[suggestion] = value;
93+
} else {
94+
// Either no suggestion (truly unknown) or the canonical key is
95+
// already present. Capture the suggestion so the error message
96+
// can include a helpful hint.
97+
const isDuplicate = !!suggestion && (suggestion in data || suggestion in normalized);
98+
unknownEntries.push({ key, hint: suggestion, isDuplicate });
99+
}
100+
}
101+
}
102+
103+
// Report unknown / duplicate properties with actionable messages
104+
for (const { key, hint, isDuplicate } of unknownEntries) {
105+
const message = isDuplicate && hint
106+
? `duplicate property: both '${key}' and its canonical form '${hint}' were provided; use only '${hint}'`
107+
: hint
108+
? `unknown property '${key}' — did you mean '${hint}'?`
109+
: `unknown property '${key}'`;
110+
ctx.addIssue({
111+
code: z.ZodIssueCode.custom,
112+
message,
113+
path: [key],
114+
});
115+
}
116+
117+
if (unknownEntries.length > 0) {
118+
return z.NEVER;
119+
}
120+
121+
return normalized;
122+
});
123+
}

0 commit comments

Comments
 (0)