Skip to content

Commit b5ceba5

Browse files
Copilotdata-douser
andauthored
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>
1 parent 13b4ddb commit b5ceba5

File tree

6 files changed

+490
-8
lines changed

6 files changed

+490
-8
lines changed

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188583,6 +188583,56 @@ 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 if (suggestion && (suggestion in data || suggestion in normalized)) {
188615+
} else {
188616+
unknownEntries.push({ key, value });
188617+
}
188618+
}
188619+
}
188620+
for (const { key } of unknownEntries) {
188621+
const hint = suggestPropertyName(key, knownKeys);
188622+
const message = hint ? `unknown property '${key}' \u2014 did you mean '${hint}'?` : `unknown property '${key}'`;
188623+
ctx.addIssue({
188624+
code: external_exports.ZodIssueCode.custom,
188625+
message,
188626+
path: [key]
188627+
});
188628+
}
188629+
if (unknownEntries.length > 0) {
188630+
return external_exports.NEVER;
188631+
}
188632+
return normalized;
188633+
});
188634+
}
188635+
188586188636
// src/lib/query-resolver.ts
188587188637
init_package_paths();
188588188638
import { basename as basename3 } from "path";
@@ -193491,10 +193541,14 @@ function registerCLITool(server, definition) {
193491193541
inputSchema,
193492193542
resultProcessor = defaultCLIResultProcessor
193493193543
} = definition;
193544+
const enhancedSchema = buildEnhancedToolSchema(inputSchema);
193494193545
server.tool(
193495193546
name,
193496193547
description,
193497-
inputSchema,
193548+
// The enhanced schema is a pre-built ZodEffects (passthrough + transform).
193549+
// The MCP SDK's getZodSchemaObject() detects it as a Zod schema instance
193550+
// and passes it through without re-wrapping.
193551+
enhancedSchema,
193498193552
async (params) => {
193499193553
const tempDirsToCleanup = [];
193500193554
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: 17 additions & 2 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,17 @@ export function registerCLITool(server: McpServer, definition: CLIToolDefinition
103111
resultProcessor = defaultCLIResultProcessor
104112
} = definition;
105113

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+
106118
server.tool(
107119
name,
108120
description,
109-
inputSchema,
121+
// The enhanced schema is a pre-built ZodEffects (passthrough + transform).
122+
// The MCP SDK's getZodSchemaObject() detects it as a Zod schema instance
123+
// and passes it through without re-wrapping.
124+
enhancedSchema as unknown as Record<string, z.ZodTypeAny>,
110125
async (params: Record<string, unknown>) => {
111126
// Track temporary directories for cleanup
112127
const tempDirsToCleanup: string[] = [];
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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; value: unknown }> = [];
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 if (suggestion && (suggestion in data || suggestion in normalized)) {
94+
// Both forms provided — prefer the canonical (kebab-case) key,
95+
// silently ignore the alias
96+
} else {
97+
unknownEntries.push({ key, value });
98+
}
99+
}
100+
}
101+
102+
// Report truly unknown properties with "did you mean?" hints
103+
for (const { key } of unknownEntries) {
104+
const hint = suggestPropertyName(key, knownKeys);
105+
const message = hint
106+
? `unknown property '${key}' — did you mean '${hint}'?`
107+
: `unknown property '${key}'`;
108+
ctx.addIssue({
109+
code: z.ZodIssueCode.custom,
110+
message,
111+
path: [key],
112+
});
113+
}
114+
115+
if (unknownEntries.length > 0) {
116+
return z.NEVER;
117+
}
118+
119+
return normalized;
120+
});
121+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,11 @@ describe('registerCLITool', () => {
204204

205205
registerCLITool(mockServer, definition);
206206

207+
// The enhanced schema wraps the raw shape in a ZodEffects (passthrough + transform)
207208
expect(mockServer.tool).toHaveBeenCalledWith(
208209
'test_codeql_tool',
209210
'Test CodeQL tool',
210-
definition.inputSchema,
211+
expect.objectContaining({ _def: expect.objectContaining({ typeName: 'ZodEffects' }) }),
211212
expect.any(Function)
212213
);
213214
});
@@ -226,10 +227,11 @@ describe('registerCLITool', () => {
226227

227228
registerCLITool(mockServer, definition);
228229

230+
// The enhanced schema wraps the raw shape in a ZodEffects (passthrough + transform)
229231
expect(mockServer.tool).toHaveBeenCalledWith(
230232
'test_qlt_tool',
231233
'Test QLT tool',
232-
definition.inputSchema,
234+
expect.objectContaining({ _def: expect.objectContaining({ typeName: 'ZodEffects' }) }),
233235
expect.any(Function)
234236
);
235237
});

0 commit comments

Comments
 (0)