From 075eb5d7561b2a8a1a02b54c3f9792d43043aa83 Mon Sep 17 00:00:00 2001 From: Burke Davison Date: Wed, 5 Nov 2025 15:53:03 +0000 Subject: [PATCH 01/43] chore: add integration test for gcloud invocation --- .github/workflows/presubmit.yml | 13 +++-- .gitignore | 1 + packages/gcloud-mcp/package.json | 1 + .../gcloud-mcp/src/gcloud.integration.test.ts | 50 +++++++++++++++++++ .../gcloud-mcp/vitest.config.integration.ts | 46 +++++++++++++++++ packages/gcloud-mcp/vitest.config.ts | 2 +- vitest.config.ts | 2 +- 7 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 packages/gcloud-mcp/src/gcloud.integration.test.ts create mode 100644 packages/gcloud-mcp/vitest.config.integration.ts diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index 8b7dcaa6..d94a3480 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -56,9 +56,16 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: | - npm run test:i + - name: Run unit tests + run: npm run test:i + + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db' # v3 + with: + version: '>= 530.0.0' + + - name: Run integration tests + run: npm run test:integration --workspace packages/gcloud-mcp coverage: if: github.actor != 'dependabot[bot]' diff --git a/.gitignore b/.gitignore index e2714fee..e56b5310 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ Thumbs.db coverage/ packages/*/coverage/ junit.xml +junit.integration.xml packages/*/junit.xml # Ignore package-lock.json files in subdirectories diff --git a/packages/gcloud-mcp/package.json b/packages/gcloud-mcp/package.json index e6a35dc6..c50bad8c 100644 --- a/packages/gcloud-mcp/package.json +++ b/packages/gcloud-mcp/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "tsc --noemit && node build.js", "test": "vitest run", + "test:integration": "vitest run --config vitest.config.integration.ts", "start": "node dist/bundle.js", "lint": "prettier --check . && eslint . --max-warnings 0", "fix": "prettier --write . && eslint . --fix", diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts new file mode 100644 index 00000000..4e6af7b8 --- /dev/null +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, assert } from 'vitest'; +import * as gcloud from './gcloud.js'; + +test('gcloud is available', async () => { + const result = await gcloud.isAvailable(); + expect(result).toBe(true); +}); + +test('can invoke gcloud to lint a command', async () => { + const result = await gcloud.lint('compute instances list'); + assert(result.success); + expect(result.parsedCommand).toBe('compute instances list'); +}, 10000); + +test('cannot inject a command by appending arguments', async () => { + const result = await gcloud.invoke(['config', 'list', '&&', 'echo', 'asdf']); + expect(result.stdout).not.toContain('asdf'); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command by appending command', async () => { + const result = await gcloud.invoke(['config', 'list', '&&', 'echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command with a final argument', async () => { + const result = await gcloud.invoke(['config', 'list', '&& echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command with a single argument', async () => { + const result = await gcloud.invoke(['config list && echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); \ No newline at end of file diff --git a/packages/gcloud-mcp/vitest.config.integration.ts b/packages/gcloud-mcp/vitest.config.integration.ts new file mode 100644 index 00000000..49c60478 --- /dev/null +++ b/packages/gcloud-mcp/vitest.config.integration.ts @@ -0,0 +1,46 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// +import { defineConfig } from 'vitest/config'; + +// eslint-disable-next-line import/no-default-export +export default defineConfig({ + test: { + include: ['**/src/**/*.integration.test.ts', '**/src/**/*.integration.test.js'], + exclude: ['**/node_modules/**', '**/dist/**'], + environment: 'node', + globals: true, + reporters: ['default', 'junit'], + silent: true, + outputFile: { junit: 'junit.integration.xml' }, + coverage: { + enabled: true, + provider: 'v8', + reportsDirectory: './coverage', + include: ['**/src/**/*'], + reporter: [ + 'cobertura', + 'html', + 'json', + ['json-summary', { outputFile: 'coverage-summary.integration.json' }], + 'lcov', + 'text', + ['text', { file: 'full-text-summary.integration.txt' }], + ], + }, + }, +}); diff --git a/packages/gcloud-mcp/vitest.config.ts b/packages/gcloud-mcp/vitest.config.ts index 222a085e..5e12a762 100644 --- a/packages/gcloud-mcp/vitest.config.ts +++ b/packages/gcloud-mcp/vitest.config.ts @@ -21,7 +21,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['**/src/**/*.test.ts', '**/src/**/*.test.js'], - exclude: ['**/node_modules/**', '**/dist/**'], + exclude: ['**/node_modules/**', '**/dist/**', '**/src/**/*.integration.test.ts'], environment: 'node', globals: true, reporters: ['default', 'junit'], diff --git a/vitest.config.ts b/vitest.config.ts index 222a085e..5e12a762 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['**/src/**/*.test.ts', '**/src/**/*.test.js'], - exclude: ['**/node_modules/**', '**/dist/**'], + exclude: ['**/node_modules/**', '**/dist/**', '**/src/**/*.integration.test.ts'], environment: 'node', globals: true, reporters: ['default', 'junit'], From fcd02f050e0a2e17849b37e0c90bcc0b965858cb Mon Sep 17 00:00:00 2001 From: Burke Davison Date: Wed, 5 Nov 2025 15:55:53 +0000 Subject: [PATCH 02/43] chore: formatting --- packages/gcloud-mcp/src/gcloud.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts index 4e6af7b8..f2fba638 100644 --- a/packages/gcloud-mcp/src/gcloud.integration.test.ts +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -47,4 +47,4 @@ test('cannot inject a command with a final argument', async () => { test('cannot inject a command with a single argument', async () => { const result = await gcloud.invoke(['config list && echo asdf']); expect(result.code).toBeGreaterThan(0); -}, 10000); \ No newline at end of file +}, 10000); From 8196255bbc45c4bb92e7a4c75e1addbfa8ebd281 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Mon, 10 Nov 2025 16:51:35 +0000 Subject: [PATCH 03/43] FIX: Fix Windows Platform Support --- packages/gcloud-mcp/src/index.ts | 5 +- .../src/tools/run_gcloud_command.ts | 6 +- packages/gcloud-mcp/src/windows_gcloud.ts | 1 + .../gcloud-mcp/src/windows_gcloud_utils.ts | 184 ++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 packages/gcloud-mcp/src/windows_gcloud.ts create mode 100644 packages/gcloud-mcp/src/windows_gcloud_utils.ts diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index a33e6fa3..259e1d75 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -28,6 +28,7 @@ import { log } from './utility/logger.js'; import fs from 'fs'; import path from 'path'; import { createAccessControlList } from './denylist.js'; +import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; export const default_deny: string[] = [ 'compute start-iap-tunnel', @@ -113,7 +114,9 @@ const main = async () => { { capabilities: { tools: {} } }, ); const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); - createRunGcloudCommand(acl).register(server); + const windowsCloudSettings = getWindowsCloudSDKSettings(); + console.log('Windows Cloud SDK Settings:', windowsCloudSettings); + createRunGcloudCommand(acl,windowsCloudSettings).register(server); await server.connect(new StdioServerTransport()); log.info('🚀 gcloud mcp server started'); diff --git a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts index cbd2d262..d44f12e8 100644 --- a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts +++ b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts @@ -20,6 +20,7 @@ import { AccessControlList } from '../denylist.js'; import { findSuggestedAlternativeCommand } from '../suggest.js'; import { z } from 'zod'; import { log } from '../utility/logger.js'; +import { WindowsCloudSDKSettings } from 'src/windows_gcloud_utils.js'; const suggestionErrorMessage = (suggestedCommand: string) => `Execution denied: This command not permitted. However, a similar command is permitted. @@ -31,7 +32,7 @@ const aclErrorMessage = (aclMessage: string) => '\n\n' + 'To get the access control list details, invoke this tool again with the args ["gcloud-mcp", "debug", "config"]'; -export const createRunGcloudCommand = (acl: AccessControlList) => ({ +export const createRunGcloudCommand = (acl: AccessControlList, windowsCloudSDKSettings: WindowsCloudSDKSettings | null = null) => ({ register: (server: McpServer) => { server.registerTool( 'run_gcloud_command', @@ -63,6 +64,9 @@ export const createRunGcloudCommand = (acl: AccessControlList) => ({ if (args.join(' ') === 'gcloud-mcp debug config') { return successfulTextResult(acl.print()); } + if (windowsCloudSDKSettings) { + console.log("some error"); + } let parsedCommand; try { diff --git a/packages/gcloud-mcp/src/windows_gcloud.ts b/packages/gcloud-mcp/src/windows_gcloud.ts new file mode 100644 index 00000000..79529ad5 --- /dev/null +++ b/packages/gcloud-mcp/src/windows_gcloud.ts @@ -0,0 +1 @@ +// For invoking gcloud on windows diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts new file mode 100644 index 00000000..c3dbc0f6 --- /dev/null +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -0,0 +1,184 @@ +import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface CloudSdkSettings { + cloudSdkRootDir: string; + cloudSdkPython: string; + cloudSdkGsutilPython: string; + cloudSdkPythonArgs: string; + noWorkingPythonFound: boolean; + /** Environment variables to use when spawning gcloud.py */ + env: {[key: string]: string|undefined}; +} + +export interface WindowsCloudSDKSettings { + isWindowsPlatform: boolean; + cloudSDKSettings: CloudSdkSettings | null; +} + + +export function runWhere(command: string, spawnEnv: {[key: string]: string|undefined}): string[] { + try { + const result = child_process + .execSync(`where ${command}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + env: spawnEnv, // Use updated PATH for where command + }) + .trim(); + return result.split(/\r?\n/).filter(line => line.length > 0); + } catch (e) { + return []; + } +} + +export function getPythonVersion(pythonPath: string, spawnEnv: {[key: string]: string|undefined}): string | undefined { + try { + const escapedPath = + pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath; + const cmd = `${escapedPath} -c "import sys; print(sys.version)"`; + const result = child_process + .execSync(cmd, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + env: spawnEnv, // Use env without PYTHONHOME + }) + .trim(); + return result.split(/[\r\n]+/)[0]; + } catch (e) { + return undefined; + } +} + +export function findWindowsPythonPath(spawnEnv: {[key: string]: string|undefined}): string { + // Try to find a Python installation on Windows + // Try Python, python3, python2 + + const pythonCandidates = runWhere('python', spawnEnv); + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = getPythonVersion(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } + } + } + + const python3Candidates = runWhere('python3', spawnEnv); + if (python3Candidates.length > 0) { + for (const candidate of python3Candidates) { + const version = getPythonVersion(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } + } + } + + // Try to find python2 last + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = getPythonVersion(candidate, spawnEnv); + if (version && version.startsWith('2')) { + return candidate; + } + } + } + return "python.exe"; // Fallback to default python command +} + + +export function getCloudSDKSettings( + currentEnv: NodeJS.ProcessEnv = process.env, + scriptDir: string = __dirname, +): CloudSdkSettings { + const env = {...currentEnv }; + let cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; + if (!cloudSdkRootDir) { + cloudSdkRootDir = path.resolve(scriptDir, '..'); + } + const sdkBinPath = path.join(cloudSdkRootDir, 'bin', 'sdk'); + const newPath = `${sdkBinPath}${path.delimiter}${env['PATH'] || ''}`; + const pythonHome = undefined; + const spawnEnv: {[key: string]: string|undefined} = { + ...env, + PATH: newPath, + PYTHONHOME: pythonHome, + }; + let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; + // Find bundled python if no python is set in the environment. + if (!cloudSdkPython) { + const bundledPython = path.join( + cloudSdkRootDir, + 'platform', + 'bundledpython', + 'python.exe', + ); + if (fs.existsSync(bundledPython)) { + cloudSdkPython = bundledPython; + } + } + // If not bundled Python is found, try to find a Python installatikon on windows + if (!cloudSdkPython) { + cloudSdkPython = findWindowsPythonPath(spawnEnv); + } + + // Where always exist in a Windows Platform + let noWorkingPythonFound = false; + // Juggling check to hit null and undefined at the same time + if (!getPythonVersion(cloudSdkPython, spawnEnv)) { + noWorkingPythonFound = true; + } + // ? Not sure the point of this is + let cloudSdkPythonSitePackages = env['CLOUDSDK_PYTHON_SITEPACKAGES']; + if (cloudSdkPythonSitePackages === undefined) { + if (env['VIRTUAL_ENV']) { + cloudSdkPythonSitePackages = '1'; + } else { + cloudSdkPythonSitePackages = ''; + } + } + + let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; + const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); + if (!cloudSdkPythonSitePackages) { + // Site packages disabled + if (!cloudSdkPythonArgs.includes('-S')) { + cloudSdkPythonArgs = argsWithoutS ? `${argsWithoutS} -S` : '-S'; + } + } else { + // Site packages enabled + cloudSdkPythonArgs = argsWithoutS; + } + + const cloudSdkGsutilPython = env['CLOUDSDK_GSUTIL_PYTHON'] || cloudSdkPython; + + if (env['CLOUDSDK_ENCODING']) { + spawnEnv['PYTHONIOENCODING'] = env['CLOUDSDK_ENCODING']; + } + return { + cloudSdkRootDir, + cloudSdkPython, + cloudSdkGsutilPython, + cloudSdkPythonArgs, + noWorkingPythonFound, + env: spawnEnv, + }; +} + +export function getWindowsCloudSDKSettings() : WindowsCloudSDKSettings { + const isWindowsPlatform = os.platform() === 'win32'; + if (isWindowsPlatform) { + return { + isWindowsPlatform: true, + cloudSDKSettings: getCloudSDKSettings(), + }; + } + else { + return { + isWindowsPlatform: false, + cloudSDKSettings: null + } + } +} \ No newline at end of file From 7119299aec9f35504f3e10773b5c8368288fa3f6 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Mon, 10 Nov 2025 18:24:56 +0000 Subject: [PATCH 04/43] FIX: Windows support --- packages/gcloud-mcp/src/gcloud.ts | 25 +++++++++++++++---- .../src/tools/run_gcloud_command.ts | 2 +- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 7cca2816..c5994a40 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -16,6 +16,9 @@ import { z } from 'zod'; import * as child_process from 'child_process'; +import * as path from 'path'; +import { WindowsCloudSDKSettings } from './windows_gcloud_utils.js'; +import Stream from 'stream'; export const isWindows = (): boolean => process.platform === 'win32'; @@ -36,12 +39,24 @@ export interface GcloudInvocationResult { stderr: string; } -export const invoke = (args: string[]): Promise => + + +export const invoke = (args: string[], windowsCloudSDKSettings: WindowsCloudSDKSettings | null = null): Promise => new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; - - const gcloud = child_process.spawn('gcloud', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + let gcloud: child_process.ChildProcessByStdio; + if (windowsCloudSDKSettings && windowsCloudSDKSettings.isWindowsPlatform && windowsCloudSDKSettings.cloudSDKSettings) { + // Use the specialized logic to invoke gcloud on Windows + const pythonArgs = windowsCloudSDKSettings.cloudSDKSettings.cloudSdkPythonArgs.split(' ').filter(arg => arg.length > 0); + const gcloudPath = path.join(windowsCloudSDKSettings.cloudSDKSettings.cloudSdkRootDir, 'bin', 'gcloud'); + const newArgs = [...pythonArgs, gcloudPath, ...args]; + gcloud = child_process.spawn(windowsCloudSDKSettings.cloudSDKSettings.cloudSdkPython, newArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: windowsCloudSDKSettings.cloudSDKSettings.env }); + } + else { + gcloud = child_process.spawn('gcloud', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + } gcloud.stdout.on('data', (data) => { stdout += data.toString(); @@ -81,13 +96,13 @@ export type ParsedGcloudLintResult = error: string; }; -export const lint = async (command: string): Promise => { +export const lint = async (command: string, windowsCloudSDKSettings: WindowsCloudSDKSettings | null = null): Promise => { const { code, stdout, stderr } = await invoke([ 'meta', 'lint-gcloud-commands', '--command-string', `gcloud ${command}`, - ]); + ], windowsCloudSDKSettings); const json = JSON.parse(stdout); const lintCommands: LintCommandsOutput = LintCommandsSchema.parse(json); diff --git a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts index d44f12e8..5debbb48 100644 --- a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts +++ b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts @@ -74,7 +74,7 @@ export const createRunGcloudCommand = (acl: AccessControlList, windowsCloudSDKSe // Example // Given: gcloud compute --log-http=true instance list // Desired command string is: compute instances list - const parsedLintResult = await gcloud.lint(args.join(' ')); + const parsedLintResult = await gcloud.lint(args.join(' '), windowsCloudSDKSettings); if (!parsedLintResult.success) { return errorTextResult(parsedLintResult.error); } From d8e3103d08eeab176559b8711adbd87464343c5f Mon Sep 17 00:00:00 2001 From: xujack Date: Thu, 13 Nov 2025 11:25:48 -0800 Subject: [PATCH 05/43] FEAT: fix finding root sdk directory --- .../gcloud-mcp/src/windows_gcloud_utils.ts | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index c3dbc0f6..30d53ea2 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -22,7 +22,7 @@ export interface WindowsCloudSDKSettings { export function runWhere(command: string, spawnEnv: {[key: string]: string|undefined}): string[] { try { const result = child_process - .execSync(`where ${command}`, { + .execSync(`which ${command}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], env: spawnEnv, // Use updated PATH for where command @@ -88,16 +88,37 @@ export function findWindowsPythonPath(spawnEnv: {[key: string]: string|undefined return "python.exe"; // Fallback to default python command } +export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { + let cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; + if (cloudSdkRootDir) { + return cloudSdkRootDir; + } + + try { + // Use 'where gcloud' to find the gcloud executable on Windows + const gcloudPathOutput = child_process.execSync('where gcloud', { encoding: 'utf8', env: env }).trim(); + const gcloudPath = gcloudPathOutput.split(/\r?\n/)[0]; // Take the first path if multiple are returned + + if (gcloudPath) { + // Assuming gcloud.cmd is in /bin/gcloud.cmd + // We need to go up two levels from the gcloud.cmd path + const binDir = path.dirname(gcloudPath); + const sdkRoot = path.dirname(binDir); + return sdkRoot; + } + } catch (e) { + // gcloud not found in PATH, or other error + console.warn('gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.'); + } + + return ''; // Return empty string if not found +} export function getCloudSDKSettings( currentEnv: NodeJS.ProcessEnv = process.env, - scriptDir: string = __dirname, ): CloudSdkSettings { const env = {...currentEnv }; - let cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; - if (!cloudSdkRootDir) { - cloudSdkRootDir = path.resolve(scriptDir, '..'); - } + let cloudSdkRootDir = getSDKRootDirectory(env); const sdkBinPath = path.join(cloudSdkRootDir, 'bin', 'sdk'); const newPath = `${sdkBinPath}${path.delimiter}${env['PATH'] || ''}`; const pythonHome = undefined; From 4bc01b15d7f92d722925f867cb58d029957fbbb9 Mon Sep 17 00:00:00 2001 From: xujack Date: Thu, 13 Nov 2025 11:40:50 -0800 Subject: [PATCH 06/43] local save --- packages/gcloud-mcp/src/index.ts | 3 ++- packages/gcloud-mcp/src/windows_gcloud_utils.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 259e1d75..8c2e49e9 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -115,7 +115,8 @@ const main = async () => { ); const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); const windowsCloudSettings = getWindowsCloudSDKSettings(); - console.log('Windows Cloud SDK Settings:', windowsCloudSettings); + log.info("WHat in the world"); + log.info(`Windows Cloud SDK Settings: ${JSON.stringify(windowsCloudSettings)}`); createRunGcloudCommand(acl,windowsCloudSettings).register(server); await server.connect(new StdioServerTransport()); log.info('🚀 gcloud mcp server started'); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 30d53ea2..7ede47e7 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -96,7 +96,7 @@ export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { try { // Use 'where gcloud' to find the gcloud executable on Windows - const gcloudPathOutput = child_process.execSync('where gcloud', { encoding: 'utf8', env: env }).trim(); + const gcloudPathOutput = child_process.execSync('which gcloud', { encoding: 'utf8', env: env }).trim(); const gcloudPath = gcloudPathOutput.split(/\r?\n/)[0]; // Take the first path if multiple are returned if (gcloudPath) { From c0a29c12152ab5f58b44a6fc5da06e58b492e6ef Mon Sep 17 00:00:00 2001 From: xujack Date: Fri, 14 Nov 2025 00:20:04 +0000 Subject: [PATCH 07/43] fix: refactor logic --- packages/gcloud-mcp/src/gcloud.ts | 43 ++++++++++++------- packages/gcloud-mcp/src/index.ts | 8 ++-- .../gcloud-mcp/src/windows_gcloud_utils.ts | 8 +++- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index c5994a40..b829a522 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -17,10 +17,10 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; -import { WindowsCloudSDKSettings } from './windows_gcloud_utils.js'; -import Stream from 'stream'; +import { getCloudSDKSettings, getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; export const isWindows = (): boolean => process.platform === 'win32'; +export const windowsCloudSDKSettings = getWindowsCloudSDKSettings(); export const isAvailable = (): Promise => new Promise((resolve) => { @@ -41,22 +41,33 @@ export interface GcloudInvocationResult { -export const invoke = (args: string[], windowsCloudSDKSettings: WindowsCloudSDKSettings | null = null): Promise => +export const getPlatformSpecificGcloudCommand = (args: string[]) : {command: string, args: string[]} => { + if (windowsCloudSDKSettings.isWindowsPlatform && windowsCloudSDKSettings.cloudSDKSettings) { + + const windowsPathForGcloudPy = path.join(windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkRootDir, 'lib', 'gcloud.py'); + const pythonPath = windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPython; + + return { + command : pythonPath, + args: [ + ...windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPythonArgs, + windowsPathForGcloudPy, + ...args + ] + } + } else { + return { command: 'gcloud', args: args }; + } +} + +export const invoke = (args: string[]): Promise => new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; - let gcloud: child_process.ChildProcessByStdio; - if (windowsCloudSDKSettings && windowsCloudSDKSettings.isWindowsPlatform && windowsCloudSDKSettings.cloudSDKSettings) { - // Use the specialized logic to invoke gcloud on Windows - const pythonArgs = windowsCloudSDKSettings.cloudSDKSettings.cloudSdkPythonArgs.split(' ').filter(arg => arg.length > 0); - const gcloudPath = path.join(windowsCloudSDKSettings.cloudSDKSettings.cloudSdkRootDir, 'bin', 'gcloud'); - const newArgs = [...pythonArgs, gcloudPath, ...args]; - gcloud = child_process.spawn(windowsCloudSDKSettings.cloudSDKSettings.cloudSdkPython, newArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: windowsCloudSDKSettings.cloudSDKSettings.env }); - } - else { - gcloud = child_process.spawn('gcloud', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - } + const { command: command, args: allArgs } = getPlatformSpecificGcloudCommand(args); + + const gcloud = child_process.spawn(command, allArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); gcloud.stdout.on('data', (data) => { stdout += data.toString(); @@ -96,13 +107,13 @@ export type ParsedGcloudLintResult = error: string; }; -export const lint = async (command: string, windowsCloudSDKSettings: WindowsCloudSDKSettings | null = null): Promise => { +export const lint = async (command: string): Promise => { const { code, stdout, stderr } = await invoke([ 'meta', 'lint-gcloud-commands', '--command-string', `gcloud ${command}`, - ], windowsCloudSDKSettings); + ]); const json = JSON.parse(stdout); const lintCommands: LintCommandsOutput = LintCommandsSchema.parse(json); diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 8c2e49e9..b59ca791 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -114,10 +114,10 @@ const main = async () => { { capabilities: { tools: {} } }, ); const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); - const windowsCloudSettings = getWindowsCloudSDKSettings(); - log.info("WHat in the world"); - log.info(`Windows Cloud SDK Settings: ${JSON.stringify(windowsCloudSettings)}`); - createRunGcloudCommand(acl,windowsCloudSettings).register(server); + // const windowsCloudSettings = getWindowsCloudSDKSettings(); + // log.info("WHat in the world"); + // log.info(`Windows Cloud SDK Settings: ${JSON.stringify(windowsCloudSettings)}`); + createRunGcloudCommand(acl).register(server); await server.connect(new StdioServerTransport()); log.info('🚀 gcloud mcp server started'); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 7ede47e7..d2213969 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -190,7 +190,7 @@ export function getCloudSDKSettings( export function getWindowsCloudSDKSettings() : WindowsCloudSDKSettings { const isWindowsPlatform = os.platform() === 'win32'; - if (isWindowsPlatform) { + if (!isWindowsPlatform) { return { isWindowsPlatform: true, cloudSDKSettings: getCloudSDKSettings(), @@ -202,4 +202,8 @@ export function getWindowsCloudSDKSettings() : WindowsCloudSDKSettings { cloudSDKSettings: null } } -} \ No newline at end of file +} + +export function getGcloudLibPath(cloudSdkRootDir: string) : string { + return path.join(cloudSdkRootDir, 'lib', 'gcloud'); +} From 761e0a415c4fc3f115be8efd32aabd2e7ec601da Mon Sep 17 00:00:00 2001 From: xujack Date: Fri, 14 Nov 2025 00:22:04 +0000 Subject: [PATCH 08/43] fix: refactor logic --- packages/gcloud-mcp/src/gcloud.ts | 4 +--- packages/gcloud-mcp/src/index.ts | 4 ---- packages/gcloud-mcp/src/tools/run_gcloud_command.ts | 8 ++------ 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index b829a522..ad56bc92 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -17,7 +17,7 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; -import { getCloudSDKSettings, getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; +import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; export const isWindows = (): boolean => process.platform === 'win32'; export const windowsCloudSDKSettings = getWindowsCloudSDKSettings(); @@ -39,8 +39,6 @@ export interface GcloudInvocationResult { stderr: string; } - - export const getPlatformSpecificGcloudCommand = (args: string[]) : {command: string, args: string[]} => { if (windowsCloudSDKSettings.isWindowsPlatform && windowsCloudSDKSettings.cloudSDKSettings) { diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index b59ca791..a33e6fa3 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -28,7 +28,6 @@ import { log } from './utility/logger.js'; import fs from 'fs'; import path from 'path'; import { createAccessControlList } from './denylist.js'; -import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; export const default_deny: string[] = [ 'compute start-iap-tunnel', @@ -114,9 +113,6 @@ const main = async () => { { capabilities: { tools: {} } }, ); const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); - // const windowsCloudSettings = getWindowsCloudSDKSettings(); - // log.info("WHat in the world"); - // log.info(`Windows Cloud SDK Settings: ${JSON.stringify(windowsCloudSettings)}`); createRunGcloudCommand(acl).register(server); await server.connect(new StdioServerTransport()); log.info('🚀 gcloud mcp server started'); diff --git a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts index 5debbb48..cbd2d262 100644 --- a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts +++ b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts @@ -20,7 +20,6 @@ import { AccessControlList } from '../denylist.js'; import { findSuggestedAlternativeCommand } from '../suggest.js'; import { z } from 'zod'; import { log } from '../utility/logger.js'; -import { WindowsCloudSDKSettings } from 'src/windows_gcloud_utils.js'; const suggestionErrorMessage = (suggestedCommand: string) => `Execution denied: This command not permitted. However, a similar command is permitted. @@ -32,7 +31,7 @@ const aclErrorMessage = (aclMessage: string) => '\n\n' + 'To get the access control list details, invoke this tool again with the args ["gcloud-mcp", "debug", "config"]'; -export const createRunGcloudCommand = (acl: AccessControlList, windowsCloudSDKSettings: WindowsCloudSDKSettings | null = null) => ({ +export const createRunGcloudCommand = (acl: AccessControlList) => ({ register: (server: McpServer) => { server.registerTool( 'run_gcloud_command', @@ -64,9 +63,6 @@ export const createRunGcloudCommand = (acl: AccessControlList, windowsCloudSDKSe if (args.join(' ') === 'gcloud-mcp debug config') { return successfulTextResult(acl.print()); } - if (windowsCloudSDKSettings) { - console.log("some error"); - } let parsedCommand; try { @@ -74,7 +70,7 @@ export const createRunGcloudCommand = (acl: AccessControlList, windowsCloudSDKSe // Example // Given: gcloud compute --log-http=true instance list // Desired command string is: compute instances list - const parsedLintResult = await gcloud.lint(args.join(' '), windowsCloudSDKSettings); + const parsedLintResult = await gcloud.lint(args.join(' ')); if (!parsedLintResult.success) { return errorTextResult(parsedLintResult.error); } From a5bb653d7f1e8c4d490e8a35c0799f918ee1bf2f Mon Sep 17 00:00:00 2001 From: xujack Date: Mon, 17 Nov 2025 13:31:33 -0500 Subject: [PATCH 09/43] FEAT: Working on windows --- packages/gcloud-mcp/src/gcloud.ts | 5 ++- packages/gcloud-mcp/src/index.ts | 11 +++++ .../gcloud-mcp/src/windows_gcloud_utils.ts | 42 ++++++------------- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index ad56bc92..6490ec66 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -18,9 +18,12 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; +import { log } from './utility/logger.js'; export const isWindows = (): boolean => process.platform === 'win32'; export const windowsCloudSDKSettings = getWindowsCloudSDKSettings(); +log.info("What happened here"); +log.info(`${JSON.stringify(windowsCloudSDKSettings)}`) export const isAvailable = (): Promise => new Promise((resolve) => { @@ -48,7 +51,7 @@ export const getPlatformSpecificGcloudCommand = (args: string[]) : {command: str return { command : pythonPath, args: [ - ...windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPythonArgs, + windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPythonArgs, windowsPathForGcloudPy, ...args ] diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index a33e6fa3..4cc8efaf 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -28,6 +28,8 @@ import { log } from './utility/logger.js'; import fs from 'fs'; import path from 'path'; import { createAccessControlList } from './denylist.js'; +import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; +import {invoke} from "./gcloud.js"; export const default_deny: string[] = [ 'compute start-iap-tunnel', @@ -112,6 +114,15 @@ const main = async () => { }, { capabilities: { tools: {} } }, ); + + const windows = getWindowsCloudSDKSettings(); + log.info(` what in the fuck ${JSON.stringify(windows)}`); + const commandArgList = ["config", "list"]; + const { code, stdout, stderr } = await invoke(commandArgList); + log.info(`What code ${JSON.stringify(code)}`); + log.info(`What stdout ${JSON.stringify(stdout)}`); + log.info(`What stderr ${JSON.stringify(stderr)}`); + const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); createRunGcloudCommand(acl).register(server); await server.connect(new StdioServerTransport()); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index d2213969..7728a5af 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -2,11 +2,11 @@ import * as child_process from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +// import { log } from './utility/logger.js'; export interface CloudSdkSettings { cloudSdkRootDir: string; cloudSdkPython: string; - cloudSdkGsutilPython: string; cloudSdkPythonArgs: string; noWorkingPythonFound: boolean; /** Environment variables to use when spawning gcloud.py */ @@ -19,10 +19,10 @@ export interface WindowsCloudSDKSettings { } -export function runWhere(command: string, spawnEnv: {[key: string]: string|undefined}): string[] { +export function execWhere(command: string, spawnEnv: {[key: string]: string|undefined}): string[] { try { const result = child_process - .execSync(`which ${command}`, { + .execSync(`where ${command}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], env: spawnEnv, // Use updated PATH for where command @@ -56,7 +56,7 @@ export function findWindowsPythonPath(spawnEnv: {[key: string]: string|undefined // Try to find a Python installation on Windows // Try Python, python3, python2 - const pythonCandidates = runWhere('python', spawnEnv); + const pythonCandidates = execWhere('python', spawnEnv); if (pythonCandidates.length > 0) { for (const candidate of pythonCandidates) { const version = getPythonVersion(candidate, spawnEnv); @@ -66,7 +66,7 @@ export function findWindowsPythonPath(spawnEnv: {[key: string]: string|undefined } } - const python3Candidates = runWhere('python3', spawnEnv); + const python3Candidates = execWhere('python3', spawnEnv); if (python3Candidates.length > 0) { for (const candidate of python3Candidates) { const version = getPythonVersion(candidate, spawnEnv); @@ -96,7 +96,7 @@ export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { try { // Use 'where gcloud' to find the gcloud executable on Windows - const gcloudPathOutput = child_process.execSync('which gcloud', { encoding: 'utf8', env: env }).trim(); + const gcloudPathOutput = child_process.execSync('where gcloud', { encoding: 'utf8', env: env }).trim(); const gcloudPath = gcloudPathOutput.split(/\r?\n/)[0]; // Take the first path if multiple are returned if (gcloudPath) { @@ -119,13 +119,8 @@ export function getCloudSDKSettings( ): CloudSdkSettings { const env = {...currentEnv }; let cloudSdkRootDir = getSDKRootDirectory(env); - const sdkBinPath = path.join(cloudSdkRootDir, 'bin', 'sdk'); - const newPath = `${sdkBinPath}${path.delimiter}${env['PATH'] || ''}`; - const pythonHome = undefined; const spawnEnv: {[key: string]: string|undefined} = { ...env, - PATH: newPath, - PYTHONHOME: pythonHome, }; let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; // Find bundled python if no python is set in the environment. @@ -140,7 +135,7 @@ export function getCloudSDKSettings( cloudSdkPython = bundledPython; } } - // If not bundled Python is found, try to find a Python installatikon on windows + // If not bundled Python is found, try to find a Python installation on windows if (!cloudSdkPython) { cloudSdkPython = findWindowsPythonPath(spawnEnv); } @@ -151,8 +146,9 @@ export function getCloudSDKSettings( if (!getPythonVersion(cloudSdkPython, spawnEnv)) { noWorkingPythonFound = true; } - // ? Not sure the point of this is - let cloudSdkPythonSitePackages = env['CLOUDSDK_PYTHON_SITEPACKAGES']; + + // Check if the User has site package enabled + let cloudSdkPythonSitePackages = env['CLOUDSDK_PYTHON_SITEPACKAGES'] || ''; if (cloudSdkPythonSitePackages === undefined) { if (env['VIRTUAL_ENV']) { cloudSdkPythonSitePackages = '1'; @@ -163,25 +159,13 @@ export function getCloudSDKSettings( let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); - if (!cloudSdkPythonSitePackages) { - // Site packages disabled - if (!cloudSdkPythonArgs.includes('-S')) { - cloudSdkPythonArgs = argsWithoutS ? `${argsWithoutS} -S` : '-S'; - } - } else { - // Site packages enabled - cloudSdkPythonArgs = argsWithoutS; - } - const cloudSdkGsutilPython = env['CLOUDSDK_GSUTIL_PYTHON'] || cloudSdkPython; + // Spacing here matters + cloudSdkPythonArgs = !cloudSdkPythonSitePackages ? `${argsWithoutS}-S` : argsWithoutS; - if (env['CLOUDSDK_ENCODING']) { - spawnEnv['PYTHONIOENCODING'] = env['CLOUDSDK_ENCODING']; - } return { cloudSdkRootDir, cloudSdkPython, - cloudSdkGsutilPython, cloudSdkPythonArgs, noWorkingPythonFound, env: spawnEnv, @@ -190,7 +174,7 @@ export function getCloudSDKSettings( export function getWindowsCloudSDKSettings() : WindowsCloudSDKSettings { const isWindowsPlatform = os.platform() === 'win32'; - if (!isWindowsPlatform) { + if (isWindowsPlatform) { return { isWindowsPlatform: true, cloudSDKSettings: getCloudSDKSettings(), From 005ac3ef543f9f7f9dccb5a65977ed91ca6bb62b Mon Sep 17 00:00:00 2001 From: xujack Date: Mon, 17 Nov 2025 13:33:25 -0500 Subject: [PATCH 10/43] chore: clean up --- packages/gcloud-mcp/src/index.ts | 8 -------- packages/gcloud-mcp/src/windows_gcloud_utils.ts | 10 ++++------ 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 4cc8efaf..7a5aa393 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -115,14 +115,6 @@ const main = async () => { { capabilities: { tools: {} } }, ); - const windows = getWindowsCloudSDKSettings(); - log.info(` what in the fuck ${JSON.stringify(windows)}`); - const commandArgList = ["config", "list"]; - const { code, stdout, stderr } = await invoke(commandArgList); - log.info(`What code ${JSON.stringify(code)}`); - log.info(`What stdout ${JSON.stringify(stdout)}`); - log.info(`What stderr ${JSON.stringify(stderr)}`); - const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); createRunGcloudCommand(acl).register(server); await server.connect(new StdioServerTransport()); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 7728a5af..ee155004 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -119,9 +119,7 @@ export function getCloudSDKSettings( ): CloudSdkSettings { const env = {...currentEnv }; let cloudSdkRootDir = getSDKRootDirectory(env); - const spawnEnv: {[key: string]: string|undefined} = { - ...env, - }; + let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; // Find bundled python if no python is set in the environment. if (!cloudSdkPython) { @@ -137,13 +135,13 @@ export function getCloudSDKSettings( } // If not bundled Python is found, try to find a Python installation on windows if (!cloudSdkPython) { - cloudSdkPython = findWindowsPythonPath(spawnEnv); + cloudSdkPython = findWindowsPythonPath(env); } // Where always exist in a Windows Platform let noWorkingPythonFound = false; // Juggling check to hit null and undefined at the same time - if (!getPythonVersion(cloudSdkPython, spawnEnv)) { + if (!getPythonVersion(cloudSdkPython, env)) { noWorkingPythonFound = true; } @@ -168,7 +166,7 @@ export function getCloudSDKSettings( cloudSdkPython, cloudSdkPythonArgs, noWorkingPythonFound, - env: spawnEnv, + env: env, }; } From f646d0b22655c74a1184c44514c59c2de9f61b42 Mon Sep 17 00:00:00 2001 From: xujack Date: Mon, 17 Nov 2025 20:21:15 +0000 Subject: [PATCH 11/43] chore: cleanup unused --- packages/gcloud-mcp/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 7a5aa393..0ed8adce 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -28,8 +28,6 @@ import { log } from './utility/logger.js'; import fs from 'fs'; import path from 'path'; import { createAccessControlList } from './denylist.js'; -import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; -import {invoke} from "./gcloud.js"; export const default_deny: string[] = [ 'compute start-iap-tunnel', From 65962468610faad71fa791f69c6373aa218b7979 Mon Sep 17 00:00:00 2001 From: xujack Date: Mon, 17 Nov 2025 20:23:59 +0000 Subject: [PATCH 12/43] chore delete file --- packages/gcloud-mcp/src/windows_gcloud.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/gcloud-mcp/src/windows_gcloud.ts diff --git a/packages/gcloud-mcp/src/windows_gcloud.ts b/packages/gcloud-mcp/src/windows_gcloud.ts deleted file mode 100644 index 79529ad5..00000000 --- a/packages/gcloud-mcp/src/windows_gcloud.ts +++ /dev/null @@ -1 +0,0 @@ -// For invoking gcloud on windows From 7420537dba746805c95a0deb0db380eab3bac59e Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 08:27:22 -0800 Subject: [PATCH 13/43] Refactor --- packages/gcloud-mcp/src/windows_gcloud_utils.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index ee155004..11724565 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -96,13 +96,12 @@ export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { try { // Use 'where gcloud' to find the gcloud executable on Windows - const gcloudPathOutput = child_process.execSync('where gcloud', { encoding: 'utf8', env: env }).trim(); - const gcloudPath = gcloudPathOutput.split(/\r?\n/)[0]; // Take the first path if multiple are returned + const gcloudPathOutput = execWhere("gcloud", env)[0]; - if (gcloudPath) { + if (gcloudPathOutput) { // Assuming gcloud.cmd is in /bin/gcloud.cmd // We need to go up two levels from the gcloud.cmd path - const binDir = path.dirname(gcloudPath); + const binDir = path.dirname(gcloudPathOutput); const sdkRoot = path.dirname(binDir); return sdkRoot; } @@ -138,7 +137,7 @@ export function getCloudSDKSettings( cloudSdkPython = findWindowsPythonPath(env); } - // Where always exist in a Windows Platform + // Where.exe always exist in a Windows Platform let noWorkingPythonFound = false; // Juggling check to hit null and undefined at the same time if (!getPythonVersion(cloudSdkPython, env)) { @@ -185,7 +184,3 @@ export function getWindowsCloudSDKSettings() : WindowsCloudSDKSettings { } } } - -export function getGcloudLibPath(cloudSdkRootDir: string) : string { - return path.join(cloudSdkRootDir, 'lib', 'gcloud'); -} From 6abe001bd1df52c084e9946cf06c1ba2047b51e6 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 08:29:47 -0800 Subject: [PATCH 14/43] Refactor --- packages/gcloud-mcp/src/gcloud.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 6490ec66..5300bdd6 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -66,9 +66,9 @@ export const invoke = (args: string[]): Promise => let stdout = ''; let stderr = ''; - const { command: command, args: allArgs } = getPlatformSpecificGcloudCommand(args); + const { command: command, args: executionArgs } = getPlatformSpecificGcloudCommand(args); - const gcloud = child_process.spawn(command, allArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + const gcloud = child_process.spawn(command, executionArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); gcloud.stdout.on('data', (data) => { stdout += data.toString(); From 3be0b136bdbbe29ed0c600b853a4d5323dcbf884 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 08:30:53 -0800 Subject: [PATCH 15/43] final state. Time for tests --- packages/gcloud-mcp/src/gcloud.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 5300bdd6..4e60364a 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -18,12 +18,9 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; -import { log } from './utility/logger.js'; export const isWindows = (): boolean => process.platform === 'win32'; export const windowsCloudSDKSettings = getWindowsCloudSDKSettings(); -log.info("What happened here"); -log.info(`${JSON.stringify(windowsCloudSDKSettings)}`) export const isAvailable = (): Promise => new Promise((resolve) => { From 054ad29f0335fd6b5f71e5db50853d6f122d6a23 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 08:35:39 -0800 Subject: [PATCH 16/43] chore: linter --- .../gcloud-mcp/src/windows_gcloud_utils.ts | 324 +++++++++--------- 1 file changed, 169 insertions(+), 155 deletions(-) diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 11724565..21b20f79 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -1,186 +1,200 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import * as child_process from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -// import { log } from './utility/logger.js'; +import { log } from './utility/logger.js'; export interface CloudSdkSettings { - cloudSdkRootDir: string; - cloudSdkPython: string; - cloudSdkPythonArgs: string; - noWorkingPythonFound: boolean; - /** Environment variables to use when spawning gcloud.py */ - env: {[key: string]: string|undefined}; + cloudSdkRootDir: string; + cloudSdkPython: string; + cloudSdkPythonArgs: string; + noWorkingPythonFound: boolean; + /** Environment variables to use when spawning gcloud.py */ + env: { [key: string]: string | undefined }; } export interface WindowsCloudSDKSettings { - isWindowsPlatform: boolean; - cloudSDKSettings: CloudSdkSettings | null; + isWindowsPlatform: boolean; + cloudSDKSettings: CloudSdkSettings | null; } - -export function execWhere(command: string, spawnEnv: {[key: string]: string|undefined}): string[] { - try { - const result = child_process - .execSync(`where ${command}`, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - env: spawnEnv, // Use updated PATH for where command - }) - .trim(); - return result.split(/\r?\n/).filter(line => line.length > 0); - } catch (e) { - return []; - } +export function execWhere( + command: string, + spawnEnv: { [key: string]: string | undefined }, +): string[] { + try { + const result = child_process + .execSync(`where ${command}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + env: spawnEnv, // Use updated PATH for where command + }) + .trim(); + return result.split(/\r?\n/).filter((line) => line.length > 0); + } catch { + return []; + } } -export function getPythonVersion(pythonPath: string, spawnEnv: {[key: string]: string|undefined}): string | undefined { - try { - const escapedPath = - pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath; - const cmd = `${escapedPath} -c "import sys; print(sys.version)"`; - const result = child_process - .execSync(cmd, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - env: spawnEnv, // Use env without PYTHONHOME - }) - .trim(); - return result.split(/[\r\n]+/)[0]; - } catch (e) { - return undefined; - } +export function getPythonVersion( + pythonPath: string, + spawnEnv: { [key: string]: string | undefined }, +): string | undefined { + try { + const escapedPath = pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath; + const cmd = `${escapedPath} -c "import sys; print(sys.version)"`; + const result = child_process + .execSync(cmd, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + env: spawnEnv, // Use env without PYTHONHOME + }) + .trim(); + return result.split(/[\r\n]+/)[0]; + } catch { + return undefined; + } } -export function findWindowsPythonPath(spawnEnv: {[key: string]: string|undefined}): string { - // Try to find a Python installation on Windows - // Try Python, python3, python2 - - const pythonCandidates = execWhere('python', spawnEnv); - if (pythonCandidates.length > 0) { - for (const candidate of pythonCandidates) { - const version = getPythonVersion(candidate, spawnEnv); - if (version && version.startsWith('3')) { - return candidate; - } - } - } - - const python3Candidates = execWhere('python3', spawnEnv); - if (python3Candidates.length > 0) { - for (const candidate of python3Candidates) { - const version = getPythonVersion(candidate, spawnEnv); - if (version && version.startsWith('3')) { - return candidate; - } - } +export function findWindowsPythonPath(spawnEnv: { [key: string]: string | undefined }): string { + // Try to find a Python installation on Windows + // Try Python, python3, python2 + + const pythonCandidates = execWhere('python', spawnEnv); + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = getPythonVersion(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } } - - // Try to find python2 last - if (pythonCandidates.length > 0) { - for (const candidate of pythonCandidates) { - const version = getPythonVersion(candidate, spawnEnv); - if (version && version.startsWith('2')) { - return candidate; - } - } - } - return "python.exe"; // Fallback to default python command + } + + const python3Candidates = execWhere('python3', spawnEnv); + if (python3Candidates.length > 0) { + for (const candidate of python3Candidates) { + const version = getPythonVersion(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } + } + } + + // Try to find python2 last + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = getPythonVersion(candidate, spawnEnv); + if (version && version.startsWith('2')) { + return candidate; + } + } + } + return 'python.exe'; // Fallback to default python command } export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { - let cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; - if (cloudSdkRootDir) { - return cloudSdkRootDir; - } - - try { - // Use 'where gcloud' to find the gcloud executable on Windows - const gcloudPathOutput = execWhere("gcloud", env)[0]; - - if (gcloudPathOutput) { - // Assuming gcloud.cmd is in /bin/gcloud.cmd - // We need to go up two levels from the gcloud.cmd path - const binDir = path.dirname(gcloudPathOutput); - const sdkRoot = path.dirname(binDir); - return sdkRoot; - } - } catch (e) { - // gcloud not found in PATH, or other error - console.warn('gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.'); + const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; + if (cloudSdkRootDir) { + return cloudSdkRootDir; + } + + try { + // Use 'where gcloud' to find the gcloud executable on Windows + const gcloudPathOutput = execWhere('gcloud', env)[0]; + + if (gcloudPathOutput) { + // Assuming gcloud.cmd is in /bin/gcloud.cmd + // We need to go up two levels from the gcloud.cmd path + const binDir = path.dirname(gcloudPathOutput); + const sdkRoot = path.dirname(binDir); + return sdkRoot; } - - return ''; // Return empty string if not found + } catch { + // gcloud not found in PATH, or other error + log.warn( + 'gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.', + ); + } + + return ''; // Return empty string if not found } -export function getCloudSDKSettings( - currentEnv: NodeJS.ProcessEnv = process.env, -): CloudSdkSettings { - const env = {...currentEnv }; - let cloudSdkRootDir = getSDKRootDirectory(env); - - let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; - // Find bundled python if no python is set in the environment. - if (!cloudSdkPython) { - const bundledPython = path.join( - cloudSdkRootDir, - 'platform', - 'bundledpython', - 'python.exe', - ); - if (fs.existsSync(bundledPython)) { - cloudSdkPython = bundledPython; - } - } - // If not bundled Python is found, try to find a Python installation on windows - if (!cloudSdkPython) { - cloudSdkPython = findWindowsPythonPath(env); +export function getCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = process.env): CloudSdkSettings { + const env = { ...currentEnv }; + const cloudSdkRootDir = getSDKRootDirectory(env); + + let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; + // Find bundled python if no python is set in the environment. + if (!cloudSdkPython) { + const bundledPython = path.join(cloudSdkRootDir, 'platform', 'bundledpython', 'python.exe'); + if (fs.existsSync(bundledPython)) { + cloudSdkPython = bundledPython; } - - // Where.exe always exist in a Windows Platform - let noWorkingPythonFound = false; - // Juggling check to hit null and undefined at the same time - if (!getPythonVersion(cloudSdkPython, env)) { - noWorkingPythonFound = true; + } + // If not bundled Python is found, try to find a Python installation on windows + if (!cloudSdkPython) { + cloudSdkPython = findWindowsPythonPath(env); + } + + // Where.exe always exist in a Windows Platform + let noWorkingPythonFound = false; + // Juggling check to hit null and undefined at the same time + if (!getPythonVersion(cloudSdkPython, env)) { + noWorkingPythonFound = true; + } + + // Check if the User has site package enabled + let cloudSdkPythonSitePackages = env['CLOUDSDK_PYTHON_SITEPACKAGES'] || ''; + if (cloudSdkPythonSitePackages === undefined) { + if (env['VIRTUAL_ENV']) { + cloudSdkPythonSitePackages = '1'; + } else { + cloudSdkPythonSitePackages = ''; } + } - // Check if the User has site package enabled - let cloudSdkPythonSitePackages = env['CLOUDSDK_PYTHON_SITEPACKAGES'] || ''; - if (cloudSdkPythonSitePackages === undefined) { - if (env['VIRTUAL_ENV']) { - cloudSdkPythonSitePackages = '1'; - } else { - cloudSdkPythonSitePackages = ''; - } - } + let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; + const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); - let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; - const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); + // Spacing here matters + cloudSdkPythonArgs = !cloudSdkPythonSitePackages ? `${argsWithoutS}-S` : argsWithoutS; - // Spacing here matters - cloudSdkPythonArgs = !cloudSdkPythonSitePackages ? `${argsWithoutS}-S` : argsWithoutS; + return { + cloudSdkRootDir, + cloudSdkPython, + cloudSdkPythonArgs, + noWorkingPythonFound, + env, + }; +} +export function getWindowsCloudSDKSettings(): WindowsCloudSDKSettings { + const isWindowsPlatform = os.platform() === 'win32'; + if (isWindowsPlatform) { return { - cloudSdkRootDir, - cloudSdkPython, - cloudSdkPythonArgs, - noWorkingPythonFound, - env: env, + isWindowsPlatform: true, + cloudSDKSettings: getCloudSDKSettings(), }; -} - -export function getWindowsCloudSDKSettings() : WindowsCloudSDKSettings { - const isWindowsPlatform = os.platform() === 'win32'; - if (isWindowsPlatform) { - return { - isWindowsPlatform: true, - cloudSDKSettings: getCloudSDKSettings(), - }; - } - else { - return { - isWindowsPlatform: false, - cloudSDKSettings: null - } - } + } else { + return { + isWindowsPlatform: false, + cloudSDKSettings: null, + }; + } } From 9ddf63c4f864a953905aa9e56d3bbdf775d95798 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 08:37:57 -0800 Subject: [PATCH 17/43] chore: linter --- packages/gcloud-mcp/src/gcloud.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 4e60364a..5ffac490 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -39,33 +39,40 @@ export interface GcloudInvocationResult { stderr: string; } -export const getPlatformSpecificGcloudCommand = (args: string[]) : {command: string, args: string[]} => { +export const getPlatformSpecificGcloudCommand = ( + args: string[], +): { command: string; args: string[] } => { if (windowsCloudSDKSettings.isWindowsPlatform && windowsCloudSDKSettings.cloudSDKSettings) { - - const windowsPathForGcloudPy = path.join(windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkRootDir, 'lib', 'gcloud.py'); + const windowsPathForGcloudPy = path.join( + windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkRootDir, + 'lib', + 'gcloud.py', + ); const pythonPath = windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPython; - + return { - command : pythonPath, + command: pythonPath, args: [ windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPythonArgs, windowsPathForGcloudPy, - ...args - ] - } + ...args, + ], + }; } else { - return { command: 'gcloud', args: args }; + return { command: 'gcloud', args }; } -} +}; export const invoke = (args: string[]): Promise => new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; - + const { command: command, args: executionArgs } = getPlatformSpecificGcloudCommand(args); - const gcloud = child_process.spawn(command, executionArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + const gcloud = child_process.spawn(command, executionArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + }); gcloud.stdout.on('data', (data) => { stdout += data.toString(); From fda1cd8b7141fdcd534a1d928fb21b19e63d0850 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 16:44:06 +0000 Subject: [PATCH 18/43] chore: minor refactoring --- .../gcloud-mcp/src/windows_gcloud_utils.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 21b20f79..5440bdae 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -20,7 +20,7 @@ import * as path from 'path'; import * as os from 'os'; import { log } from './utility/logger.js'; -export interface CloudSdkSettings { +export interface WindowsCloudSDKSettings { cloudSdkRootDir: string; cloudSdkPython: string; cloudSdkPythonArgs: string; @@ -29,9 +29,9 @@ export interface CloudSdkSettings { env: { [key: string]: string | undefined }; } -export interface WindowsCloudSDKSettings { +export interface CloudSDKSettings { isWindowsPlatform: boolean; - cloudSDKSettings: CloudSdkSettings | null; + windowsCloudSDKSettings: WindowsCloudSDKSettings | null; } export function execWhere( @@ -135,7 +135,7 @@ export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { return ''; // Return empty string if not found } -export function getCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = process.env): CloudSdkSettings { +export function getWindowsCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = process.env): WindowsCloudSDKSettings { const env = { ...currentEnv }; const cloudSdkRootDir = getSDKRootDirectory(env); @@ -184,17 +184,10 @@ export function getCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = process.env) }; } -export function getWindowsCloudSDKSettings(): WindowsCloudSDKSettings { +export function getCloudSDKSettings(): CloudSDKSettings { const isWindowsPlatform = os.platform() === 'win32'; - if (isWindowsPlatform) { - return { - isWindowsPlatform: true, - cloudSDKSettings: getCloudSDKSettings(), - }; - } else { - return { - isWindowsPlatform: false, - cloudSDKSettings: null, - }; + return { + isWindowsPlatform : isWindowsPlatform, + windowsCloudSDKSettings : isWindowsPlatform ? getWindowsCloudSDKSettings() : null, } } From 42a7cfbe1b7c5c2711a3ce0390df1bd3f4dbcd04 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 16:45:40 +0000 Subject: [PATCH 19/43] chore: refactoring --- packages/gcloud-mcp/src/gcloud.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 5ffac490..5bda4433 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -17,10 +17,10 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; -import { getWindowsCloudSDKSettings } from './windows_gcloud_utils.js'; +import { getCloudSDKSettings } from './windows_gcloud_utils.js'; export const isWindows = (): boolean => process.platform === 'win32'; -export const windowsCloudSDKSettings = getWindowsCloudSDKSettings(); +export const cloudSDKSettings = getCloudSDKSettings(); export const isAvailable = (): Promise => new Promise((resolve) => { @@ -42,18 +42,18 @@ export interface GcloudInvocationResult { export const getPlatformSpecificGcloudCommand = ( args: string[], ): { command: string; args: string[] } => { - if (windowsCloudSDKSettings.isWindowsPlatform && windowsCloudSDKSettings.cloudSDKSettings) { + if (cloudSDKSettings.isWindowsPlatform && cloudSDKSettings.windowsCloudSDKSettings) { const windowsPathForGcloudPy = path.join( - windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkRootDir, + cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkRootDir, 'lib', 'gcloud.py', ); - const pythonPath = windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPython; + const pythonPath = cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPython; return { command: pythonPath, args: [ - windowsCloudSDKSettings.cloudSDKSettings?.cloudSdkPythonArgs, + cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPythonArgs, windowsPathForGcloudPy, ...args, ], From 1bd4c15d8d4b01a8ce17258d52c6e92c0d65876e Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 16:57:46 +0000 Subject: [PATCH 20/43] chore: add tests and minor refactoring --- .../src/windows_gcloud_utils.test.ts | 227 ++++++++++++++++++ .../gcloud-mcp/src/windows_gcloud_utils.ts | 10 +- 2 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 packages/gcloud-mcp/src/windows_gcloud_utils.test.ts diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts new file mode 100644 index 00000000..aa3905c3 --- /dev/null +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + execWhere, + getPythonVersion, + findWindowsPythonPath, + getSDKRootDirectory, + getWindowsCloudSDKSettings, + getCloudSDKSettings, +} from './windows_gcloud_utils'; + +vi.mock('child_process'); +vi.mock('fs'); +vi.mock('os'); +vi.mock('path'); + +describe('windows_gcloud_utils', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('execWhere', () => { + it('should return paths when command is found', () => { + vi.spyOn(child_process, 'execSync').mockReturnValue('C:\\Program Files\\Python\\Python39\\python.exe\nC:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe'); + const result = execWhere('command', {}); + expect(result).toEqual(['C:\\Program Files\\Python\\Python39\\python.exe', 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe']); + }); + + it('should return empty array when command is not found', () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error(); + }); + const result = execWhere('command', {}); + expect(result).toEqual([]); + }); + }); + + describe('getPythonVersion', () => { + it('should return python version', () => { + vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + const version = getPythonVersion('python', {}); + expect(version).toBe('3.9.0'); + }); + + it('should return undefined if python not found', () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error(); + }); + const version = getPythonVersion('python', {}); + expect(version).toBeUndefined(); + }); + }); + + describe('findWindowsPythonPath', () => { + it('should find python3 when multiple python versions are present', () => { + // Mock `execWhere('python', ...)` to return a list of python paths + vi.spyOn(child_process, 'execSync') + .mockReturnValueOnce( + 'C:\\Python27\\python.exe\nC:\\Python39\\python.exe' + ) // For execWhere('python') + .mockReturnValueOnce('2.7.18') // For getPythonVersion('C:\\Python27\\python.exe') + .mockReturnValueOnce('3.9.5') // For getPythonVersion('C:\\Python39\\python.exe') + .mockReturnValueOnce(''); // For execWhere('python3') - no python3 found + + const pythonPath = findWindowsPythonPath({}); + expect(pythonPath).toBe('C:\\Python39\\python.exe'); + }); + + it('should find python2 if no python3 is available', () => { + // Mock `execWhere('python', ...)` to return a list of python paths + vi.spyOn(child_process, 'execSync') + .mockReturnValueOnce( + 'C:\\Python27\\python.exe' + ) // For execWhere('python') + .mockReturnValueOnce('2.7.18') // For getPythonVersion('C:\\Python27\\python.exe') + .mockReturnValueOnce('') // For execWhere('python3') - no python3 found + .mockReturnValueOnce('2.7.18'); // For getPythonVersion('C:\\Python27\\python.exe') - second check for python2 + + const pythonPath = findWindowsPythonPath({}); + expect(pythonPath).toBe('C:\\Python27\\python.exe'); + }); + + it('should return default python.exe if no python is found', () => { + vi.spyOn(child_process, 'execSync').mockReturnValueOnce(''); // For execWhere('python') + vi.spyOn(child_process, 'execSync').mockReturnValueOnce(''); // For execWhere('python3') + const pythonPath = findWindowsPythonPath({}); + expect(pythonPath).toBe('python.exe'); + }); + }); + + describe('getSDKRootDirectory', () => { + it('should get root directory from CLOUDSDK_ROOT_DIR', () => { + const sdkRoot = getSDKRootDirectory({ CLOUDSDK_ROOT_DIR: 'sdk_root' }); + expect(sdkRoot).toBe('sdk_root'); + }); + + it('should get root directory from where gcloud', () => { + vi.spyOn(child_process, 'execSync').mockReturnValue('C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd'); + vi.spyOn(path, 'dirname').mockImplementation((p) => { + if (p === 'C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd') return 'C:\\Program Files\\Google\\Cloud SDK\\bin'; + if (p === 'C:\\Program Files\\Google\\Cloud SDK\\bin') return 'C:\\Program Files\\Google\\Cloud SDK'; + return p; + }); + const sdkRoot = getSDKRootDirectory({}); + expect(sdkRoot).toBe('C:\\Program Files\\Google\\Cloud SDK'); + }); + + it('should return empty string if gcloud not found', () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error('gcloud not found'); + }); + const sdkRoot = getSDKRootDirectory({}); + expect(sdkRoot).toBe(''); + }); + }); + + describe('getWindowsCloudSDKSettings', () => { + it('should get settings with bundled python', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); + vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); // For getPythonVersion + + const settings = getWindowsCloudSDKSettings({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON_SITEPACKAGES: '' // no site packages + }); + + expect(settings.cloudSdkRootDir).toBe('C:\\CloudSDK'); + expect(settings.cloudSdkPython).toBe('C:\\CloudSDK\\platform\\bundledpython\\python.exe'); + expect(settings.cloudSdkPythonArgs).toBe('-S'); // Expect -S to be added + expect(settings.noWorkingPythonFound).toBe(false); + }); + + it('should get settings with CLOUDSDK_PYTHON and site packages enabled', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python + vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); // For getPythonVersion + + const settings = getWindowsCloudSDKSettings({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON: 'C:\\Python39\\python.exe', + CLOUDSDK_PYTHON_SITEPACKAGES: '1', + }); + + expect(settings.cloudSdkRootDir).toBe('C:\\CloudSDK'); + expect(settings.cloudSdkPython).toBe('C:\\Python39\\python.exe'); + expect(settings.cloudSdkPythonArgs).toBe(''); // Expect no -S + expect(settings.noWorkingPythonFound).toBe(false); + }); + + it('should set noWorkingPythonFound to true if python version cannot be determined', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error(); + }); // getPythonVersion throws + + const settings = getWindowsCloudSDKSettings({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON: 'C:\\NonExistentPython\\python.exe', + }); + + expect(settings.noWorkingPythonFound).toBe(true); + }); + + it('should handle VIRTUAL_ENV for site packages', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + + const settings = getWindowsCloudSDKSettings({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON: 'C:\\Python39\\python.exe', // Explicitly set python to avoid findWindowsPythonPath + VIRTUAL_ENV: 'C:\\MyVirtualEnv', + CLOUDSDK_PYTHON_SITEPACKAGES: undefined, // Ensure this is undefined to hit the if condition + }); + expect(settings.cloudSdkPythonArgs).toBe(''); + }); + + it('should keep existing CLOUDSDK_PYTHON_ARGS and add -S if no site packages', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); + vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + + const settings = getWindowsCloudSDKSettings({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON_ARGS: '-v', + CLOUDSDK_PYTHON_SITEPACKAGES: '' + }); + expect(settings.cloudSdkPythonArgs).toBe('-v -S'); + }); + + it('should remove -S from CLOUDSDK_PYTHON_ARGS if site packages enabled', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); + vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + + const settings = getWindowsCloudSDKSettings({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON_ARGS: '-v -S', + CLOUDSDK_PYTHON_SITEPACKAGES: '1' + }); + expect(settings.cloudSdkPythonArgs).toBe('-v'); + }); + }); + + describe('getCloudSDKSettings', () => { + it('should return windows settings on windows', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); + + const settings = getCloudSDKSettings(); + expect(settings.isWindowsPlatform).toBe(true); + expect(settings.windowsCloudSDKSettings).not.toBeNull(); + expect(settings.windowsCloudSDKSettings?.noWorkingPythonFound).toBe(false); + }); + + it('should not return windows settings on other platforms', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + const settings = getCloudSDKSettings(); + expect(settings.isWindowsPlatform).toBe(false); + expect(settings.windowsCloudSDKSettings).toBeNull(); + }); + }); +}); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 5440bdae..ece99da6 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -160,20 +160,24 @@ export function getWindowsCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = proce } // Check if the User has site package enabled - let cloudSdkPythonSitePackages = env['CLOUDSDK_PYTHON_SITEPACKAGES'] || ''; + let cloudSdkPythonSitePackages = currentEnv['CLOUDSDK_PYTHON_SITEPACKAGES']; if (cloudSdkPythonSitePackages === undefined) { - if (env['VIRTUAL_ENV']) { + if (currentEnv['VIRTUAL_ENV']) { cloudSdkPythonSitePackages = '1'; } else { cloudSdkPythonSitePackages = ''; } + } else if (cloudSdkPythonSitePackages === null) { + cloudSdkPythonSitePackages = ''; } let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); // Spacing here matters - cloudSdkPythonArgs = !cloudSdkPythonSitePackages ? `${argsWithoutS}-S` : argsWithoutS; + cloudSdkPythonArgs = !cloudSdkPythonSitePackages + ? `${argsWithoutS}${argsWithoutS ? ' ' : ''}-S` + : argsWithoutS; return { cloudSdkRootDir, From 66b4dfcf381a5fcf669360fdbea8b72d37ef2f9e Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 08:59:55 -0800 Subject: [PATCH 21/43] chore: refactoring --- packages/gcloud-mcp/src/windows_gcloud_utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts index aa3905c3..643eb187 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -10,7 +10,7 @@ import { getSDKRootDirectory, getWindowsCloudSDKSettings, getCloudSDKSettings, -} from './windows_gcloud_utils'; +} from './windows_gcloud_utils.js'; vi.mock('child_process'); vi.mock('fs'); From 19eaea0cf3c3d02da251acf72434efe2d060977f Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 19:32:01 +0000 Subject: [PATCH 22/43] chore: add unit test --- packages/gcloud-mcp/src/gcloud.test.ts | 154 +++++++++++++++++- packages/gcloud-mcp/src/gcloud.ts | 22 ++- .../src/windows_gcloud_utils.test.ts | 22 +-- .../gcloud-mcp/src/windows_gcloud_utils.ts | 15 +- 4 files changed, 179 insertions(+), 34 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.test.ts b/packages/gcloud-mcp/src/gcloud.test.ts index a90dec4b..3aa3cd63 100644 --- a/packages/gcloud-mcp/src/gcloud.test.ts +++ b/packages/gcloud-mcp/src/gcloud.test.ts @@ -1,7 +1,7 @@ /** * Copyright 2025 Google LLC * - * Licensed under the Apache License, Version 2.0 (the "License"); + * Licensed under the Apache License, Version 20 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -9,7 +9,7 @@ * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.e * See the License for the specific language governing permissions and * limitations under the License. */ @@ -17,17 +17,133 @@ 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: cloudSdkRootDir, + cloudSdkPython: 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 () => { @@ -39,6 +155,10 @@ test('should return true if which command succeeds', async () => { }), }; mockedSpawn.mockReturnValue(mockChildProcess); + mockedGetCloudSDKSettings.mockReturnValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); const result = await gcloud.isAvailable(); @@ -102,10 +222,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'); @@ -133,10 +257,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'); @@ -163,6 +291,10 @@ test('should reject when process fails to start', async () => { } }), }); + mockedGetCloudSDKSettings.mockReturnValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); const resultPromise = gcloud.invoke(['some-command']); @@ -184,6 +316,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"'); @@ -230,6 +366,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'"); diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 5bda4433..2f75685f 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -17,10 +17,21 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; -import { getCloudSDKSettings } from './windows_gcloud_utils.js'; +import { + getCloudSDKSettings as getRealCloudSDKSettings, + CloudSDKSettings, +} from './windows_gcloud_utils.js'; export const isWindows = (): boolean => process.platform === 'win32'; -export const cloudSDKSettings = getCloudSDKSettings(); + +let memoizedCloudSDKSettings: CloudSDKSettings | undefined; + +function getMemoizedCloudSDKSettings(): CloudSDKSettings { + if (!memoizedCloudSDKSettings) { + memoizedCloudSDKSettings = getRealCloudSDKSettings(); + } + return memoizedCloudSDKSettings; +} export const isAvailable = (): Promise => new Promise((resolve) => { @@ -37,18 +48,19 @@ export interface GcloudInvocationResult { code: number | null; stdout: string; stderr: string; -} +}3 export const getPlatformSpecificGcloudCommand = ( args: string[], ): { command: string; args: string[] } => { + const cloudSDKSettings = getMemoizedCloudSDKSettings(); if (cloudSDKSettings.isWindowsPlatform && cloudSDKSettings.windowsCloudSDKSettings) { - const windowsPathForGcloudPy = path.join( + const windowsPathForGcloudPy = path.win32.join( cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkRootDir, 'lib', 'gcloud.py', ); - const pythonPath = cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPython; + const pythonPath = path.win32.normalize(cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPython); return { command: pythonPath, diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts index 643eb187..5bf53ea1 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -15,7 +15,6 @@ import { vi.mock('child_process'); vi.mock('fs'); vi.mock('os'); -vi.mock('path'); describe('windows_gcloud_utils', () => { beforeEach(() => { @@ -26,7 +25,7 @@ describe('windows_gcloud_utils', () => { it('should return paths when command is found', () => { vi.spyOn(child_process, 'execSync').mockReturnValue('C:\\Program Files\\Python\\Python39\\python.exe\nC:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe'); const result = execWhere('command', {}); - expect(result).toEqual(['C:\\Program Files\\Python\\Python39\\python.exe', 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe']); + expect(result).toEqual(['C:\\Program Files\\Python\\Python39\\python.exe', 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe'].map(p => path.win32.normalize(p))); }); it('should return empty array when command is not found', () => { @@ -94,18 +93,13 @@ describe('windows_gcloud_utils', () => { describe('getSDKRootDirectory', () => { it('should get root directory from CLOUDSDK_ROOT_DIR', () => { const sdkRoot = getSDKRootDirectory({ CLOUDSDK_ROOT_DIR: 'sdk_root' }); - expect(sdkRoot).toBe('sdk_root'); + expect(sdkRoot).toBe(path.win32.normalize('sdk_root')); }); it('should get root directory from where gcloud', () => { vi.spyOn(child_process, 'execSync').mockReturnValue('C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd'); - vi.spyOn(path, 'dirname').mockImplementation((p) => { - if (p === 'C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd') return 'C:\\Program Files\\Google\\Cloud SDK\\bin'; - if (p === 'C:\\Program Files\\Google\\Cloud SDK\\bin') return 'C:\\Program Files\\Google\\Cloud SDK'; - return p; - }); const sdkRoot = getSDKRootDirectory({}); - expect(sdkRoot).toBe('C:\\Program Files\\Google\\Cloud SDK'); + expect(sdkRoot).toBe(path.win32.normalize('C:\\Program Files\\Google\\Cloud SDK')); }); it('should return empty string if gcloud not found', () => { @@ -120,7 +114,6 @@ describe('windows_gcloud_utils', () => { describe('getWindowsCloudSDKSettings', () => { it('should get settings with bundled python', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); // For getPythonVersion const settings = getWindowsCloudSDKSettings({ @@ -128,8 +121,8 @@ describe('windows_gcloud_utils', () => { CLOUDSDK_PYTHON_SITEPACKAGES: '' // no site packages }); - expect(settings.cloudSdkRootDir).toBe('C:\\CloudSDK'); - expect(settings.cloudSdkPython).toBe('C:\\CloudSDK\\platform\\bundledpython\\python.exe'); + expect(settings.cloudSdkRootDir).toBe(path.win32.normalize('C:\\CloudSDK')); + expect(settings.cloudSdkPython).toBe(path.win32.normalize('C:\\CloudSDK\\platform\\bundledpython\\python.exe')); expect(settings.cloudSdkPythonArgs).toBe('-S'); // Expect -S to be added expect(settings.noWorkingPythonFound).toBe(false); }); @@ -144,7 +137,7 @@ describe('windows_gcloud_utils', () => { CLOUDSDK_PYTHON_SITEPACKAGES: '1', }); - expect(settings.cloudSdkRootDir).toBe('C:\\CloudSDK'); + expect(settings.cloudSdkRootDir).toBe(path.win32.normalize('C:\\CloudSDK')); expect(settings.cloudSdkPython).toBe('C:\\Python39\\python.exe'); expect(settings.cloudSdkPythonArgs).toBe(''); // Expect no -S expect(settings.noWorkingPythonFound).toBe(false); @@ -179,7 +172,6 @@ describe('windows_gcloud_utils', () => { it('should keep existing CLOUDSDK_PYTHON_ARGS and add -S if no site packages', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); const settings = getWindowsCloudSDKSettings({ @@ -192,7 +184,6 @@ describe('windows_gcloud_utils', () => { it('should remove -S from CLOUDSDK_PYTHON_ARGS if site packages enabled', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); const settings = getWindowsCloudSDKSettings({ @@ -209,7 +200,6 @@ describe('windows_gcloud_utils', () => { vi.spyOn(os, 'platform').mockReturnValue('win32'); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(path, 'join').mockImplementation((...args) => args.join('\\')); const settings = getCloudSDKSettings(); expect(settings.isWindowsPlatform).toBe(true); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index ece99da6..781a72dd 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -46,7 +46,10 @@ export function execWhere( env: spawnEnv, // Use updated PATH for where command }) .trim(); - return result.split(/\r?\n/).filter((line) => line.length > 0); + return result + .split(/\r?\n/) + .filter((line) => line.length > 0) + .map((line) => path.win32.normalize(line)); } catch { return []; } @@ -109,9 +112,9 @@ export function findWindowsPythonPath(spawnEnv: { [key: string]: string | undefi } export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { - const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; + let cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; if (cloudSdkRootDir) { - return cloudSdkRootDir; + return path.win32.normalize(cloudSdkRootDir); } try { @@ -121,8 +124,8 @@ export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { if (gcloudPathOutput) { // Assuming gcloud.cmd is in /bin/gcloud.cmd // We need to go up two levels from the gcloud.cmd path - const binDir = path.dirname(gcloudPathOutput); - const sdkRoot = path.dirname(binDir); + const binDir = path.win32.dirname(gcloudPathOutput); + const sdkRoot = path.win32.dirname(binDir); return sdkRoot; } } catch { @@ -142,7 +145,7 @@ export function getWindowsCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = proce let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; // Find bundled python if no python is set in the environment. if (!cloudSdkPython) { - const bundledPython = path.join(cloudSdkRootDir, 'platform', 'bundledpython', 'python.exe'); + const bundledPython = path.win32.join(cloudSdkRootDir, 'platform', 'bundledpython', 'python.exe'); if (fs.existsSync(bundledPython)) { cloudSdkPython = bundledPython; } From 67c9b5a293968f30ad0341fb4ea0c6b2c07f5dc7 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 19:59:38 +0000 Subject: [PATCH 23/43] chore: linter --- packages/gcloud-mcp/src/gcloud.test.ts | 51 +++++++++++-------- packages/gcloud-mcp/src/gcloud.ts | 7 ++- .../src/windows_gcloud_utils.test.ts | 49 +++++++++++++----- .../gcloud-mcp/src/windows_gcloud_utils.ts | 21 +++++--- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.test.ts b/packages/gcloud-mcp/src/gcloud.test.ts index 3aa3cd63..0fff58c1 100644 --- a/packages/gcloud-mcp/src/gcloud.test.ts +++ b/packages/gcloud-mcp/src/gcloud.test.ts @@ -1,7 +1,7 @@ /** * Copyright 2025 Google LLC * - * Licensed under the Apache License, Version 20 (the "License"); + * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -9,7 +9,7 @@ * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.e + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ @@ -38,8 +38,8 @@ beforeEach(async () => { vi.doMock('./windows_gcloud_utils.js', () => ({ getCloudSDKSettings: vi.fn(), })); - mockedGetCloudSDKSettings = - (await import('./windows_gcloud_utils.js')).getCloudSDKSettings as unknown as Mock; + 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'); @@ -61,14 +61,19 @@ test('getPlatformSpecificGcloudCommand should return gcloud command for non-wind 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 cloudSdkPython = path.win32.join( + cloudSdkRootDir, + 'platform', + 'bundledpython', + 'python.exe', + ); const gcloudPyPath = path.win32.join(cloudSdkRootDir, 'lib', 'gcloud.py'); mockedGetCloudSDKSettings.mockReturnValue({ isWindowsPlatform: true, windowsCloudSDKSettings: { - cloudSdkRootDir: cloudSdkRootDir, - cloudSdkPython: cloudSdkPython, + cloudSdkRootDir, + cloudSdkPython, cloudSdkPythonArgs: '-S', }, }); @@ -77,12 +82,7 @@ test('getPlatformSpecificGcloudCommand should return python command for windows '--project=test-project', ]); expect(command).toBe(path.win32.normalize(cloudSdkPython)); - expect(args).toEqual([ - '-S', - gcloudPyPath, - 'test', - '--project=test-project', - ]); + expect(args).toEqual(['-S', gcloudPyPath, 'test', '--project=test-project']); }); test('invoke should call gcloud with the correct arguments on non-windows platform', async () => { @@ -127,7 +127,8 @@ test('invoke should call python with the correct arguments on windows platform', 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', + cloudSdkPython: + 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\platform\\bundledpython\\python.exe', cloudSdkPythonArgs: '-S', }, }); @@ -136,14 +137,20 @@ test('invoke should call python with the correct arguments on windows platform', 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'], - }); + 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 () => { diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 2f75685f..8f4d45bf 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -48,7 +48,8 @@ export interface GcloudInvocationResult { code: number | null; stdout: string; stderr: string; -}3 +} +3; export const getPlatformSpecificGcloudCommand = ( args: string[], @@ -60,7 +61,9 @@ export const getPlatformSpecificGcloudCommand = ( 'lib', 'gcloud.py', ); - const pythonPath = path.win32.normalize(cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPython); + const pythonPath = path.win32.normalize( + cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPython, + ); return { command: pythonPath, diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts index 5bf53ea1..0068267a 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -1,3 +1,19 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as child_process from 'child_process'; import * as fs from 'fs'; @@ -23,9 +39,16 @@ describe('windows_gcloud_utils', () => { describe('execWhere', () => { it('should return paths when command is found', () => { - vi.spyOn(child_process, 'execSync').mockReturnValue('C:\\Program Files\\Python\\Python39\\python.exe\nC:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe'); + vi.spyOn(child_process, 'execSync').mockReturnValue( + 'C:\\Program Files\\Python\\Python39\\python.exe\nC:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', + ); const result = execWhere('command', {}); - expect(result).toEqual(['C:\\Program Files\\Python\\Python39\\python.exe', 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe'].map(p => path.win32.normalize(p))); + expect(result).toEqual( + [ + 'C:\\Program Files\\Python\\Python39\\python.exe', + 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', + ].map((p) => path.win32.normalize(p)), + ); }); it('should return empty array when command is not found', () => { @@ -57,9 +80,7 @@ describe('windows_gcloud_utils', () => { it('should find python3 when multiple python versions are present', () => { // Mock `execWhere('python', ...)` to return a list of python paths vi.spyOn(child_process, 'execSync') - .mockReturnValueOnce( - 'C:\\Python27\\python.exe\nC:\\Python39\\python.exe' - ) // For execWhere('python') + .mockReturnValueOnce('C:\\Python27\\python.exe\nC:\\Python39\\python.exe') // For execWhere('python') .mockReturnValueOnce('2.7.18') // For getPythonVersion('C:\\Python27\\python.exe') .mockReturnValueOnce('3.9.5') // For getPythonVersion('C:\\Python39\\python.exe') .mockReturnValueOnce(''); // For execWhere('python3') - no python3 found @@ -71,9 +92,7 @@ describe('windows_gcloud_utils', () => { it('should find python2 if no python3 is available', () => { // Mock `execWhere('python', ...)` to return a list of python paths vi.spyOn(child_process, 'execSync') - .mockReturnValueOnce( - 'C:\\Python27\\python.exe' - ) // For execWhere('python') + .mockReturnValueOnce('C:\\Python27\\python.exe') // For execWhere('python') .mockReturnValueOnce('2.7.18') // For getPythonVersion('C:\\Python27\\python.exe') .mockReturnValueOnce('') // For execWhere('python3') - no python3 found .mockReturnValueOnce('2.7.18'); // For getPythonVersion('C:\\Python27\\python.exe') - second check for python2 @@ -97,7 +116,9 @@ describe('windows_gcloud_utils', () => { }); it('should get root directory from where gcloud', () => { - vi.spyOn(child_process, 'execSync').mockReturnValue('C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd'); + vi.spyOn(child_process, 'execSync').mockReturnValue( + 'C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd', + ); const sdkRoot = getSDKRootDirectory({}); expect(sdkRoot).toBe(path.win32.normalize('C:\\Program Files\\Google\\Cloud SDK')); }); @@ -118,11 +139,13 @@ describe('windows_gcloud_utils', () => { const settings = getWindowsCloudSDKSettings({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', - CLOUDSDK_PYTHON_SITEPACKAGES: '' // no site packages + CLOUDSDK_PYTHON_SITEPACKAGES: '', // no site packages }); expect(settings.cloudSdkRootDir).toBe(path.win32.normalize('C:\\CloudSDK')); - expect(settings.cloudSdkPython).toBe(path.win32.normalize('C:\\CloudSDK\\platform\\bundledpython\\python.exe')); + expect(settings.cloudSdkPython).toBe( + path.win32.normalize('C:\\CloudSDK\\platform\\bundledpython\\python.exe'), + ); expect(settings.cloudSdkPythonArgs).toBe('-S'); // Expect -S to be added expect(settings.noWorkingPythonFound).toBe(false); }); @@ -177,7 +200,7 @@ describe('windows_gcloud_utils', () => { const settings = getWindowsCloudSDKSettings({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON_ARGS: '-v', - CLOUDSDK_PYTHON_SITEPACKAGES: '' + CLOUDSDK_PYTHON_SITEPACKAGES: '', }); expect(settings.cloudSdkPythonArgs).toBe('-v -S'); }); @@ -189,7 +212,7 @@ describe('windows_gcloud_utils', () => { const settings = getWindowsCloudSDKSettings({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON_ARGS: '-v -S', - CLOUDSDK_PYTHON_SITEPACKAGES: '1' + CLOUDSDK_PYTHON_SITEPACKAGES: '1', }); expect(settings.cloudSdkPythonArgs).toBe('-v'); }); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 781a72dd..6349ecbd 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -112,7 +112,7 @@ export function findWindowsPythonPath(spawnEnv: { [key: string]: string | undefi } export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { - let cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; + const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; if (cloudSdkRootDir) { return path.win32.normalize(cloudSdkRootDir); } @@ -138,14 +138,21 @@ export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { return ''; // Return empty string if not found } -export function getWindowsCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = process.env): WindowsCloudSDKSettings { +export function getWindowsCloudSDKSettings( + currentEnv: NodeJS.ProcessEnv = process.env, +): WindowsCloudSDKSettings { const env = { ...currentEnv }; const cloudSdkRootDir = getSDKRootDirectory(env); let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; // Find bundled python if no python is set in the environment. if (!cloudSdkPython) { - const bundledPython = path.win32.join(cloudSdkRootDir, 'platform', 'bundledpython', 'python.exe'); + const bundledPython = path.win32.join( + cloudSdkRootDir, + 'platform', + 'bundledpython', + 'python.exe', + ); if (fs.existsSync(bundledPython)) { cloudSdkPython = bundledPython; } @@ -171,7 +178,7 @@ export function getWindowsCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = proce cloudSdkPythonSitePackages = ''; } } else if (cloudSdkPythonSitePackages === null) { - cloudSdkPythonSitePackages = ''; + cloudSdkPythonSitePackages = ''; } let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; @@ -194,7 +201,7 @@ export function getWindowsCloudSDKSettings(currentEnv: NodeJS.ProcessEnv = proce export function getCloudSDKSettings(): CloudSDKSettings { const isWindowsPlatform = os.platform() === 'win32'; return { - isWindowsPlatform : isWindowsPlatform, - windowsCloudSDKSettings : isWindowsPlatform ? getWindowsCloudSDKSettings() : null, - } + isWindowsPlatform, + windowsCloudSDKSettings: isWindowsPlatform ? getWindowsCloudSDKSettings() : null, + }; } From 32042612aadc7e3c8921ca118a55891c27944c89 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 20:02:28 +0000 Subject: [PATCH 24/43] fix: linter --- packages/gcloud-mcp/src/gcloud.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 8f4d45bf..527b5e9f 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -49,7 +49,6 @@ export interface GcloudInvocationResult { stdout: string; stderr: string; } -3; export const getPlatformSpecificGcloudCommand = ( args: string[], From e0349d067f9557521cb720f4231a14cb9d5d0107 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 20:13:55 +0000 Subject: [PATCH 25/43] fix: fail gcloud-mcp start up if not valid windows configuration is found --- packages/gcloud-mcp/src/gcloud.ts | 14 +------------- packages/gcloud-mcp/src/index.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 527b5e9f..680942ca 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -17,22 +17,10 @@ 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'; +import { getMemoizedCloudSDKSettings } from './index.js'; export const isWindows = (): boolean => process.platform === 'win32'; -let memoizedCloudSDKSettings: CloudSDKSettings | undefined; - -function getMemoizedCloudSDKSettings(): CloudSDKSettings { - if (!memoizedCloudSDKSettings) { - memoizedCloudSDKSettings = getRealCloudSDKSettings(); - } - return memoizedCloudSDKSettings; -} - export const isAvailable = (): Promise => new Promise((resolve) => { const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']); diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 0ed8adce..f8a7e870 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -28,6 +28,10 @@ import { log } from './utility/logger.js'; import fs from 'fs'; import path from 'path'; import { createAccessControlList } from './denylist.js'; +import { + getCloudSDKSettings as getRealCloudSDKSettings, + CloudSDKSettings, +} from './windows_gcloud_utils.js'; export const default_deny: string[] = [ 'compute start-iap-tunnel', @@ -57,6 +61,15 @@ interface McpConfig { export type { McpConfig }; +let memoizedCloudSDKSettings: CloudSDKSettings | undefined; + +export function getMemoizedCloudSDKSettings(): CloudSDKSettings { + if (!memoizedCloudSDKSettings) { + memoizedCloudSDKSettings = getRealCloudSDKSettings(); + } + return memoizedCloudSDKSettings; +} + const main = async () => { const argv = (await yargs(hideBin(process.argv)) .command('$0', 'Run the gcloud mcp server', (yargs) => @@ -77,6 +90,14 @@ const main = async () => { process.exit(1); } + // Platform verification + if (memoizedCloudSDKSettings?.isWindowsPlatform) { + if (memoizedCloudSDKSettings.windowsCloudSDKSettings == null || memoizedCloudSDKSettings.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; From c60ba910f1d41a28ef3f8698984e788af2db2e00 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 20:20:36 +0000 Subject: [PATCH 26/43] chore: actual refactoring --- packages/gcloud-mcp/src/gcloud.ts | 14 +++++++++++++- packages/gcloud-mcp/src/index.ts | 19 +++---------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 680942ca..11c53af0 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -17,10 +17,22 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; -import { getMemoizedCloudSDKSettings } from './index.js'; +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 => new Promise((resolve) => { const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']); diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index f8a7e870..09d6b3b1 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -28,10 +28,7 @@ import { log } from './utility/logger.js'; import fs from 'fs'; import path from 'path'; import { createAccessControlList } from './denylist.js'; -import { - getCloudSDKSettings as getRealCloudSDKSettings, - CloudSDKSettings, -} from './windows_gcloud_utils.js'; +import { CloudSDKSettings } from './windows_gcloud_utils.js'; export const default_deny: string[] = [ 'compute start-iap-tunnel', @@ -61,15 +58,6 @@ interface McpConfig { export type { McpConfig }; -let memoizedCloudSDKSettings: CloudSDKSettings | undefined; - -export function getMemoizedCloudSDKSettings(): CloudSDKSettings { - if (!memoizedCloudSDKSettings) { - memoizedCloudSDKSettings = getRealCloudSDKSettings(); - } - return memoizedCloudSDKSettings; -} - const main = async () => { const argv = (await yargs(hideBin(process.argv)) .command('$0', 'Run the gcloud mcp server', (yargs) => @@ -90,12 +78,11 @@ const main = async () => { process.exit(1); } + const cloudSDKSettings: CloudSDKSettings = gcloud.getMemoizedCloudSDKSettings(); // Platform verification - if (memoizedCloudSDKSettings?.isWindowsPlatform) { - if (memoizedCloudSDKSettings.windowsCloudSDKSettings == null || memoizedCloudSDKSettings.windowsCloudSDKSettings.noWorkingPythonFound) { + 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 = {}; From bfdfebcec3ab56bbb2671f32f9d8586a04c97be8 Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 20:23:00 +0000 Subject: [PATCH 27/43] chore: fix test --- packages/gcloud-mcp/src/index.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/gcloud-mcp/src/index.test.ts b/packages/gcloud-mcp/src/index.test.ts index 9f9dbd4d..d319efb2 100644 --- a/packages/gcloud-mcp/src/index.test.ts +++ b/packages/gcloud-mcp/src/index.test.ts @@ -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(); }); From 088806d1550440f35e1b132cfe7e598ea9e435ec Mon Sep 17 00:00:00 2001 From: xujack Date: Tue, 18 Nov 2025 20:38:24 +0000 Subject: [PATCH 28/43] fix: fail server initialization if no valid python is found on windows --- packages/gcloud-mcp/src/index.test.ts | 25 +++++++++++++++++++++++++ packages/gcloud-mcp/src/index.ts | 3 +-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/gcloud-mcp/src/index.test.ts b/packages/gcloud-mcp/src/index.test.ts index d319efb2..0d435d01 100644 --- a/packages/gcloud-mcp/src/index.test.ts +++ b/packages/gcloud-mcp/src/index.test.ts @@ -154,3 +154,28 @@ 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); +}); \ No newline at end of file diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 09d6b3b1..6212ffbf 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -28,7 +28,6 @@ import { log } from './utility/logger.js'; import fs from 'fs'; import path from 'path'; import { createAccessControlList } from './denylist.js'; -import { CloudSDKSettings } from './windows_gcloud_utils.js'; export const default_deny: string[] = [ 'compute start-iap-tunnel', @@ -78,7 +77,7 @@ const main = async () => { process.exit(1); } - const cloudSDKSettings: CloudSDKSettings = gcloud.getMemoizedCloudSDKSettings(); + 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.`); From 4b128f88f976c1185b97f51103cea978042b4d2f Mon Sep 17 00:00:00 2001 From: xujack Date: Wed, 19 Nov 2025 15:32:38 +0000 Subject: [PATCH 29/43] fix: linter --- packages/gcloud-mcp/src/index.test.ts | 9 +++++---- packages/gcloud-mcp/src/index.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/gcloud-mcp/src/index.test.ts b/packages/gcloud-mcp/src/index.test.ts index 0d435d01..c9d2178b 100644 --- a/packages/gcloud-mcp/src/index.test.ts +++ b/packages/gcloud-mcp/src/index.test.ts @@ -155,7 +155,6 @@ 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); @@ -168,14 +167,16 @@ test('should exit if os is windows and it can not find working python', async () 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.stringContaining( + 'Unable to start gcloud mcp server: No working Python installation found for Windows gcloud execution.', + ), ); expect(process.exit).toHaveBeenCalledWith(1); -}); \ No newline at end of file +}); diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 6212ffbf..62327f3d 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -79,9 +79,15 @@ const main = async () => { 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); + 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 = {}; From 4789be4ead441016c829ee733e84da50cc00a13c Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Wed, 19 Nov 2025 20:51:54 +0000 Subject: [PATCH 30/43] fix: make function async and fix test --- packages/gcloud-mcp/src/gcloud.test.ts | 209 ++++++------------ packages/gcloud-mcp/src/gcloud.ts | 23 +- .../gcloud-mcp/src/windows_gcloud_utils.ts | 184 +++++++++++++++ 3 files changed, 273 insertions(+), 143 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.test.ts b/packages/gcloud-mcp/src/gcloud.test.ts index 0fff58c1..fe964dc5 100644 --- a/packages/gcloud-mcp/src/gcloud.test.ts +++ b/packages/gcloud-mcp/src/gcloud.test.ts @@ -1,19 +1,3 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - import { test, expect, beforeEach, Mock, vi, assert } from 'vitest'; import * as child_process from 'child_process'; import { PassThrough } from 'stream'; @@ -29,29 +13,44 @@ vi.mock('child_process', () => ({ const mockedSpawn = child_process.spawn as unknown as Mock; let mockedGetCloudSDKSettings: Mock; +// Helper function to create a mock child process +const createMockChildProcess = (exitCode: number) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new PassThrough() as any; + child.stdout = stdout; + child.stderr = stderr; + child.on = vi.fn((event, callback) => { + if (event === 'close') { + setTimeout(() => callback(exitCode), 0); + } + }); + return child; +}; + 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(), + getCloudSDKSettingsAsync: vi.fn(), })); mockedGetCloudSDKSettings = (await import('./windows_gcloud_utils.js')) - .getCloudSDKSettings as unknown as Mock; + .getCloudSDKSettingsAsync 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({ +test('getPlatformSpecificGcloudCommand should return gcloud command for non-windows platform', async () => { + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); - const { command, args } = gcloud.getPlatformSpecificGcloudCommand([ + const { command, args } = await gcloud.getPlatformSpecificGcloudCommand([ 'test', '--project=test-project', ]); @@ -59,7 +58,7 @@ test('getPlatformSpecificGcloudCommand should return gcloud command for non-wind expect(args).toEqual(['test', '--project=test-project']); }); -test('getPlatformSpecificGcloudCommand should return python command for windows platform', () => { +test('getPlatformSpecificGcloudCommand should return python command for windows platform', async () => { const cloudSdkRootDir = 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK'; const cloudSdkPython = path.win32.join( cloudSdkRootDir, @@ -69,7 +68,7 @@ test('getPlatformSpecificGcloudCommand should return python command for windows ); const gcloudPyPath = path.win32.join(cloudSdkRootDir, 'lib', 'gcloud.py'); - mockedGetCloudSDKSettings.mockReturnValue({ + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: true, windowsCloudSDKSettings: { cloudSdkRootDir, @@ -77,7 +76,7 @@ test('getPlatformSpecificGcloudCommand should return python command for windows cloudSdkPythonArgs: '-S', }, }); - const { command, args } = gcloud.getPlatformSpecificGcloudCommand([ + const { command, args } = await gcloud.getPlatformSpecificGcloudCommand([ 'test', '--project=test-project', ]); @@ -86,24 +85,15 @@ test('getPlatformSpecificGcloudCommand should return python command for windows }); 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({ + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); const resultPromise = gcloud.invoke(['test', '--project=test-project']); - mockChildProcess.stdout.end(); + mockChild.stdout.end(); await resultPromise; expect(mockedSpawn).toHaveBeenCalledWith('gcloud', ['test', '--project=test-project'], { @@ -112,18 +102,9 @@ test('invoke should call gcloud with the correct arguments on non-windows platfo }); 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({ + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: true, windowsCloudSDKSettings: { cloudSdkRootDir: 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK', @@ -134,7 +115,7 @@ test('invoke should call python with the correct arguments on windows platform', }); const resultPromise = gcloud.invoke(['test', '--project=test-project']); - mockChildProcess.stdout.end(); + mockChild.stdout.end(); await resultPromise; expect(mockedSpawn).toHaveBeenCalledWith( @@ -154,15 +135,9 @@ test('invoke should call python with the correct arguments on windows platform', }); test('should return true if which command succeeds', async () => { - const mockChildProcess = { - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(0), 0); - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); - mockedGetCloudSDKSettings.mockReturnValue({ + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); @@ -178,14 +153,8 @@ test('should return true if which command succeeds', async () => { }); test('should return false if which command fails with non-zero exit code', async () => { - const mockChildProcess = { - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(1), 0); - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); + const mockChild = createMockChildProcess(1); + mockedSpawn.mockReturnValue(mockChild); const result = await gcloud.isAvailable(); @@ -198,14 +167,14 @@ test('should return false if which command fails with non-zero exit code', async }); test('should return false if which command fails', async () => { - const mockChildProcess = { + const mockChild = { on: vi.fn((event, callback) => { if (event === 'error') { setTimeout(() => callback(new Error('Failed to start')), 0); } }), }; - mockedSpawn.mockReturnValue(mockChildProcess); + mockedSpawn.mockReturnValue(mockChild); const result = await gcloud.isAvailable(); @@ -218,29 +187,22 @@ test('should return false if which command fails', async () => { }); test('should correctly handle stdout and stderr', 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({ + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); const resultPromise = gcloud.invoke(['interactive-command']); - mockChildProcess.stdout.emit('data', 'Standard output'); - mockChildProcess.stderr.emit('data', 'Stan'); - mockChildProcess.stdout.emit('data', 'put'); - mockChildProcess.stderr.emit('data', 'dard error'); - mockChildProcess.stdout.end(); + process.nextTick(() => { + mockChild.stdout.emit('data', 'Standard output'); + mockChild.stderr.emit('data', 'Stan'); + mockChild.stdout.emit('data', 'put'); + mockChild.stderr.emit('data', 'dard error'); + mockChild.stdout.end(); + }); const result = await resultPromise; @@ -253,29 +215,22 @@ test('should correctly handle stdout and stderr', async () => { }); test('should correctly non-zero exit codes', async () => { - const mockChildProcess = { - stdout: new PassThrough(), - stderr: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(1), 0); // Error code - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); - mockedGetCloudSDKSettings.mockReturnValue({ + const mockChild = createMockChildProcess(1); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); const resultPromise = gcloud.invoke(['interactive-command']); - mockChildProcess.stdout.emit('data', 'Standard output'); - mockChildProcess.stderr.emit('data', 'Stan'); - mockChildProcess.stdout.emit('data', 'put'); - mockChildProcess.stderr.emit('data', 'dard error'); - mockChildProcess.stdout.end(); + process.nextTick(() => { + mockChild.stdout.emit('data', 'Standard output'); + mockChild.stderr.emit('data', 'Stan'); + mockChild.stdout.emit('data', 'put'); + mockChild.stderr.emit('data', 'dard error'); + mockChild.stdout.end(); + }); const result = await resultPromise; @@ -288,17 +243,17 @@ test('should correctly non-zero exit codes', async () => { }); test('should reject when process fails to start', async () => { - mockedSpawn.mockReturnValue({ + const mockChild = { stdout: new PassThrough(), stderr: new PassThrough(), - stdin: new PassThrough(), on: vi.fn((event, callback) => { if (event === 'error') { setTimeout(() => callback(new Error('Failed to start')), 0); } }), - }); - mockedGetCloudSDKSettings.mockReturnValue({ + }; + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); @@ -312,18 +267,9 @@ test('should reject when process fails to start', async () => { }); test('should correctly call lint double quotes', 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({ + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); @@ -338,9 +284,8 @@ test('should correctly call lint double quotes', async () => { error_type: null, }, ]); - mockChildProcess.stdout.emit('data', json); - mockChildProcess.stderr.emit('data', 'Update available'); - mockChildProcess.stdout.end(); + mockChild.stdout.write(json); + mockChild.stdout.end(); const result = await resultPromise; @@ -362,18 +307,9 @@ test('should correctly call lint double quotes', async () => { }); test('should correctly call lint single quotes', 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({ + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); @@ -388,9 +324,8 @@ test('should correctly call lint single quotes', async () => { error_type: null, }, ]); - mockChildProcess.stdout.emit('data', json); - mockChildProcess.stderr.emit('data', 'Update available'); - mockChildProcess.stdout.end(); + mockChild.stdout.write(json); + mockChild.stdout.end(); const result = await resultPromise; diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 11c53af0..0b5a493f 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -19,6 +19,7 @@ import * as child_process from 'child_process'; import * as path from 'path'; import { getCloudSDKSettings as getRealCloudSDKSettings, + getCloudSDKSettingsAsync as getRealCloudSDKSettingsAsync, CloudSDKSettings, } from './windows_gcloud_utils.js'; @@ -26,6 +27,16 @@ export const isWindows = (): boolean => process.platform === 'win32'; let memoizedCloudSDKSettings: CloudSDKSettings | undefined; +export async function getMemoizedCloudSDKSettingsAsync(): Promise { + if (!memoizedCloudSDKSettings) { + memoizedCloudSDKSettings = await getRealCloudSDKSettingsAsync(); + } + return memoizedCloudSDKSettings; +} + +/** + * @deprecated Use `getMemoizedCloudSDKSettingsAsync` instead. + */ export function getMemoizedCloudSDKSettings(): CloudSDKSettings { if (!memoizedCloudSDKSettings) { memoizedCloudSDKSettings = getRealCloudSDKSettings(); @@ -50,10 +61,10 @@ export interface GcloudInvocationResult { stderr: string; } -export const getPlatformSpecificGcloudCommand = ( +export const getPlatformSpecificGcloudCommand = async ( args: string[], -): { command: string; args: string[] } => { - const cloudSDKSettings = getMemoizedCloudSDKSettings(); +): Promise<{ command: string; args: string[] }> => { + const cloudSDKSettings = await getMemoizedCloudSDKSettingsAsync(); if (cloudSDKSettings.isWindowsPlatform && cloudSDKSettings.windowsCloudSDKSettings) { const windowsPathForGcloudPy = path.win32.join( cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkRootDir, @@ -77,12 +88,12 @@ export const getPlatformSpecificGcloudCommand = ( } }; -export const invoke = (args: string[]): Promise => - new Promise((resolve, reject) => { +export const invoke = async (args: string[]): Promise => + new Promise(async (resolve, reject) => { let stdout = ''; let stderr = ''; - const { command: command, args: executionArgs } = getPlatformSpecificGcloudCommand(args); + const { command, args: executionArgs } = await getPlatformSpecificGcloudCommand(args); const gcloud = child_process.spawn(command, executionArgs, { stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 6349ecbd..b9f3218a 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -34,6 +34,34 @@ export interface CloudSDKSettings { windowsCloudSDKSettings: WindowsCloudSDKSettings | null; } +export async function execWhereAsync( + command: string, + spawnEnv: { [key: string]: string | undefined }, +): Promise { + return new Promise((resolve) => { + child_process.exec( + `where ${command}`, + { + encoding: 'utf8', + env: spawnEnv, // Use updated PATH for where command + }, + (error, stdout) => { + if (error) { + resolve([]); + return; + } + const result = stdout.trim(); + resolve( + result + .split(/\r?\n/) + .filter((line) => line.length > 0) + .map((line) => path.win32.normalize(line)), + ); + }, + ); + }); +} + export function execWhere( command: string, spawnEnv: { [key: string]: string | undefined }, @@ -55,6 +83,31 @@ export function execWhere( } } +export async function getPythonVersionAsync( + pythonPath: string, + spawnEnv: { [key: string]: string | undefined }, +): Promise { + return new Promise((resolve) => { + const escapedPath = pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath; + const cmd = `${escapedPath} -c "import sys; print(sys.version)"`; + child_process.exec( + cmd, + { + encoding: 'utf8', + env: spawnEnv, // Use env without PYTHONHOME + }, + (error, stdout) => { + if (error) { + resolve(undefined); + return; + } + const result = stdout.trim(); + resolve(result.split(/[\r\n]+/)[0]); + }, + ); + }); +} + export function getPythonVersion( pythonPath: string, spawnEnv: { [key: string]: string | undefined }, @@ -75,6 +128,44 @@ export function getPythonVersion( } } +export async function findWindowsPythonPathAsync(spawnEnv: { + [key: string]: string | undefined; +}): Promise { + // Try to find a Python installation on Windows + // Try Python, python3, python2 + + const pythonCandidates = await execWhereAsync('python', spawnEnv); + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = await getPythonVersionAsync(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } + } + } + + const python3Candidates = await execWhereAsync('python3', spawnEnv); + if (python3Candidates.length > 0) { + for (const candidate of python3Candidates) { + const version = await getPythonVersionAsync(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } + } + } + + // Try to find python2 last + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = await getPythonVersionAsync(candidate, spawnEnv); + if (version && version.startsWith('2')) { + return candidate; + } + } + } + return 'python.exe'; // Fallback to default python command +} + export function findWindowsPythonPath(spawnEnv: { [key: string]: string | undefined }): string { // Try to find a Python installation on Windows // Try Python, python3, python2 @@ -111,6 +202,31 @@ export function findWindowsPythonPath(spawnEnv: { [key: string]: string | undefi return 'python.exe'; // Fallback to default python command } +export async function getSDKRootDirectoryAsync(env: NodeJS.ProcessEnv): Promise { + const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; + if (cloudSdkRootDir) { + return path.win32.normalize(cloudSdkRootDir); + } + + // Use 'where gcloud' to find the gcloud executable on Windows + const gcloudPathOutput = (await execWhereAsync('gcloud', env))[0]; + + if (gcloudPathOutput) { + // Assuming gcloud.cmd is in /bin/gcloud.cmd + // We need to go up two levels from the gcloud.cmd path + const binDir = path.win32.dirname(gcloudPathOutput); + const sdkRoot = path.win32.dirname(binDir); + return sdkRoot; + } + + // gcloud not found in PATH, or other error + log.warn( + 'gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.', + ); + + return ''; // Return empty string if not found +} + export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; if (cloudSdkRootDir) { @@ -138,6 +254,66 @@ export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { return ''; // Return empty string if not found } +export async function getWindowsCloudSDKSettingsAsync( + currentEnv: NodeJS.ProcessEnv = process.env, +): Promise { + const env = { ...currentEnv }; + const cloudSdkRootDir = await getSDKRootDirectoryAsync(env); + + let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; + // Find bundled python if no python is set in the environment. + if (!cloudSdkPython) { + const bundledPython = path.win32.join( + cloudSdkRootDir, + 'platform', + 'bundledpython', + 'python.exe', + ); + if (fs.existsSync(bundledPython)) { + cloudSdkPython = bundledPython; + } + } + // If not bundled Python is found, try to find a Python installation on windows + if (!cloudSdkPython) { + cloudSdkPython = await findWindowsPythonPathAsync(env); + } + + // Where.exe always exist in a Windows Platform + let noWorkingPythonFound = false; + // Juggling check to hit null and undefined at the same time + if (!(await getPythonVersionAsync(cloudSdkPython, env))) { + noWorkingPythonFound = true; + } + + // Check if the User has site package enabled + let cloudSdkPythonSitePackages = currentEnv['CLOUDSDK_PYTHON_SITEPACKAGES']; + if (cloudSdkPythonSitePackages === undefined) { + if (currentEnv['VIRTUAL_ENV']) { + cloudSdkPythonSitePackages = '1'; + } else { + cloudSdkPythonSitePackages = ''; + } + } else if (cloudSdkPythonSitePackages === null) { + cloudSdkPythonSitePackages = ''; + } + + let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; + const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); + + // Spacing here matters + cloudSdkPythonArgs = !cloudSdkPythonSitePackages + ? `${argsWithoutS}${argsWithoutS ? ' ' : ''}-S` + : argsWithoutS; + + return { + cloudSdkRootDir, + cloudSdkPython, + cloudSdkPythonArgs, + noWorkingPythonFound, + env, + }; +} + export function getWindowsCloudSDKSettings( currentEnv: NodeJS.ProcessEnv = process.env, ): WindowsCloudSDKSettings { @@ -198,6 +374,14 @@ export function getWindowsCloudSDKSettings( }; } +export async function getCloudSDKSettingsAsync(): Promise { + const isWindowsPlatform = os.platform() === 'win32'; + return { + isWindowsPlatform, + windowsCloudSDKSettings: isWindowsPlatform ? await getWindowsCloudSDKSettingsAsync() : null, + }; +} + export function getCloudSDKSettings(): CloudSDKSettings { const isWindowsPlatform = os.platform() === 'win32'; return { From 341784a4736554e1093981edfcbe3dfdb4042993 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Wed, 19 Nov 2025 20:54:58 +0000 Subject: [PATCH 31/43] fix: linter --- packages/gcloud-mcp/src/gcloud.test.ts | 24 ++++++++++++++++++- .../gcloud-mcp/src/windows_gcloud_utils.ts | 4 +--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.test.ts b/packages/gcloud-mcp/src/gcloud.test.ts index fe964dc5..b9b1bdff 100644 --- a/packages/gcloud-mcp/src/gcloud.test.ts +++ b/packages/gcloud-mcp/src/gcloud.test.ts @@ -1,3 +1,19 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { test, expect, beforeEach, Mock, vi, assert } from 'vitest'; import * as child_process from 'child_process'; import { PassThrough } from 'stream'; @@ -13,11 +29,17 @@ vi.mock('child_process', () => ({ const mockedSpawn = child_process.spawn as unknown as Mock; let mockedGetCloudSDKSettings: Mock; +interface MockChildProcess extends PassThrough { + stdout: PassThrough; + stderr: PassThrough; + on: Mock; +} + // Helper function to create a mock child process const createMockChildProcess = (exitCode: number) => { const stdout = new PassThrough(); const stderr = new PassThrough(); - const child = new PassThrough() as any; + const child = new PassThrough() as MockChildProcess; child.stdout = stdout; child.stderr = stderr; child.on = vi.fn((event, callback) => { diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index b9f3218a..d52357a6 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -220,9 +220,7 @@ export async function getSDKRootDirectoryAsync(env: NodeJS.ProcessEnv): Promise< } // gcloud not found in PATH, or other error - log.warn( - 'gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.', - ); + log.warn('gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.'); return ''; // Return empty string if not found } From 9f3354998957f1482d8f83936df8e74956d6b90f Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Wed, 19 Nov 2025 21:18:01 +0000 Subject: [PATCH 32/43] fix: use async --- packages/gcloud-mcp/src/gcloud.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 0b5a493f..60d23997 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -18,7 +18,6 @@ import { z } from 'zod'; import * as child_process from 'child_process'; import * as path from 'path'; import { - getCloudSDKSettings as getRealCloudSDKSettings, getCloudSDKSettingsAsync as getRealCloudSDKSettingsAsync, CloudSDKSettings, } from './windows_gcloud_utils.js'; @@ -34,16 +33,6 @@ export async function getMemoizedCloudSDKSettingsAsync(): Promise => new Promise((resolve) => { const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']); From 73103c2a859e66e2fbd08015798c248c96fdaaf6 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Wed, 19 Nov 2025 21:22:14 +0000 Subject: [PATCH 33/43] fix: use async and fix test --- packages/gcloud-mcp/src/index.test.ts | 4 ++-- packages/gcloud-mcp/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/gcloud-mcp/src/index.test.ts b/packages/gcloud-mcp/src/index.test.ts index c9d2178b..4770942a 100644 --- a/packages/gcloud-mcp/src/index.test.ts +++ b/packages/gcloud-mcp/src/index.test.ts @@ -45,7 +45,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); - vi.spyOn(gcloud, 'getMemoizedCloudSDKSettings').mockResolvedValue({ + vi.spyOn(gcloud, 'getMemoizedCloudSDKSettingsAsync').mockResolvedValue({ isWindowsPlatform: false, windowsCloudSDKSettings: null, }); @@ -158,7 +158,7 @@ test('should exit if config file is invalid JSON', async () => { 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({ + vi.spyOn(gcloud, 'getMemoizedCloudSDKSettingsAsync').mockResolvedValue({ isWindowsPlatform: true, windowsCloudSDKSettings: { cloudSdkRootDir: '', diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 62327f3d..50111576 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -77,7 +77,7 @@ const main = async () => { process.exit(1); } - const cloudSDKSettings = await gcloud.getMemoizedCloudSDKSettings(); + const cloudSDKSettings = await gcloud.getMemoizedCloudSDKSettingsAsync(); // Platform verification if ( cloudSDKSettings.isWindowsPlatform && From d761b370bacbf2d85e220e2b4f38dd546df9c7b7 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Thu, 20 Nov 2025 20:02:29 +0000 Subject: [PATCH 34/43] chore: integration test --- .github/workflows/presubmit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index d94a3480..ec0845ea 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -6,6 +6,7 @@ on: pull_request: branches: [main, release] merge_group: + workflow_dispatch: jobs: lint: From 203c7dea25ceb185811dfdc132192adacd65587f Mon Sep 17 00:00:00 2001 From: jackxu9946 Date: Thu, 20 Nov 2025 15:27:53 -0500 Subject: [PATCH 35/43] fix: change to list of strings --- .../gcloud-mcp/src/gcloud.integration.test.ts | 69 +++++++++++-------- packages/gcloud-mcp/src/gcloud.ts | 2 +- packages/gcloud-mcp/src/index.test.ts | 2 +- .../src/windows_gcloud_utils.test.ts | 10 +-- .../gcloud-mcp/src/windows_gcloud_utils.ts | 6 +- 5 files changed, 51 insertions(+), 38 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts index f2fba638..dac9e717 100644 --- a/packages/gcloud-mcp/src/gcloud.integration.test.ts +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -14,37 +14,48 @@ * limitations under the License. */ -import { test, expect, assert } from 'vitest'; -import * as gcloud from './gcloud.js'; - -test('gcloud is available', async () => { - const result = await gcloud.isAvailable(); - expect(result).toBe(true); -}); - -test('can invoke gcloud to lint a command', async () => { - const result = await gcloud.lint('compute instances list'); - assert(result.success); - expect(result.parsedCommand).toBe('compute instances list'); -}, 10000); - -test('cannot inject a command by appending arguments', async () => { - const result = await gcloud.invoke(['config', 'list', '&&', 'echo', 'asdf']); - expect(result.stdout).not.toContain('asdf'); - expect(result.code).toBeGreaterThan(0); -}, 10000); +import { test, expect, + // assert +} from 'vitest'; +// import * as gcloud from './gcloud.js'; +// import path from 'path'; -test('cannot inject a command by appending command', async () => { - const result = await gcloud.invoke(['config', 'list', '&&', 'echo asdf']); - expect(result.code).toBeGreaterThan(0); -}, 10000); +// test('gcloud is available', async () => { +// const result = await gcloud.isAvailable(); +// expect(result).toBe(true); +// }); -test('cannot inject a command with a final argument', async () => { - const result = await gcloud.invoke(['config', 'list', '&& echo asdf']); - expect(result.code).toBeGreaterThan(0); -}, 10000); +// test('can invoke gcloud to lint a command', async () => { +// const result = await gcloud.lint('compute instances list'); +// assert(result.success); +// expect(result.parsedCommand).toBe('compute instances list'); +// }, 10000); + +// test('cannot inject a command by appending arguments', async () => { +// const result = await gcloud.invoke(['config', 'list', '&&', 'echo', 'asdf']); +// expect(result.stdout).not.toContain('asdf'); +// expect(result.code).toBeGreaterThan(0); +// }, 10000); + +// test('cannot inject a command by appending command', async () => { +// const result = await gcloud.invoke(['config', 'list', '&&', 'echo asdf']); +// expect(result.code).toBeGreaterThan(0); +// }, 10000); + +// test('cannot inject a command with a final argument', async () => { +// const result = await gcloud.invoke(['config', 'list', '&& echo asdf']); +// expect(result.code).toBeGreaterThan(0); +// }, 10000); + +// test('cannot inject a command with a single argument', async () => { +// const result = await gcloud.invoke(['config list && echo asdf']); +// expect(result.code).toBeGreaterThan(0); +// }, 10000); -test('cannot inject a command with a single argument', async () => { +test('can invoke gcloud when there are multiple python args', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_ARGS'] = "-S -u -B"; + const gcloud = await import('./gcloud.js'); const result = await gcloud.invoke(['config list && echo asdf']); expect(result.code).toBeGreaterThan(0); -}, 10000); +}, 10000); \ No newline at end of file diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 60d23997..2e812d59 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -67,7 +67,7 @@ export const getPlatformSpecificGcloudCommand = async ( return { command: pythonPath, args: [ - cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPythonArgs, + ...cloudSDKSettings.windowsCloudSDKSettings?.cloudSdkPythonArgsList, windowsPathForGcloudPy, ...args, ], diff --git a/packages/gcloud-mcp/src/index.test.ts b/packages/gcloud-mcp/src/index.test.ts index 4770942a..debaaae1 100644 --- a/packages/gcloud-mcp/src/index.test.ts +++ b/packages/gcloud-mcp/src/index.test.ts @@ -163,7 +163,7 @@ test('should exit if os is windows and it can not find working python', async () windowsCloudSDKSettings: { cloudSdkRootDir: '', cloudSdkPython: '', - cloudSdkPythonArgs: '', + cloudSdkPythonArgsList: [], noWorkingPythonFound: true, env: {}, }, diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts index 0068267a..6ec1d260 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -146,7 +146,7 @@ describe('windows_gcloud_utils', () => { expect(settings.cloudSdkPython).toBe( path.win32.normalize('C:\\CloudSDK\\platform\\bundledpython\\python.exe'), ); - expect(settings.cloudSdkPythonArgs).toBe('-S'); // Expect -S to be added + expect(settings.cloudSdkPythonArgsList).toBe(['-S']); // Expect -S to be added expect(settings.noWorkingPythonFound).toBe(false); }); @@ -162,7 +162,7 @@ describe('windows_gcloud_utils', () => { expect(settings.cloudSdkRootDir).toBe(path.win32.normalize('C:\\CloudSDK')); expect(settings.cloudSdkPython).toBe('C:\\Python39\\python.exe'); - expect(settings.cloudSdkPythonArgs).toBe(''); // Expect no -S + expect(settings.cloudSdkPythonArgsList).toBe([]); // Expect no -S expect(settings.noWorkingPythonFound).toBe(false); }); @@ -190,7 +190,7 @@ describe('windows_gcloud_utils', () => { VIRTUAL_ENV: 'C:\\MyVirtualEnv', CLOUDSDK_PYTHON_SITEPACKAGES: undefined, // Ensure this is undefined to hit the if condition }); - expect(settings.cloudSdkPythonArgs).toBe(''); + expect(settings.cloudSdkPythonArgsList).toBe([]); }); it('should keep existing CLOUDSDK_PYTHON_ARGS and add -S if no site packages', () => { @@ -202,7 +202,7 @@ describe('windows_gcloud_utils', () => { CLOUDSDK_PYTHON_ARGS: '-v', CLOUDSDK_PYTHON_SITEPACKAGES: '', }); - expect(settings.cloudSdkPythonArgs).toBe('-v -S'); + expect(settings.cloudSdkPythonArgsList).toBe(['-v', '-S']); }); it('should remove -S from CLOUDSDK_PYTHON_ARGS if site packages enabled', () => { @@ -214,7 +214,7 @@ describe('windows_gcloud_utils', () => { CLOUDSDK_PYTHON_ARGS: '-v -S', CLOUDSDK_PYTHON_SITEPACKAGES: '1', }); - expect(settings.cloudSdkPythonArgs).toBe('-v'); + expect(settings.cloudSdkPythonArgsList).toBe(['-v']); }); }); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index d52357a6..28aceb86 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -23,7 +23,7 @@ import { log } from './utility/logger.js'; export interface WindowsCloudSDKSettings { cloudSdkRootDir: string; cloudSdkPython: string; - cloudSdkPythonArgs: string; + cloudSdkPythonArgsList: string[]; noWorkingPythonFound: boolean; /** Environment variables to use when spawning gcloud.py */ env: { [key: string]: string | undefined }; @@ -302,11 +302,13 @@ export async function getWindowsCloudSDKSettingsAsync( cloudSdkPythonArgs = !cloudSdkPythonSitePackages ? `${argsWithoutS}${argsWithoutS ? ' ' : ''}-S` : argsWithoutS; + + const cloudSdkPythonArgsList = cloudSdkPythonArgs.split(" ") == undefined ? [] : cloudSdkPythonArgs.split(" "); return { cloudSdkRootDir, cloudSdkPython, - cloudSdkPythonArgs, + cloudSdkPythonArgsList, noWorkingPythonFound, env, }; From fbce461c6391fbe9bae582e440b057fa67ec6439 Mon Sep 17 00:00:00 2001 From: jackxu9946 Date: Thu, 20 Nov 2025 15:36:08 -0500 Subject: [PATCH 36/43] fix: async tests --- packages/gcloud-mcp/src/gcloud.test.ts | 4 +- .../src/windows_gcloud_utils.test.ts | 40 +++++------ .../gcloud-mcp/src/windows_gcloud_utils.ts | 66 ------------------- 3 files changed, 22 insertions(+), 88 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.test.ts b/packages/gcloud-mcp/src/gcloud.test.ts index b9b1bdff..018e2fc9 100644 --- a/packages/gcloud-mcp/src/gcloud.test.ts +++ b/packages/gcloud-mcp/src/gcloud.test.ts @@ -95,7 +95,7 @@ test('getPlatformSpecificGcloudCommand should return python command for windows windowsCloudSDKSettings: { cloudSdkRootDir, cloudSdkPython, - cloudSdkPythonArgs: '-S', + cloudSdkPythonArgsList: ['-S'], }, }); const { command, args } = await gcloud.getPlatformSpecificGcloudCommand([ @@ -132,7 +132,7 @@ test('invoke should call python with the correct arguments on windows platform', cloudSdkRootDir: 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK', cloudSdkPython: 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\platform\\bundledpython\\python.exe', - cloudSdkPythonArgs: '-S', + cloudSdkPythonArgsList: ['-S'], }, }); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts index 6ec1d260..13e08d2b 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -24,8 +24,8 @@ import { getPythonVersion, findWindowsPythonPath, getSDKRootDirectory, - getWindowsCloudSDKSettings, - getCloudSDKSettings, + getWindowsCloudSDKSettingsAsync, + getCloudSDKSettingsAsync, } from './windows_gcloud_utils.js'; vi.mock('child_process'); @@ -132,12 +132,12 @@ describe('windows_gcloud_utils', () => { }); }); - describe('getWindowsCloudSDKSettings', () => { - it('should get settings with bundled python', () => { + describe('getWindowsCloudSDKSettingsAsync', () => { + it('should get settings with bundled python', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); // For getPythonVersion - const settings = getWindowsCloudSDKSettings({ + const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON_SITEPACKAGES: '', // no site packages }); @@ -150,11 +150,11 @@ describe('windows_gcloud_utils', () => { expect(settings.noWorkingPythonFound).toBe(false); }); - it('should get settings with CLOUDSDK_PYTHON and site packages enabled', () => { + it('should get settings with CLOUDSDK_PYTHON and site packages enabled', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); // For getPythonVersion - const settings = getWindowsCloudSDKSettings({ + const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON: 'C:\\Python39\\python.exe', CLOUDSDK_PYTHON_SITEPACKAGES: '1', @@ -166,13 +166,13 @@ describe('windows_gcloud_utils', () => { expect(settings.noWorkingPythonFound).toBe(false); }); - it('should set noWorkingPythonFound to true if python version cannot be determined', () => { + it('should set noWorkingPythonFound to true if python version cannot be determined', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python vi.spyOn(child_process, 'execSync').mockImplementation(() => { throw new Error(); }); // getPythonVersion throws - const settings = getWindowsCloudSDKSettings({ + const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON: 'C:\\NonExistentPython\\python.exe', }); @@ -180,11 +180,11 @@ describe('windows_gcloud_utils', () => { expect(settings.noWorkingPythonFound).toBe(true); }); - it('should handle VIRTUAL_ENV for site packages', () => { + it('should handle VIRTUAL_ENV for site packages', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); - const settings = getWindowsCloudSDKSettings({ + const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON: 'C:\\Python39\\python.exe', // Explicitly set python to avoid findWindowsPythonPath VIRTUAL_ENV: 'C:\\MyVirtualEnv', @@ -193,11 +193,11 @@ describe('windows_gcloud_utils', () => { expect(settings.cloudSdkPythonArgsList).toBe([]); }); - it('should keep existing CLOUDSDK_PYTHON_ARGS and add -S if no site packages', () => { + it('should keep existing CLOUDSDK_PYTHON_ARGS and add -S if no site packages', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); - const settings = getWindowsCloudSDKSettings({ + const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON_ARGS: '-v', CLOUDSDK_PYTHON_SITEPACKAGES: '', @@ -205,11 +205,11 @@ describe('windows_gcloud_utils', () => { expect(settings.cloudSdkPythonArgsList).toBe(['-v', '-S']); }); - it('should remove -S from CLOUDSDK_PYTHON_ARGS if site packages enabled', () => { + it('should remove -S from CLOUDSDK_PYTHON_ARGS if site packages enabled', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); - const settings = getWindowsCloudSDKSettings({ + const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON_ARGS: '-v -S', CLOUDSDK_PYTHON_SITEPACKAGES: '1', @@ -218,21 +218,21 @@ describe('windows_gcloud_utils', () => { }); }); - describe('getCloudSDKSettings', () => { - it('should return windows settings on windows', () => { + describe('getCloudSDKSettingsAsync', () => { + it('should return windows settings on windows', async () => { vi.spyOn(os, 'platform').mockReturnValue('win32'); vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const settings = getCloudSDKSettings(); + const settings = await getCloudSDKSettingsAsync(); expect(settings.isWindowsPlatform).toBe(true); expect(settings.windowsCloudSDKSettings).not.toBeNull(); expect(settings.windowsCloudSDKSettings?.noWorkingPythonFound).toBe(false); }); - it('should not return windows settings on other platforms', () => { + it('should not return windows settings on other platforms', async () => { vi.spyOn(os, 'platform').mockReturnValue('linux'); - const settings = getCloudSDKSettings(); + const settings = await getCloudSDKSettingsAsync(); expect(settings.isWindowsPlatform).toBe(false); expect(settings.windowsCloudSDKSettings).toBeNull(); }); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index 28aceb86..e4bc0305 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -314,65 +314,6 @@ export async function getWindowsCloudSDKSettingsAsync( }; } -export function getWindowsCloudSDKSettings( - currentEnv: NodeJS.ProcessEnv = process.env, -): WindowsCloudSDKSettings { - const env = { ...currentEnv }; - const cloudSdkRootDir = getSDKRootDirectory(env); - - let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; - // Find bundled python if no python is set in the environment. - if (!cloudSdkPython) { - const bundledPython = path.win32.join( - cloudSdkRootDir, - 'platform', - 'bundledpython', - 'python.exe', - ); - if (fs.existsSync(bundledPython)) { - cloudSdkPython = bundledPython; - } - } - // If not bundled Python is found, try to find a Python installation on windows - if (!cloudSdkPython) { - cloudSdkPython = findWindowsPythonPath(env); - } - - // Where.exe always exist in a Windows Platform - let noWorkingPythonFound = false; - // Juggling check to hit null and undefined at the same time - if (!getPythonVersion(cloudSdkPython, env)) { - noWorkingPythonFound = true; - } - - // Check if the User has site package enabled - let cloudSdkPythonSitePackages = currentEnv['CLOUDSDK_PYTHON_SITEPACKAGES']; - if (cloudSdkPythonSitePackages === undefined) { - if (currentEnv['VIRTUAL_ENV']) { - cloudSdkPythonSitePackages = '1'; - } else { - cloudSdkPythonSitePackages = ''; - } - } else if (cloudSdkPythonSitePackages === null) { - cloudSdkPythonSitePackages = ''; - } - - let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; - const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); - - // Spacing here matters - cloudSdkPythonArgs = !cloudSdkPythonSitePackages - ? `${argsWithoutS}${argsWithoutS ? ' ' : ''}-S` - : argsWithoutS; - - return { - cloudSdkRootDir, - cloudSdkPython, - cloudSdkPythonArgs, - noWorkingPythonFound, - env, - }; -} export async function getCloudSDKSettingsAsync(): Promise { const isWindowsPlatform = os.platform() === 'win32'; @@ -382,10 +323,3 @@ export async function getCloudSDKSettingsAsync(): Promise { }; } -export function getCloudSDKSettings(): CloudSDKSettings { - const isWindowsPlatform = os.platform() === 'win32'; - return { - isWindowsPlatform, - windowsCloudSDKSettings: isWindowsPlatform ? getWindowsCloudSDKSettings() : null, - }; -} From 632617b693164528e9c5053730c2e1e296ea7e9c Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Thu, 20 Nov 2025 21:50:53 +0000 Subject: [PATCH 37/43] fix: remove sync --- .../src/windows_gcloud_utils.test.ts | 243 ++++++++++++------ .../gcloud-mcp/src/windows_gcloud_utils.ts | 110 +------- 2 files changed, 173 insertions(+), 180 deletions(-) diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts index 13e08d2b..397c285b 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -20,10 +20,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { - execWhere, - getPythonVersion, - findWindowsPythonPath, - getSDKRootDirectory, + execWhereAsync, + getPythonVersionAsync, + findWindowsPythonPathAsync, + getSDKRootDirectoryAsync, getWindowsCloudSDKSettingsAsync, getCloudSDKSettingsAsync, } from './windows_gcloud_utils.js'; @@ -37,13 +37,20 @@ describe('windows_gcloud_utils', () => { vi.resetAllMocks(); }); - describe('execWhere', () => { - it('should return paths when command is found', () => { - vi.spyOn(child_process, 'execSync').mockReturnValue( - 'C:\\Program Files\\Python\\Python39\\python.exe\nC:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', - ); - const result = execWhere('command', {}); - expect(result).toEqual( + describe('execWhereAsync', () => { + it('should return paths when command is found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback( + null, + 'C:\\Program Files\\Python\\Python39\\python.exe\r\nC:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', + '', + ); + } + return {} as child_process.ChildProcess; + }); + const result = await execWhereAsync('command', {}); + expect(result).toStrictEqual( [ 'C:\\Program Files\\Python\\Python39\\python.exe', 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', @@ -51,91 +58,156 @@ describe('windows_gcloud_utils', () => { ); }); - it('should return empty array when command is not found', () => { - vi.spyOn(child_process, 'execSync').mockImplementation(() => { - throw new Error(); + it('should return empty array when command is not found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; }); - const result = execWhere('command', {}); - expect(result).toEqual([]); + const result = await execWhereAsync('command', {}); + expect(result).toStrictEqual([]); }); }); - describe('getPythonVersion', () => { - it('should return python version', () => { - vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); - const version = getPythonVersion('python', {}); + describe('getPythonVersionAsync', () => { + it('should return python version', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; + }); + const version = await getPythonVersionAsync('python', {}); expect(version).toBe('3.9.0'); }); - it('should return undefined if python not found', () => { - vi.spyOn(child_process, 'execSync').mockImplementation(() => { - throw new Error(); + it('should return undefined if python not found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; }); - const version = getPythonVersion('python', {}); + const version = await getPythonVersionAsync('python', {}); expect(version).toBeUndefined(); }); }); - describe('findWindowsPythonPath', () => { - it('should find python3 when multiple python versions are present', () => { - // Mock `execWhere('python', ...)` to return a list of python paths - vi.spyOn(child_process, 'execSync') - .mockReturnValueOnce('C:\\Python27\\python.exe\nC:\\Python39\\python.exe') // For execWhere('python') - .mockReturnValueOnce('2.7.18') // For getPythonVersion('C:\\Python27\\python.exe') - .mockReturnValueOnce('3.9.5') // For getPythonVersion('C:\\Python39\\python.exe') - .mockReturnValueOnce(''); // For execWhere('python3') - no python3 found + describe('findWindowsPythonPathAsync', () => { + it('should find python3 when multiple python versions are present', async () => { + const execMock = vi.spyOn(child_process, 'exec'); + // Mock for execWhereAsync('python', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, 'C:\\Python27\\python.exe\r\nC:\\Python39\\python.exe', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for getPythonVersionAsync('C:\\Python27\\python.exe', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '2.7.18', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for getPythonVersionAsync('C:\\Python39\\python.exe', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.5', ''); + } + return {} as child_process.ChildProcess; + }); - const pythonPath = findWindowsPythonPath({}); + const pythonPath = await findWindowsPythonPathAsync({}); expect(pythonPath).toBe('C:\\Python39\\python.exe'); }); - it('should find python2 if no python3 is available', () => { - // Mock `execWhere('python', ...)` to return a list of python paths - vi.spyOn(child_process, 'execSync') - .mockReturnValueOnce('C:\\Python27\\python.exe') // For execWhere('python') - .mockReturnValueOnce('2.7.18') // For getPythonVersion('C:\\Python27\\python.exe') - .mockReturnValueOnce('') // For execWhere('python3') - no python3 found - .mockReturnValueOnce('2.7.18'); // For getPythonVersion('C:\\Python27\\python.exe') - second check for python2 + it('should find python2 if no python3 is available', async () => { + const execMock = vi.spyOn(child_process, 'exec'); + // Mock for execWhereAsync('python', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, 'C:\\Python27\\python.exe', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for getPythonVersionAsync('C:\\Python27\\python.exe', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '2.7.18', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for execWhereAsync('python3', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for the second loop of pythonCandidates + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '2.7.18', ''); + } + return {} as child_process.ChildProcess; + }); - const pythonPath = findWindowsPythonPath({}); + const pythonPath = await findWindowsPythonPathAsync({}); expect(pythonPath).toBe('C:\\Python27\\python.exe'); }); - it('should return default python.exe if no python is found', () => { - vi.spyOn(child_process, 'execSync').mockReturnValueOnce(''); // For execWhere('python') - vi.spyOn(child_process, 'execSync').mockReturnValueOnce(''); // For execWhere('python3') - const pythonPath = findWindowsPythonPath({}); + it('should return default python.exe if no python is found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; + }); + const pythonPath = await findWindowsPythonPathAsync({}); expect(pythonPath).toBe('python.exe'); }); }); - describe('getSDKRootDirectory', () => { - it('should get root directory from CLOUDSDK_ROOT_DIR', () => { - const sdkRoot = getSDKRootDirectory({ CLOUDSDK_ROOT_DIR: 'sdk_root' }); + describe('getSDKRootDirectoryAsync', () => { + it('should get root directory from CLOUDSDK_ROOT_DIR', async () => { + const sdkRoot = await getSDKRootDirectoryAsync({ CLOUDSDK_ROOT_DIR: 'sdk_root' }); expect(sdkRoot).toBe(path.win32.normalize('sdk_root')); }); - it('should get root directory from where gcloud', () => { - vi.spyOn(child_process, 'execSync').mockReturnValue( - 'C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd', - ); - const sdkRoot = getSDKRootDirectory({}); + it('should get root directory from where gcloud', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, 'C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd', ''); + } + return {} as child_process.ChildProcess; + }); + const sdkRoot = await getSDKRootDirectoryAsync({}); expect(sdkRoot).toBe(path.win32.normalize('C:\\Program Files\\Google\\Cloud SDK')); }); - it('should return empty string if gcloud not found', () => { - vi.spyOn(child_process, 'execSync').mockImplementation(() => { - throw new Error('gcloud not found'); + it('should return empty string if gcloud not found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; }); - const sdkRoot = getSDKRootDirectory({}); + const sdkRoot = await getSDKRootDirectoryAsync({}); expect(sdkRoot).toBe(''); }); }); describe('getWindowsCloudSDKSettingsAsync', () => { - it('should get settings with bundled python', async () => { + it('should get settings with bundled python', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); // For getPythonVersion + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', @@ -146,14 +218,18 @@ describe('windows_gcloud_utils', () => { expect(settings.cloudSdkPython).toBe( path.win32.normalize('C:\\CloudSDK\\platform\\bundledpython\\python.exe'), ); - expect(settings.cloudSdkPythonArgsList).toBe(['-S']); // Expect -S to be added + expect(settings.cloudSdkPythonArgsList).toStrictEqual(['-S']); // Expect -S to be added expect(settings.noWorkingPythonFound).toBe(false); }); it('should get settings with CLOUDSDK_PYTHON and site packages enabled', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python - vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); // For getPythonVersion - + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON: 'C:\\Python39\\python.exe', @@ -162,14 +238,17 @@ describe('windows_gcloud_utils', () => { expect(settings.cloudSdkRootDir).toBe(path.win32.normalize('C:\\CloudSDK')); expect(settings.cloudSdkPython).toBe('C:\\Python39\\python.exe'); - expect(settings.cloudSdkPythonArgsList).toBe([]); // Expect no -S + expect(settings.cloudSdkPythonArgsList).toStrictEqual([]); // Expect no -S expect(settings.noWorkingPythonFound).toBe(false); }); it('should set noWorkingPythonFound to true if python version cannot be determined', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python - vi.spyOn(child_process, 'execSync').mockImplementation(() => { - throw new Error(); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('whoops'), '', ''); + } + return {} as child_process.ChildProcess; }); // getPythonVersion throws const settings = await getWindowsCloudSDKSettingsAsync({ @@ -182,7 +261,12 @@ describe('windows_gcloud_utils', () => { it('should handle VIRTUAL_ENV for site packages', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', @@ -190,38 +274,53 @@ describe('windows_gcloud_utils', () => { VIRTUAL_ENV: 'C:\\MyVirtualEnv', CLOUDSDK_PYTHON_SITEPACKAGES: undefined, // Ensure this is undefined to hit the if condition }); - expect(settings.cloudSdkPythonArgsList).toBe([]); + expect(settings.cloudSdkPythonArgsList).toStrictEqual([]); }); it('should keep existing CLOUDSDK_PYTHON_ARGS and add -S if no site packages', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON_ARGS: '-v', CLOUDSDK_PYTHON_SITEPACKAGES: '', }); - expect(settings.cloudSdkPythonArgsList).toBe(['-v', '-S']); + expect(settings.cloudSdkPythonArgsList).toStrictEqual(['-v', '-S']); }); it('should remove -S from CLOUDSDK_PYTHON_ARGS if site packages enabled', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; + }); const settings = await getWindowsCloudSDKSettingsAsync({ CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', CLOUDSDK_PYTHON_ARGS: '-v -S', CLOUDSDK_PYTHON_SITEPACKAGES: '1', }); - expect(settings.cloudSdkPythonArgsList).toBe(['-v']); + expect(settings.cloudSdkPythonArgsList).toStrictEqual(['-v']); }); }); describe('getCloudSDKSettingsAsync', () => { it('should return windows settings on windows', async () => { vi.spyOn(os, 'platform').mockReturnValue('win32'); - vi.spyOn(child_process, 'execSync').mockReturnValue('3.9.0'); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync vi.spyOn(fs, 'existsSync').mockReturnValue(true); const settings = await getCloudSDKSettingsAsync(); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index e4bc0305..f8111cbc 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -62,27 +62,6 @@ export async function execWhereAsync( }); } -export function execWhere( - command: string, - spawnEnv: { [key: string]: string | undefined }, -): string[] { - try { - const result = child_process - .execSync(`where ${command}`, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - env: spawnEnv, // Use updated PATH for where command - }) - .trim(); - return result - .split(/\r?\n/) - .filter((line) => line.length > 0) - .map((line) => path.win32.normalize(line)); - } catch { - return []; - } -} - export async function getPythonVersionAsync( pythonPath: string, spawnEnv: { [key: string]: string | undefined }, @@ -108,26 +87,6 @@ export async function getPythonVersionAsync( }); } -export function getPythonVersion( - pythonPath: string, - spawnEnv: { [key: string]: string | undefined }, -): string | undefined { - try { - const escapedPath = pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath; - const cmd = `${escapedPath} -c "import sys; print(sys.version)"`; - const result = child_process - .execSync(cmd, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - env: spawnEnv, // Use env without PYTHONHOME - }) - .trim(); - return result.split(/[\r\n]+/)[0]; - } catch { - return undefined; - } -} - export async function findWindowsPythonPathAsync(spawnEnv: { [key: string]: string | undefined; }): Promise { @@ -166,42 +125,6 @@ export async function findWindowsPythonPathAsync(spawnEnv: { return 'python.exe'; // Fallback to default python command } -export function findWindowsPythonPath(spawnEnv: { [key: string]: string | undefined }): string { - // Try to find a Python installation on Windows - // Try Python, python3, python2 - - const pythonCandidates = execWhere('python', spawnEnv); - if (pythonCandidates.length > 0) { - for (const candidate of pythonCandidates) { - const version = getPythonVersion(candidate, spawnEnv); - if (version && version.startsWith('3')) { - return candidate; - } - } - } - - const python3Candidates = execWhere('python3', spawnEnv); - if (python3Candidates.length > 0) { - for (const candidate of python3Candidates) { - const version = getPythonVersion(candidate, spawnEnv); - if (version && version.startsWith('3')) { - return candidate; - } - } - } - - // Try to find python2 last - if (pythonCandidates.length > 0) { - for (const candidate of pythonCandidates) { - const version = getPythonVersion(candidate, spawnEnv); - if (version && version.startsWith('2')) { - return candidate; - } - } - } - return 'python.exe'; // Fallback to default python command -} - export async function getSDKRootDirectoryAsync(env: NodeJS.ProcessEnv): Promise { const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; if (cloudSdkRootDir) { @@ -225,33 +148,6 @@ export async function getSDKRootDirectoryAsync(env: NodeJS.ProcessEnv): Promise< return ''; // Return empty string if not found } -export function getSDKRootDirectory(env: NodeJS.ProcessEnv): string { - const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; - if (cloudSdkRootDir) { - return path.win32.normalize(cloudSdkRootDir); - } - - try { - // Use 'where gcloud' to find the gcloud executable on Windows - const gcloudPathOutput = execWhere('gcloud', env)[0]; - - if (gcloudPathOutput) { - // Assuming gcloud.cmd is in /bin/gcloud.cmd - // We need to go up two levels from the gcloud.cmd path - const binDir = path.win32.dirname(gcloudPathOutput); - const sdkRoot = path.win32.dirname(binDir); - return sdkRoot; - } - } catch { - // gcloud not found in PATH, or other error - log.warn( - 'gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.', - ); - } - - return ''; // Return empty string if not found -} - export async function getWindowsCloudSDKSettingsAsync( currentEnv: NodeJS.ProcessEnv = process.env, ): Promise { @@ -302,8 +198,8 @@ export async function getWindowsCloudSDKSettingsAsync( cloudSdkPythonArgs = !cloudSdkPythonSitePackages ? `${argsWithoutS}${argsWithoutS ? ' ' : ''}-S` : argsWithoutS; - - const cloudSdkPythonArgsList = cloudSdkPythonArgs.split(" ") == undefined ? [] : cloudSdkPythonArgs.split(" "); + + const cloudSdkPythonArgsList = cloudSdkPythonArgs ? cloudSdkPythonArgs.split(' ') : []; return { cloudSdkRootDir, @@ -314,7 +210,6 @@ export async function getWindowsCloudSDKSettingsAsync( }; } - export async function getCloudSDKSettingsAsync(): Promise { const isWindowsPlatform = os.platform() === 'win32'; return { @@ -322,4 +217,3 @@ export async function getCloudSDKSettingsAsync(): Promise { windowsCloudSDKSettings: isWindowsPlatform ? await getWindowsCloudSDKSettingsAsync() : null, }; } - From cb0ad2870afd0f1f40aa2ecb7097bd9b3a0d26a1 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Thu, 20 Nov 2025 21:54:22 +0000 Subject: [PATCH 38/43] chore: local save --- packages/gcloud-mcp/src/gcloud.integration.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts index dac9e717..24a71497 100644 --- a/packages/gcloud-mcp/src/gcloud.integration.test.ts +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -import { test, expect, - // assert +import { + test, + expect, + // assert } from 'vitest'; // import * as gcloud from './gcloud.js'; // import path from 'path'; @@ -54,7 +56,7 @@ import { test, expect, test('can invoke gcloud when there are multiple python args', async () => { // Set the environment variables correctly and then reimport gcloud to force it to reload - process.env['CLOUDSDK_PYTHON_ARGS'] = "-S -u -B"; + process.env['CLOUDSDK_PYTHON_ARGS'] = '-S -u -B'; const gcloud = await import('./gcloud.js'); const result = await gcloud.invoke(['config list && echo asdf']); expect(result.code).toBeGreaterThan(0); From 7b79ef3f3d6d5c7d25d489f0580a9362c8ed9b47 Mon Sep 17 00:00:00 2001 From: jackxu9946 Date: Fri, 21 Nov 2025 10:43:34 -0500 Subject: [PATCH 39/43] chore: add additioanl integration test --- .../gcloud-mcp/src/gcloud.integration.test.ts | 110 +++++++++++------- .../gcloud-mcp/src/windows_gcloud_utils.ts | 2 + 2 files changed, 73 insertions(+), 39 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts index 24a71497..8d2c72f1 100644 --- a/packages/gcloud-mcp/src/gcloud.integration.test.ts +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -17,47 +17,79 @@ import { test, expect, - // assert + assert } from 'vitest'; -// import * as gcloud from './gcloud.js'; -// import path from 'path'; - -// test('gcloud is available', async () => { -// const result = await gcloud.isAvailable(); -// expect(result).toBe(true); -// }); - -// test('can invoke gcloud to lint a command', async () => { -// const result = await gcloud.lint('compute instances list'); -// assert(result.success); -// expect(result.parsedCommand).toBe('compute instances list'); -// }, 10000); - -// test('cannot inject a command by appending arguments', async () => { -// const result = await gcloud.invoke(['config', 'list', '&&', 'echo', 'asdf']); -// expect(result.stdout).not.toContain('asdf'); -// expect(result.code).toBeGreaterThan(0); -// }, 10000); - -// test('cannot inject a command by appending command', async () => { -// const result = await gcloud.invoke(['config', 'list', '&&', 'echo asdf']); -// expect(result.code).toBeGreaterThan(0); -// }, 10000); - -// test('cannot inject a command with a final argument', async () => { -// const result = await gcloud.invoke(['config', 'list', '&& echo asdf']); -// expect(result.code).toBeGreaterThan(0); -// }, 10000); - -// test('cannot inject a command with a single argument', async () => { -// const result = await gcloud.invoke(['config list && echo asdf']); -// expect(result.code).toBeGreaterThan(0); -// }, 10000); - -test('can invoke gcloud when there are multiple python args', async () => { +import * as gcloud from './gcloud.js'; + +test('gcloud is available', async () => { + const result = await gcloud.isAvailable(); + expect(result).toBe(true); +}); + +test('can invoke gcloud to lint a command', async () => { + const result = await gcloud.lint('compute instances list'); + assert(result.success); + expect(result.parsedCommand).toBe('compute instances list'); +}, 10000); + +test('cannot inject a command by appending arguments', async () => { + const result = await gcloud.invoke(['config', 'list', '&&', 'echo', 'asdf']); + expect(result.stdout).not.toContain('asdf'); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command by appending command', async () => { + const result = await gcloud.invoke(['config', 'list', '&&', 'echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command with a final argument', async () => { + const result = await gcloud.invoke(['config', 'list', '&& echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command with a single argument', async () => { + const result = await gcloud.invoke(['config list && echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('can invoke windows gcloud when there are multiple python args', async () => { // Set the environment variables correctly and then reimport gcloud to force it to reload process.env['CLOUDSDK_PYTHON_ARGS'] = '-S -u -B'; const gcloud = await import('./gcloud.js'); - const result = await gcloud.invoke(['config list && echo asdf']); - expect(result.code).toBeGreaterThan(0); + const result = await gcloud.invoke(['config','list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when there are 1 python args', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_ARGS'] = '-u'; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config','list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when there are no python args', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_ARGS'] = ''; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config','list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when site packages are enabled', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_SITEPACKAGES'] = '1'; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config','list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when site packages are enabled and python args exists', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_SITEPACKAGES'] = '1'; + process.env['CLOUDSDK_PYTHON_ARGS'] = '-u'; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config','list']); + expect(result.code).toBe(0); }, 10000); \ No newline at end of file diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts index f8111cbc..4d884cca 100644 --- a/packages/gcloud-mcp/src/windows_gcloud_utils.ts +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -195,6 +195,8 @@ export async function getWindowsCloudSDKSettingsAsync( const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); // Spacing here matters + // When site pacakge is set, invoke without -S + // otherwise, invoke with -S cloudSdkPythonArgs = !cloudSdkPythonSitePackages ? `${argsWithoutS}${argsWithoutS ? ' ' : ''}-S` : argsWithoutS; From a2a69698fe5ebbcafd01fe793911ff27d7b10646 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Fri, 21 Nov 2025 16:33:30 +0000 Subject: [PATCH 40/43] chore: linter --- packages/gcloud-mcp/src/gcloud.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts index 8d2c72f1..081f8fa3 100644 --- a/packages/gcloud-mcp/src/gcloud.integration.test.ts +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -92,4 +92,4 @@ test('can invoke windows gcloud when site packages are enabled and python args e const gcloud = await import('./gcloud.js'); const result = await gcloud.invoke(['config','list']); expect(result.code).toBe(0); -}, 10000); \ No newline at end of file +}, 10000); From f9ecfc7ce0c37bab775aef095c03227c0a5a425e Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Fri, 21 Nov 2025 16:41:23 +0000 Subject: [PATCH 41/43] chore: linter --- .../gcloud-mcp/src/gcloud.integration.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts index 081f8fa3..ad7ab50d 100644 --- a/packages/gcloud-mcp/src/gcloud.integration.test.ts +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - test, - expect, - assert -} from 'vitest'; +import { test, expect, assert } from 'vitest'; import * as gcloud from './gcloud.js'; test('gcloud is available', async () => { @@ -57,7 +53,7 @@ test('can invoke windows gcloud when there are multiple python args', async () = // Set the environment variables correctly and then reimport gcloud to force it to reload process.env['CLOUDSDK_PYTHON_ARGS'] = '-S -u -B'; const gcloud = await import('./gcloud.js'); - const result = await gcloud.invoke(['config','list']); + const result = await gcloud.invoke(['config', 'list']); expect(result.code).toBe(0); }, 10000); @@ -65,7 +61,7 @@ test('can invoke windows gcloud when there are 1 python args', async () => { // Set the environment variables correctly and then reimport gcloud to force it to reload process.env['CLOUDSDK_PYTHON_ARGS'] = '-u'; const gcloud = await import('./gcloud.js'); - const result = await gcloud.invoke(['config','list']); + const result = await gcloud.invoke(['config', 'list']); expect(result.code).toBe(0); }, 10000); @@ -73,7 +69,7 @@ test('can invoke windows gcloud when there are no python args', async () => { // Set the environment variables correctly and then reimport gcloud to force it to reload process.env['CLOUDSDK_PYTHON_ARGS'] = ''; const gcloud = await import('./gcloud.js'); - const result = await gcloud.invoke(['config','list']); + const result = await gcloud.invoke(['config', 'list']); expect(result.code).toBe(0); }, 10000); @@ -81,7 +77,7 @@ test('can invoke windows gcloud when site packages are enabled', async () => { // Set the environment variables correctly and then reimport gcloud to force it to reload process.env['CLOUDSDK_PYTHON_SITEPACKAGES'] = '1'; const gcloud = await import('./gcloud.js'); - const result = await gcloud.invoke(['config','list']); + const result = await gcloud.invoke(['config', 'list']); expect(result.code).toBe(0); }, 10000); @@ -90,6 +86,6 @@ test('can invoke windows gcloud when site packages are enabled and python args e process.env['CLOUDSDK_PYTHON_SITEPACKAGES'] = '1'; process.env['CLOUDSDK_PYTHON_ARGS'] = '-u'; const gcloud = await import('./gcloud.js'); - const result = await gcloud.invoke(['config','list']); + const result = await gcloud.invoke(['config', 'list']); expect(result.code).toBe(0); }, 10000); From 9ddc6d4f074a95d5703608328426eff20557b363 Mon Sep 17 00:00:00 2001 From: Jack Xu Date: Fri, 21 Nov 2025 16:46:41 +0000 Subject: [PATCH 42/43] chore: cleanup --- .github/workflows/presubmit.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index ec0845ea..d94a3480 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -6,7 +6,6 @@ on: pull_request: branches: [main, release] merge_group: - workflow_dispatch: jobs: lint: From aec602877a6563e3e9f5149c0a209f78e62b40b2 Mon Sep 17 00:00:00 2001 From: Burke Davison Date: Mon, 1 Dec 2025 17:32:59 +0000 Subject: [PATCH 43/43] refactor: windows support --- packages/gcloud-mcp/src/gcloud-executor.ts | 116 +++++++++++++ packages/gcloud-mcp/src/gcloud.ts | 159 +++++------------- packages/gcloud-mcp/src/index.ts | 34 ++-- .../src/tools/run_gcloud_command.ts | 4 +- 4 files changed, 175 insertions(+), 138 deletions(-) create mode 100644 packages/gcloud-mcp/src/gcloud-executor.ts diff --git a/packages/gcloud-mcp/src/gcloud-executor.ts b/packages/gcloud-mcp/src/gcloud-executor.ts new file mode 100644 index 00000000..10b16315 --- /dev/null +++ b/packages/gcloud-mcp/src/gcloud-executor.ts @@ -0,0 +1,116 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as child_process from 'child_process'; +import { getWindowsCloudSDKSettingsAsync } from './windows_gcloud_utils.js'; +import * as path from 'path'; + +export const isWindows = (): boolean => process.platform === 'win32'; + +export interface GcloudExecutionResult { + code: number | null; + stdout: string; + stderr: string; +} + +export interface GcloudExecutor { + execute: (args: string[]) => Promise +} + +export const findExecutable = async (): Promise => { + const executor = await createExecutor(); + return { + execute: async (args: string[]): Promise => + new Promise(async (resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const gcloud = executor.execute(args); + + gcloud.stdout.on('data', (data) => { + stdout += data.toString(); + }); + gcloud.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + gcloud.on('close', (code) => { + // All responses from gcloud, including non-zero codes. + resolve({ code, stdout, stderr }); + }); + gcloud.on('error', (err) => { + // Process failed to start. gcloud isn't able to be invoked. + reject(err); + }); + }) + } +} + +const isAvailable = (): Promise => + new Promise((resolve) => { + const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']); + which.on('close', (code) => { + resolve(code === 0); + }); + which.on('error', () => { + resolve(false); + }); + }); + +const createExecutor = async () => { + if(!await isAvailable()) { + throw Error('gcloud executable not found'); + } + if (isWindows()) { + return await createWindowsExecutor(); + } + return createDirectExecutor(); +} + +/** Creates an executor that directly invokes the gcloud binary on the current PATH. */ +const createDirectExecutor = () => ({ + execute: (args: string[]) => child_process.spawn('gcloud', args, { + stdio: ['ignore', 'pipe', 'pipe'], + }) +}); + +const createWindowsExecutor = async () => { + const settings = await getWindowsCloudSDKSettingsAsync(); + + if (settings == null || settings.noWorkingPythonFound) { + throw Error('no working Python installation found for Windows gcloud execution.'); + } + + const windowsPathForGcloudPy = path.join( + settings.cloudSdkRootDir, + 'lib', + 'gcloud.py', + ); + + const pythonPath = path.normalize( + settings.cloudSdkPython, + ); + + return { + execute: (args: string[]) => child_process.spawn(pythonPath, [ + ...settings.cloudSdkPythonArgsList, + windowsPathForGcloudPy, + ...args + ], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + } +} diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 2e812d59..8aa6d835 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -15,96 +15,63 @@ */ import { z } from 'zod'; -import * as child_process from 'child_process'; -import * as path from 'path'; -import { - getCloudSDKSettingsAsync as getRealCloudSDKSettingsAsync, - CloudSDKSettings, -} from './windows_gcloud_utils.js'; +import { findExecutable } from './gcloud-executor.js'; -export const isWindows = (): boolean => process.platform === 'win32'; +export interface GcloudExecutable { + invoke: (args: string[]) => Promise; + lint: (command: string) => Promise; +} -let memoizedCloudSDKSettings: CloudSDKSettings | undefined; +export const create = async (): Promise => { + const gcloud = await findExecutable(); -export async function getMemoizedCloudSDKSettingsAsync(): Promise { - if (!memoizedCloudSDKSettings) { - memoizedCloudSDKSettings = await getRealCloudSDKSettingsAsync(); + return { + invoke: gcloud.execute, + lint: async (command: string): Promise => { + const { code, stdout, stderr } = await gcloud.execute([ + 'meta', + 'lint-gcloud-commands', + '--command-string', + `gcloud ${command}`, + ]); + + const json = JSON.parse(stdout); + const lintCommands: LintCommandsOutput = LintCommandsSchema.parse(json); + const lintCommand = lintCommands[0]; + if (!lintCommand) { + throw new Error('gcloud lint result contained no contents'); + } + + // gcloud returned a non-zero response + if (code !== 0) { + return { success: false, error: stderr }; + } + + // Command has bad syntax + if (!lintCommand.success) { + let error = `${lintCommand.error_message}`; + if (lintCommand.error_type) { + error = `${lintCommand.error_type}: ${error}`; + } + return { success: false, error }; + } + + // Else, success. + return { + success: true, + // Remove gcloud prefix since we added it in during the invocation, above. + parsedCommand: lintCommand.command_string_no_args.slice('gcloud '.length), + }; + } } - return memoizedCloudSDKSettings; } -export const isAvailable = (): Promise => - new Promise((resolve) => { - const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']); - which.on('close', (code) => { - resolve(code === 0); - }); - which.on('error', () => { - resolve(false); - }); - }); - export interface GcloudInvocationResult { code: number | null; stdout: string; stderr: string; } -export const getPlatformSpecificGcloudCommand = async ( - args: string[], -): Promise<{ command: string; args: string[] }> => { - const cloudSDKSettings = await getMemoizedCloudSDKSettingsAsync(); - 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?.cloudSdkPythonArgsList, - windowsPathForGcloudPy, - ...args, - ], - }; - } else { - return { command: 'gcloud', args }; - } -}; - -export const invoke = async (args: string[]): Promise => - new Promise(async (resolve, reject) => { - let stdout = ''; - let stderr = ''; - - const { command, args: executionArgs } = await getPlatformSpecificGcloudCommand(args); - - const gcloud = child_process.spawn(command, executionArgs, { - stdio: ['ignore', 'pipe', 'pipe'], - }); - - gcloud.stdout.on('data', (data) => { - stdout += data.toString(); - }); - gcloud.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - gcloud.on('close', (code) => { - // All responses from gcloud, including non-zero codes. - resolve({ code, stdout, stderr }); - }); - gcloud.on('error', (err) => { - // Process failed to start. gcloud isn't able to be invoked. - reject(err); - }); - }); - // There are more fields in this object, but we're only parsing the ones currently in use. const LintCommandSchema = z.object({ command_string_no_args: z.string(), @@ -126,39 +93,3 @@ export type ParsedGcloudLintResult = error: string; }; -export const lint = async (command: string): Promise => { - const { code, stdout, stderr } = await invoke([ - 'meta', - 'lint-gcloud-commands', - '--command-string', - `gcloud ${command}`, - ]); - - const json = JSON.parse(stdout); - const lintCommands: LintCommandsOutput = LintCommandsSchema.parse(json); - const lintCommand = lintCommands[0]; - if (!lintCommand) { - throw new Error('gcloud lint result contained no contents'); - } - - // gcloud returned a non-zero response - if (code !== 0) { - return { success: false, error: stderr }; - } - - // Command has bad syntax - if (!lintCommand.success) { - let error = `${lintCommand.error_message}`; - if (lintCommand.error_type) { - error = `${lintCommand.error_type}: ${error}`; - } - return { success: false, error }; - } - - // Else, success. - return { - success: true, - // Remove gcloud prefix since we added it in during the invocation, above. - parsedCommand: lintCommand.command_string_no_args.slice('gcloud '.length), - }; -}; diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index 50111576..44a93f92 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -71,25 +71,6 @@ const main = async () => { .help() .parse()) as { config?: string; [key: string]: unknown }; - const isAvailable = await gcloud.isAvailable(); - if (!isAvailable) { - log.error('Unable to start gcloud mcp server: gcloud executable not found.'); - process.exit(1); - } - - const cloudSDKSettings = await gcloud.getMemoizedCloudSDKSettingsAsync(); - // 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; @@ -127,9 +108,18 @@ const main = async () => { ); const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); - createRunGcloudCommand(acl).register(server); - await server.connect(new StdioServerTransport()); - log.info('🚀 gcloud mcp server started'); + + try { + const cli = await gcloud.create(); + createRunGcloudCommand(cli, acl).register(server); + await server.connect(new StdioServerTransport()); + log.info('🚀 gcloud mcp server started'); + + } catch (e: unknown) { + const error = String(e); + log.error(`Unable to start gcloud mcp server: ${error}`) + process.exit(1); + } process.on('uncaughtException', async (err: unknown) => { await server.close(); diff --git a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts index cbd2d262..cedb7c23 100644 --- a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts +++ b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts @@ -15,7 +15,7 @@ */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import * as gcloud from '../gcloud.js'; +import { GcloudExecutable } from '../gcloud.js'; import { AccessControlList } from '../denylist.js'; import { findSuggestedAlternativeCommand } from '../suggest.js'; import { z } from 'zod'; @@ -31,7 +31,7 @@ const aclErrorMessage = (aclMessage: string) => '\n\n' + 'To get the access control list details, invoke this tool again with the args ["gcloud-mcp", "debug", "config"]'; -export const createRunGcloudCommand = (acl: AccessControlList) => ({ +export const createRunGcloudCommand = (gcloud: GcloudExecutable, acl: AccessControlList) => ({ register: (server: McpServer) => { server.registerTool( 'run_gcloud_command',