Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
075eb5d
chore: add integration test for gcloud invocation
burkedavison Nov 5, 2025
fcd02f0
chore: formatting
burkedavison Nov 5, 2025
8196255
FIX: Fix Windows Platform Support
Jackxu9946 Nov 10, 2025
7119299
FIX: Windows support
Jackxu9946 Nov 10, 2025
d8e3103
FEAT: fix finding root sdk directory
Jackxu9946 Nov 13, 2025
4bc01b1
local save
Jackxu9946 Nov 13, 2025
c0a29c1
fix: refactor logic
Jackxu9946 Nov 14, 2025
761e0a4
fix: refactor logic
Jackxu9946 Nov 14, 2025
a5bb653
FEAT: Working on windows
Jackxu9946 Nov 17, 2025
005ac3e
chore: clean up
Jackxu9946 Nov 17, 2025
f646d0b
chore: cleanup unused
Jackxu9946 Nov 17, 2025
6596246
chore delete file
Jackxu9946 Nov 17, 2025
7420537
Refactor
Jackxu9946 Nov 18, 2025
6abe001
Refactor
Jackxu9946 Nov 18, 2025
3be0b13
final state. Time for tests
Jackxu9946 Nov 18, 2025
054ad29
chore: linter
Jackxu9946 Nov 18, 2025
9ddf63c
chore: linter
Jackxu9946 Nov 18, 2025
fda1cd8
chore: minor refactoring
Jackxu9946 Nov 18, 2025
42a7cfb
chore: refactoring
Jackxu9946 Nov 18, 2025
1bd4c15
chore: add tests and minor refactoring
Jackxu9946 Nov 18, 2025
66b4dfc
chore: refactoring
Jackxu9946 Nov 18, 2025
19eaea0
chore: add unit test
Jackxu9946 Nov 18, 2025
67c9b5a
chore: linter
Jackxu9946 Nov 18, 2025
3204261
fix: linter
Jackxu9946 Nov 18, 2025
e0349d0
fix: fail gcloud-mcp start up if not valid windows configuration is f…
Jackxu9946 Nov 18, 2025
c60ba91
chore: actual refactoring
Jackxu9946 Nov 18, 2025
bfdfebc
chore: fix test
Jackxu9946 Nov 18, 2025
088806d
fix: fail server initialization if no valid python is found on windows
Jackxu9946 Nov 18, 2025
4b128f8
fix: linter
Jackxu9946 Nov 19, 2025
1125a9c
Merge branch 'burkedavison/it-gcloud-invocation' into user/xujack/fix…
Jackxu9946 Nov 19, 2025
4789be4
fix: make function async and fix test
Jackxu9946 Nov 19, 2025
341784a
fix: linter
Jackxu9946 Nov 19, 2025
9f33549
fix: use async
Jackxu9946 Nov 19, 2025
73103c2
fix: use async and fix test
Jackxu9946 Nov 19, 2025
d761b37
chore: integration test
Jackxu9946 Nov 20, 2025
203c7de
fix: change to list of strings
Jackxu9946 Nov 20, 2025
fbce461
fix: async tests
Jackxu9946 Nov 20, 2025
a0365a7
Merge branch 'user/xujack/fixWindowsPlatform' of https://github.com/g…
Jackxu9946 Nov 20, 2025
632617b
fix: remove sync
Jackxu9946 Nov 20, 2025
cb0ad28
chore: local save
Jackxu9946 Nov 20, 2025
7b79ef3
chore: add additioanl integration test
Jackxu9946 Nov 21, 2025
a2a6969
chore: linter
Jackxu9946 Nov 21, 2025
f9ecfc7
chore: linter
Jackxu9946 Nov 21, 2025
9ddc6d4
chore: cleanup
Jackxu9946 Nov 21, 2025
28ae879
Merge remote-tracking branch 'origin/main' into HEAD
burkedavison Nov 21, 2025
aec6028
refactor: windows support
burkedavison Dec 1, 2025
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
157 changes: 152 additions & 5 deletions packages/gcloud-mcp/src/gcloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,140 @@
import { test, expect, beforeEach, Mock, vi, assert } from 'vitest';
import * as child_process from 'child_process';
import { PassThrough } from 'stream';
import * as gcloud from './gcloud.js';
import { isWindows } from './gcloud.js';
import * as path from 'path'; // Import path module

let gcloud: typeof import('./gcloud.js');
let isWindows: typeof import('./gcloud.js').isWindows;

vi.mock('child_process', () => ({
spawn: vi.fn(),
}));

const mockedSpawn = child_process.spawn as unknown as Mock;
let mockedGetCloudSDKSettings: Mock;

beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules(); // Clear module cache before each test
vi.resetModules(); // Clear module cache before each test

// Explicitly mock windows_gcloud_utils.js here to ensure it's active before gcloud.js is imported.
vi.doMock('./windows_gcloud_utils.js', () => ({
getCloudSDKSettings: vi.fn(),
}));
mockedGetCloudSDKSettings = (await import('./windows_gcloud_utils.js'))
.getCloudSDKSettings as unknown as Mock;

// Dynamically import gcloud.js after mocks are set up.
gcloud = await import('./gcloud.js');
isWindows = gcloud.isWindows;
});

test('getPlatformSpecificGcloudCommand should return gcloud command for non-windows platform', () => {
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});
const { command, args } = gcloud.getPlatformSpecificGcloudCommand([
'test',
'--project=test-project',
]);
expect(command).toBe('gcloud');
expect(args).toEqual(['test', '--project=test-project']);
});

test('getPlatformSpecificGcloudCommand should return python command for windows platform', () => {
const cloudSdkRootDir = 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK';
const cloudSdkPython = path.win32.join(
cloudSdkRootDir,
'platform',
'bundledpython',
'python.exe',
);
const gcloudPyPath = path.win32.join(cloudSdkRootDir, 'lib', 'gcloud.py');

mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: true,
windowsCloudSDKSettings: {
cloudSdkRootDir,
cloudSdkPython,
cloudSdkPythonArgs: '-S',
},
});
const { command, args } = gcloud.getPlatformSpecificGcloudCommand([
'test',
'--project=test-project',
]);
expect(command).toBe(path.win32.normalize(cloudSdkPython));
expect(args).toEqual(['-S', gcloudPyPath, 'test', '--project=test-project']);
});

test('invoke should call gcloud with the correct arguments on non-windows platform', async () => {
const mockChildProcess = {
stdout: new PassThrough(),
stderr: new PassThrough(),
stdin: new PassThrough(),
on: vi.fn((event, callback) => {
if (event === 'close') {
setTimeout(() => callback(0), 0);
}
}),
};
mockedSpawn.mockReturnValue(mockChildProcess);
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});

const resultPromise = gcloud.invoke(['test', '--project=test-project']);
mockChildProcess.stdout.end();
await resultPromise;

expect(mockedSpawn).toHaveBeenCalledWith('gcloud', ['test', '--project=test-project'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
});

test('invoke should call python with the correct arguments on windows platform', async () => {
const mockChildProcess = {
stdout: new PassThrough(),
stderr: new PassThrough(),
stdin: new PassThrough(),
on: vi.fn((event, callback) => {
if (event === 'close') {
setTimeout(() => callback(0), 0);
}
}),
};
mockedSpawn.mockReturnValue(mockChildProcess);
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: true,
windowsCloudSDKSettings: {
cloudSdkRootDir: 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK',
cloudSdkPython:
'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\platform\\bundledpython\\python.exe',
cloudSdkPythonArgs: '-S',
},
});

const resultPromise = gcloud.invoke(['test', '--project=test-project']);
mockChildProcess.stdout.end();
await resultPromise;

expect(mockedSpawn).toHaveBeenCalledWith(
path.win32.normalize(
'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\platform\\bundledpython\\python.exe',
),
[
'-S',
path.win32.normalize('C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\lib\\gcloud.py'),
'test',
'--project=test-project',
],
{
stdio: ['ignore', 'pipe', 'pipe'],
},
);
});

test('should return true if which command succeeds', async () => {
Expand All @@ -39,6 +162,10 @@ test('should return true if which command succeeds', async () => {
}),
};
mockedSpawn.mockReturnValue(mockChildProcess);
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});

const result = await gcloud.isAvailable();

Expand Down Expand Up @@ -102,10 +229,14 @@ test('should correctly handle stdout and stderr', async () => {
}),
};
mockedSpawn.mockReturnValue(mockChildProcess);
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});

const resultPromise = gcloud.invoke(['interactive-command']);

mockChildProcess.stdout.emit('data', 'Standard out');
mockChildProcess.stdout.emit('data', 'Standard output');
mockChildProcess.stderr.emit('data', 'Stan');
mockChildProcess.stdout.emit('data', 'put');
mockChildProcess.stderr.emit('data', 'dard error');
Expand Down Expand Up @@ -133,10 +264,14 @@ test('should correctly non-zero exit codes', async () => {
}),
};
mockedSpawn.mockReturnValue(mockChildProcess);
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});

const resultPromise = gcloud.invoke(['interactive-command']);

mockChildProcess.stdout.emit('data', 'Standard out');
mockChildProcess.stdout.emit('data', 'Standard output');
mockChildProcess.stderr.emit('data', 'Stan');
mockChildProcess.stdout.emit('data', 'put');
mockChildProcess.stderr.emit('data', 'dard error');
Expand All @@ -163,6 +298,10 @@ test('should reject when process fails to start', async () => {
}
}),
});
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});

const resultPromise = gcloud.invoke(['some-command']);

Expand All @@ -184,6 +323,10 @@ test('should correctly call lint double quotes', async () => {
}),
};
mockedSpawn.mockReturnValue(mockChildProcess);
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});

const resultPromise = gcloud.lint('compute instances list --project "cloud123"');

Expand Down Expand Up @@ -230,6 +373,10 @@ test('should correctly call lint single quotes', async () => {
}),
};
mockedSpawn.mockReturnValue(mockChildProcess);
mockedGetCloudSDKSettings.mockReturnValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});

const resultPromise = gcloud.lint("compute instances list --project 'cloud123'");

Expand Down
47 changes: 46 additions & 1 deletion packages/gcloud-mcp/src/gcloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,23 @@

import { z } from 'zod';
import * as child_process from 'child_process';
import * as path from 'path';
import {
getCloudSDKSettings as getRealCloudSDKSettings,
CloudSDKSettings,
} from './windows_gcloud_utils.js';

export const isWindows = (): boolean => process.platform === 'win32';

let memoizedCloudSDKSettings: CloudSDKSettings | undefined;

export function getMemoizedCloudSDKSettings(): CloudSDKSettings {
if (!memoizedCloudSDKSettings) {
memoizedCloudSDKSettings = getRealCloudSDKSettings();
}
return memoizedCloudSDKSettings;
}

export const isAvailable = (): Promise<boolean> =>
new Promise((resolve) => {
const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']);
Expand All @@ -36,12 +50,43 @@ export interface GcloudInvocationResult {
stderr: string;
}

export const getPlatformSpecificGcloudCommand = (
args: string[],
): { command: string; args: string[] } => {
const cloudSDKSettings = getMemoizedCloudSDKSettings();
if (cloudSDKSettings.isWindowsPlatform && cloudSDKSettings.windowsCloudSDKSettings) {
const windowsPathForGcloudPy = path.win32.join(
cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkRootDir,
'lib',
'gcloud.py',
);
const pythonPath = path.win32.normalize(
cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPython,
);

return {
command: pythonPath,
args: [
cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPythonArgs,
windowsPathForGcloudPy,
...args,
],
};
} else {
return { command: 'gcloud', args };
}
};

export const invoke = (args: string[]): Promise<GcloudInvocationResult> =>
new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';

const gcloud = child_process.spawn('gcloud', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const { command: command, args: executionArgs } = getPlatformSpecificGcloudCommand(args);

const gcloud = child_process.spawn(command, executionArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
});

gcloud.stdout.on('data', (data) => {
stdout += data.toString();
Expand Down
30 changes: 30 additions & 0 deletions packages/gcloud-mcp/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
vi.spyOn(gcloud, 'getMemoizedCloudSDKSettings').mockResolvedValue({
isWindowsPlatform: false,
windowsCloudSDKSettings: null,
});
registerToolSpy.mockClear();
});

Expand Down Expand Up @@ -150,3 +154,29 @@ test('should exit if config file is invalid JSON', async () => {
);
expect(process.exit).toHaveBeenCalledWith(1);
});

test('should exit if os is windows and it can not find working python', async () => {
process.argv = ['node', 'index.js'];
vi.spyOn(gcloud, 'isAvailable').mockResolvedValue(true);
vi.spyOn(gcloud, 'getMemoizedCloudSDKSettings').mockResolvedValue({
isWindowsPlatform: true,
windowsCloudSDKSettings: {
cloudSdkRootDir: '',
cloudSdkPython: '',
cloudSdkPythonArgs: '',
noWorkingPythonFound: true,
env: {},
},
});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('process', { ...process, exit: vi.fn(), on: vi.fn() });

await import('./index.js');

expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Unable to start gcloud mcp server: No working Python installation found for Windows gcloud execution.',
),
);
expect(process.exit).toHaveBeenCalledWith(1);
});
14 changes: 14 additions & 0 deletions packages/gcloud-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ const main = async () => {
process.exit(1);
}

const cloudSDKSettings = await gcloud.getMemoizedCloudSDKSettings();
// Platform verification
if (
cloudSDKSettings.isWindowsPlatform &&
(cloudSDKSettings.windowsCloudSDKSettings == null ||
cloudSDKSettings.windowsCloudSDKSettings.noWorkingPythonFound)
) {
log.error(
`Unable to start gcloud mcp server: No working Python installation found for Windows gcloud execution.`,
);
process.exit(1);
}

let config: McpConfig = {};
const configFile = argv.config;

Expand Down Expand Up @@ -112,6 +125,7 @@ const main = async () => {
},
{ capabilities: { tools: {} } },
);

const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]);
createRunGcloudCommand(acl).register(server);
await server.connect(new StdioServerTransport());
Expand Down
Loading