Skip to content

Commit 2dd1557

Browse files
authored
Support IDE connections via stdio MCP (google-gemini#6417)
1 parent ec41b8d commit 2dd1557

2 files changed

Lines changed: 315 additions & 81 deletions

File tree

packages/core/src/ide/ide-client.test.ts

Lines changed: 211 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,75 +4,224 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, it, expect } from 'vitest';
8-
import * as path from 'path';
9-
import { IdeClient } from './ide-client.js';
10-
11-
describe('IdeClient.validateWorkspacePath', () => {
12-
it('should return valid if cwd is a subpath of the IDE workspace path', () => {
13-
const result = IdeClient.validateWorkspacePath(
14-
'/Users/person/gemini-cli',
15-
'VS Code',
16-
'/Users/person/gemini-cli/sub-dir',
17-
);
18-
expect(result.isValid).toBe(true);
19-
});
7+
import {
8+
describe,
9+
it,
10+
expect,
11+
vi,
12+
beforeEach,
13+
afterEach,
14+
type Mocked,
15+
} from 'vitest';
16+
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
17+
import * as fs from 'node:fs';
18+
import { getIdeProcessId } from './process-utils.js';
19+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
20+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
21+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
22+
import {
23+
detectIde,
24+
DetectedIde,
25+
getIdeInfo,
26+
type IdeInfo,
27+
} from './detect-ide.js';
28+
import * as os from 'node:os';
29+
import * as path from 'node:path';
2030

21-
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is undefined', () => {
22-
const result = IdeClient.validateWorkspacePath(
23-
undefined,
24-
'VS Code',
25-
'/Users/person/gemini-cli/sub-dir',
26-
);
27-
expect(result.isValid).toBe(false);
28-
expect(result.error).toContain('Failed to connect');
29-
});
31+
vi.mock('node:fs', async (importOriginal) => {
32+
const actual = await importOriginal();
33+
return {
34+
...(actual as object),
35+
promises: {
36+
readFile: vi.fn(),
37+
},
38+
realpathSync: (p: string) => p,
39+
existsSync: () => false,
40+
};
41+
});
42+
vi.mock('./process-utils.js');
43+
vi.mock('@modelcontextprotocol/sdk/client/index.js');
44+
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
45+
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
46+
vi.mock('./detect-ide.js');
47+
vi.mock('node:os');
3048

31-
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is empty', () => {
32-
const result = IdeClient.validateWorkspacePath(
33-
'',
34-
'VS Code',
35-
'/Users/person/gemini-cli/sub-dir',
36-
);
37-
expect(result.isValid).toBe(false);
38-
expect(result.error).toContain('please open a workspace folder');
39-
});
49+
describe('IdeClient', () => {
50+
let mockClient: Mocked<Client>;
51+
let mockHttpTransport: Mocked<StreamableHTTPClientTransport>;
52+
let mockStdioTransport: Mocked<StdioClientTransport>;
4053

41-
it('should return invalid if cwd is not within the IDE workspace path', () => {
42-
const result = IdeClient.validateWorkspacePath(
43-
'/some/other/path',
44-
'VS Code',
45-
'/Users/person/gemini-cli/sub-dir',
46-
);
47-
expect(result.isValid).toBe(false);
48-
expect(result.error).toContain('Directory mismatch');
49-
});
54+
beforeEach(() => {
55+
// Reset singleton instance for test isolation
56+
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
57+
undefined;
58+
59+
// Mock environment variables
60+
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = '/test/workspace';
61+
delete process.env['GEMINI_CLI_IDE_SERVER_PORT'];
62+
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
63+
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'];
64+
65+
// Mock dependencies
66+
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
67+
vi.mocked(detectIde).mockReturnValue(DetectedIde.VSCode);
68+
vi.mocked(getIdeInfo).mockReturnValue({
69+
displayName: 'VS Code',
70+
} as IdeInfo);
71+
vi.mocked(getIdeProcessId).mockResolvedValue(12345);
72+
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
5073

51-
it('should handle multiple workspace paths and return valid', () => {
52-
const result = IdeClient.validateWorkspacePath(
53-
['/some/other/path', '/Users/person/gemini-cli'].join(path.delimiter),
54-
'VS Code',
55-
'/Users/person/gemini-cli/sub-dir',
56-
);
57-
expect(result.isValid).toBe(true);
74+
// Mock MCP client and transports
75+
mockClient = {
76+
connect: vi.fn().mockResolvedValue(undefined),
77+
close: vi.fn(),
78+
setNotificationHandler: vi.fn(),
79+
callTool: vi.fn(),
80+
} as unknown as Mocked<Client>;
81+
mockHttpTransport = {
82+
close: vi.fn(),
83+
} as unknown as Mocked<StreamableHTTPClientTransport>;
84+
mockStdioTransport = {
85+
close: vi.fn(),
86+
} as unknown as Mocked<StdioClientTransport>;
87+
88+
vi.mocked(Client).mockReturnValue(mockClient);
89+
vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);
90+
vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);
5891
});
5992

60-
it('should return invalid if cwd is not in any of the multiple workspace paths', () => {
61-
const result = IdeClient.validateWorkspacePath(
62-
['/some/other/path', '/another/path'].join(path.delimiter),
63-
'VS Code',
64-
'/Users/person/gemini-cli/sub-dir',
65-
);
66-
expect(result.isValid).toBe(false);
67-
expect(result.error).toContain('Directory mismatch');
93+
afterEach(() => {
94+
vi.restoreAllMocks();
6895
});
6996

70-
it.skipIf(process.platform !== 'win32')('should handle windows paths', () => {
71-
const result = IdeClient.validateWorkspacePath(
72-
'c:/some/other/path;d:/Users/person/gemini-cli',
73-
'VS Code',
74-
'd:/Users/person/gemini-cli/sub-dir',
75-
);
76-
expect(result.isValid).toBe(true);
97+
describe('connect', () => {
98+
it('should connect using HTTP when port is provided in config file', async () => {
99+
const config = { port: '8080' };
100+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
101+
102+
const ideClient = IdeClient.getInstance();
103+
await ideClient.connect();
104+
105+
expect(fs.promises.readFile).toHaveBeenCalledWith(
106+
path.join('/tmp', 'gemini-ide-server-12345.json'),
107+
'utf8',
108+
);
109+
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
110+
new URL('http://localhost:8080/mcp'),
111+
expect.any(Object),
112+
);
113+
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
114+
expect(ideClient.getConnectionStatus().status).toBe(
115+
IDEConnectionStatus.Connected,
116+
);
117+
});
118+
119+
it('should connect using stdio when stdio config is provided in file', async () => {
120+
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
121+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
122+
123+
const ideClient = IdeClient.getInstance();
124+
await ideClient.connect();
125+
126+
expect(StdioClientTransport).toHaveBeenCalledWith({
127+
command: 'test-cmd',
128+
args: ['--foo'],
129+
});
130+
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
131+
expect(ideClient.getConnectionStatus().status).toBe(
132+
IDEConnectionStatus.Connected,
133+
);
134+
});
135+
136+
it('should prioritize port over stdio when both are in config file', async () => {
137+
const config = {
138+
port: '8080',
139+
stdio: { command: 'test-cmd', args: ['--foo'] },
140+
};
141+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
142+
143+
const ideClient = IdeClient.getInstance();
144+
await ideClient.connect();
145+
146+
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
147+
expect(StdioClientTransport).not.toHaveBeenCalled();
148+
expect(ideClient.getConnectionStatus().status).toBe(
149+
IDEConnectionStatus.Connected,
150+
);
151+
});
152+
153+
it('should connect using HTTP when port is provided in environment variables', async () => {
154+
vi.mocked(fs.promises.readFile).mockRejectedValue(
155+
new Error('File not found'),
156+
);
157+
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
158+
159+
const ideClient = IdeClient.getInstance();
160+
await ideClient.connect();
161+
162+
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
163+
new URL('http://localhost:9090/mcp'),
164+
expect.any(Object),
165+
);
166+
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
167+
expect(ideClient.getConnectionStatus().status).toBe(
168+
IDEConnectionStatus.Connected,
169+
);
170+
});
171+
172+
it('should connect using stdio when stdio config is in environment variables', async () => {
173+
vi.mocked(fs.promises.readFile).mockRejectedValue(
174+
new Error('File not found'),
175+
);
176+
process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
177+
process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
178+
179+
const ideClient = IdeClient.getInstance();
180+
await ideClient.connect();
181+
182+
expect(StdioClientTransport).toHaveBeenCalledWith({
183+
command: 'env-cmd',
184+
args: ['--bar'],
185+
});
186+
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
187+
expect(ideClient.getConnectionStatus().status).toBe(
188+
IDEConnectionStatus.Connected,
189+
);
190+
});
191+
192+
it('should prioritize file config over environment variables', async () => {
193+
const config = { port: '8080' };
194+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
195+
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
196+
197+
const ideClient = IdeClient.getInstance();
198+
await ideClient.connect();
199+
200+
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
201+
new URL('http://localhost:8080/mcp'),
202+
expect.any(Object),
203+
);
204+
expect(ideClient.getConnectionStatus().status).toBe(
205+
IDEConnectionStatus.Connected,
206+
);
207+
});
208+
209+
it('should be disconnected if no config is found', async () => {
210+
vi.mocked(fs.promises.readFile).mockRejectedValue(
211+
new Error('File not found'),
212+
);
213+
214+
const ideClient = IdeClient.getInstance();
215+
await ideClient.connect();
216+
217+
expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();
218+
expect(StdioClientTransport).not.toHaveBeenCalled();
219+
expect(ideClient.getConnectionStatus().status).toBe(
220+
IDEConnectionStatus.Disconnected,
221+
);
222+
expect(ideClient.getConnectionStatus().details).toContain(
223+
'Failed to connect',
224+
);
225+
});
77226
});
78227
});

0 commit comments

Comments
 (0)