@different-ai/opencode-browser 3.0.0 → 4.0.1

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.
package/src/mcp-server.ts DELETED
@@ -1,440 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * OpenCode Browser MCP Server
4
- *
5
- * MCP Server <--STDIO--> OpenCode
6
- * MCP Server <--WebSocket:19222--> Chrome Extension
7
- *
8
- * This is a standalone MCP server that manages browser automation.
9
- * It runs as a separate process and communicates with OpenCode via STDIO.
10
- */
11
-
12
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
- import { z } from "zod";
15
- import { existsSync, mkdirSync, writeFileSync } from "fs";
16
- import { homedir } from "os";
17
- import { join } from "path";
18
-
19
- const WS_PORT = 19222;
20
- const BASE_DIR = join(homedir(), ".opencode-browser");
21
-
22
- mkdirSync(BASE_DIR, { recursive: true });
23
-
24
- // WebSocket state for Chrome extension connection
25
- let ws: any = null;
26
- let isConnected = false;
27
- let server: ReturnType<typeof Bun.serve> | null = null;
28
- let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
29
- let requestId = 0;
30
-
31
- // Create MCP server
32
- const mcpServer = new McpServer({
33
- name: "opencode-browser",
34
- version: "2.1.0",
35
- });
36
-
37
- // ============================================================================
38
- // WebSocket Server for Chrome Extension
39
- // ============================================================================
40
-
41
- function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void {
42
- if (message.type === "tool_response" && message.id !== undefined) {
43
- const pending = pendingRequests.get(message.id);
44
- if (!pending) return;
45
-
46
- pendingRequests.delete(message.id);
47
- if (message.error) {
48
- pending.reject(new Error(message.error.content || String(message.error)));
49
- } else {
50
- pending.resolve(message.result?.content);
51
- }
52
- }
53
- }
54
-
55
- function sendToChrome(message: any): boolean {
56
- if (ws && isConnected) {
57
- ws.send(JSON.stringify(message));
58
- return true;
59
- }
60
- return false;
61
- }
62
-
63
- async function isPortFree(port: number): Promise<boolean> {
64
- try {
65
- const testSocket = await Bun.connect({
66
- hostname: "localhost",
67
- port,
68
- socket: {
69
- data() {},
70
- open(socket) {
71
- socket.end();
72
- },
73
- close() {},
74
- error() {},
75
- },
76
- });
77
- testSocket.end();
78
- return false;
79
- } catch (e: any) {
80
- if (e.code === "ECONNREFUSED" || e.message?.includes("ECONNREFUSED")) {
81
- return true;
82
- }
83
- return true;
84
- }
85
- }
86
-
87
- async function killProcessOnPort(port: number): Promise<boolean> {
88
- try {
89
- // Use lsof to find PID using the port
90
- const proc = Bun.spawn(["lsof", "-t", `-i:${port}`], {
91
- stdout: "pipe",
92
- stderr: "pipe",
93
- });
94
- const output = await new Response(proc.stdout).text();
95
- const pids = output.trim().split("\n").filter(Boolean);
96
-
97
- if (pids.length === 0) {
98
- return true; // No process found, port should be free
99
- }
100
-
101
- // Kill each PID found
102
- for (const pid of pids) {
103
- const pidNum = parseInt(pid, 10);
104
- if (isNaN(pidNum)) continue;
105
-
106
- console.error(`[browser-mcp] Killing existing process ${pidNum} on port ${port}`);
107
- try {
108
- process.kill(pidNum, "SIGTERM");
109
- } catch (e) {
110
- // Process may have already exited
111
- }
112
- }
113
-
114
- // Wait a bit for process to die
115
- await sleep(500);
116
-
117
- // Verify port is now free
118
- return await isPortFree(port);
119
- } catch (e) {
120
- console.error(`[browser-mcp] Failed to kill process on port:`, e);
121
- return false;
122
- }
123
- }
124
-
125
- async function startWebSocketServer(): Promise<boolean> {
126
- if (server) return true;
127
-
128
- if (!(await isPortFree(WS_PORT))) {
129
- console.error(`[browser-mcp] Port ${WS_PORT} is in use, attempting to take over...`);
130
- const killed = await killProcessOnPort(WS_PORT);
131
- if (!killed) {
132
- console.error(`[browser-mcp] Failed to free port ${WS_PORT}`);
133
- return false;
134
- }
135
- console.error(`[browser-mcp] Successfully freed port ${WS_PORT}`);
136
- }
137
-
138
- try {
139
- server = Bun.serve({
140
- port: WS_PORT,
141
- fetch(req, server) {
142
- if (server.upgrade(req)) return;
143
- return new Response("OpenCode Browser MCP Server", { status: 200 });
144
- },
145
- websocket: {
146
- open(wsClient) {
147
- console.error(`[browser-mcp] Chrome extension connected`);
148
- ws = wsClient;
149
- isConnected = true;
150
- },
151
- close() {
152
- console.error(`[browser-mcp] Chrome extension disconnected`);
153
- ws = null;
154
- isConnected = false;
155
- },
156
- message(_wsClient, data) {
157
- try {
158
- const message = JSON.parse(data.toString());
159
- handleMessage(message);
160
- } catch (e) {
161
- console.error(`[browser-mcp] Parse error:`, e);
162
- }
163
- },
164
- },
165
- });
166
-
167
- console.error(`[browser-mcp] WebSocket server listening on port ${WS_PORT}`);
168
- return true;
169
- } catch (e) {
170
- console.error(`[browser-mcp] Failed to start WebSocket server:`, e);
171
- return false;
172
- }
173
- }
174
-
175
- function sleep(ms: number): Promise<void> {
176
- return new Promise((resolve) => setTimeout(resolve, ms));
177
- }
178
-
179
- async function waitForExtensionConnection(timeoutMs: number): Promise<boolean> {
180
- const start = Date.now();
181
- while (Date.now() - start < timeoutMs) {
182
- if (isConnected) return true;
183
- await sleep(100);
184
- }
185
- return isConnected;
186
- }
187
-
188
- async function ensureConnection(): Promise<void> {
189
- if (!server) {
190
- const started = await startWebSocketServer();
191
- if (!started) {
192
- throw new Error("Failed to start WebSocket server. Port may be in use.");
193
- }
194
- }
195
-
196
- if (!isConnected) {
197
- const connected = await waitForExtensionConnection(5000);
198
- if (!connected) {
199
- throw new Error(
200
- "Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
201
- );
202
- }
203
- }
204
- }
205
-
206
- async function executeCommand(toolName: string, args: Record<string, any>): Promise<any> {
207
- await ensureConnection();
208
-
209
- const id = ++requestId;
210
-
211
- return new Promise((resolve, reject) => {
212
- pendingRequests.set(id, { resolve, reject });
213
-
214
- sendToChrome({
215
- type: "tool_request",
216
- id,
217
- tool: toolName,
218
- args,
219
- });
220
-
221
- setTimeout(() => {
222
- if (!pendingRequests.has(id)) return;
223
- pendingRequests.delete(id);
224
- reject(new Error("Tool execution timed out after 60 seconds"));
225
- }, 60000);
226
- });
227
- }
228
-
229
- // ============================================================================
230
- // Register MCP Tools
231
- // ============================================================================
232
-
233
- mcpServer.tool(
234
- "browser_status",
235
- "Check if browser extension is connected. Returns connection status.",
236
- {},
237
- async () => {
238
- const status = isConnected
239
- ? "Browser extension connected and ready."
240
- : "Browser extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled.";
241
-
242
- return {
243
- content: [{ type: "text", text: status }],
244
- };
245
- }
246
- );
247
-
248
- mcpServer.tool(
249
- "browser_navigate",
250
- "Navigate to a URL in the browser",
251
- {
252
- url: z.string().describe("The URL to navigate to"),
253
- tabId: z.number().optional().describe("Optional tab ID to navigate in"),
254
- },
255
- async ({ url, tabId }) => {
256
- const result = await executeCommand("navigate", { url, tabId });
257
- return {
258
- content: [{ type: "text", text: result || `Navigated to ${url}` }],
259
- };
260
- }
261
- );
262
-
263
- mcpServer.tool(
264
- "browser_click",
265
- "Click an element on the page using a CSS selector",
266
- {
267
- selector: z.string().describe("CSS selector for element to click"),
268
- tabId: z.number().optional().describe("Optional tab ID"),
269
- },
270
- async ({ selector, tabId }) => {
271
- const result = await executeCommand("click", { selector, tabId });
272
- return {
273
- content: [{ type: "text", text: result || `Clicked ${selector}` }],
274
- };
275
- }
276
- );
277
-
278
- mcpServer.tool(
279
- "browser_type",
280
- "Type text into an input element",
281
- {
282
- selector: z.string().describe("CSS selector for input element"),
283
- text: z.string().describe("Text to type"),
284
- clear: z.boolean().optional().describe("Clear field before typing"),
285
- tabId: z.number().optional().describe("Optional tab ID"),
286
- },
287
- async ({ selector, text, clear, tabId }) => {
288
- const result = await executeCommand("type", { selector, text, clear, tabId });
289
- return {
290
- content: [{ type: "text", text: result || `Typed "${text}" into ${selector}` }],
291
- };
292
- }
293
- );
294
-
295
- mcpServer.tool(
296
- "browser_screenshot",
297
- "Take a screenshot of the current page. Returns base64 image data that can be viewed directly. Optionally saves to a file.",
298
- {
299
- tabId: z.number().optional().describe("Optional tab ID"),
300
- save: z.boolean().optional().describe("Save to file (default: false, just returns base64)"),
301
- path: z.string().optional().describe("Custom file path to save screenshot (implies save=true). Defaults to cwd if just save=true"),
302
- },
303
- async ({ tabId, save, path: savePath }) => {
304
- const result = await executeCommand("screenshot", { tabId });
305
-
306
- if (result && typeof result === "string" && result.startsWith("data:image")) {
307
- const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
308
-
309
- const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [
310
- {
311
- type: "image",
312
- data: base64Data,
313
- mimeType: "image/png",
314
- },
315
- ];
316
-
317
- // Optionally save to file
318
- if (save || savePath) {
319
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
320
- let filepath: string;
321
-
322
- if (savePath) {
323
- // Use provided path (add .png if no extension)
324
- filepath = savePath.endsWith(".png") ? savePath : `${savePath}.png`;
325
- // If relative path, resolve from cwd
326
- if (!savePath.startsWith("/")) {
327
- filepath = join(process.cwd(), filepath);
328
- }
329
- } else {
330
- // Default to cwd with timestamp
331
- filepath = join(process.cwd(), `screenshot-${timestamp}.png`);
332
- }
333
-
334
- writeFileSync(filepath, Buffer.from(base64Data, "base64"));
335
- content.push({ type: "text", text: `Saved: ${filepath}` });
336
- }
337
-
338
- return { content };
339
- }
340
-
341
- return {
342
- content: [{ type: "text", text: result || "Screenshot failed" }],
343
- };
344
- }
345
- );
346
-
347
- mcpServer.tool(
348
- "browser_snapshot",
349
- "Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking, plus all links on the page.",
350
- {
351
- tabId: z.number().optional().describe("Optional tab ID"),
352
- },
353
- async ({ tabId }) => {
354
- const result = await executeCommand("snapshot", { tabId });
355
- return {
356
- content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }],
357
- };
358
- }
359
- );
360
-
361
- mcpServer.tool(
362
- "browser_get_tabs",
363
- "List all open browser tabs",
364
- {},
365
- async () => {
366
- const result = await executeCommand("get_tabs", {});
367
- return {
368
- content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }],
369
- };
370
- }
371
- );
372
-
373
- mcpServer.tool(
374
- "browser_scroll",
375
- "Scroll the page or scroll an element into view",
376
- {
377
- selector: z.string().optional().describe("CSS selector to scroll into view"),
378
- x: z.number().optional().describe("Horizontal scroll amount in pixels"),
379
- y: z.number().optional().describe("Vertical scroll amount in pixels"),
380
- tabId: z.number().optional().describe("Optional tab ID"),
381
- },
382
- async ({ selector, x, y, tabId }) => {
383
- const result = await executeCommand("scroll", { selector, x, y, tabId });
384
- return {
385
- content: [{ type: "text", text: result || `Scrolled ${selector ? `to ${selector}` : `by (${x || 0}, ${y || 0})`}` }],
386
- };
387
- }
388
- );
389
-
390
- mcpServer.tool(
391
- "browser_wait",
392
- "Wait for a specified duration",
393
- {
394
- ms: z.number().optional().describe("Milliseconds to wait (default: 1000)"),
395
- },
396
- async ({ ms }) => {
397
- const waitMs = ms || 1000;
398
- const result = await executeCommand("wait", { ms: waitMs });
399
- return {
400
- content: [{ type: "text", text: result || `Waited ${waitMs}ms` }],
401
- };
402
- }
403
- );
404
-
405
- mcpServer.tool(
406
- "browser_execute",
407
- "Execute JavaScript code in the page context and return the result. Note: May fail on pages with strict CSP.",
408
- {
409
- code: z.string().describe("JavaScript code to execute"),
410
- tabId: z.number().optional().describe("Optional tab ID"),
411
- },
412
- async ({ code, tabId }) => {
413
- const result = await executeCommand("execute_script", { code, tabId });
414
- return {
415
- content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
416
- };
417
- }
418
- );
419
-
420
- // ============================================================================
421
- // Main
422
- // ============================================================================
423
-
424
- async function main() {
425
- console.error("[browser-mcp] Starting OpenCode Browser MCP Server...");
426
-
427
- // Start WebSocket server for Chrome extension
428
- await startWebSocketServer();
429
-
430
- // Connect MCP server to STDIO transport
431
- const transport = new StdioServerTransport();
432
- await mcpServer.connect(transport);
433
-
434
- console.error("[browser-mcp] MCP Server running on STDIO");
435
- }
436
-
437
- main().catch((error) => {
438
- console.error("[browser-mcp] Fatal error:", error);
439
- process.exit(1);
440
- });