Bazel MCP Server
by nacgarg
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequest,
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
// Logger configuration
let logPath = '';
/**
* Centralized logging function that handles both console and file logging
*/
function log(message: string, level: 'info' | 'error' = 'info', toConsole = true): void {
const timestamp = new Date().toISOString();
const logMessage = `${timestamp}: ${message}`;
// Log to file if path is configured
if (logPath) {
fs.appendFileSync(logPath, `${logMessage}\n`);
}
// Log to console if requested
if (toConsole) {
if (level === 'error') {
console.error(logMessage);
} else if (process.env.DEBUG) {
console.error(logMessage); // Debug logs also go to stderr
}
}
}
// Type definitions for tool arguments
interface BuildTargetArgs {
targets: string[];
additionalArgs?: string[];
}
interface QueryTargetArgs {
pattern: string;
additionalArgs?: string[];
}
interface TestTargetArgs {
targets: string[];
additionalArgs?: string[];
}
interface ListTargetsArgs {
path: string;
additionalArgs?: string[];
}
interface FetchDependenciesArgs {
targets?: string[];
additionalArgs?: string[];
}
interface SetWorkspacePathArgs {
path: string;
}
// Tool definitions
const buildTargetTool: Tool = {
name: "bazel_build_target",
description: "Build specified Bazel targets",
inputSchema: {
type: "object",
properties: {
targets: {
type: "array",
items: {
type: "string",
},
description: "List of Bazel targets to build (e.g. ['//path/to:target'])",
},
additionalArgs: {
type: "array",
items: {
type: "string",
},
description: "Additional Bazel command line arguments (e.g. ['--verbose_failures', '--sandbox_debug'])",
},
},
required: ["targets"],
},
};
const queryTargetTool: Tool = {
name: "bazel_query_target",
description: "Query the Bazel dependency graph for targets matching a pattern",
inputSchema: {
type: "object",
properties: {
pattern: {
type: "string",
description: "Bazel query pattern (e.g. 'deps(//path/to:target)')",
},
additionalArgs: {
type: "array",
items: {
type: "string",
},
description: "Additional Bazel command line arguments (e.g. ['--output=label_kind', '--noimplicit_deps'])",
},
},
required: ["pattern"],
},
};
const testTargetTool: Tool = {
name: "bazel_test_target",
description: "Run Bazel tests for specified targets",
inputSchema: {
type: "object",
properties: {
targets: {
type: "array",
items: {
type: "string",
},
description: "List of Bazel test targets to run (e.g. ['//path/to:test'])",
},
additionalArgs: {
type: "array",
items: {
type: "string",
},
description: "Additional Bazel command line arguments (e.g. ['--cache_test_results=no', '--test_output=all'])",
},
},
required: ["targets"],
},
};
const listTargetsTool: Tool = {
name: "bazel_list_targets",
description: "List all available Bazel targets under a given path",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path within the workspace to list targets for (e.g. '//path/to' or '//' for all targets)",
},
additionalArgs: {
type: "array",
items: {
type: "string",
},
description: "Additional Bazel command line arguments (e.g. ['--output=build', '--keep_going'])",
},
},
required: ["path"],
},
};
const fetchDependenciesTool: Tool = {
name: "bazel_fetch_dependencies",
description: "Fetch Bazel external dependencies",
inputSchema: {
type: "object",
properties: {
targets: {
type: "array",
items: {
type: "string",
},
description: "List of specific targets to fetch dependencies for",
},
additionalArgs: {
type: "array",
items: {
type: "string",
},
description: "Additional Bazel command line arguments (e.g. ['--experimental_repository_cache_hardlinks', '--repository_cache=path/to/cache'])",
},
},
},
};
const setWorkspacePathTool: Tool = {
name: "bazel_set_workspace_path",
description: "Set the current Bazel workspace path for subsequent commands",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "The absolute path to the Bazel workspace directory",
},
},
required: ["path"],
},
};
class BazelClient {
private bazelPath: string;
private workspacePath: string;
private workspaceConfig: string | undefined;
// List of allowed prefixes for additional arguments to prevent command injection
private readonly allowedArgPrefixes = ['--', '-'];
constructor(
bazelPath: string,
workspacePath: string,
workspaceConfig?: string
) {
this.bazelPath = bazelPath;
this.workspacePath = workspacePath;
this.workspaceConfig = workspaceConfig;
}
/**
* Validates and sanitizes additional Bazel arguments to prevent command injection
* @param args Array of additional arguments to validate
* @returns Array of validated and sanitized arguments
* @throws Error if any argument is invalid or potentially dangerous
*/
private validateAdditionalArgs(args: string[] | undefined): string[] {
if (!args || args.length === 0) {
return [];
}
const sanitizedArgs: string[] = [];
for (const arg of args) {
// Skip empty arguments
if (!arg || arg.trim() === '') {
continue;
}
// Check if argument starts with allowed prefix
const isAllowed = this.allowedArgPrefixes.some(prefix => arg.startsWith(prefix));
if (!isAllowed) {
throw new Error(`Invalid argument format: "${arg}". Additional arguments must start with -- or -`);
}
// Check for potentially dangerous characters that could enable command injection
const dangerousChars = /[;&|<>$`\\]/;
if (dangerousChars.test(arg)) {
throw new Error(`Argument contains potentially dangerous characters: "${arg}"`);
}
sanitizedArgs.push(arg);
}
return sanitizedArgs;
}
private runBazelCommand(
command: string,
args: string[] = [],
onOutput?: (chunk: string) => void
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const fullArgs = [command, ...args];
if (this.workspaceConfig) {
fullArgs.unshift(`--bazelrc=${this.workspaceConfig}`);
}
const cmdString = `${this.bazelPath} ${fullArgs.join(" ")}`;
log(`Running command: ${cmdString} in directory: ${this.workspacePath}`);
const process = spawn(this.bazelPath, fullArgs, {
cwd: this.workspacePath,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
process.stdout.on("data", (data) => {
const chunk = data.toString();
stdout += chunk;
log(`STDOUT: ${chunk}`, 'info', false);
if (onOutput) {
onOutput(chunk);
}
});
process.stderr.on("data", (data) => {
const chunk = data.toString();
stderr += chunk;
log(`STDERR: ${chunk}`, 'info', false);
if (onOutput) {
onOutput(chunk);
}
});
process.on("close", (code) => {
log(`Command completed with exit code: ${code}`);
if (code === 0) {
resolve({ stdout, stderr });
} else {
const errorMsg = `Bazel command failed with code ${code}: ${stderr}`;
log(errorMsg, 'error');
reject(new Error(errorMsg));
}
});
process.on("error", (err) => {
log(`Command execution error: ${err.message}`, 'error');
reject(err);
});
});
}
async buildTargets(targets: string[], additionalArgs?: string[], onOutput?: (chunk: string) => void): Promise<string> {
const validatedArgs = this.validateAdditionalArgs(additionalArgs);
const allArgs = [...targets, ...validatedArgs];
const { stdout, stderr } = await this.runBazelCommand("build", allArgs, onOutput);
return `${stdout}\n${stderr}`;
}
async queryTarget(pattern: string, additionalArgs?: string[], onOutput?: (chunk: string) => void): Promise<string> {
const validatedArgs = this.validateAdditionalArgs(additionalArgs);
const allArgs = [pattern, ...validatedArgs];
const { stdout, stderr } = await this.runBazelCommand("query", allArgs, onOutput);
return stdout || stderr;
}
async testTargets(targets: string[], additionalArgs?: string[], onOutput?: (chunk: string) => void): Promise<string> {
const validatedArgs = this.validateAdditionalArgs(additionalArgs);
const allArgs = [...targets, ...validatedArgs];
const { stdout, stderr } = await this.runBazelCommand("test", allArgs, onOutput);
return `${stdout}\n${stderr}`;
}
async listTargets(path: string, additionalArgs?: string[], onOutput?: (chunk: string) => void): Promise<string> {
const queryPattern = `${path}/...`;
const validatedArgs = this.validateAdditionalArgs(additionalArgs);
const allArgs = [queryPattern, ...validatedArgs];
const { stdout } = await this.runBazelCommand("query", allArgs, onOutput);
return stdout;
}
async fetchDependencies(targets?: string[], additionalArgs?: string[], onOutput?: (chunk: string) => void): Promise<string> {
const validatedArgs = this.validateAdditionalArgs(additionalArgs);
const args = ["fetch"];
if (targets && targets.length > 0) {
args.push(...targets);
} else {
args.push("//...");
}
args.push(...validatedArgs);
const { stdout, stderr } = await this.runBazelCommand("build", args, onOutput);
return `${stdout}\n${stderr}`;
}
setWorkspacePath(newPath: string): string {
if (!fs.existsSync(newPath)) {
throw new Error(`Workspace path does not exist: ${newPath}`);
}
// Check if it appears to be a Bazel workspace
const isWorkspace = fs.existsSync(path.join(newPath, 'WORKSPACE')) ||
fs.existsSync(path.join(newPath, 'WORKSPACE.bazel')) ||
fs.existsSync(path.join(newPath, 'MODULE.bazel'));
if (!isWorkspace) {
throw new Error(`Path does not appear to be a Bazel workspace: ${newPath}`);
}
const oldPath = this.workspacePath;
this.workspacePath = newPath;
return `Workspace path updated from ${oldPath} to ${newPath}`;
}
}
// Parse CLI arguments and environment variables
function getConfig() {
const args = process.argv.slice(2);
const config: {
bazelPath: string;
workspacePath: string;
workspaceConfig?: string;
logPath: string;
} = {
bazelPath: "bazel",
workspacePath: process.cwd(),
logPath: ""
};
// Parse command line arguments
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--bazel_path" && i + 1 < args.length) {
config.bazelPath = args[++i];
} else if (arg === "--workspace_path" && i + 1 < args.length) {
config.workspacePath = args[++i];
} else if (arg === "--workspace_config" && i + 1 < args.length) {
config.workspaceConfig = args[++i];
} else if (arg === "--log_path" && i + 1 < args.length) {
config.logPath = args[++i];
}
}
// Override with environment variables if set
if (process.env.MCP_BAZEL_PATH) {
config.bazelPath = process.env.MCP_BAZEL_PATH;
}
if (process.env.MCP_WORKSPACE_PATH) {
config.workspacePath = process.env.MCP_WORKSPACE_PATH;
}
if (process.env.MCP_WORKSPACE_CONFIG) {
config.workspaceConfig = process.env.MCP_WORKSPACE_CONFIG;
}
if (process.env.MCP_LOG_PATH) {
config.logPath = process.env.MCP_LOG_PATH;
}
// Check for config file
const configFilePath = path.resolve(process.cwd(), ".bazel-mcp-config.json");
if (fs.existsSync(configFilePath)) {
try {
const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8"));
if (!process.env.MCP_BAZEL_PATH && !args.includes("--bazel_path") && fileConfig.bazel_path) {
config.bazelPath = fileConfig.bazel_path;
}
if (!process.env.MCP_WORKSPACE_PATH && !args.includes("--workspace_path") && fileConfig.workspace_path) {
config.workspacePath = fileConfig.workspace_path;
}
if (!process.env.MCP_WORKSPACE_CONFIG && !args.includes("--workspace_config") && fileConfig.workspace_config) {
config.workspaceConfig = fileConfig.workspace_config;
}
if (!process.env.MCP_LOG_PATH && !args.includes("--log_path") && fileConfig.log_path) {
config.logPath = fileConfig.log_path;
}
} catch (error) {
console.error("Error reading config file:", error);
}
}
// Update the global log path
logPath = config.logPath;
return config;
}
async function main() {
const config = getConfig();
// Server startup
log(`Server starting. PWD: ${process.cwd()}`);
if (logPath) {
log(`Log path configured: ${logPath}`);
}
log("Starting Bazel MCP Server...", 'info', true);
log(`Using Bazel at: ${config.bazelPath}`);
log(`Workspace path: ${config.workspacePath}`);
if (config.workspaceConfig) {
log(`Workspace config: ${config.workspaceConfig}`);
}
// Debug info (only logged to file)
log(`Environment variables: ${JSON.stringify(process.env)}`, 'info', false);
log(`Command line arguments: ${JSON.stringify(process.argv)}`, 'info', false);
try {
// Check if we're in a Bazel workspace
const workspaceExists = fs.existsSync(path.join(config.workspacePath, 'WORKSPACE')) ||
fs.existsSync(path.join(config.workspacePath, 'WORKSPACE.bazel')) ||
fs.existsSync(path.join(config.workspacePath, 'MODULE.bazel'));
log(`Is Bazel workspace: ${workspaceExists}`);
if (!workspaceExists) {
log(`Warning: ${config.workspacePath} does not appear to be a Bazel workspace`, 'error');
}
} catch (err) {
log(`Error checking workspace: ${err}`, 'error');
}
const server = new Server(
{
name: "Bazel MCP Server",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
},
);
const bazelClient = new BazelClient(
config.bazelPath,
config.workspacePath,
config.workspaceConfig
);
server.setRequestHandler(
CallToolRequestSchema,
async (request: CallToolRequest) => {
log(`Received CallToolRequest for tool: ${request.params.name}`, 'info', process.env.DEBUG === 'true');
try {
if (!request.params.arguments) {
throw new Error("No arguments provided");
}
let response;
switch (request.params.name) {
case "bazel_build_target": {
const args = request.params.arguments as unknown as BuildTargetArgs;
log(`Processing bazel_build_target with args: ${JSON.stringify(args)}`, 'info', false);
if (!args.targets || args.targets.length === 0) {
throw new Error("Missing required argument: targets");
}
response = await bazelClient.buildTargets(args.targets, args.additionalArgs);
break;
}
case "bazel_query_target": {
const args = request.params.arguments as unknown as QueryTargetArgs;
log(`Processing bazel_query_target with pattern: ${args.pattern}`, 'info', false);
if (!args.pattern) {
throw new Error("Missing required argument: pattern");
}
response = await bazelClient.queryTarget(args.pattern, args.additionalArgs);
break;
}
case "bazel_test_target": {
const args = request.params.arguments as unknown as TestTargetArgs;
log(`Processing bazel_test_target with args: ${JSON.stringify(args)}`, 'info', false);
if (!args.targets || args.targets.length === 0) {
throw new Error("Missing required argument: targets");
}
response = await bazelClient.testTargets(args.targets, args.additionalArgs);
break;
}
case "bazel_list_targets": {
const args = request.params.arguments as unknown as ListTargetsArgs;
log(`Processing bazel_list_targets for path: ${args.path}`, 'info', false);
if (!args.path) {
throw new Error("Missing required argument: path");
}
response = await bazelClient.listTargets(args.path, args.additionalArgs);
break;
}
case "bazel_fetch_dependencies": {
const args = request.params.arguments as unknown as FetchDependenciesArgs;
log(`Processing bazel_fetch_dependencies`, 'info', false);
response = await bazelClient.fetchDependencies(args.targets, args.additionalArgs);
break;
}
case "bazel_set_workspace_path": {
const args = request.params.arguments as unknown as SetWorkspacePathArgs;
log(`Processing bazel_set_workspace_path to: ${args.path}`, 'info', false);
if (!args.path) {
throw new Error("Missing required argument: path");
}
response = bazelClient.setWorkspacePath(args.path);
break;
}
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
const result = {
content: [{ type: "text", text: response }],
};
log(`Tool execution completed successfully`, 'info', false);
return result;
} catch (error) {
log(`Error executing tool: ${error instanceof Error ? error.message : String(error)}`, 'error');
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}),
},
],
};
}
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
log("Received ListToolsRequest", 'info', process.env.DEBUG === 'true');
const response = {
tools: [
buildTargetTool,
queryTargetTool,
testTargetTool,
listTargetsTool,
fetchDependenciesTool,
setWorkspacePathTool,
],
};
log(`Sending ListToolsResponse with ${response.tools.length} tools`, 'info', false);
return response;
});
const transport = new StdioServerTransport();
log("Connecting server to transport...", 'info', true);
await server.connect(transport);
log("Bazel MCP Server running on stdio", 'info', true);
}
main().catch((error) => {
log(`FATAL ERROR: ${error.message}`, 'error');
log(`Stack trace: ${error.stack || 'No stack trace available'}`, 'error', false);
process.exit(1);
});