Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion .github/workflows/client-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ jobs:
run: ./server/scripts/install-packs.sh

## Extract test databases used in the integration tests.
## Defaults to integration scope (javascript/examples + all tools databases).
## Defaults to integration scope (javascript/examples + specific tools
## databases referenced by integration test fixtures).
## Query unit tests auto-extract their own databases via `codeql test run`.
- name: MCP Integration Tests - Extract test databases
shell: bash
Expand Down
5 changes: 5 additions & 0 deletions extensions/vscode/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export const workspace = {
onDidCreateFiles: noopReturn({ dispose: noop }),
onDidSaveTextDocument: noopReturn({ dispose: noop }),
fs: { stat: noop, readFile: noop, readDirectory: noop },
asRelativePath: (pathOrUri: any) => {
const p = typeof pathOrUri === 'string' ? pathOrUri : pathOrUri?.fsPath ?? String(pathOrUri);
return p;
},
updateWorkspaceFolders: () => true,
};

export const window = {
Expand Down
2 changes: 2 additions & 0 deletions extensions/vscode/esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const testSuiteConfig = {
'test/suite/bridge.integration.test.ts',
'test/suite/copydb-e2e.integration.test.ts',
'test/suite/extension.integration.test.ts',
'test/suite/file-watcher-stability.integration.test.ts',
'test/suite/mcp-completion-e2e.integration.test.ts',
'test/suite/mcp-prompt-e2e.integration.test.ts',
'test/suite/mcp-resource-e2e.integration.test.ts',
'test/suite/mcp-server.integration.test.ts',
Expand Down
8 changes: 8 additions & 0 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@
"default": ".codeql/ql-mcp",
"markdownDescription": "Workspace-relative path for the ql-mcp scratch directory used for temporary files (query logs, external predicates, etc). The `.codeql/` parent is shared with other CodeQL CLI commands like `codeql pack bundle`. Set to an absolute path to override workspace-relative resolution."
},
"codeql-mcp.scanExcludeDirs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"markdownDescription": "Additional directory names to exclude from workspace scanning (prompt completions, QL code search). Entries are merged with the built-in defaults (`.git`, `node_modules`, `dist`, etc.). Prefix an entry with `!` to remove a default (e.g., `!build` re-includes the `build` directory). Passed to the server as `CODEQL_MCP_SCAN_EXCLUDE_DIRS`."
},
"codeql-mcp.watchCodeqlExtension": {
"type": "boolean",
"default": true,
Expand Down
4 changes: 2 additions & 2 deletions extensions/vscode/src/bridge/database-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,15 @@ export class DatabaseWatcher extends DisposableObject {
const dbRoot = ymlPath.replace(/\/codeql-database\.yml$/, '');
if (!this.knownDatabases.has(dbRoot)) {
this.knownDatabases.add(dbRoot);
this.logger.info(`Database discovered: ${dbRoot}`);
this.logger.info(`Database discovered: ${vscode.workspace.asRelativePath(dbRoot)}`);
this._onDidChange.fire();
}
}

private handleDatabaseRemoved(ymlPath: string): void {
const dbRoot = ymlPath.replace(/\/codeql-database\.yml$/, '');
if (this.knownDatabases.delete(dbRoot)) {
this.logger.info(`Database removed: ${dbRoot}`);
this.logger.info(`Database removed: ${vscode.workspace.asRelativePath(dbRoot)}`);
this._onDidChange.fire();
}
}
Expand Down
8 changes: 8 additions & 0 deletions extensions/vscode/src/bridge/environment-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ export class EnvironmentBuilder extends DisposableObject {
env.MONITORING_STORAGE_LOCATION = env.CODEQL_MCP_SCRATCH_DIR;
}

// Scan exclusion directories for prompt completions and QL code search.
// The server reads CODEQL_MCP_SCAN_EXCLUDE_DIRS to merge with built-in
// defaults. The setting accepts additions and `!`-prefixed negations.
const scanExcludeDirs = config.get<string[]>('scanExcludeDirs', []);
if (scanExcludeDirs.length > 0) {
env.CODEQL_MCP_SCAN_EXCLUDE_DIRS = scanExcludeDirs.join(',');
}

// User-configured additional environment variables (overrides above defaults)
const additionalEnv = config.get<Record<string, string>>('additionalEnv', {});
for (const [key, value] of Object.entries(additionalEnv)) {
Expand Down
4 changes: 2 additions & 2 deletions extensions/vscode/src/bridge/query-results-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class QueryResultsWatcher extends DisposableObject {
const bqrsWatcher = vscode.workspace.createFileSystemWatcher('**/*.bqrs');
this.push(bqrsWatcher);
bqrsWatcher.onDidCreate((uri) => {
this.logger.info(`Query result (BQRS) created: ${uri.fsPath}`);
this.logger.info(`Query result (BQRS) created: ${vscode.workspace.asRelativePath(uri)}`);
this._onDidChange.fire();
});

Expand All @@ -36,7 +36,7 @@ export class QueryResultsWatcher extends DisposableObject {
);
this.push(sarifWatcher);
sarifWatcher.onDidCreate((uri) => {
this.logger.info(`Query result (SARIF) created: ${uri.fsPath}`);
this.logger.info(`Query result (SARIF) created: ${vscode.workspace.asRelativePath(uri)}`);
this._onDidChange.fire();
});

Expand Down
54 changes: 51 additions & 3 deletions extensions/vscode/src/codeql/cli-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ const KNOWN_LOCATIONS = [
export class CliResolver extends DisposableObject {
private cachedPath: string | undefined | null = null; // null = not yet resolved
private cachedVersion: string | undefined;
private resolvePromise: Promise<string | undefined> | null = null;

/**
* Monotonically increasing generation counter, bumped by `invalidateCache()`.
* Used to discard results from in-flight `doResolve()` calls that started
* before the most recent invalidation.
*/
private _generation = 0;

constructor(
private readonly logger: Logger,
Expand All @@ -57,12 +65,42 @@ export class CliResolver extends DisposableObject {
return this.cachedPath;
}

// Return the in-flight promise if a resolution is already in progress
if (this.resolvePromise) {
return this.resolvePromise;
}

this.resolvePromise = this.doResolve();
try {
return await this.resolvePromise;
} finally {
this.resolvePromise = null;
}
}

/** Internal resolution logic. Called at most once per cache cycle. */
private async doResolve(): Promise<string | undefined> {
const startGeneration = this._generation;
this.logger.debug('Resolving CodeQL CLI path...');

/**
* Check whether the cache was invalidated while an async operation
* was in flight. If so, discard any version that `validateBinary()`
* may have written and bail out immediately.
*/
const isStale = (): boolean => {
if (this._generation !== startGeneration) {
this.cachedVersion = undefined;
return true;
}
return false;
};

// Strategy 1: CODEQL_PATH env var
const envPath = process.env.CODEQL_PATH;
if (envPath) {
const validated = await this.validateBinary(envPath);
if (isStale()) return undefined;
if (validated) {
this.logger.info(`CodeQL CLI found via CODEQL_PATH: ${envPath}`);
this.cachedPath = envPath;
Expand All @@ -73,14 +111,21 @@ export class CliResolver extends DisposableObject {

// Strategy 2: which/command -v
const whichPath = await this.resolveFromPath();
if (isStale()) return undefined;
if (whichPath) {
this.logger.info(`CodeQL CLI found on PATH: ${whichPath}`);
this.cachedPath = whichPath;
return whichPath;
const validated = await this.validateBinary(whichPath);
if (isStale()) return undefined;
if (validated) {
this.logger.info(`CodeQL CLI found on PATH: ${whichPath}`);
this.cachedPath = whichPath;
return whichPath;
}
this.logger.warn(`Found 'codeql' on PATH at '${whichPath}' but it failed validation.`);
}

// Strategy 3: vscode-codeql managed distribution
const distPath = await this.resolveFromVsCodeDistribution();
if (isStale()) return undefined;
if (distPath) {
this.logger.info(`CodeQL CLI found via vscode-codeql distribution: ${distPath}`);
this.cachedPath = distPath;
Expand All @@ -90,6 +135,7 @@ export class CliResolver extends DisposableObject {
// Strategy 4: known filesystem locations
for (const location of KNOWN_LOCATIONS) {
const validated = await this.validateBinary(location);
if (isStale()) return undefined;
if (validated) {
this.logger.info(`CodeQL CLI found at known location: ${location}`);
this.cachedPath = location;
Expand All @@ -106,6 +152,8 @@ export class CliResolver extends DisposableObject {
invalidateCache(): void {
this.cachedPath = null;
this.cachedVersion = undefined;
this.resolvePromise = null;
this._generation++;
}

/** Check if a path exists and responds to `--version`. */
Expand Down
18 changes: 8 additions & 10 deletions extensions/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,14 @@ export async function activate(
const queryWatcher = new QueryResultsWatcher(storagePaths, logger);
disposables.push(dbWatcher, queryWatcher);

// When databases or query results change, rebuild the environment and
// notify the MCP provider that the server definition has changed.
dbWatcher.onDidChange(() => {
envBuilder.invalidate();
mcpProvider.fireDidChange();
});
queryWatcher.onDidChange(() => {
envBuilder.invalidate();
mcpProvider.fireDidChange();
});
// File-content changes (new databases, query results) do NOT require
// a new MCP server definition. The running server discovers files on
// its own through filesystem scanning at tool invocation time. The
// definition only needs to change when the server binary, workspace
// folder registration, or configuration changes.
//
// The watchers are still useful: they log file events for debugging
// and DatabaseWatcher tracks known databases internally.
} catch (err) {
logger.warn(
`Failed to initialize file watchers: ${err instanceof Error ? err.message : String(err)}`,
Expand Down
30 changes: 29 additions & 1 deletion extensions/vscode/src/server/mcp-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export class McpProvider
*/
private readonly _extensionVersion: string;

/**
* Handle for the pending debounced `fireDidChange()` timer.
* Used to coalesce rapid file-system events into a single notification.
*/
private _debounceTimer: ReturnType<typeof globalThis.setTimeout> | undefined;

constructor(
private readonly serverManager: ServerManager,
private readonly envBuilder: EnvironmentBuilder,
Expand All @@ -43,6 +49,14 @@ export class McpProvider
this.push(this._onDidChange);
}

override dispose(): void {
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
this._debounceTimer = undefined;
}
super.dispose();
}

/**
* Soft notification: tell VS Code that definitions may have changed.
*
Expand All @@ -51,9 +65,18 @@ export class McpProvider
* will NOT restart the server. Use for lightweight updates (file watcher
* events, extension changes, background install completion) where the
* running server can continue with its current environment.
*
* Debounced: rapid-fire calls (e.g. from file-system watchers during
* a build) are coalesced into a single notification after a short delay.
*/
fireDidChange(): void {
this._onDidChange.fire();
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
}
this._debounceTimer = globalThis.setTimeout(() => {
this._debounceTimer = undefined;
this._onDidChange.fire();
}, 1_000);
}

/**
Expand All @@ -68,6 +91,11 @@ export class McpProvider
* Use for changes that require a server restart (configuration changes).
*/
requestRestart(): void {
// Cancel any pending debounced fireDidChange — the restart supersedes it.
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
this._debounceTimer = undefined;
}
this.envBuilder.invalidate();
this._revision++;
this.logger.info(
Expand Down
34 changes: 32 additions & 2 deletions extensions/vscode/src/server/pack-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ export class PackInstaller extends DisposableObject {
const actualCliVersion = this.cliResolver.getCliVersion();
const targetCliVersion = this.getTargetCliVersion();

if (actualCliVersion) {
this.logger.info(
`Detected CodeQL CLI version: ${actualCliVersion}, target: ${targetCliVersion}.`,
);
} else {
this.logger.info(
`CodeQL CLI version could not be determined. Target: ${targetCliVersion}. ` +
'Using bundled pack install.',
);
}

if (downloadEnabled && actualCliVersion && actualCliVersion !== targetCliVersion) {
this.logger.info(
`CodeQL CLI version ${actualCliVersion} differs from VSIX target ${targetCliVersion}. ` +
Expand All @@ -167,6 +178,10 @@ export class PackInstaller extends DisposableObject {
this.logger.info(
'Pack download did not succeed for all languages — falling back to bundled pack install.',
);
} else if (actualCliVersion && actualCliVersion === targetCliVersion) {
this.logger.info(
`CLI and target versions match (${actualCliVersion}). Using bundled pack install.`,
);
}

// Default path: install dependencies for bundled packs
Expand Down Expand Up @@ -199,20 +214,25 @@ export class PackInstaller extends DisposableObject {
);

let allSucceeded = true;
let successCount = 0;
for (const lang of languages) {
const packRef =
`${PackInstaller.PACK_SCOPE}/ql-mcp-${lang}-tools-src@${packVersion}`;
this.logger.info(`Downloading ${packRef}...`);
try {
await this.runCodeqlPackDownload(codeqlPath, packRef);
this.logger.info(`Downloaded ${packRef}.`);
successCount++;
} catch (err) {
this.logger.error(
`Failed to download ${packRef}: ${err instanceof Error ? err.message : String(err)}`,
);
allSucceeded = false;
}
}
this.logger.info(
`Pack download complete: ${successCount}/${languages.length} languages succeeded.`,
);
return allSucceeded;
}

Expand All @@ -226,28 +246,38 @@ export class PackInstaller extends DisposableObject {
languages: string[],
): Promise<void> {
const qlRoot = this.getQlpackRoot();
let successCount = 0;
let skippedCount = 0;

for (const lang of languages) {
const packDir = join(qlRoot, 'ql', lang, 'tools', 'src');
const packName = `${PackInstaller.PACK_SCOPE}/ql-mcp-${lang}-tools-src`;

// Check if the pack directory exists
try {
await access(packDir, constants.R_OK);
} catch {
this.logger.debug(`Pack directory not found, skipping: ${packDir}`);
skippedCount++;
continue;
}

this.logger.info(`Installing CodeQL pack dependencies for ${lang}...`);
this.logger.info(`Installing CodeQL pack dependencies for ${packName} (${lang})...`);
try {
await this.runCodeqlPackInstall(codeqlPath, packDir);
this.logger.info(`Pack dependencies installed for ${lang}.`);
this.logger.info(`Pack dependencies installed for ${packName} (${lang}).`);
successCount++;
} catch (err) {
this.logger.error(
`Failed to install pack dependencies for ${lang}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
const attemptCount = languages.length - skippedCount;
const skippedSuffix = skippedCount > 0 ? `, ${skippedCount} skipped` : '';
this.logger.info(
`Bundled pack install complete: ${successCount}/${attemptCount} languages succeeded${skippedSuffix}.`,
);
Comment thread
data-douser marked this conversation as resolved.
Outdated
}

/** Run `codeql pack install` for a single pack directory. */
Expand Down
3 changes: 1 addition & 2 deletions extensions/vscode/src/server/server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@ export class ServerManager extends DisposableObject {
// VSIX bundle or monorepo server is present — no npm install required.
if (this.getBundledQlRoot()) {
this.logger.info(
`Using bundled server (v${this.getExtensionVersion()}). ` +
'No npm install required.',
`Bundled server ready (v${this.getExtensionVersion()}).`,
);
return false;
}
Expand Down
Loading
Loading