Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ _Changes on `main` since the latest tagged release that have not yet been includ
- **Build-time overlay for custom VSIXes** β€” A `--customizations-dir` CLI flag (or `CODEQL_MCP_CUSTOMIZATIONS_DIR` env var) on `bundle:customizations` enables building custom VSIXes with overlay agents and skills. ([#281](https://github.com/advanced-security/codeql-development-mcp-server/pull/281))
- **`codeql-mcp.showAgentsStatus` command** β€” New Command Palette entry (**CodeQL MCP: Show Built-in Custom Agents Status**) that reports the bundled directory and the agents contributed via `contributes.chatAgents`. ([#281](https://github.com/advanced-security/codeql-development-mcp-server/pull/281))
- **Bundled skills** β€” Two skills (`ql-mcp-ext-create-workshop`, `ql-mcp-ext-validate-tools-queries`) are copied into the VSIX as static `contributes.chatSkills` contributions so they are available to Copilot Chat alongside the bundled agents. Source dirs in `.github/skills/` retain their original names; the bundler renames on copy and rewrites the `name:` frontmatter so the VS Code skill registry resolves them under the bundled name. ([#281](https://github.com/advanced-security/codeql-development-mcp-server/pull/281))
- **`codeql-mcp.queryPackIncludeDirs` / `codeql-mcp.queryPackExcludeDirs` settings** β€” Two new array settings give explicit, workspace-folder-ordering-independent control over which directories the prompt-driven workflows resolve CodeQL query and pack paths against. `queryPackIncludeDirs` adds extra roots (e.g. a query repository that is not opened as the first folder, or not opened at all); `queryPackExcludeDirs` drops roots (matching directories and anything nested inside them). Absolute entries are used as-is; relative entries are resolved against every workspace folder. Both are folded into the `CODEQL_MCP_WORKSPACE_FOLDERS` and `CODEQL_ADDITIONAL_PACKS` environment variables. ([#307](https://github.com/advanced-security/codeql-development-mcp-server/pull/307))

### Changed

Expand Down Expand Up @@ -71,6 +72,7 @@ _Changes on `main` since the latest tagged release that have not yet been includ

### Fixed

- **VS Code extension: MCP workflow prompts could not target queries outside the first workspace folder.** In a multi-root workspace, the prompt-driven workflows only surfaced and resolved CodeQL queries, packs, databases, and SARIF files in the first root folder. The MCP server's prompt-argument completion providers now scan **every** workspace root (`CODEQL_MCP_WORKSPACE_FOLDERS`), and the extension's environment builder folds the new `queryPackIncludeDirs`/`queryPackExcludeDirs` settings into the resolution roots, so a query that lives in a non-first root (or an out-of-workspace query repository) is found and usable regardless of folder order. ([#307](https://github.com/advanced-security/codeql-development-mcp-server/pull/307))
- **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
32 changes: 32 additions & 0 deletions extensions/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,38 @@ All settings are under the `codeql-mcp` namespace in VS Code settings:
| `codeql-mcp.additionalDatabaseDirs` | `[]` | Additional directories to search for CodeQL databases. |
| `codeql-mcp.additionalMrvaRunResultsDirs` | `[]` | Additional directories containing MRVA run results. |
| `codeql-mcp.additionalQueryRunResultsDirs` | `[]` | Additional directories containing query run results. |
| `codeql-mcp.queryPackIncludeDirs` | `[]` | Extra directories to resolve query/pack paths against (see below). |
| `codeql-mcp.queryPackExcludeDirs` | `[]` | Directories to exclude as query/pack resolution roots (see below). |

### Multi-root workspaces and query/pack resolution

The prompt-driven workflows (slash commands) resolve query, pack, database, and
SARIF paths against **every** folder of a [multi-root workspace](https://code.visualstudio.com/docs/editor/multi-root-workspaces),
not just the first one. So a query that lives in the second/third root folder β€”
for example when the query-development repository and the analysis-target
repository are opened as separate roots β€” is found and usable regardless of
folder order.

Two settings give you explicit, ordering-independent control over which
directories are used as resolution roots:

- **`codeql-mcp.queryPackIncludeDirs`** β€” extra directories to resolve query and
pack paths against, _in addition_ to the open workspace folders. Use this to
target a query repository that is **not** the first workspace folder, or that
is not opened as a folder at all. **Absolute** paths are used as-is;
**relative** paths are resolved against every workspace folder (so `queries`
expands to one candidate per root).
- **`codeql-mcp.queryPackExcludeDirs`** β€” directories to exclude as resolution
roots. Any workspace folder or `queryPackIncludeDirs` entry that matches (or is
nested inside) one of these directories is dropped. Use this to scope
resolution away from a large or irrelevant root. Absolute paths are used
as-is; relative paths are resolved against every workspace folder.

These settings are folded into the `CODEQL_MCP_WORKSPACE_FOLDERS` and
`CODEQL_ADDITIONAL_PACKS` environment variables passed to the MCP server.
They control whole resolution **roots**; to skip _nested_ directory names such
as `node_modules` or vendor trees during scans, use `codeql-mcp.scanExcludeDirs`
instead.

## Commands

Expand Down
16 changes: 16 additions & 0 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@
"default": true,
"markdownDescription": "Copy CodeQL databases from the `GitHub.vscode-codeql` extension storage into a managed directory, removing query-server lock files so the MCP server CLI can operate without contention. Disable to use databases in-place (may fail when the CodeQL query server is running)."
},
"codeql-mcp.queryPackIncludeDirs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"markdownDescription": "Extra directories to resolve CodeQL query and pack paths against, in addition to the open workspace folders. Use this to make the prompt-driven workflows target a query repository that is **not** the first workspace folder (or is not opened as a folder at all). Absolute paths are used as-is; relative paths are resolved against **every** workspace folder (so `queries` expands to one candidate per root). Folded into `CODEQL_MCP_WORKSPACE_FOLDERS` and `CODEQL_ADDITIONAL_PACKS`."
},
"codeql-mcp.queryPackExcludeDirs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"markdownDescription": "Directories to exclude when resolving CodeQL query and pack paths. Any workspace folder or `queryPackIncludeDirs` entry that matches (or is nested inside) one of these directories is dropped from `CODEQL_MCP_WORKSPACE_FOLDERS` and `CODEQL_ADDITIONAL_PACKS`. Absolute paths are used as-is; relative paths are resolved against every workspace folder. This excludes whole resolution **roots**; to skip nested directory names (e.g. `node_modules`) during scans, use `scanExcludeDirs` instead."
},
"codeql-mcp.serverArgs": {
"type": "array",
"items": {
Expand Down
105 changes: 94 additions & 11 deletions extensions/vscode/src/bridge/environment-builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
import { delimiter, isAbsolute, join } from 'path';
import { delimiter, isAbsolute, join, normalize, relative } from 'path';
import { DisposableObject } from '../common/disposable';
import type { Logger } from '../common/logger';
import type { CliResolver } from '../codeql/cli-resolver';
Expand All @@ -12,6 +12,85 @@ export type DatabaseCopierFactory = (dest: string, logger: Logger) => DatabaseCo
const defaultCopierFactory: DatabaseCopierFactory = (dest, logger) =>
new DatabaseCopier(dest, logger);

/** True when `child` is the same path as, or nested inside, `parent`. */
function isWithin(child: string, parent: string): boolean {
if (child === parent) {
return true;
}
const rel = relative(parent, child);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}

/**
* Resolve user-configured directory entries to absolute candidate paths.
*
* Absolute entries are used verbatim; relative entries are resolved against
* every workspace folder so that, in a multi-root workspace, a relative entry
* like `queries` expands to one candidate per root. Blank entries are skipped.
*/
function resolveConfiguredDirs(
entries: string[],
workspaceFolderPaths: string[],
): string[] {
const out: string[] = [];
for (const entry of entries) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
if (isAbsolute(trimmed)) {
out.push(normalize(trimmed));
} else {
for (const folder of workspaceFolderPaths) {
out.push(normalize(join(folder, trimmed)));
}
}
}
return out;
}

/**
* Compute the ordered, de-duplicated set of directories used to resolve
* CodeQL query/database/pack paths.
*
* Starts with every workspace folder, appends any `queryPackIncludeDirs`, and
* removes any root that matches (or is nested inside) a `queryPackExcludeDirs`
* entry. This gives users deterministic, ordering-independent control over
* which roots the MCP server scans and resolves against (see issue #300).
*/
function computeResolutionRoots(
workspaceFolderPaths: string[],
config: vscode.WorkspaceConfiguration,
): string[] {
const includeResolved = resolveConfiguredDirs(
config.get<string[]>('queryPackIncludeDirs', []),
workspaceFolderPaths,
);
const excludeResolved = resolveConfiguredDirs(
config.get<string[]>('queryPackExcludeDirs', []),
workspaceFolderPaths,
);

const roots: string[] = [];
const seen = new Set<string>();
for (const candidate of [
...workspaceFolderPaths.map((p) => normalize(p)),
...includeResolved,
]) {
if (!seen.has(candidate)) {
seen.add(candidate);
roots.push(candidate);
}
}

if (excludeResolved.length === 0) {
return roots;
}
return roots.filter(
(root) => !excludeResolved.some((excluded) => isWithin(root, excluded)),
);
}

/**
* Assembles the environment variables for the MCP server process.
*
Expand Down Expand Up @@ -64,13 +143,20 @@ export class EnvironmentBuilder extends DisposableObject {
env.CODEQL_PATH = cliPath;
}

// Workspace root and all workspace folders
// Workspace root and all workspace folders. The set of directories used to
// resolve CodeQL query/database/pack paths is computed from every workspace
// folder, then expanded with `queryPackIncludeDirs` and narrowed with
// `queryPackExcludeDirs` so multi-root layouts (and query repos opened as a
// non-first root, or not opened at all) work deterministically β€” see #300.
const workspaceFolders = vscode.workspace.workspaceFolders;
const workspaceFolderPaths =
workspaceFolders?.map((f) => f.uri.fsPath) ?? [];
const resolutionRoots = computeResolutionRoots(workspaceFolderPaths, config);
if (workspaceFolders && workspaceFolders.length > 0) {
env.CODEQL_MCP_WORKSPACE = workspaceFolders[0].uri.fsPath;
env.CODEQL_MCP_WORKSPACE_FOLDERS = workspaceFolders
.map((f) => f.uri.fsPath)
.join(delimiter);
}
if (resolutionRoots.length > 0) {
env.CODEQL_MCP_WORKSPACE_FOLDERS = resolutionRoots.join(delimiter);
}

// Workspace-local scratch directory for tool output (query logs, etc.)
Expand Down Expand Up @@ -99,12 +185,9 @@ export class EnvironmentBuilder extends DisposableObject {
this.storagePaths.getDatabaseStoragePath(),
];

// Also include workspace folder paths
if (workspaceFolders) {
for (const folder of workspaceFolders) {
additionalPaths.push(folder.uri.fsPath);
}
}
// Also include the effective resolution roots (workspace folders plus any
// explicitly included query/pack directories, minus excluded ones).
additionalPaths.push(...resolutionRoots);

env.CODEQL_ADDITIONAL_PACKS = additionalPaths.join(delimiter);

Expand Down
126 changes: 126 additions & 0 deletions extensions/vscode/test/bridge/environment-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,130 @@ describe('EnvironmentBuilder', () => {
const env = await builder.build();
expect(env.CODEQL_MCP_SCAN_EXCLUDE_DIRS).toBeUndefined();
});

it('should append absolute queryPackIncludeDirs to resolution roots', async () => {
const vscode = await import('vscode');
const { delimiter } = await import('path');
const origFolders = vscode.workspace.workspaceFolders;
const originalGetConfig = vscode.workspace.getConfiguration;

try {
(vscode.workspace.workspaceFolders as any) = [
{ uri: { fsPath: '/mock/ws-a' }, name: 'a', index: 0 },
];
vscode.workspace.getConfiguration = () => ({
get: (_key: string, defaultVal?: any) => {
if (_key === 'queryPackIncludeDirs') return ['/extra/query-repo'];
return defaultVal;
},
has: () => false,
inspect: () => undefined as any,
update: () => Promise.resolve(),
}) as any;

builder.invalidate();
const env = await builder.build();
const roots = env.CODEQL_MCP_WORKSPACE_FOLDERS.split(delimiter);
expect(roots).toContain('/mock/ws-a');
expect(roots).toContain('/extra/query-repo');
// Additional packs should also include the explicit include dir.
expect(env.CODEQL_ADDITIONAL_PACKS).toContain('/extra/query-repo');
} finally {
(vscode.workspace.workspaceFolders as any) = origFolders;
vscode.workspace.getConfiguration = originalGetConfig;
}
});

it('should resolve relative queryPackIncludeDirs against each workspace folder', async () => {
const vscode = await import('vscode');
const { delimiter, join } = await import('path');
const origFolders = vscode.workspace.workspaceFolders;
const originalGetConfig = vscode.workspace.getConfiguration;

try {
(vscode.workspace.workspaceFolders as any) = [
{ uri: { fsPath: '/mock/ws-a' }, name: 'a', index: 0 },
{ uri: { fsPath: '/mock/ws-b' }, name: 'b', index: 1 },
];
vscode.workspace.getConfiguration = () => ({
get: (_key: string, defaultVal?: any) => {
if (_key === 'queryPackIncludeDirs') return ['queries'];
return defaultVal;
},
has: () => false,
inspect: () => undefined as any,
update: () => Promise.resolve(),
}) as any;

builder.invalidate();
const env = await builder.build();
const roots = env.CODEQL_MCP_WORKSPACE_FOLDERS.split(delimiter);
expect(roots).toContain(join('/mock/ws-a', 'queries'));
expect(roots).toContain(join('/mock/ws-b', 'queries'));
} finally {
(vscode.workspace.workspaceFolders as any) = origFolders;
vscode.workspace.getConfiguration = originalGetConfig;
}
});

it('should remove excluded workspace folders from resolution roots and additional packs', async () => {
const vscode = await import('vscode');
const { delimiter } = await import('path');
const origFolders = vscode.workspace.workspaceFolders;
const originalGetConfig = vscode.workspace.getConfiguration;

try {
(vscode.workspace.workspaceFolders as any) = [
{ uri: { fsPath: '/mock/ws-a' }, name: 'a', index: 0 },
{ uri: { fsPath: '/mock/ws-b' }, name: 'b', index: 1 },
];
vscode.workspace.getConfiguration = () => ({
get: (_key: string, defaultVal?: any) => {
if (_key === 'queryPackExcludeDirs') return ['/mock/ws-b'];
return defaultVal;
},
has: () => false,
inspect: () => undefined as any,
update: () => Promise.resolve(),
}) as any;

builder.invalidate();
const env = await builder.build();
const roots = env.CODEQL_MCP_WORKSPACE_FOLDERS.split(delimiter);
expect(roots).toContain('/mock/ws-a');
expect(roots).not.toContain('/mock/ws-b');
expect(env.CODEQL_ADDITIONAL_PACKS).not.toContain('/mock/ws-b');
} finally {
(vscode.workspace.workspaceFolders as any) = origFolders;
vscode.workspace.getConfiguration = originalGetConfig;
}
});

it('should support absolute queryPackIncludeDirs even when no workspace is open', async () => {
const vscode = await import('vscode');
const { delimiter } = await import('path');
const origFolders = vscode.workspace.workspaceFolders;
const originalGetConfig = vscode.workspace.getConfiguration;

try {
(vscode.workspace.workspaceFolders as any) = undefined;
vscode.workspace.getConfiguration = () => ({
get: (_key: string, defaultVal?: any) => {
if (_key === 'queryPackIncludeDirs') return ['/extra/query-repo'];
return defaultVal;
},
has: () => false,
inspect: () => undefined as any,
update: () => Promise.resolve(),
}) as any;

builder.invalidate();
const env = await builder.build();
const roots = (env.CODEQL_MCP_WORKSPACE_FOLDERS ?? '').split(delimiter).filter(Boolean);
expect(roots).toContain('/extra/query-repo');
} finally {
(vscode.workspace.workspaceFolders as any) = origFolders;
vscode.workspace.getConfiguration = originalGetConfig;
}
});
});
Loading