[ide-mode] Create an IDE manager class to handle connecting to and exposing methods from the IDE server (#4797)

This commit is contained in:
christine betts
2025-07-25 17:46:55 +00:00
committed by GitHub
parent 3c16429fc4
commit 1b8ba5ca6b
14 changed files with 178 additions and 256 deletions

View File

@@ -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",
@@ -44,8 +45,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"undici": "^7.10.0",
"ws": "^8.18.0",
"chardet": "^2.1.0"
"ws": "^8.18.0"
},
"devDependencies": {
"@types/diff": "^7.0.2",

View File

@@ -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<string, SummarizeToolOutputSettings>;
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<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir);

View File

@@ -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[] = [

View File

@@ -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';

View File

@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ideContext, OpenFilesNotificationSchema } from '../ide/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),
};
export type IDEConnectionState = {
status: IDEConnectionStatus;
details?: string;
};
export enum IDEConnectionStatus {
Connected = 'connected',
Disconnected = 'disconnected',
Connecting = 'connecting',
}
/**
* Manages the connection to and interaction with the IDE server.
*/
export class IdeClient {
client: Client | undefined = undefined;
connectionStatus: IDEConnectionStatus = IDEConnectionStatus.Disconnected;
constructor() {
this.connectToMcpServer().catch((err) => {
logger.debug('Failed to initialize IdeClient:', err);
});
}
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<void> {
this.connectionStatus = IDEConnectionStatus.Connecting;
const idePort = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
if (!idePort) {
logger.debug(
'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',
// TODO(#3487): use the CLI version here.
version: '1.0.0',
});
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.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;
logger.debug('Failed to connect to MCP server:', error);
}
}
}

View File

@@ -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.
*/

View File

@@ -40,7 +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 IDE specific logic
export * from './ide/ide-client.js';
export * from './ide/ideContext.js';
// Export base tool definitions
export * from './tools/tools.js';

View File

@@ -24,11 +24,6 @@ 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';
export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes
@@ -379,24 +374,11 @@ export async function connectAndDiscover(
);
try {
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED);
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,