Set port dynamically in VSCode extension and read from it in gemini-cli and send initial notification (#4255)

This commit is contained in:
christine betts 2025-07-15 22:13:03 +00:00 committed by GitHub
parent bf51de1a4d
commit b61016f2a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 76 additions and 27 deletions

12
package-lock.json generated
View File

@ -5699,6 +5699,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-port": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz",
"integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-proto": { "node_modules/get-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",

View File

@ -840,6 +840,7 @@ describe('loadCliConfig ideMode', () => {
// Explicitly delete TERM_PROGRAM and SANDBOX before each test // Explicitly delete TERM_PROGRAM and SANDBOX before each test
delete process.env.TERM_PROGRAM; delete process.env.TERM_PROGRAM;
delete process.env.SANDBOX; delete process.env.SANDBOX;
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
}); });
afterEach(() => { afterEach(() => {
@ -876,6 +877,7 @@ describe('loadCliConfig ideMode', () => {
process.argv = ['node', 'script.js', '--ide-mode']; process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments(); const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode'; process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = {}; const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv); const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true); expect(config.getIdeMode()).toBe(true);
@ -885,6 +887,7 @@ describe('loadCliConfig ideMode', () => {
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const argv = await parseArguments(); const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode'; process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: true }; const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv); const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true); expect(config.getIdeMode()).toBe(true);
@ -894,6 +897,7 @@ describe('loadCliConfig ideMode', () => {
process.argv = ['node', 'script.js', '--ide-mode']; process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments(); const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode'; process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: false }; const settings: Settings = { ideMode: false };
const config = await loadCliConfig(settings, [], 'test-session', argv); const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true); expect(config.getIdeMode()).toBe(true);
@ -932,6 +936,7 @@ describe('loadCliConfig ideMode', () => {
process.argv = ['node', 'script.js', '--ide-mode']; process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments(); const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode'; process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = {}; const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv); const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true); expect(config.getIdeMode()).toBe(true);
@ -941,4 +946,16 @@ describe('loadCliConfig ideMode', () => {
expect(mcpServers['_ide_server'].description).toBe('IDE connection'); expect(mcpServers['_ide_server'].description).toBe('IDE connection');
expect(mcpServers['_ide_server'].trust).toBe(false); expect(mcpServers['_ide_server'].trust).toBe(false);
}); });
it('should throw an error if ideMode is true and no port is set', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
const settings: Settings = {};
await expect(
loadCliConfig(settings, [], 'test-session', argv),
).rejects.toThrow(
"Could not run in ide mode, make sure you're running in vs code integrated terminal. Try running in a fresh terminal.",
);
});
}); });

View File

@ -306,13 +306,20 @@ export async function loadCliConfig(
} }
if (ideMode) { if (ideMode) {
const companionPort = process.env.GEMINI_CLI_IDE_SERVER_PORT;
if (!companionPort) {
throw new Error(
"Could not run in ide mode, make sure you're running in vs code integrated terminal. Try running in a fresh terminal.",
);
}
const httpUrl = `http://localhost:${companionPort}/mcp`;
mcpServers[IDE_SERVER_NAME] = new MCPServerConfig( mcpServers[IDE_SERVER_NAME] = new MCPServerConfig(
undefined, // command undefined, // command
undefined, // args undefined, // args
undefined, // env undefined, // env
undefined, // cwd undefined, // cwd
undefined, // url undefined, // url
'http://localhost:3000/mcp', // httpUrl httpUrl, // httpUrl
undefined, // headers undefined, // headers
undefined, // tcp undefined, // tcp
undefined, // timeout undefined, // timeout

View File

@ -14,21 +14,31 @@ import {
type JSONRPCNotification, type JSONRPCNotification,
} from '@modelcontextprotocol/sdk/types.js'; } from '@modelcontextprotocol/sdk/types.js';
import { Server } from 'node:http';
function sendActiveFileChangedNotification(
transport: StreamableHTTPServerTransport,
) {
const editor = vscode.window.activeTextEditor;
const filePath = editor ? editor.document.uri.fsPath : '';
const notification: JSONRPCNotification = {
jsonrpc: '2.0',
method: 'ide/activeFileChanged',
params: { filePath },
};
transport.send(notification);
}
export async function startIDEServer(context: vscode.ExtensionContext) { export async function startIDEServer(context: vscode.ExtensionContext) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
const sessionsWithInitialNotification = new Set<string>();
const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => { const disposable = vscode.window.onDidChangeActiveTextEditor((_editor) => {
const filePath = editor ? editor.document.uri.fsPath : null;
const notification: JSONRPCNotification = {
jsonrpc: '2.0',
method: 'ide/activeFileChanged',
params: { filePath },
};
for (const transport of Object.values(transports)) { for (const transport of Object.values(transports)) {
transport.send(notification); sendActiveFileChangedNotification(transport);
} }
}); });
context.subscriptions.push(disposable); context.subscriptions.push(disposable);
@ -44,19 +54,12 @@ export async function startIDEServer(context: vscode.ExtensionContext) {
sessionIdGenerator: () => randomUUID(), sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => { onsessioninitialized: (newSessionId) => {
transports[newSessionId] = transport; transports[newSessionId] = transport;
const editor = vscode.window.activeTextEditor;
const filePath = editor ? editor.document.uri.fsPath : null;
const notification: JSONRPCNotification = {
jsonrpc: '2.0',
method: 'ide/activeFileChanged',
params: { filePath },
};
transport.send(notification);
}, },
}); });
transport.onclose = () => { transport.onclose = () => {
if (transport.sessionId) { if (transport.sessionId) {
sessionsWithInitialNotification.delete(transport.sessionId);
delete transports[transport.sessionId]; delete transports[transport.sessionId];
} }
}; };
@ -101,6 +104,7 @@ export async function startIDEServer(context: vscode.ExtensionContext) {
} }
const transport = transports[sessionId]; const transport = transports[sessionId];
try { try {
await transport.handleRequest(req, res); await transport.handleRequest(req, res);
} catch (error) { } catch (error) {
@ -109,20 +113,31 @@ export async function startIDEServer(context: vscode.ExtensionContext) {
res.status(400).send('Bad Request'); res.status(400).send('Bad Request');
} }
} }
if (!sessionsWithInitialNotification.has(sessionId)) {
sendActiveFileChangedNotification(transport);
sessionsWithInitialNotification.add(sessionId);
}
}; };
app.get('/mcp', handleSessionRequest); app.get('/mcp', handleSessionRequest);
// TODO(#3918): Generate dynamically and write to env variable const server = app.listen(0, () => {
const PORT = 3000; const address = (server as Server).address();
app.listen(PORT, (error?: Error) => { if (address && typeof address !== 'string') {
if (error) { const port = address.port;
console.error('Failed to start server:', error); context.environmentVariableCollection.replace(
'GEMINI_CLI_IDE_SERVER_PORT',
port.toString(),
);
console.log(`MCP Streamable HTTP Server listening on port ${port}`);
} else {
const port = 0;
console.error('Failed to start server:', 'Unknown error');
vscode.window.showErrorMessage( vscode.window.showErrorMessage(
`Companion server failed to start on port ${PORT}: ${error.message}`, `Companion server failed to start on port ${port}: Unknown error`,
); );
} }
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
}); });
} }
@ -144,9 +159,7 @@ const createMcpServer = () => {
async () => { async () => {
try { try {
const activeEditor = vscode.window.activeTextEditor; const activeEditor = vscode.window.activeTextEditor;
const filePath = activeEditor const filePath = activeEditor ? activeEditor.document.uri.fsPath : '';
? activeEditor.document.uri.fsPath
: undefined;
if (filePath) { if (filePath) {
return { return {
content: [{ type: 'text', text: `Active file: ${filePath}` }], content: [{ type: 'text', text: `Active file: ${filePath}` }],