From a468467c198367fc9d4a97d4950a56abdc3c5d83 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 23 Jul 2025 11:33:30 -0400 Subject: [PATCH 01/10] Initial draft of ide manager --- packages/core/src/ide/ide-mode-manager.ts | 61 +++++++++++++++++++++++ packages/core/src/index.ts | 3 ++ packages/core/src/tools/mcp-client.ts | 19 +------ 3 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/ide/ide-mode-manager.ts diff --git a/packages/core/src/ide/ide-mode-manager.ts b/packages/core/src/ide/ide-mode-manager.ts new file mode 100644 index 00000000000..64a74a44056 --- /dev/null +++ b/packages/core/src/ide/ide-mode-manager.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import { +// OpenFilesNotificationSchema, +// IDE_SERVER_NAME, +// ideContext, +// } from '../services/ideContext.js'; + +import { + // IDE_SERVER_NAME, + ideContext, + // OpenFiles, + OpenFilesNotificationSchema, +} from '../services/ideContext.js'; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + + +/** + * Manages the connection and interaction with the IDE MCP server. + */ +export class IdeModeManager { + client: Client | undefined = undefined; + + constructor() { + this.connectToMcpServer().then(() => { + console.log("connected"); + }); + } + + async connectToMcpServer() { + this.client = new Client({ + name: 'streamable-http-client', + version: '1.0.0' + }); + const idePort = process.env['GEMINI_CLI_IDE_SERVER_PORT']; + if (!idePort) { + console.log("unable to connect"); + } + const url = `http://localhost:${idePort}/mcp` + + const transport = new StreamableHTTPClientTransport( + new URL(url) + ); + await this.client.connect(transport); + console.log("Connected using Streamable HTTP transport"); + this.client.setNotificationHandler( + OpenFilesNotificationSchema, + (notification) => { + ideContext.setOpenFilesContext(notification.params); + }, + ); + } + +} + +export const ideModeManager = new IdeModeManager(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f560afb44aa..da9f54aa512 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,9 @@ export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; export * from './services/ideContext.js'; +// Export IDE specific logic +export * from './ide/ide-mode-manager.js'; + // Export base tool definitions export * from './tools/tools.js'; export * from './tools/tool-registry.js'; diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 457259e5f09..c3690258778 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -23,12 +23,8 @@ import { ToolRegistry } from './tool-registry.js'; import { MCPOAuthProvider } from '../mcp/oauth-provider.js'; import { OAuthUtils } from '../mcp/oauth-utils.js'; import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js'; -import { - OpenFilesNotificationSchema, - IDE_SERVER_NAME, - ideContext, -} from '../services/ideContext.js'; import { getErrorMessage } from '../utils/errors.js'; +import { ideModeManager } from '../ide/ide-mode-manager.js'; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes @@ -378,24 +374,13 @@ export async function connectAndDiscover( ); try { updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED); + ideModeManager.client?.listTools(); mcpClient.onerror = (error) => { console.error(`MCP ERROR (${mcpServerName}):`, error.toString()); updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); - if (mcpServerName === IDE_SERVER_NAME) { - ideContext.clearOpenFilesContext(); - } }; - if (mcpServerName === IDE_SERVER_NAME) { - mcpClient.setNotificationHandler( - OpenFilesNotificationSchema, - (notification) => { - ideContext.setOpenFilesContext(notification.params); - }, - ); - } - const tools = await discoverTools( mcpServerName, mcpServerConfig, From 633ff63bf899b9ead11539f513c70d50f50c8e24 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 24 Jul 2025 10:33:08 -0400 Subject: [PATCH 02/10] add'l changes --- packages/cli/src/config/config.ts | 33 ------------ .../cli/src/ui/commands/ideCommand.test.ts | 50 ++++++------------- packages/cli/src/ui/commands/ideCommand.ts | 41 +-------------- packages/core/src/ide/ide-mode-manager.ts | 46 ++++++++++------- 4 files changed, 43 insertions(+), 127 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fd4907d065a..e7a2a06fde5 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -19,8 +19,6 @@ import { FileDiscoveryService, TelemetryTarget, FileFilteringOptions, - MCPServerConfig, - IDE_SERVER_NAME, } from '@google/gemini-cli-core'; import { Settings } from './settings.js'; @@ -347,37 +345,6 @@ export async function loadCliConfig( } } - if (ideMode) { - if (mcpServers[IDE_SERVER_NAME]) { - logger.warn( - `Ignoring user-defined MCP server config for "${IDE_SERVER_NAME}" as it is a reserved name.`, - ); - } - const companionPort = process.env.GEMINI_CLI_IDE_SERVER_PORT; - if (companionPort) { - const httpUrl = `http://localhost:${companionPort}/mcp`; - mcpServers[IDE_SERVER_NAME] = new MCPServerConfig( - undefined, // command - undefined, // args - undefined, // env - undefined, // cwd - undefined, // url - httpUrl, // httpUrl - undefined, // headers - undefined, // tcp - undefined, // timeout - false, // trust - 'IDE connection', // description - undefined, // includeTools - undefined, // excludeTools - ); - } else { - logger.warn( - 'Could not connect to IDE. Make sure you have the companion VS Code extension installed from the marketplace or via /ide install.', - ); - } - } - const sandboxConfig = await loadSandboxConfig(settings, argv); return new Config({ diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 9e5f798d8df..dd83582555f 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -11,13 +11,7 @@ import { type Config } from '@google/gemini-cli-core'; import * as child_process from 'child_process'; import { glob } from 'glob'; -import { - getMCPDiscoveryState, - getMCPServerStatus, - IDE_SERVER_NAME, - MCPDiscoveryState, - MCPServerStatus, -} from '@google/gemini-cli-core'; +import { ideModeManager } from '@google/gemini-cli-core'; vi.mock('child_process'); vi.mock('glob'); @@ -26,8 +20,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...original, - getMCPServerStatus: vi.fn(), - getMCPDiscoveryState: vi.fn(), + ideModeManager: { + getServerStatus: vi.fn(), + }, }; }); @@ -37,8 +32,7 @@ describe('ideCommand', () => { let execSyncSpy: vi.SpyInstance; let globSyncSpy: vi.SpyInstance; let platformSpy: vi.SpyInstance; - let getMCPServerStatusSpy: vi.SpyInstance; - let getMCPDiscoveryStateSpy: vi.SpyInstance; + let getServerStatusSpy: vi.SpyInstance; beforeEach(() => { mockContext = { @@ -54,8 +48,7 @@ describe('ideCommand', () => { execSyncSpy = vi.spyOn(child_process, 'execSync'); globSyncSpy = vi.spyOn(glob, 'sync'); platformSpy = vi.spyOn(process, 'platform', 'get'); - getMCPServerStatusSpy = vi.mocked(getMCPServerStatus); - getMCPDiscoveryStateSpy = vi.mocked(getMCPDiscoveryState); + getServerStatusSpy = vi.mocked(ideModeManager.getServerStatus); }); afterEach(() => { @@ -84,43 +77,28 @@ describe('ideCommand', () => { }); it('should show connected status', () => { - getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.CONNECTED); - const command = ideCommand(mockConfig); - const result = command?.subCommands?.[0].action(mockContext, ''); - expect(getMCPServerStatusSpy).toHaveBeenCalledWith(IDE_SERVER_NAME); - expect(result).toEqual({ + getServerStatusSpy.mockReturnValue({ type: 'message', messageType: 'info', content: '🟢 Connected', }); - }); - - it('should show connecting status', () => { - getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.CONNECTING); const command = ideCommand(mockConfig); const result = command?.subCommands?.[0].action(mockContext, ''); + expect(getServerStatusSpy).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', - content: '🔄 Initializing...', + content: '🟢 Connected', }); }); - it('should show discovery in progress status', () => { - getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.DISCONNECTED); - getMCPDiscoveryStateSpy.mockReturnValue(MCPDiscoveryState.IN_PROGRESS); - const command = ideCommand(mockConfig); - const result = command?.subCommands?.[0].action(mockContext, ''); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: '🔄 Initializing...', - }); - }); it('should show disconnected status', () => { - getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.DISCONNECTED); - getMCPDiscoveryStateSpy.mockReturnValue(MCPDiscoveryState.NOT_FOUND); + getServerStatusSpy.mockReturnValue({ + type: 'message', + messageType: 'error', + content: '🔴 Disconnected', + }); const command = ideCommand(mockConfig); const result = command?.subCommands?.[0].action(mockContext, ''); expect(result).toEqual({ diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 6fc4f50b93a..296a449696e 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -5,14 +5,7 @@ */ import { fileURLToPath } from 'url'; -import { - Config, - getMCPDiscoveryState, - getMCPServerStatus, - IDE_SERVER_NAME, - MCPDiscoveryState, - MCPServerStatus, -} from '@google/gemini-cli-core'; +import { Config, ideModeManager } from '@google/gemini-cli-core'; import { CommandContext, SlashCommand, @@ -56,37 +49,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { - const status = getMCPServerStatus(IDE_SERVER_NAME); - const discoveryState = getMCPDiscoveryState(); - switch (status) { - case MCPServerStatus.CONNECTED: - return { - type: 'message', - messageType: 'info', - content: `🟢 Connected`, - }; - case MCPServerStatus.CONNECTING: - return { - type: 'message', - messageType: 'info', - content: `🔄 Initializing...`, - }; - case MCPServerStatus.DISCONNECTED: - default: - if (discoveryState === MCPDiscoveryState.IN_PROGRESS) { - return { - type: 'message', - messageType: 'info', - content: `🔄 Initializing...`, - }; - } else { - return { - type: 'message', - messageType: 'error', - content: `🔴 Disconnected`, - }; - } - } + return ideModeManager.getServerStatus(); }, }, { diff --git a/packages/core/src/ide/ide-mode-manager.ts b/packages/core/src/ide/ide-mode-manager.ts index 64a74a44056..c24513e9bce 100644 --- a/packages/core/src/ide/ide-mode-manager.ts +++ b/packages/core/src/ide/ide-mode-manager.ts @@ -16,9 +16,8 @@ import { // OpenFiles, OpenFilesNotificationSchema, } from '../services/ideContext.js'; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; - +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; /** * Manages the connection and interaction with the IDE MCP server. @@ -27,35 +26,44 @@ export class IdeModeManager { client: Client | undefined = undefined; constructor() { - this.connectToMcpServer().then(() => { - console.log("connected"); - }); + this.connectToMcpServer().catch(() => {}); } - async connectToMcpServer() { + getServerStatus() { + if (!this.client) { + return { + type: 'message', + messageType: 'error', + content: `🔴 Disconnected`, + } as const; + } + return { + type: 'message', + messageType: 'info', + content: `🟢 Connected`, + } as const; + } + + async connectToMcpServer(): Promise { this.client = new Client({ - name: 'streamable-http-client', - version: '1.0.0' + name: 'streamable-http-client', + version: '1.0.0', }); const idePort = process.env['GEMINI_CLI_IDE_SERVER_PORT']; if (!idePort) { - console.log("unable to connect"); + console.log('unable to connect'); } - const url = `http://localhost:${idePort}/mcp` + const url = `http://localhost:${idePort}/mcp`; - const transport = new StreamableHTTPClientTransport( - new URL(url) - ); + const transport = new StreamableHTTPClientTransport(new URL(url)); await this.client.connect(transport); - console.log("Connected using Streamable HTTP transport"); this.client.setNotificationHandler( - OpenFilesNotificationSchema, - (notification) => { + OpenFilesNotificationSchema, + (notification) => { ideContext.setOpenFilesContext(notification.params); - }, + }, ); } - } export const ideModeManager = new IdeModeManager(); From 01d0e5bb8a3991dc930f6db3adbe449e5a5bdfb3 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 24 Jul 2025 12:26:08 -0400 Subject: [PATCH 03/10] cleanup --- .../cli/src/ui/commands/ideCommand.test.ts | 1 - packages/core/src/ide/ide-mode-manager.ts | 25 ++++++++++--------- packages/core/src/tools/mcp-client.ts | 3 --- .../vscode-ide-companion/src/ide-server.ts | 2 +- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index dd83582555f..a47e6e40b26 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -92,7 +92,6 @@ describe('ideCommand', () => { }); }); - it('should show disconnected status', () => { getServerStatusSpy.mockReturnValue({ type: 'message', diff --git a/packages/core/src/ide/ide-mode-manager.ts b/packages/core/src/ide/ide-mode-manager.ts index c24513e9bce..c19c2010954 100644 --- a/packages/core/src/ide/ide-mode-manager.ts +++ b/packages/core/src/ide/ide-mode-manager.ts @@ -4,23 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -// import { -// OpenFilesNotificationSchema, -// IDE_SERVER_NAME, -// ideContext, -// } from '../services/ideContext.js'; - import { - // IDE_SERVER_NAME, ideContext, - // OpenFiles, OpenFilesNotificationSchema, } from '../services/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +const logger = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debug: (...args: any[]) => + console.debug('[DEBUG] [ImportProcessor]', ...args), +}; + /** - * Manages the connection and interaction with the IDE MCP server. + * Manages the connection to and interaction with the IDE server. */ export class IdeModeManager { client: Client | undefined = undefined; @@ -51,11 +49,14 @@ export class IdeModeManager { }); const idePort = process.env['GEMINI_CLI_IDE_SERVER_PORT']; if (!idePort) { - console.log('unable to connect'); + logger.debug( + `Unable to connect to IDE mode MCP server. Expected to connect to port ${process.env['GEMINI_CLI_IDE_SERVER_PORT']}`, + ); } - const url = `http://localhost:${idePort}/mcp`; - const transport = new StreamableHTTPClientTransport(new URL(url)); + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${idePort}/mcp`), + ); await this.client.connect(transport); this.client.setNotificationHandler( OpenFilesNotificationSchema, diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index c3690258778..7d1103a89c6 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -24,7 +24,6 @@ import { MCPOAuthProvider } from '../mcp/oauth-provider.js'; import { OAuthUtils } from '../mcp/oauth-utils.js'; import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js'; import { getErrorMessage } from '../utils/errors.js'; -import { ideModeManager } from '../ide/ide-mode-manager.js'; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes @@ -374,8 +373,6 @@ export async function connectAndDiscover( ); try { updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED); - ideModeManager.client?.listTools(); - mcpClient.onerror = (error) => { console.error(`MCP ERROR (${mcpServerName}):`, error.toString()); updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 9111f349b2a..6f567721581 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -224,7 +224,7 @@ const createMcpServer = () => { { capabilities: { logging: {} } }, ); server.registerTool( - 'getOpenFiles', + 'getActiveFile', { description: '(IDE Tool) Get the path of the file currently active in VS Code.', From 219eca790f284496dc8e3bfd9fe424874c231b19 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 24 Jul 2025 13:32:14 -0400 Subject: [PATCH 04/10] Update status threading --- .../cli/src/ui/commands/ideCommand.test.ts | 43 ++++++---------- packages/cli/src/ui/commands/ideCommand.ts | 27 +++++++++- packages/core/src/ide/ide-mode-manager.ts | 51 ++++++++++--------- 3 files changed, 69 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index b7fdadef438..9b8bb6adc99 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -20,6 +20,7 @@ import * as child_process from 'child_process'; import { glob } from 'glob'; import { ideModeManager } from '@google/gemini-cli-core'; +import { IDEConnectionStatus } from '@google/gemini-cli-core/index.js'; vi.mock('child_process'); vi.mock('glob'); @@ -28,9 +29,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...original, - ideModeManager: { - getServerStatus: vi.fn(), - }, + getMCPServerStatus: vi.fn(), + getMCPDiscoveryState: vi.fn(), }; }); @@ -44,8 +44,7 @@ describe('ideCommand', () => { let execSyncSpy: MockInstance; let globSyncSpy: MockInstance; let platformSpy: MockInstance; - let getMCPServerStatusSpy: MockInstance; - let getMCPDiscoveryStateSpy: MockInstance; + let getConnectionStatusSpy: MockInstance; beforeEach(() => { mockContext = { @@ -61,8 +60,7 @@ describe('ideCommand', () => { execSyncSpy = vi.spyOn(child_process, 'execSync'); globSyncSpy = vi.spyOn(glob, 'sync'); platformSpy = vi.spyOn(process, 'platform', 'get'); - getMCPServerStatusSpy = vi.spyOn(ideModeManager, 'getServerStatus'); - getMCPDiscoveryStateSpy = vi.mocked(ideModeManager.getServerStatus); + getConnectionStatusSpy = vi.mocked(ideModeManager.getConnectionStatus); }); afterEach(() => { @@ -91,46 +89,37 @@ describe('ideCommand', () => { }); it('should show connected status', () => { - getMCPServerStatusSpy.mockReturnValue({ - type: 'message', - messageType: 'info', - content: '🟢 Connected', - }); + getConnectionStatusSpy.mockReturnValue(IDEConnectionStatus.Connected); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); + expect(getConnectionStatusSpy).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', - content: '🔄 Initializing...', + content: '🟢 Connected', }); }); - it('should show discovery in progress status', () => { - getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.DISCONNECTED); - getMCPDiscoveryStateSpy.mockReturnValue(MCPDiscoveryState.IN_PROGRESS); + it('should show connecting status', () => { + getConnectionStatusSpy.mockReturnValue(IDEConnectionStatus.Connecting); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); + expect(getConnectionStatusSpy).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', - content: '🔄 Initializing...', + content: `🟡 Connecting...`, }); }); - - it('should show disconnected status', () => { - getMCPServerStatusSpy.mockReturnValue({ - type: 'message', - messageType: 'error', - content: '🔴 Disconnected', - }); - getMCPDiscoveryStateSpy.mockReturnValue(MCPDiscoveryState.COMPLETED); - + it('should show connecting status', () => { + getConnectionStatusSpy.mockReturnValue(IDEConnectionStatus.Disconnected); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); + expect(getConnectionStatusSpy).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', - content: '🔴 Disconnected', + content: `🔴 Disconnected`, }); }); }); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 296a449696e..1d44320ee91 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -5,7 +5,11 @@ */ import { fileURLToPath } from 'url'; -import { Config, ideModeManager } from '@google/gemini-cli-core'; +import { + Config, + ideModeManager, + IDEConnectionStatus, +} from '@google/gemini-cli-core'; import { CommandContext, SlashCommand, @@ -49,7 +53,26 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { - return ideModeManager.getServerStatus(); + switch (ideModeManager.getConnectionStatus()) { + case IDEConnectionStatus.Connected: + return { + type: 'message', + messageType: 'info', + content: `🟢 Connected`, + } as const; + case IDEConnectionStatus.Connecting: + return { + type: 'message', + messageType: 'info', + content: `🟡 Connecting...`, + } as const; + default: + return { + type: 'message', + messageType: 'error', + content: `🔴 Disconnected`, + } as const; + } }, }, { diff --git a/packages/core/src/ide/ide-mode-manager.ts b/packages/core/src/ide/ide-mode-manager.ts index c19c2010954..249291330c6 100644 --- a/packages/core/src/ide/ide-mode-manager.ts +++ b/packages/core/src/ide/ide-mode-manager.ts @@ -17,32 +17,29 @@ const logger = { console.debug('[DEBUG] [ImportProcessor]', ...args), }; +export enum IDEConnectionStatus { + Connected = 'connected', + Disconnected = 'disconnected', + Connecting = 'connecting', +} + /** * Manages the connection to and interaction with the IDE server. */ export class IdeModeManager { client: Client | undefined = undefined; + connectionStatus: IDEConnectionStatus = IDEConnectionStatus.Disconnected; constructor() { this.connectToMcpServer().catch(() => {}); } - getServerStatus() { - if (!this.client) { - return { - type: 'message', - messageType: 'error', - content: `🔴 Disconnected`, - } as const; - } - return { - type: 'message', - messageType: 'info', - content: `🟢 Connected`, - } as const; + getConnectionStatus(): IDEConnectionStatus { + return this.connectionStatus; } async connectToMcpServer(): Promise { + this.connectionStatus = IDEConnectionStatus.Connecting; this.client = new Client({ name: 'streamable-http-client', version: '1.0.0', @@ -52,18 +49,26 @@ export class IdeModeManager { logger.debug( `Unable to connect to IDE mode MCP server. Expected to connect to port ${process.env['GEMINI_CLI_IDE_SERVER_PORT']}`, ); + this.connectionStatus = IDEConnectionStatus.Disconnected; + return; } - const transport = new StreamableHTTPClientTransport( - new URL(`http://localhost:${idePort}/mcp`), - ); - await this.client.connect(transport); - this.client.setNotificationHandler( - OpenFilesNotificationSchema, - (notification) => { - ideContext.setOpenFilesContext(notification.params); - }, - ); + try { + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${idePort}/mcp`), + ); + await this.client.connect(transport); + this.client.setNotificationHandler( + OpenFilesNotificationSchema, + (notification) => { + ideContext.setOpenFilesContext(notification.params); + }, + ); + this.connectionStatus = IDEConnectionStatus.Connected; + } catch (error) { + this.connectionStatus = IDEConnectionStatus.Disconnected; + logger.debug('Failed to connect to MCP server:', error); + } } } From 48e22d57f617edbf727dc15db435c284467f7a27 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 24 Jul 2025 13:45:51 -0400 Subject: [PATCH 05/10] Address comments --- packages/core/src/ide/ide-mode-manager.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ide/ide-mode-manager.ts b/packages/core/src/ide/ide-mode-manager.ts index 249291330c6..9ee9cbb5b8d 100644 --- a/packages/core/src/ide/ide-mode-manager.ts +++ b/packages/core/src/ide/ide-mode-manager.ts @@ -31,29 +31,30 @@ export class IdeModeManager { connectionStatus: IDEConnectionStatus = IDEConnectionStatus.Disconnected; constructor() { - this.connectToMcpServer().catch(() => {}); + this.connectToMcpServer().catch((err) => { + logger.debug('Failed to initialize IdeModeManager:', err); + }); } - getConnectionStatus(): IDEConnectionStatus { return this.connectionStatus; } async connectToMcpServer(): Promise { this.connectionStatus = IDEConnectionStatus.Connecting; - this.client = new Client({ - name: 'streamable-http-client', - version: '1.0.0', - }); const idePort = process.env['GEMINI_CLI_IDE_SERVER_PORT']; if (!idePort) { logger.debug( - `Unable to connect to IDE mode MCP server. Expected to connect to port ${process.env['GEMINI_CLI_IDE_SERVER_PORT']}`, + 'Unable to connect to IDE mode MCP server. GEMINI_CLI_IDE_SERVER_PORT environment variable is not set.', ); this.connectionStatus = IDEConnectionStatus.Disconnected; return; } try { + this.client = new Client({ + name: 'streamable-http-client', + version: '1.0.0', + }); const transport = new StreamableHTTPClientTransport( new URL(`http://localhost:${idePort}/mcp`), ); From 965018c8dcbe129b74ad70ca0fa5f917f452cd41 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 24 Jul 2025 13:57:49 -0400 Subject: [PATCH 06/10] Remove unnecessary tests --- packages/cli/src/config/config.test.ts | 96 ------------------------ packages/core/src/services/ideContext.ts | 4 - 2 files changed, 100 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c0e9c215fa8..55780320828 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1011,100 +1011,4 @@ describe('loadCliConfig ideMode', () => { const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getIdeMode()).toBe(false); }); - - it('should add _ide_server when ideMode is true', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(true); - const mcpServers = config.getMcpServers(); - expect(mcpServers['_ide_server']).toBeDefined(); - expect(mcpServers['_ide_server'].httpUrl).toBe('http://localhost:3000/mcp'); - expect(mcpServers['_ide_server'].description).toBe('IDE connection'); - expect(mcpServers['_ide_server'].trust).toBe(false); - }); - - it('should warn if ideMode is true and no port is set', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - const settings: Settings = {}; - await loadCliConfig(settings, [], 'test-session', argv); - expect(consoleWarnSpy).toHaveBeenCalledWith( - '[WARN]', - 'Could not connect to IDE. Make sure you have the companion VS Code extension installed from the marketplace or via /ide install.', - ); - consoleWarnSpy.mockRestore(); - }); - - it('should warn and overwrite if settings contain the reserved _ide_server name and ideMode is active', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = { - mcpServers: { - _ide_server: new ServerConfig.MCPServerConfig( - undefined, - undefined, - undefined, - undefined, - 'http://malicious:1234', - ), - }, - }; - - const config = await loadCliConfig(settings, [], 'test-session', argv); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - '[WARN]', - 'Ignoring user-defined MCP server config for "_ide_server" as it is a reserved name.', - ); - - const mcpServers = config.getMcpServers(); - expect(mcpServers['_ide_server']).toBeDefined(); - expect(mcpServers['_ide_server'].httpUrl).toBe('http://localhost:3000/mcp'); - - consoleWarnSpy.mockRestore(); - }); - - it('should NOT warn if settings contain the reserved _ide_server name and ideMode is NOT active', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - process.argv = ['node', 'script.js']; - const argv = await parseArguments(); - const settings: Settings = { - mcpServers: { - _ide_server: new ServerConfig.MCPServerConfig( - undefined, - undefined, - undefined, - undefined, - 'http://malicious:1234', - ), - }, - }; - - const config = await loadCliConfig(settings, [], 'test-session', argv); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - - const mcpServers = config.getMcpServers(); - expect(mcpServers['_ide_server']).toBeDefined(); - expect(mcpServers['_ide_server'].url).toBe('http://malicious:1234'); - - consoleWarnSpy.mockRestore(); - }); }); diff --git a/packages/core/src/services/ideContext.ts b/packages/core/src/services/ideContext.ts index f8a50f125af..bc7383a1225 100644 --- a/packages/core/src/services/ideContext.ts +++ b/packages/core/src/services/ideContext.ts @@ -6,10 +6,6 @@ import { z } from 'zod'; -/** - * The reserved server name for the IDE's MCP server. - */ -export const IDE_SERVER_NAME = '_ide_server'; /** * Zod schema for validating a cursor position. */ From 6eb3dd53af85e1fccb611aa43d52b9c046c3ef24 Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 25 Jul 2025 12:01:43 -0400 Subject: [PATCH 07/10] Address comments --- .../cli/src/ui/commands/ideCommand.test.ts | 34 +++++++++++++++---- packages/cli/src/ui/commands/ideCommand.ts | 14 +++++--- packages/core/src/core/client.test.ts | 4 +-- packages/core/src/core/client.ts | 2 +- .../{ide-mode-manager.ts => ide-client.ts} | 23 +++++++++---- .../src/{services => ide}/ideContext.test.ts | 0 .../core/src/{services => ide}/ideContext.ts | 0 packages/core/src/index.ts | 4 +-- .../vscode-ide-companion/src/ide-server.ts | 26 -------------- 9 files changed, 59 insertions(+), 48 deletions(-) rename packages/core/src/ide/{ide-mode-manager.ts => ide-client.ts} (74%) rename packages/core/src/{services => ide}/ideContext.test.ts (100%) rename packages/core/src/{services => ide}/ideContext.ts (100%) diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 9b8bb6adc99..360cf043edc 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -19,7 +19,7 @@ import { type Config } from '@google/gemini-cli-core'; import * as child_process from 'child_process'; import { glob } from 'glob'; -import { ideModeManager } from '@google/gemini-cli-core'; +import { ideClient } from '@google/gemini-cli-core'; import { IDEConnectionStatus } from '@google/gemini-cli-core/index.js'; vi.mock('child_process'); @@ -60,7 +60,7 @@ describe('ideCommand', () => { execSyncSpy = vi.spyOn(child_process, 'execSync'); globSyncSpy = vi.spyOn(glob, 'sync'); platformSpy = vi.spyOn(process, 'platform', 'get'); - getConnectionStatusSpy = vi.mocked(ideModeManager.getConnectionStatus); + getConnectionStatusSpy = vi.mocked(ideClient.getConnectionStatus); }); afterEach(() => { @@ -89,7 +89,9 @@ describe('ideCommand', () => { }); it('should show connected status', () => { - getConnectionStatusSpy.mockReturnValue(IDEConnectionStatus.Connected); + getConnectionStatusSpy.mockReturnValue({ + status: IDEConnectionStatus.Connected, + }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); expect(getConnectionStatusSpy).toHaveBeenCalled(); @@ -101,7 +103,9 @@ describe('ideCommand', () => { }); it('should show connecting status', () => { - getConnectionStatusSpy.mockReturnValue(IDEConnectionStatus.Connecting); + getConnectionStatusSpy.mockReturnValue({ + status: IDEConnectionStatus.Connecting, + }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); expect(getConnectionStatusSpy).toHaveBeenCalled(); @@ -111,8 +115,10 @@ describe('ideCommand', () => { content: `🟡 Connecting...`, }); }); - it('should show connecting status', () => { - getConnectionStatusSpy.mockReturnValue(IDEConnectionStatus.Disconnected); + it('should show disconnected status', () => { + getConnectionStatusSpy.mockReturnValue({ + status: IDEConnectionStatus.Disconnected, + }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); expect(getConnectionStatusSpy).toHaveBeenCalled(); @@ -122,6 +128,22 @@ describe('ideCommand', () => { content: `🔴 Disconnected`, }); }); + + it('should show disconnected status with details', () => { + const details = 'Something went wrong'; + getConnectionStatusSpy.mockReturnValue({ + status: IDEConnectionStatus.Disconnected, + details, + }); + const command = ideCommand(mockConfig); + const result = command!.subCommands![0].action!(mockContext, ''); + expect(getConnectionStatusSpy).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `🔴 Disconnected: ${details}`, + }); + }); }); describe('install subcommand', () => { diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 1d44320ee91..431214ec1e7 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'; import { Config, - ideModeManager, + ideClient, IDEConnectionStatus, } from '@google/gemini-cli-core'; import { @@ -53,7 +53,8 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { - switch (ideModeManager.getConnectionStatus()) { + const connection = ideClient.getConnectionStatus(); + switch (connection.status) { case IDEConnectionStatus.Connected: return { type: 'message', @@ -66,12 +67,17 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { messageType: 'info', content: `🟡 Connecting...`, } as const; - default: + default: { + let content = `🔴 Disconnected`; + if (connection.details) { + content += `: ${connection.details}`; + } return { type: 'message', messageType: 'error', - content: `🔴 Disconnected`, + content, } as const; + } } }, }, diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 44b19f56b23..25ea9bc1e86 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -23,7 +23,7 @@ import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { tokenLimit } from './tokenLimits.js'; -import { ideContext } from '../services/ideContext.js'; +import { ideContext } from '../ide/ideContext.js'; // --- Mocks --- const mockChatCreateFn = vi.fn(); @@ -72,7 +72,7 @@ vi.mock('../telemetry/index.js', () => ({ logApiResponse: vi.fn(), logApiError: vi.fn(), })); -vi.mock('../services/ideContext.js'); +vi.mock('../ide/ideContext.js'); describe('findIndexAfterFraction', () => { const history: Content[] = [ diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 6f4823071c2..77683a45509 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -42,7 +42,7 @@ import { import { ProxyAgent, setGlobalDispatcher } from 'undici'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { LoopDetectionService } from '../services/loopDetectionService.js'; -import { ideContext } from '../services/ideContext.js'; +import { ideContext } from '../ide/ideContext.js'; import { logFlashDecidedToContinue } from '../telemetry/loggers.js'; import { FlashDecidedToContinueEvent } from '../telemetry/types.js'; diff --git a/packages/core/src/ide/ide-mode-manager.ts b/packages/core/src/ide/ide-client.ts similarity index 74% rename from packages/core/src/ide/ide-mode-manager.ts rename to packages/core/src/ide/ide-client.ts index 9ee9cbb5b8d..86849190c3e 100644 --- a/packages/core/src/ide/ide-mode-manager.ts +++ b/packages/core/src/ide/ide-client.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - ideContext, - OpenFilesNotificationSchema, -} from '../services/ideContext.js'; +import { ideContext, OpenFilesNotificationSchema } from '../ide/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -26,13 +23,13 @@ export enum IDEConnectionStatus { /** * Manages the connection to and interaction with the IDE server. */ -export class IdeModeManager { +export class IdeClient { client: Client | undefined = undefined; connectionStatus: IDEConnectionStatus = IDEConnectionStatus.Disconnected; constructor() { this.connectToMcpServer().catch((err) => { - logger.debug('Failed to initialize IdeModeManager:', err); + logger.debug('Failed to initialize IdeClient:', err); }); } getConnectionStatus(): IDEConnectionStatus { @@ -53,6 +50,7 @@ export class IdeModeManager { try { this.client = new Client({ name: 'streamable-http-client', + // TODO(#3487): use the CLI version here. version: '1.0.0', }); const transport = new StreamableHTTPClientTransport( @@ -65,6 +63,17 @@ export class IdeModeManager { ideContext.setOpenFilesContext(notification.params); }, ); + this.client.onerror = (error) => { + logger.debug('IDE MCP client error:', error); + this.connectionStatus = IDEConnectionStatus.Disconnected; + ideContext.clearOpenFilesContext(); + }; + this.client.onclose = () => { + logger.debug('IDE MCP client connection closed.'); + this.connectionStatus = IDEConnectionStatus.Disconnected; + ideContext.clearOpenFilesContext(); + }; + this.connectionStatus = IDEConnectionStatus.Connected; } catch (error) { this.connectionStatus = IDEConnectionStatus.Disconnected; @@ -73,4 +82,4 @@ export class IdeModeManager { } } -export const ideModeManager = new IdeModeManager(); +export const ideClient = new IdeClient(); diff --git a/packages/core/src/services/ideContext.test.ts b/packages/core/src/ide/ideContext.test.ts similarity index 100% rename from packages/core/src/services/ideContext.test.ts rename to packages/core/src/ide/ideContext.test.ts diff --git a/packages/core/src/services/ideContext.ts b/packages/core/src/ide/ideContext.ts similarity index 100% rename from packages/core/src/services/ideContext.ts rename to packages/core/src/ide/ideContext.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index da9f54aa512..68e7e18ccba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,10 +40,10 @@ export * from './utils/systemEncoding.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; -export * from './services/ideContext.js'; +export * from './ide/ideContext.js'; // Export IDE specific logic -export * from './ide/ide-mode-manager.js'; +export * from './ide/ide-client.js'; // Export base tool definitions export * from './tools/tools.js'; diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 6dd3d359a62..6ccb154528c 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -228,31 +228,5 @@ const createMcpServer = () => { }, { capabilities: { logging: {} } }, ); - server.registerTool( - 'getActiveFile', - { - description: - '(IDE Tool) Get the path of the file currently active in VS Code.', - inputSchema: {}, - }, - async () => { - const activeEditor = vscode.window.activeTextEditor; - const filePath = activeEditor ? activeEditor.document.uri.fsPath : ''; - if (filePath) { - return { - content: [{ type: 'text', text: `Active file: ${filePath}` }], - }; - } else { - return { - content: [ - { - type: 'text', - text: 'No file is currently active in the editor.', - }, - ], - }; - } - }, - ); return server; }; From 7159339b75ca74ef6d738e9b0f99e2709897e94f Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 25 Jul 2025 12:28:38 -0400 Subject: [PATCH 08/10] Address comment about status and fix tests --- .../cli/src/ui/commands/ideCommand.test.ts | 26 +++++++++---------- packages/core/src/ide/ide-client.ts | 21 +++++++++++++-- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 360cf043edc..6fc0eaf2596 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -15,11 +15,10 @@ import { } from 'vitest'; import { ideCommand } from './ideCommand.js'; import { type CommandContext } from './types.js'; -import { type Config } from '@google/gemini-cli-core'; +import { ideClient, type Config } from '@google/gemini-cli-core'; import * as child_process from 'child_process'; import { glob } from 'glob'; -import { ideClient } from '@google/gemini-cli-core'; import { IDEConnectionStatus } from '@google/gemini-cli-core/index.js'; vi.mock('child_process'); @@ -29,8 +28,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...original, - getMCPServerStatus: vi.fn(), - getMCPDiscoveryState: vi.fn(), + ideClient: { + getConnectionStatus: vi.fn(), + }, }; }); @@ -44,7 +44,6 @@ describe('ideCommand', () => { let execSyncSpy: MockInstance; let globSyncSpy: MockInstance; let platformSpy: MockInstance; - let getConnectionStatusSpy: MockInstance; beforeEach(() => { mockContext = { @@ -60,7 +59,6 @@ describe('ideCommand', () => { execSyncSpy = vi.spyOn(child_process, 'execSync'); globSyncSpy = vi.spyOn(glob, 'sync'); platformSpy = vi.spyOn(process, 'platform', 'get'); - getConnectionStatusSpy = vi.mocked(ideClient.getConnectionStatus); }); afterEach(() => { @@ -89,12 +87,12 @@ describe('ideCommand', () => { }); it('should show connected status', () => { - getConnectionStatusSpy.mockReturnValue({ + ideClient.getConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Connected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(getConnectionStatusSpy).toHaveBeenCalled(); + expect(ideClient.getConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -103,12 +101,12 @@ describe('ideCommand', () => { }); it('should show connecting status', () => { - getConnectionStatusSpy.mockReturnValue({ + ideClient.getConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Connecting, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(getConnectionStatusSpy).toHaveBeenCalled(); + expect(ideClient.getConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -116,12 +114,12 @@ describe('ideCommand', () => { }); }); it('should show disconnected status', () => { - getConnectionStatusSpy.mockReturnValue({ + ideClient.getConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Disconnected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(getConnectionStatusSpy).toHaveBeenCalled(); + expect(ideClient.getConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', @@ -131,13 +129,13 @@ describe('ideCommand', () => { it('should show disconnected status with details', () => { const details = 'Something went wrong'; - getConnectionStatusSpy.mockReturnValue({ + ideClient.getConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Disconnected, details, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(getConnectionStatusSpy).toHaveBeenCalled(); + expect(ideClient.getConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 86849190c3e..c1e2d128692 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -14,6 +14,11 @@ const logger = { console.debug('[DEBUG] [ImportProcessor]', ...args), }; +export type IDEConnectionState = { + status: IDEConnectionStatus; + details?: string; +}; + export enum IDEConnectionStatus { Connected = 'connected', Disconnected = 'disconnected', @@ -32,8 +37,20 @@ export class IdeClient { logger.debug('Failed to initialize IdeClient:', err); }); } - getConnectionStatus(): IDEConnectionStatus { - return this.connectionStatus; + getConnectionStatus(): { + status: IDEConnectionStatus; + details?: string; + } { + let details: string | undefined; + if (this.connectionStatus === IDEConnectionStatus.Disconnected) { + if (!process.env['GEMINI_CLI_IDE_SERVER_PORT']) { + details = 'GEMINI_CLI_IDE_SERVER_PORT environment variable is not set.'; + } + } + return { + status: this.connectionStatus, + details, + }; } async connectToMcpServer(): Promise { From 84e0c2d143aecd122dc2e283c8e3a2831176935e Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 25 Jul 2025 12:57:58 -0400 Subject: [PATCH 09/10] Thread through initial details --- package-lock.json | 12 ++++++++- packages/core/package.json | 3 ++- packages/core/src/ide/ide-client.ts | 22 ++++++++++++++++ .../vscode-ide-companion/src/ide-server.ts | 26 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6771bb17447..e9ae6d8f26b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11862,7 +11862,8 @@ "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", "undici": "^7.10.0", - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^4.0.10" }, "devDependencies": { "@types/diff": "^7.0.2", @@ -11908,6 +11909,15 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "packages/core/node_modules/zod": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.10.tgz", + "integrity": "sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", "version": "99.99.99", diff --git a/packages/core/package.json b/packages/core/package.json index 40f10aa0c55..9a9912293df 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ "@types/glob": "^8.1.0", "@types/html-to-text": "^9.0.4", "ajv": "^8.17.1", + "chardet": "^2.1.0", "diff": "^7.0.0", "dotenv": "^17.1.0", "glob": "^10.4.5", @@ -45,7 +46,7 @@ "strip-ansi": "^7.1.0", "undici": "^7.10.0", "ws": "^8.18.0", - "chardet": "^2.1.0" + "zod": "^4.0.10" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index c1e2d128692..86427611d75 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -8,12 +8,20 @@ import { ideContext, OpenFilesNotificationSchema } from '../ide/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { z } from 'zod'; + const logger = { // eslint-disable-next-line @typescript-eslint/no-explicit-any debug: (...args: any[]) => console.debug('[DEBUG] [ImportProcessor]', ...args), }; +const IdeInfoResponseSchema = z.object({ + structuredContent: z.object({ + ideName: z.string(), + }), +}); + export type IDEConnectionState = { status: IDEConnectionStatus; details?: string; @@ -97,6 +105,20 @@ export class IdeClient { logger.debug('Failed to connect to MCP server:', error); } } + + async getIdeName(): Promise { + const result = await this.client?.callTool({ + name: 'getIdeInfo', + }); + + const parsed = IdeInfoResponseSchema.safeParse(result); + if (parsed.success) { + return parsed.data.structuredContent.ideName; + } else { + logger.debug('Failed to parse getIdeInfo response:', parsed.error); + return undefined; + } + } } export const ideClient = new IdeClient(); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 6ccb154528c..9a04e4a953e 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -15,6 +15,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { Server as HTTPServer } from 'node:http'; import { RecentFilesManager } from './recent-files-manager.js'; +import { z } from 'zod'; const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; @@ -228,5 +229,30 @@ const createMcpServer = () => { }, { capabilities: { logging: {} } }, ); + server.registerTool( + 'getIdeInfo', + { + description: 'Gets information about the IDE', + inputSchema: {}, + outputSchema: { + ideName: z.string(), + }, + }, + async () => { + const structuredContent = { + ideName: 'VSCode', + }; + return { + content: [ + { + type: 'text', + text: JSON.stringify(structuredContent, null, 2), + }, + ], + structuredContent, + }; + }, + ); + return server; }; From 0e48bbbe7f2eee41cdb0909b7887d277a0d0e407 Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 25 Jul 2025 13:18:47 -0400 Subject: [PATCH 10/10] Move flag behind ideMode --- package-lock.json | 12 +------ packages/cli/src/config/config.ts | 7 ++++ .../cli/src/ui/commands/ideCommand.test.ts | 33 ++++++++----------- packages/cli/src/ui/commands/ideCommand.ts | 12 +++---- packages/core/package.json | 3 +- packages/core/src/config/config.ts | 8 +++++ packages/core/src/ide/ide-client.ts | 24 -------------- packages/core/src/index.ts | 2 +- .../vscode-ide-companion/src/ide-server.ts | 26 --------------- 9 files changed, 36 insertions(+), 91 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9ae6d8f26b..6771bb17447 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11862,8 +11862,7 @@ "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", "undici": "^7.10.0", - "ws": "^8.18.0", - "zod": "^4.0.10" + "ws": "^8.18.0" }, "devDependencies": { "@types/diff": "^7.0.2", @@ -11909,15 +11908,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "packages/core/node_modules/zod": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.10.tgz", - "integrity": "sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", "version": "99.99.99", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 094c6cacb26..27e3ec09889 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -19,6 +19,7 @@ import { FileDiscoveryService, TelemetryTarget, FileFilteringOptions, + IdeClient, } from '@google/gemini-cli-core'; import { Settings } from './settings.js'; @@ -262,6 +263,11 @@ export async function loadCliConfig( process.env.TERM_PROGRAM === 'vscode' && !process.env.SANDBOX; + let ideClient: IdeClient | undefined; + if (ideMode) { + ideClient = new IdeClient(); + } + const allExtensions = annotateActiveExtensions( extensions, argv.extensions || [], @@ -417,6 +423,7 @@ export async function loadCliConfig( noBrowser: !!process.env.NO_BROWSER, summarizeToolOutput: settings.summarizeToolOutput, ideMode, + ideClient, }); } diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 6fc0eaf2596..d1d72466891 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -15,7 +15,7 @@ import { } from 'vitest'; import { ideCommand } from './ideCommand.js'; import { type CommandContext } from './types.js'; -import { ideClient, type Config } from '@google/gemini-cli-core'; +import { type Config } from '@google/gemini-cli-core'; import * as child_process from 'child_process'; import { glob } from 'glob'; @@ -23,16 +23,6 @@ import { IDEConnectionStatus } from '@google/gemini-cli-core/index.js'; vi.mock('child_process'); vi.mock('glob'); -vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const original = - await importOriginal(); - return { - ...original, - ideClient: { - getConnectionStatus: vi.fn(), - }, - }; -}); function regexEscape(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -54,6 +44,7 @@ describe('ideCommand', () => { mockConfig = { getIdeMode: vi.fn(), + getIdeClient: vi.fn(), } as unknown as Config; execSyncSpy = vi.spyOn(child_process, 'execSync'); @@ -82,17 +73,21 @@ describe('ideCommand', () => { }); describe('status subcommand', () => { + const mockGetConnectionStatus = vi.fn(); beforeEach(() => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + vi.mocked(mockConfig.getIdeClient).mockReturnValue({ + getConnectionStatus: mockGetConnectionStatus, + } as ReturnType); }); it('should show connected status', () => { - ideClient.getConnectionStatus.mockReturnValue({ + mockGetConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Connected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(ideClient.getConnectionStatus).toHaveBeenCalled(); + expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -101,12 +96,12 @@ describe('ideCommand', () => { }); it('should show connecting status', () => { - ideClient.getConnectionStatus.mockReturnValue({ + mockGetConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Connecting, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(ideClient.getConnectionStatus).toHaveBeenCalled(); + expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -114,12 +109,12 @@ describe('ideCommand', () => { }); }); it('should show disconnected status', () => { - ideClient.getConnectionStatus.mockReturnValue({ + mockGetConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Disconnected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(ideClient.getConnectionStatus).toHaveBeenCalled(); + expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', @@ -129,13 +124,13 @@ describe('ideCommand', () => { it('should show disconnected status with details', () => { const details = 'Something went wrong'; - ideClient.getConnectionStatus.mockReturnValue({ + mockGetConnectionStatus.mockReturnValue({ status: IDEConnectionStatus.Disconnected, details, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); - expect(ideClient.getConnectionStatus).toHaveBeenCalled(); + expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 431214ec1e7..31f2371fc82 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -5,11 +5,7 @@ */ import { fileURLToPath } from 'url'; -import { - Config, - ideClient, - IDEConnectionStatus, -} from '@google/gemini-cli-core'; +import { Config, IDEConnectionStatus } from '@google/gemini-cli-core'; import { CommandContext, SlashCommand, @@ -53,8 +49,8 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { - const connection = ideClient.getConnectionStatus(); - switch (connection.status) { + const connection = config.getIdeClient()?.getConnectionStatus(); + switch (connection?.status) { case IDEConnectionStatus.Connected: return { type: 'message', @@ -69,7 +65,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { } as const; default: { let content = `🔴 Disconnected`; - if (connection.details) { + if (connection?.details) { content += `: ${connection.details}`; } return { diff --git a/packages/core/package.json b/packages/core/package.json index 9a9912293df..ba4735ea1ab 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,8 +45,7 @@ "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", "undici": "^7.10.0", - "ws": "^8.18.0", - "zod": "^4.0.10" + "ws": "^8.18.0" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 485a56c4bab..96b6f2cb3a3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -45,6 +45,7 @@ import { import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'; import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { MCPOAuthConfig } from '../mcp/oauth-provider.js'; +import { IdeClient } from '../ide/ide-client.js'; // Re-export OAuth config type export type { MCPOAuthConfig }; @@ -180,6 +181,7 @@ export interface ConfigParameters { noBrowser?: boolean; summarizeToolOutput?: Record; ideMode?: boolean; + ideClient?: IdeClient; } export class Config { @@ -221,6 +223,7 @@ export class Config { private readonly extensionContextFilePaths: string[]; private readonly noBrowser: boolean; private readonly ideMode: boolean; + private readonly ideClient: IdeClient | undefined; private modelSwitchedDuringSession: boolean = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; @@ -286,6 +289,7 @@ export class Config { this.noBrowser = params.noBrowser ?? false; this.summarizeToolOutput = params.summarizeToolOutput; this.ideMode = params.ideMode ?? false; + this.ideClient = params.ideClient; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -574,6 +578,10 @@ export class Config { return this.ideMode; } + getIdeClient(): IdeClient | undefined { + return this.ideClient; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 86427611d75..eeed60b2b15 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -8,20 +8,12 @@ import { ideContext, OpenFilesNotificationSchema } from '../ide/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { z } from 'zod'; - const logger = { // eslint-disable-next-line @typescript-eslint/no-explicit-any debug: (...args: any[]) => console.debug('[DEBUG] [ImportProcessor]', ...args), }; -const IdeInfoResponseSchema = z.object({ - structuredContent: z.object({ - ideName: z.string(), - }), -}); - export type IDEConnectionState = { status: IDEConnectionStatus; details?: string; @@ -105,20 +97,4 @@ export class IdeClient { logger.debug('Failed to connect to MCP server:', error); } } - - async getIdeName(): Promise { - const result = await this.client?.callTool({ - name: 'getIdeInfo', - }); - - const parsed = IdeInfoResponseSchema.safeParse(result); - if (parsed.success) { - return parsed.data.structuredContent.ideName; - } else { - logger.debug('Failed to parse getIdeInfo response:', parsed.error); - return undefined; - } - } } - -export const ideClient = new IdeClient(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 68e7e18ccba..9d87ce3241d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,10 +40,10 @@ export * from './utils/systemEncoding.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; -export * from './ide/ideContext.js'; // Export IDE specific logic export * from './ide/ide-client.js'; +export * from './ide/ideContext.js'; // Export base tool definitions export * from './tools/tools.js'; diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index dc500e7faeb..f47463babe8 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -15,7 +15,6 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { Server as HTTPServer } from 'node:http'; import { RecentFilesManager } from './recent-files-manager.js'; -import { z } from 'zod'; const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; @@ -245,30 +244,5 @@ const createMcpServer = () => { }, { capabilities: { logging: {} } }, ); - server.registerTool( - 'getIdeInfo', - { - description: 'Gets information about the IDE', - inputSchema: {}, - outputSchema: { - ideName: z.string(), - }, - }, - async () => { - const structuredContent = { - ideName: 'VSCode', - }; - return { - content: [ - { - type: 'text', - text: JSON.stringify(structuredContent, null, 2), - }, - ], - structuredContent, - }; - }, - ); - return server; };