blackbox-cli-vscode-ide-companion 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import type * as vscode from 'vscode';
9
+ import * as fs from 'node:fs/promises';
10
+ import type * as os from 'node:os';
11
+ import * as path from 'node:path';
12
+ import { IDEServer } from './ide-server.js';
13
+ import type { DiffManager } from './diff-manager.js';
14
+
15
+ const mocks = vi.hoisted(() => ({
16
+ diffManager: {
17
+ onDidChange: vi.fn(() => ({ dispose: vi.fn() })),
18
+ } as unknown as DiffManager,
19
+ }));
20
+
21
+ vi.mock('node:fs/promises', () => ({
22
+ writeFile: vi.fn(() => Promise.resolve(undefined)),
23
+ unlink: vi.fn(() => Promise.resolve(undefined)),
24
+ }));
25
+
26
+ vi.mock('node:os', async (importOriginal) => {
27
+ const actual = await importOriginal<typeof os>();
28
+ return {
29
+ ...actual,
30
+ tmpdir: vi.fn(() => '/tmp'),
31
+ };
32
+ });
33
+
34
+ const vscodeMock = vi.hoisted(() => ({
35
+ workspace: {
36
+ workspaceFolders: [
37
+ {
38
+ uri: {
39
+ fsPath: '/test/workspace1',
40
+ },
41
+ },
42
+ {
43
+ uri: {
44
+ fsPath: '/test/workspace2',
45
+ },
46
+ },
47
+ ],
48
+ },
49
+ }));
50
+
51
+ vi.mock('vscode', () => vscodeMock);
52
+
53
+ vi.mock('./open-files-manager', () => {
54
+ const OpenFilesManager = vi.fn();
55
+ OpenFilesManager.prototype.onDidChange = vi.fn(() => ({ dispose: vi.fn() }));
56
+ return { OpenFilesManager };
57
+ });
58
+
59
+ describe('IDEServer', () => {
60
+ let ideServer: IDEServer;
61
+ let mockContext: vscode.ExtensionContext;
62
+ let mockLog: (message: string) => void;
63
+
64
+ const getPortFromMock = (
65
+ replaceMock: ReturnType<
66
+ () => vscode.ExtensionContext['environmentVariableCollection']['replace']
67
+ >,
68
+ ) => {
69
+ const port = vi
70
+ .mocked(replaceMock)
71
+ .mock.calls.find((call) => call[0] === 'BLACKBOX_CODE_IDE_SERVER_PORT')?.[1];
72
+
73
+ if (port === undefined) {
74
+ expect.fail('Port was not set');
75
+ }
76
+ return port;
77
+ };
78
+
79
+ beforeEach(() => {
80
+ mockLog = vi.fn();
81
+ ideServer = new IDEServer(mockLog, mocks.diffManager);
82
+ mockContext = {
83
+ subscriptions: [],
84
+ environmentVariableCollection: {
85
+ replace: vi.fn(),
86
+ clear: vi.fn(),
87
+ },
88
+ } as unknown as vscode.ExtensionContext;
89
+ });
90
+
91
+ afterEach(async () => {
92
+ await ideServer.stop();
93
+ vi.restoreAllMocks();
94
+ vscodeMock.workspace.workspaceFolders = [
95
+ { uri: { fsPath: '/test/workspace1' } },
96
+ { uri: { fsPath: '/test/workspace2' } },
97
+ ];
98
+ });
99
+
100
+ it('should set environment variables and workspace path on start with multiple folders', async () => {
101
+ await ideServer.start(mockContext);
102
+
103
+ const replaceMock = mockContext.environmentVariableCollection.replace;
104
+ expect(replaceMock).toHaveBeenCalledTimes(2);
105
+
106
+ expect(replaceMock).toHaveBeenNthCalledWith(
107
+ 1,
108
+ 'BLACKBOX_CODE_IDE_SERVER_PORT',
109
+ expect.any(String), // port is a number as a string
110
+ );
111
+
112
+ const expectedWorkspacePaths = [
113
+ '/test/workspace1',
114
+ '/test/workspace2',
115
+ ].join(path.delimiter);
116
+
117
+ expect(replaceMock).toHaveBeenNthCalledWith(
118
+ 2,
119
+ 'BLACKBOX_CODE_IDE_WORKSPACE_PATH',
120
+ expectedWorkspacePaths,
121
+ );
122
+
123
+ const port = getPortFromMock(replaceMock);
124
+ const expectedPortFile = path.join(
125
+ '/tmp',
126
+ `gemini-ide-server-${process.ppid}.json`,
127
+ );
128
+ expect(fs.writeFile).toHaveBeenCalledWith(
129
+ expectedPortFile,
130
+ JSON.stringify({
131
+ port: parseInt(port, 10),
132
+ workspacePath: expectedWorkspacePaths,
133
+ }),
134
+ );
135
+ });
136
+
137
+ it('should set a single folder path', async () => {
138
+ vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/foo/bar' } }];
139
+
140
+ await ideServer.start(mockContext);
141
+ const replaceMock = mockContext.environmentVariableCollection.replace;
142
+
143
+ expect(replaceMock).toHaveBeenCalledWith(
144
+ 'BLACKBOX_CODE_IDE_WORKSPACE_PATH',
145
+ '/foo/bar',
146
+ );
147
+
148
+ const port = getPortFromMock(replaceMock);
149
+ const expectedPortFile = path.join(
150
+ '/tmp',
151
+ `gemini-ide-server-${process.ppid}.json`,
152
+ );
153
+ expect(fs.writeFile).toHaveBeenCalledWith(
154
+ expectedPortFile,
155
+ JSON.stringify({
156
+ port: parseInt(port, 10),
157
+ workspacePath: '/foo/bar',
158
+ }),
159
+ );
160
+ });
161
+
162
+ it('should set an empty string if no folders are open', async () => {
163
+ vscodeMock.workspace.workspaceFolders = [];
164
+
165
+ await ideServer.start(mockContext);
166
+ const replaceMock = mockContext.environmentVariableCollection.replace;
167
+
168
+ expect(replaceMock).toHaveBeenCalledWith(
169
+ 'BLACKBOX_CODE_IDE_WORKSPACE_PATH',
170
+ '',
171
+ );
172
+
173
+ const port = getPortFromMock(replaceMock);
174
+ const expectedPortFile = path.join(
175
+ '/tmp',
176
+ `gemini-ide-server-${process.ppid}.json`,
177
+ );
178
+ expect(fs.writeFile).toHaveBeenCalledWith(
179
+ expectedPortFile,
180
+ JSON.stringify({
181
+ port: parseInt(port, 10),
182
+ workspacePath: '',
183
+ }),
184
+ );
185
+ });
186
+
187
+ it('should update the path when workspace folders change', async () => {
188
+ vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/foo/bar' } }];
189
+ await ideServer.start(mockContext);
190
+ const replaceMock = mockContext.environmentVariableCollection.replace;
191
+
192
+ expect(replaceMock).toHaveBeenCalledWith(
193
+ 'BLACKBOX_CODE_IDE_WORKSPACE_PATH',
194
+ '/foo/bar',
195
+ );
196
+
197
+ // Simulate adding a folder
198
+ vscodeMock.workspace.workspaceFolders = [
199
+ { uri: { fsPath: '/foo/bar' } },
200
+ { uri: { fsPath: '/baz/qux' } },
201
+ ];
202
+ await ideServer.updateWorkspacePath();
203
+
204
+ const expectedWorkspacePaths = ['/foo/bar', '/baz/qux'].join(
205
+ path.delimiter,
206
+ );
207
+ expect(replaceMock).toHaveBeenCalledWith(
208
+ 'BLACKBOX_CODE_IDE_WORKSPACE_PATH',
209
+ expectedWorkspacePaths,
210
+ );
211
+
212
+ const port = getPortFromMock(replaceMock);
213
+ const expectedPortFile = path.join(
214
+ '/tmp',
215
+ `gemini-ide-server-${process.ppid}.json`,
216
+ );
217
+ expect(fs.writeFile).toHaveBeenCalledWith(
218
+ expectedPortFile,
219
+ JSON.stringify({
220
+ port: parseInt(port, 10),
221
+ workspacePath: expectedWorkspacePaths,
222
+ }),
223
+ );
224
+
225
+ // Simulate removing a folder
226
+ vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
227
+ await ideServer.updateWorkspacePath();
228
+
229
+ expect(replaceMock).toHaveBeenCalledWith(
230
+ 'BLACKBOX_CODE_IDE_WORKSPACE_PATH',
231
+ '/baz/qux',
232
+ );
233
+ expect(fs.writeFile).toHaveBeenCalledWith(
234
+ expectedPortFile,
235
+ JSON.stringify({
236
+ port: parseInt(port, 10),
237
+ workspacePath: '/baz/qux',
238
+ }),
239
+ );
240
+ });
241
+
242
+ it('should clear env vars and delete port file on stop', async () => {
243
+ await ideServer.start(mockContext);
244
+ const portFile = path.join(
245
+ '/tmp',
246
+ `gemini-ide-server-${process.ppid}.json`,
247
+ );
248
+ expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
249
+
250
+ await ideServer.stop();
251
+
252
+ expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
253
+ expect(fs.unlink).toHaveBeenCalledWith(portFile);
254
+ });
255
+
256
+ it.skipIf(process.platform !== 'win32')(
257
+ 'should handle windows paths',
258
+ async () => {
259
+ vscodeMock.workspace.workspaceFolders = [
260
+ { uri: { fsPath: 'c:\\foo\\bar' } },
261
+ { uri: { fsPath: 'd:\\baz\\qux' } },
262
+ ];
263
+
264
+ await ideServer.start(mockContext);
265
+ const replaceMock = mockContext.environmentVariableCollection.replace;
266
+ const expectedWorkspacePaths = 'c:\\foo\\bar;d:\\baz\\qux';
267
+
268
+ expect(replaceMock).toHaveBeenCalledWith(
269
+ 'BLACKBOX_CODE_IDE_WORKSPACE_PATH',
270
+ expectedWorkspacePaths,
271
+ );
272
+
273
+ const port = getPortFromMock(replaceMock);
274
+ const expectedPortFile = path.join(
275
+ '/tmp',
276
+ `gemini-ide-server-${process.ppid}.json`,
277
+ );
278
+ expect(fs.writeFile).toHaveBeenCalledWith(
279
+ expectedPortFile,
280
+ JSON.stringify({
281
+ port: parseInt(port, 10),
282
+ workspacePath: expectedWorkspacePaths,
283
+ }),
284
+ );
285
+ },
286
+ );
287
+ });
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as vscode from 'vscode';
8
+ import { IdeContextNotificationSchema } from '@blackbox_ai/blackbox-cli-core';
9
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
12
+ import express, { type Request, type Response } from 'express';
13
+ import { randomUUID } from 'node:crypto';
14
+ import { type Server as HTTPServer } from 'node:http';
15
+ import * as path from 'node:path';
16
+ import * as fs from 'node:fs/promises';
17
+ import * as os from 'node:os';
18
+ import { z } from 'zod';
19
+ import type { DiffManager } from './diff-manager.js';
20
+ import { OpenFilesManager } from './open-files-manager.js';
21
+
22
+ const MCP_SESSION_ID_HEADER = 'mcp-session-id';
23
+ const IDE_SERVER_PORT_ENV_VAR = 'BLACKBOX_CODE_IDE_SERVER_PORT';
24
+ const IDE_WORKSPACE_PATH_ENV_VAR = 'BLACKBOX_CODE_IDE_WORKSPACE_PATH';
25
+
26
+ function writePortAndWorkspace(
27
+ context: vscode.ExtensionContext,
28
+ port: number,
29
+ portFile: string,
30
+ log: (message: string) => void,
31
+ ): Promise<void> {
32
+ const workspaceFolders = vscode.workspace.workspaceFolders;
33
+ const workspacePath =
34
+ workspaceFolders && workspaceFolders.length > 0
35
+ ? workspaceFolders.map((folder) => folder.uri.fsPath).join(path.delimiter)
36
+ : '';
37
+
38
+ context.environmentVariableCollection.replace(
39
+ IDE_SERVER_PORT_ENV_VAR,
40
+ port.toString(),
41
+ );
42
+ context.environmentVariableCollection.replace(
43
+ IDE_WORKSPACE_PATH_ENV_VAR,
44
+ workspacePath,
45
+ );
46
+
47
+ log(`Writing port file to: ${portFile}`);
48
+ return fs
49
+ .writeFile(portFile, JSON.stringify({ port, workspacePath }))
50
+ .catch((err) => {
51
+ const message = err instanceof Error ? err.message : String(err);
52
+ log(`Failed to write port to file: ${message}`);
53
+ });
54
+ }
55
+
56
+ function sendIdeContextUpdateNotification(
57
+ transport: StreamableHTTPServerTransport,
58
+ log: (message: string) => void,
59
+ openFilesManager: OpenFilesManager,
60
+ ) {
61
+ const ideContext = openFilesManager.state;
62
+
63
+ const notification = IdeContextNotificationSchema.parse({
64
+ jsonrpc: '2.0',
65
+ method: 'ide/contextUpdate',
66
+ params: ideContext,
67
+ });
68
+
69
+ log(
70
+ `Sending IDE context update notification: ${JSON.stringify(
71
+ notification,
72
+ null,
73
+ 2,
74
+ )}`,
75
+ );
76
+ transport.send(notification);
77
+ }
78
+
79
+ export class IDEServer {
80
+ private server: HTTPServer | undefined;
81
+ private context: vscode.ExtensionContext | undefined;
82
+ private log: (message: string) => void;
83
+ private portFile: string;
84
+ private port: number | undefined;
85
+ diffManager: DiffManager;
86
+
87
+ constructor(log: (message: string) => void, diffManager: DiffManager) {
88
+ this.log = log;
89
+ this.diffManager = diffManager;
90
+ this.portFile = path.join(
91
+ os.tmpdir(),
92
+ `gemini-ide-server-${process.ppid}.json`,
93
+ );
94
+ }
95
+
96
+ start(context: vscode.ExtensionContext): Promise<void> {
97
+ return new Promise((resolve) => {
98
+ this.context = context;
99
+ const sessionsWithInitialNotification = new Set<string>();
100
+ const transports: { [sessionId: string]: StreamableHTTPServerTransport } =
101
+ {};
102
+
103
+ const app = express();
104
+ app.use(express.json());
105
+ const mcpServer = createMcpServer(this.diffManager);
106
+
107
+ const openFilesManager = new OpenFilesManager(context);
108
+ const onDidChangeSubscription = openFilesManager.onDidChange(() => {
109
+ for (const transport of Object.values(transports)) {
110
+ sendIdeContextUpdateNotification(
111
+ transport,
112
+ this.log.bind(this),
113
+ openFilesManager,
114
+ );
115
+ }
116
+ });
117
+ context.subscriptions.push(onDidChangeSubscription);
118
+ const onDidChangeDiffSubscription = this.diffManager.onDidChange(
119
+ (notification) => {
120
+ for (const transport of Object.values(transports)) {
121
+ transport.send(notification);
122
+ }
123
+ },
124
+ );
125
+ context.subscriptions.push(onDidChangeDiffSubscription);
126
+
127
+ app.post('/mcp', async (req: Request, res: Response) => {
128
+ const sessionId = req.headers[MCP_SESSION_ID_HEADER] as
129
+ | string
130
+ | undefined;
131
+ let transport: StreamableHTTPServerTransport;
132
+
133
+ if (sessionId && transports[sessionId]) {
134
+ transport = transports[sessionId];
135
+ } else if (!sessionId && isInitializeRequest(req.body)) {
136
+ transport = new StreamableHTTPServerTransport({
137
+ sessionIdGenerator: () => randomUUID(),
138
+ onsessioninitialized: (newSessionId) => {
139
+ this.log(`New session initialized: ${newSessionId}`);
140
+ transports[newSessionId] = transport;
141
+ },
142
+ });
143
+ const keepAlive = setInterval(() => {
144
+ try {
145
+ transport.send({ jsonrpc: '2.0', method: 'ping' });
146
+ } catch (e) {
147
+ this.log(
148
+ 'Failed to send keep-alive ping, cleaning up interval.' + e,
149
+ );
150
+ clearInterval(keepAlive);
151
+ }
152
+ }, 60000); // 60 sec
153
+
154
+ transport.onclose = () => {
155
+ clearInterval(keepAlive);
156
+ if (transport.sessionId) {
157
+ this.log(`Session closed: ${transport.sessionId}`);
158
+ sessionsWithInitialNotification.delete(transport.sessionId);
159
+ delete transports[transport.sessionId];
160
+ }
161
+ };
162
+ mcpServer.connect(transport);
163
+ } else {
164
+ this.log(
165
+ 'Bad Request: No valid session ID provided for non-initialize request.',
166
+ );
167
+ res.status(400).json({
168
+ jsonrpc: '2.0',
169
+ error: {
170
+ code: -32000,
171
+ message:
172
+ 'Bad Request: No valid session ID provided for non-initialize request.',
173
+ },
174
+ id: null,
175
+ });
176
+ return;
177
+ }
178
+
179
+ try {
180
+ await transport.handleRequest(req, res, req.body);
181
+ } catch (error) {
182
+ const errorMessage =
183
+ error instanceof Error ? error.message : 'Unknown error';
184
+ this.log(`Error handling MCP request: ${errorMessage}`);
185
+ if (!res.headersSent) {
186
+ res.status(500).json({
187
+ jsonrpc: '2.0' as const,
188
+ error: {
189
+ code: -32603,
190
+ message: 'Internal server error',
191
+ },
192
+ id: null,
193
+ });
194
+ }
195
+ }
196
+ });
197
+
198
+ const handleSessionRequest = async (req: Request, res: Response) => {
199
+ const sessionId = req.headers[MCP_SESSION_ID_HEADER] as
200
+ | string
201
+ | undefined;
202
+ if (!sessionId || !transports[sessionId]) {
203
+ this.log('Invalid or missing session ID');
204
+ res.status(400).send('Invalid or missing session ID');
205
+ return;
206
+ }
207
+
208
+ const transport = transports[sessionId];
209
+ try {
210
+ await transport.handleRequest(req, res);
211
+ } catch (error) {
212
+ const errorMessage =
213
+ error instanceof Error ? error.message : 'Unknown error';
214
+ this.log(`Error handling session request: ${errorMessage}`);
215
+ if (!res.headersSent) {
216
+ res.status(400).send('Bad Request');
217
+ }
218
+ }
219
+
220
+ if (!sessionsWithInitialNotification.has(sessionId)) {
221
+ sendIdeContextUpdateNotification(
222
+ transport,
223
+ this.log.bind(this),
224
+ openFilesManager,
225
+ );
226
+ sessionsWithInitialNotification.add(sessionId);
227
+ }
228
+ };
229
+
230
+ app.get('/mcp', handleSessionRequest);
231
+
232
+ this.server = app.listen(0, async () => {
233
+ const address = (this.server as HTTPServer).address();
234
+ if (address && typeof address !== 'string') {
235
+ this.port = address.port;
236
+ this.log(`IDE server listening on port ${this.port}`);
237
+ await writePortAndWorkspace(
238
+ context,
239
+ this.port,
240
+ this.portFile,
241
+ this.log,
242
+ );
243
+ }
244
+ resolve();
245
+ });
246
+ });
247
+ }
248
+
249
+ async updateWorkspacePath(): Promise<void> {
250
+ if (this.context && this.port) {
251
+ await writePortAndWorkspace(
252
+ this.context,
253
+ this.port,
254
+ this.portFile,
255
+ this.log,
256
+ );
257
+ }
258
+ }
259
+
260
+ async stop(): Promise<void> {
261
+ if (this.server) {
262
+ await new Promise<void>((resolve, reject) => {
263
+ this.server!.close((err?: Error) => {
264
+ if (err) {
265
+ this.log(`Error shutting down IDE server: ${err.message}`);
266
+ return reject(err);
267
+ }
268
+ this.log(`IDE server shut down`);
269
+ resolve();
270
+ });
271
+ });
272
+ this.server = undefined;
273
+ }
274
+
275
+ if (this.context) {
276
+ this.context.environmentVariableCollection.clear();
277
+ }
278
+ try {
279
+ await fs.unlink(this.portFile);
280
+ } catch (_err) {
281
+ // Ignore errors if the file doesn't exist.
282
+ }
283
+ }
284
+ }
285
+
286
+ const createMcpServer = (diffManager: DiffManager) => {
287
+ const server = new McpServer(
288
+ {
289
+ name: 'blackbox-cli-companion-mcp-server',
290
+ version: '1.0.0',
291
+ },
292
+ { capabilities: { logging: {} } },
293
+ );
294
+ server.registerTool(
295
+ 'openDiff',
296
+ {
297
+ description:
298
+ '(IDE Tool) Open a diff view to create or modify a file. Returns a notification once the diff has been accepted or rejcted.',
299
+ inputSchema: z.object({
300
+ filePath: z.string(),
301
+ // TODO(chrstn): determine if this should be required or not.
302
+ newContent: z.string().optional(),
303
+ }).shape,
304
+ },
305
+ async ({
306
+ filePath,
307
+ newContent,
308
+ }: {
309
+ filePath: string;
310
+ newContent?: string;
311
+ }) => {
312
+ await diffManager.showDiff(filePath, newContent ?? '');
313
+ return {
314
+ content: [
315
+ {
316
+ type: 'text',
317
+ text: `Showing diff for ${filePath}`,
318
+ },
319
+ ],
320
+ };
321
+ },
322
+ );
323
+ server.registerTool(
324
+ 'closeDiff',
325
+ {
326
+ description: '(IDE Tool) Close an open diff view for a specific file.',
327
+ inputSchema: z.object({
328
+ filePath: z.string(),
329
+ }).shape,
330
+ },
331
+ async ({ filePath }: { filePath: string }) => {
332
+ const content = await diffManager.closeDiff(filePath);
333
+ const response = { content: content ?? undefined };
334
+ return {
335
+ content: [
336
+ {
337
+ type: 'text',
338
+ text: JSON.stringify(response),
339
+ },
340
+ ],
341
+ };
342
+ },
343
+ );
344
+ return server;
345
+ };