Skip to content

Commit 1e567bf

Browse files
committed
Implement token-based auth for Roopik MCP WebSocket
Adds environment variable token authentication to the Roopik MCP WebSocket server and STDIO bridge. Only processes launched from the Roopik IDE process tree (with ROOPIK_MCP_TOKEN set) can connect, preventing external IDEs from accessing Roopik's MCP tools. Includes token generation in the main process, validation in the WebSocket server, and handshake/error handling in the STDIO bridge.
1 parent 8023d23 commit 1e567bf

4 files changed

Lines changed: 185 additions & 34 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ test-output.json
3838
/extensions/roopik-roo
3939
/docs
4040
/docs
41+
42+
# Roopik IDE metadata
43+
.roopik/

src/vs/workbench/contrib/roopik/electron-main/mcp/mcpServerService.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323

2424
import * as http from 'http';
25+
import * as crypto from 'crypto';
2526
import { Emitter, Event } from '../../../../../base/common/event.js';
2627
import { Disposable } from '../../../../../base/common/lifecycle.js';
2728
import { ILoggerService } from '../../../../../platform/log/common/log.js';
@@ -82,6 +83,9 @@ export class McpServerService extends Disposable implements IMcpServerService {
8283
// Settings change listener
8384
private settingsDisposable: Disposable | null = null;
8485

86+
// Session token for authentication (only processes in Roopik's tree have this)
87+
private readonly sessionToken: string;
88+
8589
constructor(
8690
@ILoggerService loggerService: ILoggerService,
8791
private readonly devServerService: DevServerService,
@@ -93,6 +97,13 @@ export class McpServerService extends Disposable implements IMcpServerService {
9397
) {
9498
super();
9599
this.logger = getRoopikLogger(loggerService, 'MCP');
100+
101+
// Generate unique session token for MCP authentication
102+
// This token is set in the environment and inherited by all child processes
103+
// External IDEs (VS Code, Cursor) won't have this token, so their connections are rejected
104+
this.sessionToken = crypto.randomUUID();
105+
process.env.ROOPIK_MCP_TOKEN = this.sessionToken;
106+
this.logger.info('MCP session token generated and set in environment');
96107
}
97108

98109
// ============================================================================
@@ -300,7 +311,8 @@ export class McpServerService extends Disposable implements IMcpServerService {
300311

301312
const configuredPort = this.configurationService.getValue<number>('roopik.mcp.stdioMCPPort') || McpServerService.DEFAULT_WS_PORT;
302313

303-
this.wsServer = new McpWebSocketServer(this.toolExecutor);
314+
// Pass the session token to WebSocket server for authentication
315+
this.wsServer = new McpWebSocketServer(this.toolExecutor, this.sessionToken);
304316
const result = await this.wsServer.start({ port: configuredPort });
305317
this.wsPort = result.port;
306318

src/vs/workbench/contrib/roopik/electron-main/mcp/websocket/mcpWebSocketServer.ts

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface McpWebSocketServerOptions {
3838
export interface McpConnectionInfo {
3939
id: string;
4040
connectedAt: number;
41+
authenticated: boolean;
4142
clientInfo?: {
4243
name?: string;
4344
version?: string;
@@ -69,6 +70,14 @@ interface WsWebSocketServer {
6970
// WebSocket ready states
7071
const WS_OPEN = 1;
7172

73+
// Authentication timeout (5 seconds)
74+
const AUTH_TIMEOUT_MS = 5000;
75+
76+
// Custom close codes for authentication
77+
const WS_CLOSE_AUTH_TIMEOUT = 4001;
78+
const WS_CLOSE_AUTH_REQUIRED = 4002;
79+
const WS_CLOSE_AUTH_INVALID = 4003;
80+
7281
// ============================================================================
7382
// MCP WebSocket Server
7483
// ============================================================================
@@ -77,12 +86,15 @@ export class McpWebSocketServer extends Disposable {
7786
private httpServer: HttpServer | null = null;
7887
private wss: WsWebSocketServer | null = null;
7988
private readonly router: McpRequestRouter;
80-
private readonly connections = new Map<string, { ws: WsWebSocket; info: McpConnectionInfo }>();
89+
private readonly connections = new Map<string, { ws: WsWebSocket; info: McpConnectionInfo; authTimeout?: ReturnType<typeof setTimeout> }>();
8190
private connectionCounter = 0;
8291

8392
private _port: number | undefined;
8493
private _host: string = 'localhost';
8594

95+
// Authentication token (only processes in Roopik's tree have this via env var)
96+
private readonly authToken: string;
97+
8698
// Events
8799
private readonly _onServerStarted = this._register(new Emitter<{ port: number; host: string }>());
88100
readonly onServerStarted: Event<{ port: number; host: string }> = this._onServerStarted.event;
@@ -96,9 +108,10 @@ export class McpWebSocketServer extends Disposable {
96108
private readonly _onClientDisconnected = this._register(new Emitter<string>());
97109
readonly onClientDisconnected: Event<string> = this._onClientDisconnected.event;
98110

99-
constructor(toolExecutor: ToolExecutor) {
111+
constructor(toolExecutor: ToolExecutor, authToken: string) {
100112
super();
101113
this.router = new McpRequestRouter(toolExecutor);
114+
this.authToken = authToken;
102115
}
103116

104117
/**
@@ -221,12 +234,21 @@ export class McpWebSocketServer extends Disposable {
221234

222235
const connectionInfo: McpConnectionInfo = {
223236
id: connectionId,
224-
connectedAt: Date.now()
237+
connectedAt: Date.now(),
238+
authenticated: false
225239
};
226240

227-
this.connections.set(connectionId, { ws, info: connectionInfo });
228-
console.log(`[MCP WebSocket] Client connected: ${connectionId}`);
229-
this._onClientConnected.fire(connectionInfo);
241+
// Set authentication timeout - client must auth within 5 seconds
242+
const authTimeout = setTimeout(() => {
243+
const conn = this.connections.get(connectionId);
244+
if (conn && !conn.info.authenticated) {
245+
console.log(`[MCP WebSocket] Auth timeout for ${connectionId}, closing`);
246+
ws.close(WS_CLOSE_AUTH_TIMEOUT, 'Authentication timeout');
247+
}
248+
}, AUTH_TIMEOUT_MS);
249+
250+
this.connections.set(connectionId, { ws, info: connectionInfo, authTimeout });
251+
console.log(`[MCP WebSocket] Client connected: ${connectionId} (awaiting auth)`);
230252

231253
// Handle incoming messages
232254
ws.on('message', async (data: unknown) => {
@@ -236,6 +258,10 @@ export class McpWebSocketServer extends Disposable {
236258
// Handle close
237259
ws.on('close', (code: number, _reason: Buffer) => {
238260
console.log(`[MCP WebSocket] Client disconnected: ${connectionId} (code: ${code})`);
261+
const conn = this.connections.get(connectionId);
262+
if (conn?.authTimeout) {
263+
clearTimeout(conn.authTimeout);
264+
}
239265
this.connections.delete(connectionId);
240266
this._onClientDisconnected.fire(connectionId);
241267
});
@@ -247,20 +273,63 @@ export class McpWebSocketServer extends Disposable {
247273
}
248274

249275
private async handleMessage(connectionId: string, ws: WsWebSocket, data: unknown): Promise<void> {
250-
let request: McpRequest;
276+
const conn = this.connections.get(connectionId);
277+
if (!conn) {
278+
return;
279+
}
280+
281+
let message: { type?: string; token?: string; jsonrpc?: string; method?: string; id?: string | number; params?: unknown };
251282

252283
try {
253284
// Parse message
254285
const messageStr = data instanceof Buffer ? data.toString('utf-8') : String(data);
255-
request = JSON.parse(messageStr) as McpRequest;
286+
message = JSON.parse(messageStr);
287+
} catch {
288+
this.sendError(ws, null, MCP_ERROR_CODES.PARSE_ERROR, 'Failed to parse JSON');
289+
return;
290+
}
256291

257-
// Validate JSON-RPC format
258-
if (request.jsonrpc !== '2.0' || !request.method) {
259-
this.sendError(ws, request?.id ?? null, MCP_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request');
292+
// ============================================================
293+
// Authentication Flow
294+
// ============================================================
295+
296+
// If not authenticated, first message MUST be auth
297+
if (!conn.info.authenticated) {
298+
if (message.type === 'auth') {
299+
// Validate token
300+
if (message.token === this.authToken) {
301+
conn.info.authenticated = true;
302+
// Clear auth timeout
303+
if (conn.authTimeout) {
304+
clearTimeout(conn.authTimeout);
305+
conn.authTimeout = undefined;
306+
}
307+
console.log(`[MCP WebSocket] Client authenticated: ${connectionId}`);
308+
this._onClientConnected.fire(conn.info);
309+
// Send success response
310+
this.send(ws, { jsonrpc: '2.0', id: 0, result: { type: 'auth_success' } });
311+
} else {
312+
console.log(`[MCP WebSocket] Invalid token from ${connectionId}, rejecting`);
313+
ws.close(WS_CLOSE_AUTH_INVALID, 'Invalid token');
314+
}
315+
return;
316+
} else {
317+
// Non-auth message before authentication
318+
console.log(`[MCP WebSocket] Non-auth message from unauthenticated client ${connectionId}`);
319+
ws.close(WS_CLOSE_AUTH_REQUIRED, 'Authentication required');
260320
return;
261321
}
262-
} catch {
263-
this.sendError(ws, null, MCP_ERROR_CODES.PARSE_ERROR, 'Failed to parse JSON');
322+
}
323+
324+
// ============================================================
325+
// Normal MCP Request Processing (authenticated clients only)
326+
// ============================================================
327+
328+
const request = message as McpRequest;
329+
330+
// Validate JSON-RPC format
331+
if (request.jsonrpc !== '2.0' || !request.method) {
332+
this.sendError(ws, request?.id ?? null, MCP_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request');
264333
return;
265334
}
266335

@@ -269,13 +338,10 @@ export class McpWebSocketServer extends Disposable {
269338

270339
// Update client info if this was an initialize request
271340
if (request.method === 'initialize' && request.params) {
272-
const conn = this.connections.get(connectionId);
273-
if (conn) {
274-
conn.info.clientInfo = {
275-
name: (request.params as { clientInfo?: { name?: string } }).clientInfo?.name,
276-
version: (request.params as { clientInfo?: { version?: string } }).clientInfo?.version
277-
};
278-
}
341+
conn.info.clientInfo = {
342+
name: (request.params as { clientInfo?: { name?: string } }).clientInfo?.name,
343+
version: (request.params as { clientInfo?: { version?: string } }).clientInfo?.version
344+
};
279345
}
280346

281347
// Send response

0 commit comments

Comments
 (0)