Skip to content
Merged
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 .agents/skills/mcp-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The package is **not published to npm** (`private: true`). Use the local `dist/c

- After source changes, run `npm run build` to update `dist/`.
- Prefer absolute paths — `~` may not expand in all MCP clients.
- **Local Bazel execution is the default.** `BITRISE_BUILD_CACHE_AUTH_TOKEN` is not required. Bitrise RBE configs (`bitrise`, `remote_linux`) are stripped automatically unless the token is set.

## Config File

Expand Down
3 changes: 2 additions & 1 deletion .cursor/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"command": "~/.nvm/versions/node/v20.19.2/bin/node",
"args": ["~/Projects/XcodeBazelMCP/dist/cli.js", "mcp"],
"env": {
"BAZEL_IOS_WORKSPACE": "~/Projects/XcodeBazelMCP/example_projects/BazelApp"
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
"BAZEL_IOS_WORKSPACE": "~/Projects/ios-migration"
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ Then switch at runtime: `xcodebazelmcp set-defaults --profile app`
| `BAZEL_IOS_COMMAND_LOG_MAX_BYTES` | Rotate the command log past this size (default 5 MiB). |
| `BAZEL_IOS_COMMAND_LOG_DISABLE`| Set to `1` to disable command logging. |
| `IDB_PATH` | Path to the `idb` binary for UI automation (else discovered on `PATH`). |
| `BITRISE_BUILD_CACHE_AUTH_TOKEN` | **Not required.** Local builds/tests are the default. Set this only when you intentionally want Bitrise RBE (`--config=bitrise` / `--config=remote_linux`). Without it, those configs are dropped automatically. |

### Local execution is the default

DoorDash's `ios` repo defines `--config=bitrise` and `--config=remote_linux` for optional Bitrise remote execution. **You do not need `BITRISE_BUILD_CACHE_AUTH_TOKEN` for XcodeBazelMCP.** Agents and tools run locally on your Mac by default.

If a call passes `--config=bitrise` or `--config=remote_linux` without the token, XcodeBazelMCP silently drops those flags and runs locally. Set the token only when you explicitly want RBE offload (e.g. heavy compiles on Bitrise executors).

## CLI Examples

Expand Down
43 changes: 37 additions & 6 deletions src/core/bazel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { CommandResult } from '../types/index.js';
import { runCommand, runCommandStreaming } from '../utils/process.js';
import {
asStringArray,
bitriseAuthAvailable,
buildCommandArgs,
configArgs,
discoverExpression,
Expand All @@ -18,8 +19,10 @@ import {
requireLabel,
sanitizeQueryExpression,
simulatorArgs,
stripBitriseAuthFlags,
testFilterArgs,
tokenizeArgs,
withoutBitriseAuthConfigs,
runBazel,
runBazelStreaming,
getLastCommand,
Expand All @@ -40,6 +43,8 @@ vi.mock('../utils/process.js', () => ({

vi.mock('./workspace.js', () => ({
assertBazelWorkspace: vi.fn(),
isBazelWorkspace: vi.fn(() => true),
discoverBazelWorkspace: vi.fn(),
}));

const mockRunCommand = vi.mocked(runCommand);
Expand Down Expand Up @@ -228,6 +233,32 @@ describe('Bazel argument helpers', () => {
expect(() => configArgs(['bad;config'])).toThrow('Invalid config value');
});

it('drops Bitrise RBE configs when BITRISE_BUILD_CACHE_AUTH_TOKEN is unset', () => {
delete process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN;
expect(bitriseAuthAvailable()).toBe(false);
expect(withoutBitriseAuthConfigs(['local', 'bitrise', 'remote_linux', 'test'])).toEqual(['local', 'test']);
expect(configArgs(['bitrise', 'test'])).toEqual(['--config=test']);
expect(stripBitriseAuthFlags(['test', '--config=bitrise', '--jobs=1', '--config=remote_linux'])).toEqual([
'test',
'--jobs=1',
]);
expect(
buildCommandArgs({
target: '//:MyApp',
configs: ['bitrise'],
extraArgs: ['--config=remote_linux', '--cache_test_results=no'],
}),
).toEqual(['build', '--cache_test_results=no', '//:MyApp']);
});

it('keeps Bitrise RBE configs when BITRISE_BUILD_CACHE_AUTH_TOKEN is set', () => {
process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN = 'test-token';
expect(bitriseAuthAvailable()).toBe(true);
expect(configArgs(['bitrise', 'test'])).toEqual(['--config=bitrise', '--config=test']);
expect(stripBitriseAuthFlags(['--config=bitrise'])).toEqual(['--config=bitrise']);
delete process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN;
});

it('testFilterArgs returns single --test_filter for simple string', () => {
expect(testFilterArgs('MyTestSuite')).toEqual(['--test_filter=MyTestSuite']);
});
Expand Down Expand Up @@ -540,7 +571,7 @@ describe('runBazel', () => {

await runBazel(['build', '//:MyApp']);

expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['build', '//:MyApp'], {
expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['build', '//:MyApp'], {
cwd: tempDir,
timeoutSeconds: undefined,
maxOutput: expect.any(Number),
Expand All @@ -553,15 +584,15 @@ describe('runBazel', () => {

await runBazel(['build', '//:MyApp'], undefined, ['--host_jvm_args=-Xmx4g']);

expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['--host_jvm_args=-Xmx4g', 'build', '//:MyApp'], expect.any(Object));
expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['--host_jvm_args=-Xmx4g', 'build', '//:MyApp'], expect.any(Object));
});

it('passes timeout', async () => {
mockRunCommand.mockResolvedValue(mockSuccess);

await runBazel(['build', '//:MyApp'], 300);

expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['build', '//:MyApp'], expect.objectContaining({ timeoutSeconds: 300 }));
expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['build', '//:MyApp'], expect.objectContaining({ timeoutSeconds: 300 }));
});

it('respects BAZEL_IOS_STARTUP_ARGS env var', async () => {
Expand All @@ -570,7 +601,7 @@ describe('runBazel', () => {

await runBazel(['build', '//:MyApp']);

expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['--batch', '--noautodetect_server_javabase', 'build', '//:MyApp'], expect.any(Object));
expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['--batch', '--noautodetect_server_javabase', 'build', '//:MyApp'], expect.any(Object));

delete process.env.BAZEL_IOS_STARTUP_ARGS;
});
Expand Down Expand Up @@ -598,7 +629,7 @@ describe('runBazelStreaming', () => {
chunks.push(chunk);
}

expect(mockRunCommandStreaming).toHaveBeenCalledWith('bazel', ['build', '//:MyApp'], {
expect(mockRunCommandStreaming).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['build', '//:MyApp'], {
cwd: tempDir,
timeoutSeconds: undefined,
maxOutput: expect.any(Number),
Expand All @@ -617,7 +648,7 @@ describe('runBazelStreaming', () => {
chunks.push(chunk);
}

expect(mockRunCommandStreaming).toHaveBeenCalledWith('bazel', ['--batch', 'test', '//:Tests'], expect.any(Object));
expect(mockRunCommandStreaming).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['--batch', 'test', '//:Tests'], expect.any(Object));
});

it('stores final result in lastCommand', async () => {
Expand Down
56 changes: 52 additions & 4 deletions src/core/bazel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,54 @@ export function testFilterArgs(testFilter: unknown): string[] {
return [`--test_filter=${trimmed}`];
}

/** Bazel configs that require `BITRISE_BUILD_CACHE_AUTH_TOKEN` (DoorDash ios `.bazelrc`). */
export const BITRISE_AUTH_CONFIGS = ['bitrise', 'remote_linux'] as const;

export type BitriseAuthConfig = (typeof BITRISE_AUTH_CONFIGS)[number];

export function bitriseAuthAvailable(): boolean {
return Boolean(process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN?.trim());
}

function isBitriseAuthConfig(name: string): name is BitriseAuthConfig {
return (BITRISE_AUTH_CONFIGS as readonly string[]).includes(name);
}

/** Drop Bitrise RBE configs from a `configs` array when the auth token is missing. */
export function withoutBitriseAuthConfigs(configs: string[]): string[] {
if (bitriseAuthAvailable()) return configs;
return configs.filter((config) => !isBitriseAuthConfig(config));
}

/**
* Remove `--config=bitrise` / `--config=remote_linux` from a Bazel argv when
* `BITRISE_BUILD_CACHE_AUTH_TOKEN` is unset so local builds/tests still run.
*/
export function stripBitriseAuthFlags(args: string[]): string[] {
if (bitriseAuthAvailable()) return args;

const stripped: string[] = [];
const kept = args.filter((arg) => {
const match = /^--config=(.+)$/.exec(arg);
if (match && isBitriseAuthConfig(match[1])) {
stripped.push(arg);
return false;
}
return true;
});

if (stripped.length > 0) {
process.stderr.write(
`[XcodeBazelMCP] Dropping Bitrise RBE config(s) ${stripped.join(', ')} — `
+ 'local execution is the default (set BITRISE_BUILD_CACHE_AUTH_TOKEN only for RBE offload).\n',
);
}

return kept;
}

export function configArgs(value: unknown): string[] {
return asStringArray(value, 'configs').map((config) => {
return withoutBitriseAuthConfigs(asStringArray(value, 'configs')).map((config) => {
if (!/^[A-Za-z0-9_.-]+$/.test(config)) {
throw new Error(`Invalid config value: ${config}`);
}
Expand Down Expand Up @@ -269,7 +315,8 @@ export async function runBazel(
assertBazelWorkspace(config.workspacePath);
const { all: allStartupArgs } = resolveStartupArgs(startupArgs);
const id = randomUUID().slice(0, 8);
const result = await runCommand(config.bazelPath, [...allStartupArgs, ...args], {
const sanitizedArgs = stripBitriseAuthFlags(args);
const result = await runCommand(config.bazelPath, [...allStartupArgs, ...sanitizedArgs], {
cwd: config.workspacePath,
timeoutSeconds,
maxOutput: config.maxOutput,
Expand All @@ -293,9 +340,10 @@ export async function* runBazelStreaming(
const { all: allStartupArgs } = resolveStartupArgs(startupArgs);
const id = randomUUID().slice(0, 8);

const sanitizedArgs = stripBitriseAuthFlags(args);
for await (const chunk of runCommandStreaming(
config.bazelPath,
[...allStartupArgs, ...args],
[...allStartupArgs, ...sanitizedArgs],
{
cwd: config.workspacePath,
timeoutSeconds,
Expand Down Expand Up @@ -325,7 +373,7 @@ export function buildCommandArgs(args: BuildArgs): string[] {
...platformArgs(args.platform),
...(isDevice ? [] : simulatorArgs(args)),
...configArgs(args.configs),
...asStringArray(args.extraArgs, 'extraArgs'),
...stripBitriseAuthFlags(asStringArray(args.extraArgs, 'extraArgs')),
target,
];
}
26 changes: 25 additions & 1 deletion src/core/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { assertBazelWorkspace, readBspStatus } from './workspace.js';
import { assertBazelWorkspace, discoverBazelWorkspace, isBazelWorkspace, readBspStatus } from './workspace.js';

const dirs: string[] = [];
function makeTmp(): string {
Expand All @@ -16,6 +16,30 @@ afterEach(() => {
dirs.length = 0;
});

describe('isBazelWorkspace', () => {
it('returns true when MODULE.bazel exists', () => {
const tmp = makeTmp();
writeFileSync(join(tmp, 'MODULE.bazel'), 'module(name = "test")');
expect(isBazelWorkspace(tmp)).toBe(true);
});

it('returns false for a plain directory', () => {
const tmp = makeTmp();
expect(isBazelWorkspace(tmp)).toBe(false);
});
});

describe('discoverBazelWorkspace', () => {
it('walks up from a nested directory to find the Bazel root', () => {
const root = makeTmp();
const nested = join(root, 'Apps', 'Dasher', 'Tests');
mkdirSync(nested, { recursive: true });
writeFileSync(join(root, 'MODULE.bazel'), 'module(name = "test")');

expect(discoverBazelWorkspace([nested])).toBe(root);
});
});

describe('assertBazelWorkspace', () => {
it('throws when path does not exist', () => {
expect(() => assertBazelWorkspace('/no/such/path')).toThrowError(
Expand Down
39 changes: 36 additions & 3 deletions src/core/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { dirname, join, resolve } from 'node:path';

const WORKSPACE_MARKERS = ['MODULE.bazel', 'WORKSPACE', 'WORKSPACE.bazel'] as const;

export function isBazelWorkspace(workspacePath: string): boolean {
if (!existsSync(workspacePath) || !statSync(workspacePath).isDirectory()) {
return false;
}
return WORKSPACE_MARKERS.some((marker) => existsSync(join(workspacePath, marker)));
}

/**
* Walk upward from each start directory and return the first Bazel workspace root.
*/
export function discoverBazelWorkspace(startDirs: string[]): string | undefined {
const seen = new Set<string>();

for (const start of startDirs) {
if (!start?.trim()) continue;
let dir = resolve(start.trim());

for (let depth = 0; depth < 64; depth += 1) {
if (seen.has(dir)) break;
seen.add(dir);

if (isBazelWorkspace(dir)) return dir;

const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}

return undefined;
}

export function assertBazelWorkspace(workspacePath: string): void {
if (!existsSync(workspacePath) || !statSync(workspacePath).isDirectory()) {
throw new Error(`Workspace does not exist or is not a directory: ${workspacePath}`);
}

const markers = ['MODULE.bazel', 'WORKSPACE', 'WORKSPACE.bazel'];
if (!markers.some((marker) => existsSync(join(workspacePath, marker)))) {
if (!isBazelWorkspace(workspacePath)) {
throw new Error(`Workspace has no MODULE.bazel, WORKSPACE, or WORKSPACE.bazel: ${workspacePath}`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ describe('getConfig', () => {
setWorkspace(tempDir);
const config = getConfig();
expect(config.workspacePath).toBe(tempDir);
expect(config.bazelPath).toBe('bazel');
expect(config.bazelPath).toMatch(/bazel(isk)?$/);
});

it('loads config from workspace .xcodebazelmcp directory', () => {
Expand Down
32 changes: 32 additions & 0 deletions src/runtime/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import type { FileConfig, ProfileConfig, RuntimeConfig, SessionDefaults } from '../types/index.js';
import { discoverBazelWorkspace, isBazelWorkspace } from '../core/workspace.js';
import { resolveBazelExecutable } from './resolve-toolchain.js';

/** Expand a leading `~` or `~/` to the user's home directory before resolving. */
export function expandTilde(p: string): string {
Expand Down Expand Up @@ -30,12 +32,40 @@ let config: RuntimeConfig = {
};

let configLoaded = false;
let workspaceExplicitlySet = false;

function workspaceDiscoveryCandidates(): string[] {
return [
process.env.BAZEL_IOS_WORKSPACE,
config.workspacePath,
process.env.CURSOR_WORKSPACE_FOLDER,
process.env.WORKSPACE_FOLDER,
process.env.VSCODE_CWD,
process.cwd(),
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => resolvePath(value.trim()));
}

function resolveRuntimePaths(): void {
if (!workspaceExplicitlySet && !isBazelWorkspace(config.workspacePath)) {
const discovered = discoverBazelWorkspace(workspaceDiscoveryCandidates());
if (discovered) {
config.workspacePath = discovered;
config.workspaceAutoDiscovered = true;
}
} else if (isBazelWorkspace(config.workspacePath)) {
config.workspaceAutoDiscovered = false;
}

config.bazelPath = resolveBazelExecutable(config.bazelPath);
}

export function getConfig(): RuntimeConfig {
if (!configLoaded) {
loadConfigFile();
configLoaded = true;
}
resolveRuntimePaths();
const clonedProfiles: Record<string, ProfileConfig> = {};
for (const [k, v] of Object.entries(config.profiles)) {
clonedProfiles[k] = { ...v };
Expand All @@ -44,10 +74,12 @@ export function getConfig(): RuntimeConfig {
}

export function setWorkspace(workspacePath: string, bazelPath?: string): RuntimeConfig {
workspaceExplicitlySet = true;
config = {
...config,
workspacePath: resolvePath(workspacePath),
bazelPath: bazelPath ? expandTilde(bazelPath) : config.bazelPath,
workspaceAutoDiscovered: false,
};
configLoaded = false;
return getConfig();
Expand Down
Loading
Loading