2025-07-25 17:46:55 +00:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-08-07 17:19:31 -04:00
import * as fs from 'node:fs' ;
2025-08-14 20:12:57 +00:00
import { isSubpath } from '../utils/paths.js' ;
2025-08-12 20:08:47 +00:00
import { detectIde , DetectedIde , getIdeInfo } from '../ide/detect-ide.js' ;
2025-08-06 17:36:05 +00:00
import {
ideContext ,
IdeContextNotificationSchema ,
IdeDiffAcceptedNotificationSchema ,
IdeDiffClosedNotificationSchema ,
CloseDiffResponseSchema ,
DiffUpdateResult ,
} from '../ide/ideContext.js' ;
2025-08-25 11:39:57 -07:00
import { getIdeProcessInfo } from './process-utils.js' ;
2025-07-25 17:46:55 +00:00
import { Client } from '@modelcontextprotocol/sdk/client/index.js' ;
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' ;
2025-08-21 15:00:05 -07:00
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' ;
2025-08-14 18:09:19 +00:00
import * as os from 'node:os' ;
import * as path from 'node:path' ;
2025-08-20 08:32:08 +08:00
import { EnvHttpProxyAgent } from 'undici' ;
2025-07-25 17:46:55 +00:00
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2025-07-25 21:57:34 -04:00
debug : ( . . . args : any [ ] ) = > console . debug ( '[DEBUG] [IDEClient]' , . . . args ) ,
2025-08-07 17:19:31 -04:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error : ( . . . args : any [ ] ) = > console . error ( '[ERROR] [IDEClient]' , . . . args ) ,
2025-07-25 17:46:55 +00:00
} ;
export type IDEConnectionState = {
status : IDEConnectionStatus ;
2025-07-28 16:55:00 -04:00
details? : string ; // User-facing
2025-07-25 17:46:55 +00:00
} ;
export enum IDEConnectionStatus {
Connected = 'connected' ,
Disconnected = 'disconnected' ,
Connecting = 'connecting' ,
}
2025-08-21 15:00:05 -07:00
type StdioConfig = {
command : string ;
args : string [ ] ;
} ;
type ConnectionConfig = {
port? : string ;
stdio? : StdioConfig ;
} ;
2025-08-07 17:19:31 -04:00
function getRealPath ( path : string ) : string {
try {
return fs . realpathSync ( path ) ;
} catch ( _e ) {
// If realpathSync fails, it might be because the path doesn't exist.
// In that case, we can fall back to the original path.
return path ;
}
}
2025-07-25 17:46:55 +00:00
/ * *
* Manages the connection to and interaction with the IDE server .
* /
export class IdeClient {
2025-08-05 18:52:58 -04:00
private static instance : IdeClient ;
private client : Client | undefined = undefined ;
2025-07-28 16:55:00 -04:00
private state : IDEConnectionState = {
status : IDEConnectionStatus.Disconnected ,
2025-08-05 18:52:58 -04:00
details :
'IDE integration is currently disabled. To enable it, run /ide enable.' ,
2025-07-28 16:55:00 -04:00
} ;
2025-08-25 11:39:57 -07:00
private currentIde : DetectedIde | undefined ;
private currentIdeDisplayName : string | undefined ;
private ideProcessInfo : { pid : number ; command : string } | undefined ;
2025-08-06 17:36:05 +00:00
private diffResponses = new Map < string , ( result : DiffUpdateResult ) = > void > ( ) ;
2025-08-19 10:24:58 -07:00
private statusListeners = new Set < ( state : IDEConnectionState ) = > void > ( ) ;
2025-07-25 17:46:55 +00:00
2025-08-25 11:39:57 -07:00
private constructor ( ) { }
2025-07-25 21:57:34 -04:00
2025-08-25 11:39:57 -07:00
static async getInstance ( ) : Promise < IdeClient > {
2025-07-30 21:26:31 +00:00
if ( ! IdeClient . instance ) {
2025-08-25 11:39:57 -07:00
const client = new IdeClient ( ) ;
client . ideProcessInfo = await getIdeProcessInfo ( ) ;
client . currentIde = detectIde ( client . ideProcessInfo ) ;
if ( client . currentIde ) {
client . currentIdeDisplayName = getIdeInfo (
client . currentIde ,
) . displayName ;
}
IdeClient . instance = client ;
2025-07-30 21:26:31 +00:00
}
return IdeClient . instance ;
}
2025-08-19 10:24:58 -07:00
addStatusChangeListener ( listener : ( state : IDEConnectionState ) = > void ) {
this . statusListeners . add ( listener ) ;
}
removeStatusChangeListener ( listener : ( state : IDEConnectionState ) = > void ) {
this . statusListeners . delete ( listener ) ;
}
2025-08-05 18:52:58 -04:00
async connect ( ) : Promise < void > {
if ( ! this . currentIde || ! this . currentIdeDisplayName ) {
2025-08-07 17:19:31 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
2025-08-25 11:39:57 -07:00
` IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks ` ,
2025-08-11 15:57:56 -04:00
false ,
2025-08-07 17:19:31 -04:00
) ;
2025-08-05 18:52:58 -04:00
return ;
}
2025-08-11 15:57:56 -04:00
this . setState ( IDEConnectionStatus . Connecting ) ;
2025-08-21 15:00:05 -07:00
const configFromFile = await this . getConnectionConfigFromFile ( ) ;
2025-08-20 14:11:31 -07:00
const workspacePath =
2025-08-21 15:00:05 -07:00
configFromFile ? . workspacePath ? ?
2025-08-20 14:11:31 -07:00
process . env [ 'GEMINI_CLI_IDE_WORKSPACE_PATH' ] ;
2025-08-14 20:12:57 +00:00
const { isValid , error } = IdeClient . validateWorkspacePath (
2025-08-20 14:11:31 -07:00
workspacePath ,
2025-08-14 20:12:57 +00:00
this . currentIdeDisplayName ,
process . cwd ( ) ,
) ;
if ( ! isValid ) {
this . setState ( IDEConnectionStatus . Disconnected , error , true ) ;
2025-08-05 18:52:58 -04:00
return ;
}
2025-08-21 15:00:05 -07:00
if ( configFromFile ) {
if ( configFromFile . port ) {
const connected = await this . establishHttpConnection (
configFromFile . port ,
) ;
if ( connected ) {
return ;
}
}
if ( configFromFile . stdio ) {
const connected = await this . establishStdioConnection (
configFromFile . stdio ,
) ;
if ( connected ) {
return ;
}
2025-08-14 18:09:19 +00:00
}
2025-08-05 18:52:58 -04:00
}
2025-08-14 18:09:19 +00:00
const portFromEnv = this . getPortFromEnv ( ) ;
if ( portFromEnv ) {
2025-08-21 15:00:05 -07:00
const connected = await this . establishHttpConnection ( portFromEnv ) ;
if ( connected ) {
return ;
}
}
const stdioConfigFromEnv = this . getStdioConfigFromEnv ( ) ;
if ( stdioConfigFromEnv ) {
const connected = await this . establishStdioConnection ( stdioConfigFromEnv ) ;
2025-08-14 18:09:19 +00:00
if ( connected ) {
return ;
}
}
this . setState (
IDEConnectionStatus . Disconnected ,
2025-08-25 11:39:57 -07:00
` Failed to connect to IDE companion extension in ${ this . currentIdeDisplayName } . Please ensure the extension is running. To install the extension, run /ide install. ` ,
2025-08-14 18:09:19 +00:00
true ,
) ;
2025-08-05 18:52:58 -04:00
}
2025-08-06 17:36:05 +00:00
/ * *
* A diff is accepted with any modifications if the user performs one of the
* following actions :
* - Clicks the checkbox icon in the IDE to accept
* - Runs ` command+shift+p ` > "Gemini CLI: Accept Diff in IDE" to accept
* - Selects "accept" in the CLI UI
* - Saves the file via ` ctrl/command+s `
*
* A diff is rejected if the user performs one of the following actions :
* - Clicks the "x" icon in the IDE
* - Runs "Gemini CLI: Close Diff in IDE"
* - Selects "no" in the CLI UI
* - Closes the file
* /
async openDiff (
filePath : string ,
newContent? : string ,
) : Promise < DiffUpdateResult > {
return new Promise < DiffUpdateResult > ( ( resolve , reject ) = > {
this . diffResponses . set ( filePath , resolve ) ;
this . client
? . callTool ( {
name : ` openDiff ` ,
arguments : {
filePath ,
newContent ,
} ,
} )
. catch ( ( err ) = > {
logger . debug ( ` callTool for ${ filePath } failed: ` , err ) ;
reject ( err ) ;
} ) ;
} ) ;
}
async closeDiff ( filePath : string ) : Promise < string | undefined > {
try {
const result = await this . client ? . callTool ( {
name : ` closeDiff ` ,
arguments : {
filePath ,
} ,
} ) ;
if ( result ) {
const parsed = CloseDiffResponseSchema . parse ( result ) ;
return parsed . content ;
}
} catch ( err ) {
logger . debug ( ` callTool for ${ filePath } failed: ` , err ) ;
}
return ;
}
// Closes the diff. Instead of waiting for a notification,
// manually resolves the diff resolver as the desired outcome.
async resolveDiffFromCli ( filePath : string , outcome : 'accepted' | 'rejected' ) {
const content = await this . closeDiff ( filePath ) ;
const resolver = this . diffResponses . get ( filePath ) ;
if ( resolver ) {
if ( outcome === 'accepted' ) {
resolver ( { status : 'accepted' , content } ) ;
} else {
resolver ( { status : 'rejected' , content : undefined } ) ;
}
this . diffResponses . delete ( filePath ) ;
}
}
2025-08-08 15:38:30 +00:00
async disconnect() {
if ( this . state . status === IDEConnectionStatus . Disconnected ) {
return ;
}
for ( const filePath of this . diffResponses . keys ( ) ) {
await this . closeDiff ( filePath ) ;
}
this . diffResponses . clear ( ) ;
2025-08-05 18:52:58 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
'IDE integration disabled. To enable it again, run /ide enable.' ,
) ;
this . client ? . close ( ) ;
}
2025-07-30 21:26:31 +00:00
getCurrentIde ( ) : DetectedIde | undefined {
return this . currentIde ;
}
2025-07-28 16:55:00 -04:00
getConnectionStatus ( ) : IDEConnectionState {
return this . state ;
}
2025-08-05 18:52:58 -04:00
getDetectedIdeDisplayName ( ) : string | undefined {
return this . currentIdeDisplayName ;
}
2025-08-07 17:19:31 -04:00
private setState (
status : IDEConnectionStatus ,
details? : string ,
logToConsole = false ,
) {
2025-08-05 18:52:58 -04:00
const isAlreadyDisconnected =
this . state . status === IDEConnectionStatus . Disconnected &&
status === IDEConnectionStatus . Disconnected ;
2025-08-08 17:48:02 -04:00
// Only update details & log to console if the state wasn't already
// disconnected, so that the first detail message is preserved.
2025-08-05 18:52:58 -04:00
if ( ! isAlreadyDisconnected ) {
this . state = { status , details } ;
2025-08-19 10:24:58 -07:00
for ( const listener of this . statusListeners ) {
listener ( this . state ) ;
}
2025-08-11 12:27:45 -04:00
if ( details ) {
if ( logToConsole ) {
logger . error ( details ) ;
} else {
// We only want to log disconnect messages to debug
// if they are not already being logged to the console.
logger . debug ( details ) ;
}
2025-08-07 17:19:31 -04:00
}
2025-08-08 17:48:02 -04:00
}
if ( status === IDEConnectionStatus . Disconnected ) {
2025-07-28 16:55:00 -04:00
ideContext . clearIdeContext ( ) ;
2025-07-25 17:46:55 +00:00
}
}
2025-08-14 20:12:57 +00:00
static validateWorkspacePath (
ideWorkspacePath : string | undefined ,
currentIdeDisplayName : string | undefined ,
cwd : string ,
) : { isValid : boolean ; error? : string } {
2025-08-05 18:52:58 -04:00
if ( ideWorkspacePath === undefined ) {
2025-08-14 20:12:57 +00:00
return {
isValid : false ,
2025-08-25 11:39:57 -07:00
error : ` Failed to connect to IDE companion extension in ${ currentIdeDisplayName } . Please ensure the extension is running. To install the extension, run /ide install. ` ,
2025-08-14 20:12:57 +00:00
} ;
2025-07-28 16:55:00 -04:00
}
2025-08-14 20:12:57 +00:00
2025-08-05 18:52:58 -04:00
if ( ideWorkspacePath === '' ) {
2025-08-14 20:12:57 +00:00
return {
isValid : false ,
error : ` To use this feature, please open a workspace folder in ${ currentIdeDisplayName } and try again. ` ,
} ;
2025-07-28 16:55:00 -04:00
}
2025-08-11 21:06:01 +00:00
2025-08-15 15:58:31 -07:00
const ideWorkspacePaths = ideWorkspacePath . split ( path . delimiter ) ;
2025-08-14 20:12:57 +00:00
const realCwd = getRealPath ( cwd ) ;
const isWithinWorkspace = ideWorkspacePaths . some ( ( workspacePath ) = > {
const idePath = getRealPath ( workspacePath ) ;
return isSubpath ( idePath , realCwd ) ;
} ) ;
if ( ! isWithinWorkspace ) {
return {
isValid : false ,
error : ` Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${ currentIdeDisplayName } . Please run the CLI from one of the following directories: ${ ideWorkspacePaths . join (
', ' ,
) } ` ,
} ;
2025-07-28 16:55:00 -04:00
}
2025-08-14 20:12:57 +00:00
return { isValid : true } ;
2025-07-28 16:55:00 -04:00
}
2025-08-05 18:52:58 -04:00
private getPortFromEnv ( ) : string | undefined {
const port = process . env [ 'GEMINI_CLI_IDE_SERVER_PORT' ] ;
if ( ! port ) {
return undefined ;
}
return port ;
}
2025-08-21 15:00:05 -07:00
private getStdioConfigFromEnv ( ) : StdioConfig | undefined {
const command = process . env [ 'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND' ] ;
if ( ! command ) {
return undefined ;
}
const argsStr = process . env [ 'GEMINI_CLI_IDE_SERVER_STDIO_ARGS' ] ;
let args : string [ ] = [ ] ;
if ( argsStr ) {
try {
const parsedArgs = JSON . parse ( argsStr ) ;
if ( Array . isArray ( parsedArgs ) ) {
args = parsedArgs ;
} else {
logger . error (
'GEMINI_CLI_IDE_SERVER_STDIO_ARGS must be a JSON array string.' ,
) ;
}
} catch ( e ) {
logger . error ( 'Failed to parse GEMINI_CLI_IDE_SERVER_STDIO_ARGS:' , e ) ;
}
}
return { command , args } ;
}
private async getConnectionConfigFromFile ( ) : Promise <
( ConnectionConfig & { workspacePath? : string } ) | undefined
> {
2025-08-25 11:39:57 -07:00
if ( ! this . ideProcessInfo ) {
return { } ;
}
2025-08-14 18:09:19 +00:00
try {
const portFile = path . join (
os . tmpdir ( ) ,
2025-08-25 11:39:57 -07:00
` gemini-ide-server- ${ this . ideProcessInfo . pid } .json ` ,
2025-08-14 18:09:19 +00:00
) ;
const portFileContents = await fs . promises . readFile ( portFile , 'utf8' ) ;
2025-08-21 15:00:05 -07:00
return JSON . parse ( portFileContents ) ;
2025-08-14 18:09:19 +00:00
} catch ( _ ) {
2025-08-21 15:00:05 -07:00
return undefined ;
2025-08-14 18:09:19 +00:00
}
}
2025-08-20 08:32:08 +08:00
private createProxyAwareFetch() {
// ignore proxy for 'localhost' by deafult to allow connecting to the ide mcp server
const existingNoProxy = process . env [ 'NO_PROXY' ] || '' ;
const agent = new EnvHttpProxyAgent ( {
noProxy : [ existingNoProxy , 'localhost' ] . filter ( Boolean ) . join ( ',' ) ,
} ) ;
const undiciPromise = import ( 'undici' ) ;
return async ( url : string | URL , init? : RequestInit ) : Promise < Response > = > {
const { fetch : fetchFn } = await undiciPromise ;
const fetchOptions : RequestInit & { dispatcher? : unknown } = {
. . . init ,
dispatcher : agent ,
} ;
const options = fetchOptions as unknown as import ( 'undici' ) . RequestInit ;
const response = await fetchFn ( url , options ) ;
return new Response ( response . body as ReadableStream < unknown > | null , {
status : response.status ,
statusText : response.statusText ,
headers : response.headers ,
} ) ;
} ;
}
2025-07-28 16:55:00 -04:00
private registerClientHandlers() {
if ( ! this . client ) {
2025-07-25 17:46:55 +00:00
return ;
}
2025-07-28 16:55:00 -04:00
this . client . setNotificationHandler (
IdeContextNotificationSchema ,
( notification ) = > {
ideContext . setIdeContext ( notification . params ) ;
} ,
) ;
this . client . onerror = ( _error ) = > {
2025-08-05 18:52:58 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
` IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable ` ,
2025-08-07 17:19:31 -04:00
true ,
2025-08-05 18:52:58 -04:00
) ;
2025-07-28 16:55:00 -04:00
} ;
this . client . onclose = ( ) = > {
2025-08-05 18:52:58 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
` IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable ` ,
2025-08-07 17:19:31 -04:00
true ,
2025-08-05 18:52:58 -04:00
) ;
2025-07-28 16:55:00 -04:00
} ;
2025-08-06 17:36:05 +00:00
this . client . setNotificationHandler (
IdeDiffAcceptedNotificationSchema ,
( notification ) = > {
const { filePath , content } = notification . params ;
const resolver = this . diffResponses . get ( filePath ) ;
if ( resolver ) {
resolver ( { status : 'accepted' , content } ) ;
this . diffResponses . delete ( filePath ) ;
} else {
logger . debug ( ` No resolver found for ${ filePath } ` ) ;
}
} ,
) ;
this . client . setNotificationHandler (
IdeDiffClosedNotificationSchema ,
( notification ) = > {
const { filePath } = notification . params ;
const resolver = this . diffResponses . get ( filePath ) ;
if ( resolver ) {
resolver ( { status : 'rejected' , content : undefined } ) ;
this . diffResponses . delete ( filePath ) ;
} else {
logger . debug ( ` No resolver found for ${ filePath } ` ) ;
}
} ,
) ;
2025-07-28 16:55:00 -04:00
}
2025-08-21 15:00:05 -07:00
private async establishHttpConnection ( port : string ) : Promise < boolean > {
2025-07-25 21:57:34 -04:00
let transport : StreamableHTTPClientTransport | undefined ;
2025-07-25 17:46:55 +00:00
try {
2025-08-21 15:00:05 -07:00
logger . debug ( 'Attempting to connect to IDE via HTTP SSE' ) ;
2025-07-25 17:46:55 +00:00
this . client = new Client ( {
name : 'streamable-http-client' ,
// TODO(#3487): use the CLI version here.
version : '1.0.0' ,
} ) ;
2025-07-25 21:57:34 -04:00
transport = new StreamableHTTPClientTransport (
2025-08-08 15:35:47 +00:00
new URL ( ` http:// ${ getIdeServerHost ( ) } : ${ port } /mcp ` ) ,
2025-08-20 08:32:08 +08:00
{
fetch : this.createProxyAwareFetch ( ) ,
} ,
2025-07-25 17:46:55 +00:00
) ;
await this . client . connect ( transport ) ;
2025-08-05 18:52:58 -04:00
this . registerClientHandlers ( ) ;
2025-07-28 16:55:00 -04:00
this . setState ( IDEConnectionStatus . Connected ) ;
2025-08-14 18:09:19 +00:00
return true ;
2025-08-05 18:52:58 -04:00
} catch ( _error ) {
2025-07-25 21:57:34 -04:00
if ( transport ) {
try {
await transport . close ( ) ;
} catch ( closeError ) {
logger . debug ( 'Failed to close transport:' , closeError ) ;
}
}
2025-08-14 18:09:19 +00:00
return false ;
2025-07-25 17:46:55 +00:00
}
}
2025-08-21 15:00:05 -07:00
private async establishStdioConnection ( {
command ,
args ,
} : StdioConfig ) : Promise < boolean > {
let transport : StdioClientTransport | undefined ;
try {
logger . debug ( 'Attempting to connect to IDE via stdio' ) ;
this . client = new Client ( {
name : 'stdio-client' ,
// TODO(#3487): use the CLI version here.
version : '1.0.0' ,
} ) ;
transport = new StdioClientTransport ( {
command ,
args ,
} ) ;
await this . client . connect ( transport ) ;
this . registerClientHandlers ( ) ;
this . setState ( IDEConnectionStatus . Connected ) ;
return true ;
} catch ( _error ) {
if ( transport ) {
try {
await transport . close ( ) ;
} catch ( closeError ) {
logger . debug ( 'Failed to close transport:' , closeError ) ;
}
}
return false ;
}
}
2025-07-25 17:46:55 +00:00
}
2025-08-08 15:35:47 +00:00
function getIdeServerHost() {
const isInContainer =
2025-08-11 21:09:57 +00:00
fs . existsSync ( '/.dockerenv' ) || fs . existsSync ( '/run/.containerenv' ) ;
2025-08-08 15:35:47 +00:00
return isInContainer ? 'host.docker.internal' : 'localhost' ;
}