Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ _Changes on `main` since the latest tagged release that have not yet been includ

### Fixed

- **`codeql_bqrs_decode` rejected a singular `file` argument.** The tool advertised only the plural `files` (array) input, so callers (including LLM clients) that passed `{ "file": "…" }` hit a hard schema-validation error (`must have required property 'files'`) even though the registry already handled a singular `file` for BQRS tools. The tool now accepts either `file` (a single string path) or `files` (an array), and produces a clear, actionable error naming both parameters when neither is supplied. ([#303](https://github.com/advanced-security/codeql-development-mcp-server/pull/303), [#302](https://github.com/advanced-security/codeql-development-mcp-server/issues/302))
- **Rust `PrintAST` and `PrintCFG` unit tests failed on CI.** Two distinct root causes: (1) CI had no Rust toolchain installed, so the extractor could not expand `format!`/`println!`/`vec!` and the entire `getMacroCallExpansion()` subtrees were missing from `PrintAST` output; (2) the legacy rust test extractor produces non-deterministic CFG entity ordering under parallel evaluation, which made the `PrintCFG` snapshot test flaky (5 distinct outputs across 5 runs with `--threads=-1`, identical output across every run with `--threads=1`). Fixes: install Rust in CI via the composite action; regenerate the rust `PrintAST.expected` baseline; and force `--threads=1` for the rust language entry in `run-query-unit-tests.sh` so `PrintCFG` produces deterministic output. ([#279](https://github.com/advanced-security/codeql-development-mcp-server/pull/279))
- **`run-query-unit-tests.sh` broke the Swift macOS workflow with `unbound variable`.** Bash 3.2 (the default `/bin/bash` on macOS GitHub Actions runners) errors when expanding an empty array under `set -u`. Replaced the `local _threads_arg=()` array with a plain scalar string and unquoted expansion so the script is portable across Bash 3.2 and 4+. ([#279](https://github.com/advanced-security/codeql-development-mcp-server/pull/279))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# `codeql_bqrs_decode` - decode_singular_file

## Purpose

Regression test for [#302](https://github.com/advanced-security/codeql-development-mcp-server/issues/302):
the `codeql_bqrs_decode` tool must accept a singular `file` (string) argument,
not only the plural `files` (array) form. Automated/LLM clients naturally try
`file` first, and it previously failed schema validation with
`must have required property 'files'`.

This test invokes `codeql_bqrs_decode` with a singular `file` argument and
verifies that the BQRS file is decoded successfully.

## Inputs

- `before/results.bqrs` β€” BQRS file with a `#select` result set.
- `test-config.json` β€” Specifies the singular `file`, `format`, and `no-titles` arguments.

## Outputs

- Tool returns decoded CSV output for the BQRS file without column headers.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"sessions": [
{
"id": "integration_test_session",
"calls": [
{
"tool": "codeql_bqrs_decode",
"timestamp": "2025-09-25T16:06:00.000Z",
"status": "success"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"sessions": []
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"toolName": "codeql_bqrs_decode",
"arguments": {
"file": "client/integration-tests/primitives/tools/codeql_bqrs_decode/decode_singular_file/before/results.bqrs",
"format": "csv",
"no-titles": true
},
Comment thread
data-douser marked this conversation as resolved.
"assertions": {
"responseContains": [
"Example test code file found for codeql_test_extract example query."
]
}
}
8 changes: 7 additions & 1 deletion server/dist/codeql-development-mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -193980,6 +193980,11 @@ function registerCLITool(server, definition) {
}
positionalArgs = [...positionalArgs, cleanFile];
}
if (name.startsWith("codeql_bqrs_") && !positionalArgs.some((arg) => typeof arg === "string" && arg.trim() !== "")) {
throw new Error(
`The "${name}" tool requires a BQRS file path. Provide it via "file" (a single string path) or "files" (an array of string paths).`
);
}
if (qlref && name === "codeql_resolve_qlref") {
positionalArgs = [...positionalArgs, qlref];
}
Expand Down Expand Up @@ -194437,7 +194442,8 @@ var codeqlBqrsDecodeTool = {
command: "codeql",
subcommand: "bqrs decode",
inputSchema: {
files: external_exports.array(external_exports.string()).describe('Array of BQRS file paths to decode. Pass an array even for a single file, e.g. ["/path/to/results.bqrs"]'),
file: external_exports.string().optional().describe('Path to a single BQRS file to decode, e.g. "/path/to/results.bqrs". Convenience alias for `files`; provide either `file` or `files` (at least one is required).'),
files: external_exports.array(external_exports.string()).optional().describe('Array of BQRS file paths to decode, e.g. ["/path/to/results.bqrs"]. Provide either `file` (a single path) or `files` (at least one is required).'),
output: createCodeQLSchemas.output(),
format: external_exports.enum(["csv", "json", "text", "bqrs"]).optional().describe("Output format: text (human-readable table, default), csv, json (streaming JSON), or bqrs (binary, requires --output)"),
"result-set": external_exports.string().optional().describe("Decode a specific result set by name (use codeql_bqrs_info to list available sets). If omitted, all result sets are decoded."),
Expand Down
4 changes: 2 additions & 2 deletions server/dist/codeql-development-mcp-server.js.map

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions server/src/lib/cli-tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,22 @@ export function registerCLITool(server: McpServer, definition: CLIToolDefinition
positionalArgs = [...positionalArgs, cleanFile];
}

// For BQRS tools, ensure at least one BQRS file path was supplied via
// the `file` (string) or `files` (array) parameter. Both forms are
// accepted and normalized to positional arguments above; this check
// produces a clear, actionable error that names the correct parameters
// instead of a generic schema-level "required property" rejection.
// Whitespace-only paths are treated as missing so the CLI is never
// invoked with an invalid empty path.
if (
name.startsWith('codeql_bqrs_') &&
!positionalArgs.some(arg => typeof arg === 'string' && arg.trim() !== '')
) {
throw new Error(
`The "${name}" tool requires a BQRS file path. Provide it via "file" (a single string path) or "files" (an array of string paths).`,
);
}
Comment on lines +250 to +257

// Handle qlref parameter as positional argument for resolve qlref tool
if (qlref && name === 'codeql_resolve_qlref') {
positionalArgs = [...positionalArgs, qlref as string];
Expand Down
5 changes: 4 additions & 1 deletion server/src/tools/codeql/bqrs-decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export const codeqlBqrsDecodeTool: CLIToolDefinition = {
command: 'codeql',
subcommand: 'bqrs decode',
inputSchema: {
files: z.array(z.string()).describe('Array of BQRS file paths to decode. Pass an array even for a single file, e.g. ["/path/to/results.bqrs"]'),
file: z.string().optional()
.describe('Path to a single BQRS file to decode, e.g. "/path/to/results.bqrs". Convenience alias for `files`; provide either `file` or `files` (at least one is required).'),
files: z.array(z.string()).optional()
.describe('Array of BQRS file paths to decode, e.g. ["/path/to/results.bqrs"]. Provide either `file` (a single path) or `files` (at least one is required).'),
output: createCodeQLSchemas.output(),
format: z.enum(['csv', 'json', 'text', 'bqrs']).optional()
.describe('Output format: text (human-readable table, default), csv, json (streaming JSON), or bqrs (binary, requires --output)'),
Expand Down
120 changes: 120 additions & 0 deletions server/test/src/lib/cli-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1421,6 +1421,126 @@ describe('registerCLITool handler behavior', () => {
expect(positionalArgs).toContain('/path/to/results.bqrs');
});

it('should handle files array parameter as positional for codeql_bqrs_decode', async () => {
const definition: CLIToolDefinition = {
name: 'codeql_bqrs_decode',
description: 'Decode BQRS',
command: 'codeql',
subcommand: 'bqrs decode',
inputSchema: {
files: z.array(z.string()).optional()
}
};

registerCLITool(mockServer, definition);

const handler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];

executeCodeQLCommand.mockResolvedValueOnce({
stdout: '{"results": []}',
stderr: '',
success: true
});

await handler({ files: ['/path/to/results.bqrs'] });

expect(executeCodeQLCommand).toHaveBeenCalledWith(
'bqrs decode',
expect.any(Object),
['/path/to/results.bqrs'],
undefined
);
});

it('should accept either singular file or files array for codeql_bqrs_decode', async () => {
const definition: CLIToolDefinition = {
name: 'codeql_bqrs_decode',
description: 'Decode BQRS',
command: 'codeql',
subcommand: 'bqrs decode',
inputSchema: {
file: z.string().optional(),
files: z.array(z.string()).optional()
}
};

registerCLITool(mockServer, definition);

const handler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];

executeCodeQLCommand.mockResolvedValue({
stdout: '{"results": []}',
stderr: '',
success: true
});

// Singular file form
await handler({ file: '/path/to/results.bqrs' });
expect(executeCodeQLCommand).toHaveBeenLastCalledWith(
'bqrs decode',
expect.any(Object),
['/path/to/results.bqrs'],
undefined
);

// Plural files form
await handler({ files: ['/path/to/results.bqrs'] });
expect(executeCodeQLCommand).toHaveBeenLastCalledWith(
'bqrs decode',
expect.any(Object),
['/path/to/results.bqrs'],
undefined
);
});

it('should return a clear error naming file/files when no BQRS path is provided', async () => {
const definition: CLIToolDefinition = {
name: 'codeql_bqrs_decode',
description: 'Decode BQRS',
command: 'codeql',
subcommand: 'bqrs decode',
inputSchema: {
file: z.string().optional(),
files: z.array(z.string()).optional()
}
};

registerCLITool(mockServer, definition);

const handler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];

const result = await handler({});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('"file"');
expect(result.content[0].text).toContain('"files"');
expect(executeCodeQLCommand).not.toHaveBeenCalled();
});

it('should treat whitespace-only files entries as missing for BQRS tools', async () => {
const definition: CLIToolDefinition = {
name: 'codeql_bqrs_decode',
description: 'Decode BQRS',
command: 'codeql',
subcommand: 'bqrs decode',
inputSchema: {
file: z.string().optional(),
files: z.array(z.string()).optional()
}
};

registerCLITool(mockServer, definition);

const handler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];

const result = await handler({ files: [' '] });

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('"file"');
expect(result.content[0].text).toContain('"files"');
expect(executeCodeQLCommand).not.toHaveBeenCalled();
});

it('should handle tests parameter as positional for test tools', async () => {
const definition: CLIToolDefinition = {
name: 'codeql_test_run',
Expand Down
27 changes: 26 additions & 1 deletion server/test/src/tools/codeql/bqrs-decode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { describe, expect, it } from 'vitest';
import { codeqlBqrsDecodeTool } from '../../../../src/tools/codeql/bqrs-decode';
import { buildEnhancedToolSchema } from '../../../../src/lib/param-normalization';

describe('codeql_bqrs_decode tool definition', () => {
it('should have correct tool name', () => {
Expand All @@ -20,10 +21,34 @@ describe('codeql_bqrs_decode tool definition', () => {
expect(codeqlBqrsDecodeTool.subcommand).toBe('bqrs decode');
});

it('should have files as required positional input', () => {
it('should accept a singular file path via the files array input', () => {
expect(codeqlBqrsDecodeTool.inputSchema).toHaveProperty('files');
});

it('should also accept a singular file string input as an alias for files', () => {
expect(codeqlBqrsDecodeTool.inputSchema).toHaveProperty('file');
});

describe('input schema validation (file vs files)', () => {
const schema = buildEnhancedToolSchema(codeqlBqrsDecodeTool.inputSchema);

it('should accept a singular file string argument', () => {
const result = schema.safeParse({ file: '/path/to/results.bqrs', format: 'csv' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.file).toBe('/path/to/results.bqrs');
}
});

it('should accept a files array argument', () => {
const result = schema.safeParse({ files: ['/path/to/results.bqrs'], format: 'csv' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toEqual(['/path/to/results.bqrs']);
}
});
});

it('should support result-set parameter for selecting specific result sets', () => {
expect(codeqlBqrsDecodeTool.inputSchema).toHaveProperty('result-set');
});
Expand Down