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
1 change: 1 addition & 0 deletions extensions/vscode/esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const testSuiteConfig = {
entryPoints: [
'test/suite/index.ts',
'test/suite/bridge.integration.test.ts',
'test/suite/copydb-e2e.integration.test.ts',
'test/suite/extension.integration.test.ts',
'test/suite/mcp-resource-e2e.integration.test.ts',
'test/suite/mcp-server.integration.test.ts',
Expand Down
61 changes: 33 additions & 28 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,33 +48,13 @@
"configuration": {
"title": "CodeQL MCP Server",
"properties": {
"codeql-mcp.autoInstall": {
"type": "boolean",
"default": true,
"description": "Automatically install and update the CodeQL Development MCP Server on activation."
},
"codeql-mcp.serverVersion": {
"type": "string",
"default": "latest",
"description": "The npm version of codeql-development-mcp-server to install. Use 'latest' for the most recent release."
},
"codeql-mcp.serverCommand": {
"type": "string",
"default": "node",
"description": "Command to launch the MCP server. The default 'node' runs the bundled server. Override to 'npx' to download from npm, or provide a custom path."
},
"codeql-mcp.serverArgs": {
"codeql-mcp.additionalDatabaseDirs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Custom arguments for the MCP server command. When empty, the bundled server entry point is used automatically. Set to e.g. ['/path/to/server/dist/codeql-development-mcp-server.js'] for local development."
},
"codeql-mcp.watchCodeqlExtension": {
"type": "boolean",
"default": true,
"description": "Watch for CodeQL databases and query results created by the CodeQL extension."
"description": "Additional directories to search for CodeQL databases. Appended to CODEQL_DATABASES_BASE_DIRS. When copyDatabases is enabled (default), they are appended alongside the managed databases directory; when copyDatabases is disabled, they are appended alongside the original vscode-codeql database storage directories."
},
"codeql-mcp.additionalEnv": {
"type": "object",
Expand All @@ -84,29 +64,54 @@
"type": "string"
}
},
"codeql-mcp.additionalDatabaseDirs": {
"codeql-mcp.additionalMrvaRunResultsDirs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Additional directories to search for CodeQL databases. Appended to CODEQL_DATABASES_BASE_DIRS alongside the vscode-codeql storage paths."
"description": "Additional directories containing MRVA run result subdirectories. Appended to CODEQL_MRVA_RUN_RESULTS_DIRS alongside the vscode-codeql variant analysis storage path."
},
"codeql-mcp.additionalMrvaRunResultsDirs": {
"codeql-mcp.additionalQueryRunResultsDirs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Additional directories containing MRVA run result subdirectories. Appended to CODEQL_MRVA_RUN_RESULTS_DIRS alongside the vscode-codeql variant analysis storage path."
"description": "Additional directories containing query run result subdirectories. Appended to CODEQL_QUERY_RUN_RESULTS_DIRS alongside the vscode-codeql query storage path."
},
"codeql-mcp.additionalQueryRunResultsDirs": {
"codeql-mcp.autoInstall": {
"type": "boolean",
"default": true,
"description": "Automatically install and update the CodeQL Development MCP Server on activation."
},
"codeql-mcp.copyDatabases": {
"type": "boolean",
"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.serverArgs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Additional directories containing query run result subdirectories. Appended to CODEQL_QUERY_RUN_RESULTS_DIRS alongside the vscode-codeql query storage path."
"description": "Custom arguments for the MCP server command. When empty, the bundled server entry point is used automatically. Set to e.g. ['/path/to/server/dist/codeql-development-mcp-server.js'] for local development."
},
"codeql-mcp.serverCommand": {
"type": "string",
"default": "node",
"description": "Command to launch the MCP server. The default 'node' runs the bundled server. Override to 'npx' to download from npm, or provide a custom path."
},
"codeql-mcp.serverVersion": {
"type": "string",
"default": "latest",
"description": "The npm version of codeql-development-mcp-server to install. Use 'latest' for the most recent release."
},
"codeql-mcp.watchCodeqlExtension": {
"type": "boolean",
"default": true,
"description": "Watch for CodeQL databases and query results created by the CodeQL extension."
}
}
},
Expand Down
164 changes: 164 additions & 0 deletions extensions/vscode/src/bridge/database-copier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { existsSync } from 'fs';
import { cp, mkdir, readdir, rm, stat, unlink } from 'fs/promises';
import { join } from 'path';
import type { Logger } from '../common/logger';

/**
* Copies CodeQL databases from `GitHub.vscode-codeql` extension storage
* to a managed directory, removing `.lock` files that the CodeQL query
* server creates in `<dataset>/default/cache/`.
*
* This avoids lock contention when the `ql-mcp` server runs CLI commands
* against databases that are simultaneously registered by the
* `vscode-codeql` query server.
*
* Each database is identified by its top-level directory name (which
* contains `codeql-database.yml`). A database is only re-copied when its
* source has been modified more recently than the existing copy.
*/
export class DatabaseCopier {
constructor(
private readonly destinationBase: string,
private readonly logger: Logger,
) {}

/**
* Synchronise databases from one or more source directories into the
* managed destination. Only databases that are newer than the existing
* copy (or missing entirely) are re-copied.
*
* @returns The list of database paths in the managed destination that
* are ready for use (absolute paths).
*/
async syncAll(sourceDirs: string[]): Promise<string[]> {
try {
await mkdir(this.destinationBase, { recursive: true });
} catch (err) {
this.logger.error(
`Failed to create managed database directory ${this.destinationBase}: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}

Comment on lines +33 to +42
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DatabaseCopier.syncAll() can throw if mkdir(this.destinationBase) fails (e.g. permissions / disk issues). Right now EnvironmentBuilder.build() doesn’t catch that, so enabling copyDatabases could prevent the extension from starting at all. Consider catching errors in syncAll (log + return []) and/or catching around await copier.syncAll(...) in EnvironmentBuilder and falling back to source dirs when the copy step fails.

This issue also appears on line 75 of the same file.

Copilot uses AI. Check for mistakes.
const copied: string[] = [];

for (const sourceDir of sourceDirs) {
if (!existsSync(sourceDir)) {
continue;
}

let entries: string[];
try {
entries = await readdir(sourceDir);
} catch {
continue;
}

for (const entry of entries) {
const srcDbPath = join(sourceDir, entry);
if (!(await isCodeQLDatabase(srcDbPath))) {
continue;
}

const destDbPath = join(this.destinationBase, entry);

if (await this.needsCopy(srcDbPath, destDbPath)) {
await this.copyDatabase(srcDbPath, destDbPath);
}

if (await isCodeQLDatabase(destDbPath)) {
copied.push(destDbPath);
}
}
}

return copied;
}

/**
* Copy a single database directory, then strip any `.lock` files that
* the CodeQL query server may have left behind.
*/
private async copyDatabase(src: string, dest: string): Promise<void> {
this.logger.info(`Copying database ${src} → ${dest}`);
try {
// Remove stale destination if present
try {
if (existsSync(dest)) {
await rm(dest, { recursive: true, force: true });
}
} catch (rmErr) {
this.logger.error(
`Failed to remove stale destination ${dest}: ${rmErr instanceof Error ? rmErr.message : String(rmErr)}`,
);
return;
}

await cp(src, dest, { recursive: true });
await removeLockFiles(dest);
this.logger.info(`Database copied successfully: ${dest}`);
} catch (err) {
this.logger.error(
`Failed to copy database ${src}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

/**
* A copy is needed when the destination does not exist, or the source
* `codeql-database.yml` is newer than the destination's.
*/
private async needsCopy(src: string, dest: string): Promise<boolean> {
const destYml = join(dest, 'codeql-database.yml');
if (!existsSync(destYml)) {
return true;
}

const srcYml = join(src, 'codeql-database.yml');
try {
const srcMtime = (await stat(srcYml)).mtimeMs;
const destMtime = (await stat(destYml)).mtimeMs;
return srcMtime > destMtime;
} catch {
// If stat fails, re-copy to be safe
return true;
}
}
}

/** Check whether a directory looks like a CodeQL database. */
async function isCodeQLDatabase(dirPath: string): Promise<boolean> {
try {
return (await stat(dirPath)).isDirectory() && existsSync(join(dirPath, 'codeql-database.yml'));
} catch {
return false;
}
}

/**
* Recursively remove all `.lock` files under the given directory.
* These are empty sentinel files created by the CodeQL query server in
* `<dataset>/default/cache/.lock`.
*/
async function removeLockFiles(dir: string): Promise<void> {
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return;
}

for (const entry of entries) {
const fullPath = join(dir, entry);
try {
const st = await stat(fullPath);
if (st.isDirectory()) {
await removeLockFiles(fullPath);
} else if (entry === '.lock') {
await unlink(fullPath);
}
} catch {
// Best-effort removal
}
}
}
44 changes: 37 additions & 7 deletions extensions/vscode/src/bridge/environment-builder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import * as vscode from 'vscode';
import { join } from 'path';
import { delimiter, join } from 'path';
import { DisposableObject } from '../common/disposable';
import type { Logger } from '../common/logger';
import type { CliResolver } from '../codeql/cli-resolver';
import type { StoragePaths } from './storage-paths';
import { DatabaseCopier } from './database-copier';

/** Factory that creates a DatabaseCopier for a given destination. */
export type DatabaseCopierFactory = (dest: string, logger: Logger) => DatabaseCopier;

const defaultCopierFactory: DatabaseCopierFactory = (dest, logger) =>
new DatabaseCopier(dest, logger);

/**
* Assembles the environment variables for the MCP server process.
Expand All @@ -19,14 +26,17 @@ import type { StoragePaths } from './storage-paths';
*/
export class EnvironmentBuilder extends DisposableObject {
private cachedEnv: Record<string, string> | null = null;
private readonly copierFactory: DatabaseCopierFactory;

constructor(
private readonly context: vscode.ExtensionContext,
private readonly cliResolver: CliResolver,
private readonly storagePaths: StoragePaths,
private readonly logger: Logger,
copierFactory?: DatabaseCopierFactory,
) {
super();
this.copierFactory = copierFactory ?? defaultCopierFactory;
}

/** Invalidate the cached environment so the next `build()` recomputes. */
Expand Down Expand Up @@ -79,26 +89,46 @@ export class EnvironmentBuilder extends DisposableObject {
}
}

env.CODEQL_ADDITIONAL_PACKS = additionalPaths.join(':');
env.CODEQL_ADDITIONAL_PACKS = additionalPaths.join(delimiter);

// Database discovery directories for list_codeql_databases
// Includes: global storage, workspace storage, and user-configured dirs
const dbDirs = [...this.storagePaths.getAllDatabaseStoragePaths()];
const sourceDirs = this.storagePaths.getAllDatabaseStoragePaths();
const userDbDirs = config.get<string[]>('additionalDatabaseDirs', []);
dbDirs.push(...userDbDirs);
env.CODEQL_DATABASES_BASE_DIRS = dbDirs.join(':');

// When copyDatabases is enabled, copy databases from vscode-codeql
// storage to our own managed directory, removing query-server lock
// files so the MCP server CLI can operate without contention.
const copyEnabled = config.get<boolean>('copyDatabases', true);
let dbDirs: string[];
if (copyEnabled) {
const managedDir = this.storagePaths.getManagedDatabaseStoragePath();
const copier = this.copierFactory(managedDir, this.logger);
try {
await copier.syncAll(sourceDirs);
dbDirs = [managedDir, ...userDbDirs];
} catch (err) {
this.logger.error(
`Database copy failed, falling back to source dirs: ${err instanceof Error ? err.message : String(err)}`,
);
dbDirs = [...sourceDirs, ...userDbDirs];
}
} else {
dbDirs = [...sourceDirs, ...userDbDirs];
}
env.CODEQL_DATABASES_BASE_DIRS = dbDirs.join(delimiter);

// MRVA run results directory for variant analysis discovery
const mrvaDirs = [this.storagePaths.getVariantAnalysisStoragePath()];
const userMrvaDirs = config.get<string[]>('additionalMrvaRunResultsDirs', []);
mrvaDirs.push(...userMrvaDirs);
env.CODEQL_MRVA_RUN_RESULTS_DIRS = mrvaDirs.join(':');
env.CODEQL_MRVA_RUN_RESULTS_DIRS = mrvaDirs.join(delimiter);

// Query run results directory for query history discovery
const queryDirs = [this.storagePaths.getQueryStoragePath()];
const userQueryDirs = config.get<string[]>('additionalQueryRunResultsDirs', []);
queryDirs.push(...userQueryDirs);
env.CODEQL_QUERY_RUN_RESULTS_DIRS = queryDirs.join(':');
env.CODEQL_QUERY_RUN_RESULTS_DIRS = queryDirs.join(delimiter);

// User-configured additional environment variables
const additionalEnv = config.get<Record<string, string>>('additionalEnv', {});
Expand Down
8 changes: 8 additions & 0 deletions extensions/vscode/src/bridge/storage-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export class StoragePaths extends DisposableObject {
return join(this.getCodeqlGlobalStoragePath(), 'variant-analyses');
}

/**
* Directory where the MCP extension stores lock-free copies of databases.
* Path: `<our-globalStorageUri>/databases/`
*/
getManagedDatabaseStoragePath(): string {
return join(this.context.globalStorageUri.fsPath, 'databases');
}

/** The VS Code global storage root (parent of all extension storage dirs). */
getGlobalStorageRoot(): string {
return this.vsCodeGlobalStorageRoot;
Expand Down
Loading