@xnng/browser-relay 1.6.0

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.
Files changed (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +445 -0
  3. package/docs/README.zh-CN.md +421 -0
  4. package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
  5. package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
  6. package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
  7. package/docs/benchmarks/browser-readiness-cost.json +106 -0
  8. package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
  9. package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
  10. package/docs/benchmarks/browser-runtime-balanced.json +412 -0
  11. package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
  12. package/docs/benchmarks/browser-runtime.json +254 -0
  13. package/docs/benchmarks/browser-use-parity.json +54 -0
  14. package/docs/benchmarks/codex-extension-audit.json +131 -0
  15. package/docs/benchmarks/codex-native-protocol.md +69 -0
  16. package/docs/benchmarks/codex-native-replay.js +116 -0
  17. package/docs/benchmarks/codex-native-status.json +81 -0
  18. package/docs/benchmarks/codex-native.json +1003 -0
  19. package/docs/benchmarks/extension-sessions.png +0 -0
  20. package/docs/benchmarks/extension-tasks.png +0 -0
  21. package/docs/benchmarks/iframe-routing-regression.json +37 -0
  22. package/docs/browser-use-comparison.md +331 -0
  23. package/docs/browser-use-gap-audit.md +141 -0
  24. package/docs/browser-use-parity.md +105 -0
  25. package/docs/demo/intranet.html +103 -0
  26. package/docs/releases/v1.5.0.md +59 -0
  27. package/docs/releases/v1.5.1.md +16 -0
  28. package/docs/releases/v1.5.2.md +42 -0
  29. package/docs/releases/v1.5.3.md +33 -0
  30. package/docs/releases/v1.5.4.md +42 -0
  31. package/docs/releases/v1.6.0.md +16 -0
  32. package/docs/remote-control-hub.md +523 -0
  33. package/extension/activity.js +328 -0
  34. package/extension/automation.js +1839 -0
  35. package/extension/background.js +1854 -0
  36. package/extension/i18n.js +149 -0
  37. package/extension/icons/icon128.png +0 -0
  38. package/extension/icons/icon16.png +0 -0
  39. package/extension/icons/icon32.png +0 -0
  40. package/extension/icons/icon48.png +0 -0
  41. package/extension/manifest.json +47 -0
  42. package/extension/observations.js +109 -0
  43. package/extension/options.html +289 -0
  44. package/extension/options.js +269 -0
  45. package/extension/popup.html +74 -0
  46. package/extension/popup.js +105 -0
  47. package/extension/protocol.js +45 -0
  48. package/extension/remote-auth.js +18 -0
  49. package/extension/sessions.js +134 -0
  50. package/extension/snapshot.js +161 -0
  51. package/extension/task-groups.js +108 -0
  52. package/extension/tasks.js +186 -0
  53. package/extension/wait.js +89 -0
  54. package/hub/README.md +42 -0
  55. package/hub/package-lock.json +1544 -0
  56. package/hub/package.json +13 -0
  57. package/hub/src/rpc.js +41 -0
  58. package/hub/src/worker.js +322 -0
  59. package/hub/wrangler.example.toml +19 -0
  60. package/package.json +83 -0
  61. package/server/cdp-bridge.js +200 -0
  62. package/server/cli.js +1798 -0
  63. package/server/hub-server.js +258 -0
  64. package/server/install.js +250 -0
  65. package/server/mcp-server.js +504 -0
  66. package/server/npx-runner.js +96 -0
  67. package/server/relay-server.js +1356 -0
  68. package/server/remote-protocol.js +76 -0
  69. package/server/runtime-worker.js +166 -0
  70. package/server/script-runtime.js +189 -0
  71. package/server/sdk.js +307 -0
  72. package/server/service-state.js +103 -0
  73. package/server/snapshot.js +161 -0
  74. package/server/uninstall.js +61 -0
  75. package/server/windows-service-entry.js +58 -0
  76. package/server/windows-service.js +360 -0
  77. package/skills/browser-relay/SKILL.md +192 -0
  78. package/skills/browser-relay/references/legacy-api.md +163 -0
  79. package/skills/browser-relay/references/runtime.md +240 -0
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP stdio server for browser-relay (no-auth).
4
+ *
5
+ * Exposes high-level browser tools over the Model Context Protocol.
6
+ * Each tool maps to an HTTP call to the relay-server.
7
+ *
8
+ * Works with any MCP-compatible agent (Claude Code, Claude Desktop,
9
+ * Cursor, Windsurf, etc.)
10
+ *
11
+ * Usage:
12
+ * BROWSER_RELAY_URL=http://127.0.0.1:18795 node mcp-server.js
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+ import { createTransport } from "./sdk.js";
18
+ import { isAutomationPath, isTaskRequest } from "../extension/protocol.js";
19
+ const callContext=new AsyncLocalStorage(), calls=new Map(), ownedSessions=new Set();
20
+ const defaultSession=`mcp-${randomUUID()}`;
21
+ import { DEFAULT_REMOTE_HOST, parseRemoteDeviceId, remoteHttpBase } from "./remote-protocol.js";
22
+ import { createScriptRuntime, EXEC_DESCRIPTION } from "./script-runtime.js";
23
+
24
+ const RELAY_URL = (process.env.BROWSER_RELAY_URL || "http://127.0.0.1:18795").replace(/\/$/, "");
25
+ const RELAY_PORT = parseInt(new URL(RELAY_URL).port || "18795", 10);
26
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // HTTP client to relay
30
+ // ---------------------------------------------------------------------------
31
+ function remoteContextFromEnv() {
32
+ const remoteDeviceId = process.env.BROWSER_RELAY_REMOTE_DEVICE_ID;
33
+ if (!remoteDeviceId) return null;
34
+ try {
35
+ const parsed = parseRemoteDeviceId(remoteDeviceId);
36
+ const host = process.env.BROWSER_RELAY_REMOTE_HOST || DEFAULT_REMOTE_HOST;
37
+ return { ...parsed, host: remoteHttpBase(host) };
38
+ } catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ throw relayToolError(errorPayload("invalid_remote_device_id", message, { status: 400 }));
41
+ }
42
+ }
43
+
44
+ async function relayRequest(method, path, body, options = {}) {
45
+ const context=callContext.getStore();
46
+ if (context?.controller.signal.aborted) throw relayToolError(errorPayload('request_cancelled','Request cancelled',{status:409}));
47
+ options={...options,signal:options.signal || context?.controller.signal};
48
+ if(isAutomationPath(path.split('?')[0]) && !path.startsWith('/api/capabilities')) {
49
+ const existing=new URL(path,'http://relay.local').searchParams.get('sessionId');
50
+ const sessionId=body?.sessionId || existing || defaultSession;
51
+ if(method==='GET' && !existing) path+=`${path.includes('?')?'&':'?'}sessionId=${encodeURIComponent(sessionId)}`;
52
+ if(method==='POST')body={...body,sessionId};
53
+ if(body?.action==='stop')ownedSessions.delete(sessionId);else ownedSessions.add(sessionId);
54
+ if(method==='POST' && isTaskRequest(method,path.split('?')[0])) {
55
+ body.taskId ||= `job_${randomUUID()}`;
56
+ context?.tasks.push({id:body.taskId,sessionId});
57
+ }
58
+ }
59
+ const remoteContext = remoteContextFromEnv();
60
+ const transport = createTransport({url:RELAY_URL,remoteDeviceId:remoteContext?.remoteDeviceId || '',remoteHost:remoteContext?.host});
61
+ try { return await transport(method,path,body,options); }
62
+ catch (err) {
63
+ const payload=err.payload || errorPayload('mcp_tool_error',err.message);
64
+ if(payload.code==='transport_error') {
65
+ payload.code=remoteContext?'remote_hub_unreachable':'relay_unreachable';
66
+ payload.message=`Cannot reach Browser Relay${remoteContext?' Hub':''}: ${payload.message}`;
67
+ }
68
+ throw relayToolError(payload);
69
+ }
70
+ }
71
+
72
+ async function relayGet(path) { return relayRequest("GET", path); }
73
+ async function relayPost(path, body) { return relayRequest("POST", path, body); }
74
+
75
+ function addQueryParam(params, name, value) {
76
+ if (value !== undefined && value !== null && value !== "") params.set(name, String(value));
77
+ }
78
+
79
+ function errorPayload(code, message, options = {}) {
80
+ return {
81
+ ok: false,
82
+ code,
83
+ error: message,
84
+ message,
85
+ status: options.status ?? 500,
86
+ retryable: options.retryable === true,
87
+ };
88
+ }
89
+
90
+ function relayToolError(payload) {
91
+ const message = payload?.message || payload?.error || "Browser Relay request failed";
92
+ const err = new Error(payload?.code ? `${payload.code}: ${message}` : message);
93
+ err.payload = payload;
94
+ return err;
95
+ }
96
+
97
+ function toolErrorPayload(err) {
98
+ if (err?.payload) return err.payload;
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ return errorPayload("mcp_tool_error", message);
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Tool definitions
105
+ // ---------------------------------------------------------------------------
106
+ const TOOLS = [
107
+ {
108
+ name: "browser_tabs",
109
+ description: "List all browser tabs currently attached via the Browser Relay extension. Returns tab IDs, titles, and URLs. Call this first to discover available tabs.",
110
+ inputSchema: { type: "object", properties: {} },
111
+ handler: async () => relayGet("/api/tabs"),
112
+ },
113
+ {
114
+ name: "browser_navigate",
115
+ description: "Navigate a browser tab to a URL. If no tabId is provided, uses the most recently attached tab.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ url: { type: "string", description: "URL to navigate to" },
120
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional, defaults to most recent)" },
121
+ },
122
+ required: ["url"],
123
+ },
124
+ handler: async (args) => relayPost("/api/navigate", args),
125
+ },
126
+ {
127
+ name: "browser_console",
128
+ description: "Read captured console.log/warn/error, page exceptions, and browser log entries from attached tabs. Use this to diagnose page behavior after interactions.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
133
+ level: { type: "string", description: "Filter by level, e.g. log, warning, error" },
134
+ limit: { type: "number", description: "Maximum entries to return (default: 100)" },
135
+ clear: { type: "boolean", description: "Clear returned entries after reading" },
136
+ },
137
+ },
138
+ handler: async (args) => {
139
+ const params = new URLSearchParams();
140
+ if (args.tabId) params.set("tabId", args.tabId);
141
+ if (args.level) params.set("level", args.level);
142
+ if (args.limit !== undefined) params.set("limit", String(args.limit));
143
+ if (args.clear) params.set("clear", "true");
144
+ const qs = params.toString();
145
+ return relayGet(`/api/console${qs ? "?" + qs : ""}`);
146
+ },
147
+ },
148
+ {
149
+ name: "browser_network",
150
+ description: "Read captured Network.* request/response/finished/failed entries from attached tabs. Sensitive headers such as Authorization, Cookie, and Set-Cookie are redacted. Use this to diagnose failed requests after page actions.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
155
+ type: { type: "string", enum: ["request", "response", "finished", "failed"], description: "Network entry type" },
156
+ method: { type: "string", description: "Filter by request method, e.g. GET or POST" },
157
+ status: { type: "number", description: "Filter by HTTP response status" },
158
+ requestId: { type: "string", description: "Filter by CDP request id" },
159
+ url: { type: "string", description: "Filter by URL substring" },
160
+ limit: { type: "number", description: "Maximum entries to return (default: 100)" },
161
+ clear: { type: "boolean", description: "Clear matched entries" },
162
+ },
163
+ },
164
+ handler: async (args) => {
165
+ if (args.clear) {
166
+ return relayPost("/api/network/clear", {
167
+ tabId: args.tabId,
168
+ type: args.type,
169
+ method: args.method,
170
+ status: args.status,
171
+ requestId: args.requestId,
172
+ url: args.url,
173
+ });
174
+ }
175
+ const params = new URLSearchParams();
176
+ addQueryParam(params, "tabId", args.tabId);
177
+ addQueryParam(params, "type", args.type);
178
+ addQueryParam(params, "method", args.method);
179
+ addQueryParam(params, "status", args.status);
180
+ addQueryParam(params, "requestId", args.requestId);
181
+ addQueryParam(params, "url", args.url);
182
+ addQueryParam(params, "limit", args.limit);
183
+ const qs = params.toString();
184
+ return relayGet(`/api/network${qs ? "?" + qs : ""}`);
185
+ },
186
+ },
187
+ {
188
+ name: "browser_snapshot",
189
+ description: "Get a text representation of the page. Returns annotated text with clickable elements (links, buttons, inputs) marked for easy reference. Use this to understand what is on the page before interacting.",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
194
+ format: { type: "string", enum: ["text", "html"], description: "Output format (default: text)" },
195
+ maxLength: { type: "number", description: "Max output length (default: 100000)" },
196
+ },
197
+ },
198
+ handler: async (args) => {
199
+ const params = new URLSearchParams();
200
+ if (args.tabId) params.set("tabId", args.tabId);
201
+ if (args.format) params.set("format", args.format);
202
+ if (args.maxLength) params.set("maxLength", String(args.maxLength));
203
+ const qs = params.toString();
204
+ return relayGet(`/api/snapshot${qs ? "?" + qs : ""}`);
205
+ },
206
+ },
207
+ {
208
+ name: "browser_wait",
209
+ description: "Wait for a CSS selector to be attached to the DOM or become visible. Use this after navigation or an action instead of fixed sleeps.",
210
+ inputSchema: {
211
+ type: "object",
212
+ properties: {
213
+ selector: { type: "string", description: "CSS selector to wait for" },
214
+ state: { type: "string", enum: ["attached", "visible"], description: "Condition to wait for (default: visible)" },
215
+ timeoutMs: { type: "integer", minimum: 1, maximum: 20000, description: "Timeout in milliseconds (default: 5000)" },
216
+ pollMs: { type: "integer", minimum: 50, maximum: 1000, description: "Polling interval in milliseconds (default: 100)" },
217
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional, defaults to most recent)" },
218
+ },
219
+ required: ["selector"],
220
+ },
221
+ handler: async (args) => relayPost("/api/wait", args),
222
+ },
223
+ {
224
+ name: "browser_click",
225
+ description: "Click an element on the page by CSS selector. Scrolls the element into view first. Returns the text of the clicked element.",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ selector: { type: "string", description: "CSS selector for the element to click (e.g. 'button.submit', 'a[href=\"...\"]')" },
230
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
231
+ doubleClick: { type: "boolean", description: "Double-click instead of single click" },
232
+ },
233
+ required: ["selector"],
234
+ },
235
+ handler: async (args) => relayPost("/api/click", args),
236
+ },
237
+ {
238
+ name: "browser_type",
239
+ description: "Type text into an input field. Optionally focus an element by CSS selector first. Can clear the field and/or press Enter to submit.",
240
+ inputSchema: {
241
+ type: "object",
242
+ properties: {
243
+ text: { type: "string", description: "Text to type" },
244
+ selector: { type: "string", description: "CSS selector to focus before typing (optional)" },
245
+ submit: { type: "boolean", description: "Press Enter after typing" },
246
+ clear: { type: "boolean", description: "Clear the field before typing" },
247
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
248
+ },
249
+ required: ["text"],
250
+ },
251
+ handler: async (args) => relayPost("/api/type", args),
252
+ },
253
+ {
254
+ name: "browser_key",
255
+ description: "Press a key or keyboard shortcut in the active page using real Chrome keyboard events. Use for Enter, Escape, Tab, Arrow keys, or shortcuts like Control+L.",
256
+ inputSchema: {
257
+ type: "object",
258
+ properties: {
259
+ key: { type: "string", description: "Single key to press, e.g. Enter, Escape, ArrowDown, a" },
260
+ combo: { type: "string", description: "Shortcut combo, e.g. Control+L, Shift+Tab, Meta+K" },
261
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
262
+ ctrl: { type: "boolean", description: "Hold Control while pressing key" },
263
+ alt: { type: "boolean", description: "Hold Alt/Option while pressing key" },
264
+ shift: { type: "boolean", description: "Hold Shift while pressing key" },
265
+ meta: { type: "boolean", description: "Hold Meta/Command/Windows while pressing key" },
266
+ text: { type: "string", description: "Optional text generated by this key event" },
267
+ },
268
+ },
269
+ handler: async (args) => relayPost("/api/key", args),
270
+ },
271
+ {
272
+ name: "browser_scroll",
273
+ description: "Scroll the page in a direction (up, down, top, bottom).",
274
+ inputSchema: {
275
+ type: "object",
276
+ properties: {
277
+ direction: { type: "string", enum: ["up", "down", "top", "bottom"], description: "Scroll direction" },
278
+ amount: { type: "number", description: "Pixels to scroll (default: 800)" },
279
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
280
+ },
281
+ required: ["direction"],
282
+ },
283
+ handler: async (args) => relayPost("/api/scroll", args),
284
+ },
285
+ {
286
+ name: "browser_screenshot",
287
+ description: "Capture a PNG screenshot of the page. Returns base64-encoded image data. Use to visually inspect the current page state.",
288
+ inputSchema: {
289
+ type: "object",
290
+ properties: {
291
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
292
+ fullPage: { type: "boolean", description: "Capture the full scrollable page" },
293
+ },
294
+ },
295
+ handler: async (args) => relayPost("/api/screenshot", args || {}),
296
+ },
297
+ {
298
+ name: "browser_eval",
299
+ description: "Evaluate a JavaScript expression in the page context. The escape hatch for any operation not covered by other tools. Returns the evaluation result.",
300
+ inputSchema: {
301
+ type: "object",
302
+ properties: {
303
+ expression: { type: "string", description: "JavaScript expression to evaluate" },
304
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
305
+ },
306
+ required: ["expression"],
307
+ },
308
+ handler: async (args) => relayPost("/api/eval", args),
309
+ },
310
+ {
311
+ name: "browser_download",
312
+ description: "Get the URL of an image, link, or media element on the page for downloading.",
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: {
316
+ selector: { type: "string", description: "CSS selector to find the element (e.g. 'img', 'a.download-link')" },
317
+ tabId: { type: "string", description: "Tab ID from browser_tabs (optional)" },
318
+ },
319
+ required: ["selector"],
320
+ },
321
+ handler: async (args) => relayPost("/api/download", args),
322
+ },
323
+ {
324
+ name: "browser_download_start",
325
+ description: "Start a real Chrome download from a URL using the browser profile's download manager.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ url: { type: "string", description: "URL to download" },
330
+ filename: { type: "string", description: "Optional relative filename/path suggested to Chrome" },
331
+ saveAs: { type: "boolean", description: "Ask Chrome to show the save-as dialog" },
332
+ conflictAction: { type: "string", enum: ["uniquify", "overwrite", "prompt"], description: "How Chrome should handle filename conflicts" },
333
+ },
334
+ required: ["url"],
335
+ },
336
+ handler: async (args) => relayPost("/api/download/start", args),
337
+ },
338
+ {
339
+ name: "browser_downloads",
340
+ description: "List Chrome downloads and recent Browser Relay download events. Use clear=true to clear captured relay events.",
341
+ inputSchema: {
342
+ type: "object",
343
+ properties: {
344
+ id: { type: "number", description: "Filter by Chrome download id" },
345
+ state: { type: "string", enum: ["in_progress", "interrupted", "complete"], description: "Filter by download state" },
346
+ url: { type: "string", description: "Filter by exact URL" },
347
+ filename: { type: "string", description: "Filter by exact filename" },
348
+ query: { type: "string", description: "Search term passed to chrome.downloads.search" },
349
+ limit: { type: "number", description: "Maximum downloads/events to return" },
350
+ clear: { type: "boolean", description: "Clear relay-captured download events" },
351
+ },
352
+ },
353
+ handler: async (args) => {
354
+ if (args.clear) return relayPost("/api/downloads/clear", {});
355
+ const params = new URLSearchParams();
356
+ addQueryParam(params, "id", args.id);
357
+ addQueryParam(params, "state", args.state);
358
+ addQueryParam(params, "url", args.url);
359
+ addQueryParam(params, "filename", args.filename);
360
+ addQueryParam(params, "query", args.query);
361
+ addQueryParam(params, "limit", args.limit);
362
+ const qs = params.toString();
363
+ return relayGet(`/api/downloads${qs ? "?" + qs : ""}`);
364
+ },
365
+ },
366
+ ];
367
+
368
+ const scriptRuntime = createScriptRuntime({request:(...args)=>callContext.exit(()=>relayRequest(...args))});
369
+ TOOLS.push(
370
+ {name:'browser_read',description:'Read complete main-page content, preserving semantic groups, full links and actionable refs. target selects an observed subtree. When truncated, follow nextCursor with cursor to finish the same captured observation. Use screenshots if readable content is missing; do not mistake loading states for success.',inputSchema:{type:'object',properties:{tabId:{type:'string'},sessionId:{type:'string'},target:{type:'object'},cursor:{type:'string'},maxLength:{type:'integer'},diff:{type:'boolean'}},required:['tabId']},handler:args=>relayPost('/api/read',args)},
371
+ {name:'browser_tab',description:'Create, focus, claim, release, handoff, or close a task tab. Keep new and existing tabs in the background by default; focus only when the user explicitly requests foreground operation. Another active owner must release or handoff before you operate it. close removes a tab; release only relinquishes ownership. Do not close pre-existing user tabs without authorization.',inputSchema:{type:'object',properties:{action:{type:'string',enum:['create','focus','claim','release','handoff','close']},tabId:{type:'string'},url:{type:'string'},sessionId:{type:'string'},toSessionId:{type:'string'},label:{type:'string'}},required:['action']},handler:args=>relayPost(`/api/tabs/${args.action}`,args)},
372
+ {name:'browser_session',description:'Start a named task group with an optional label, list ownership/groups, heartbeat, stop, or complete. New task tabs join the session group. complete closes only tracked task-created tabs; release result tabs first to keep them. stop/disconnect preserve pages. A stopped session cannot restart implicitly, but can still be completed.',inputSchema:{type:'object',properties:{action:{type:'string',enum:['start','list','heartbeat','stop','complete']},sessionId:{type:'string'},label:{type:'string'}},required:['action']},handler:args=>args.action==='list'?relayGet('/api/sessions'):relayPost('/api/sessions',args)},
373
+ {name:'browser_observe',description:'Read current accessibility state with actionable refs, frame IDs and viewport. Use diff=true within one session to reduce unchanged output. Screenshot mode returns an image and coordinate mapping.',inputSchema:{type:'object',properties:{tabId:{type:'string'},sessionId:{type:'string'},mode:{type:'string',enum:['snapshot','read','screenshot','both']},target:{type:'object'},cursor:{type:'string'},diff:{type:'boolean'},maxLength:{type:'integer'},fullPage:{type:'boolean'}},required:['tabId']},handler:args=>relayPost('/api/observe',args)},
374
+ {name:'browser_actions',description:'Execute a short ordered group of known browser actions on one explicit tab, then return updated state. Supported types: click, double_click, hover, move, drag, fill, type, key, scroll, wait, select, check, navigate, focus. scroll waitForChange reports text progress; background scroll uses DOM scrolling without activating the tab. Keep the user foreground unchanged; use focus or allowFocus only when the user explicitly requests foreground operation. navigate waits for document readiness; add wait for site-specific content. target is {ref}, {selector}, or {role,name,frameId?,scope?}. Optional per-action timeoutMs waits for readiness before dispatch; it never repeats dispatched input. wait also supports state=enabled. Coordinates use CSS viewport pixels, or image pixels when screenshotId is supplied. Stops at the first error. async=true returns a cancellable task.',inputSchema:{type:'object',properties:{tabId:{type:'string'},actions:{type:'array',items:{type:'object'},minItems:1,maxItems:100},observe:{type:'string',enum:['none','snapshot','read','screenshot','both']},sessionId:{type:'string'},async:{type:'boolean'},timeoutMs:{type:'integer'},maxLength:{type:'integer'}},required:['tabId','actions']},handler:args=>relayPost('/api/actions',args)},
375
+ {name:'browser_task',description:'Get a browser task or cancel pending actions. Cancellation does not undo completed actions.',inputSchema:{type:'object',properties:{id:{type:'string'},cancel:{type:'boolean'},sessionId:{type:'string'}},required:['id']},handler:args=>args.cancel?relayPost(`/api/tasks/${encodeURIComponent(args.id)}/cancel`,{sessionId:args.sessionId}):relayGet(`/api/tasks/${encodeURIComponent(args.id)}?sessionId=${encodeURIComponent(args.sessionId||defaultSession)}`)},
376
+ {name:'browser_exec',description:EXEC_DESCRIPTION,inputSchema:{type:'object',properties:{code:{type:'string'},sessionId:{type:'string'},timeoutMs:{type:'integer'}},required:['code']},handler:args=>scriptRuntime.execute(args)},
377
+ {name:'browser_exec_reset',description:'Reset one persistent JavaScript session and request cancellation of its pending browser tasks. Existing tabs remain open.',inputSchema:{type:'object',properties:{sessionId:{type:'string'}}},handler:async args=>{await scriptRuntime.reset(args.sessionId);return {ok:true};}},
378
+ );
379
+ const toolMap = new Map(TOOLS.map((t) => [t.name, t]));
380
+
381
+ function toolContent(result) {
382
+ if(Array.isArray(result?.content)) return result;
383
+ const observation=result?.task?.observation || result;
384
+ const shot=observation?.screenshot || (observation?.data ? observation : null);
385
+ if(shot?.format==='png') {
386
+ const {data,...metadata}=shot;
387
+ const obs=observation.screenshot ? {...observation,screenshot:metadata} : metadata;
388
+ const value=result.task ? {...result,task:{...result.task,observation:obs}} : obs;
389
+ return {content:[{type:'image',data,mimeType:'image/png'},{type:'text',text:JSON.stringify(value)}]};
390
+ }
391
+ return {content:[{type:'text',text:JSON.stringify(result)}]};
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // JSON-RPC / MCP protocol over stdio
396
+ // ---------------------------------------------------------------------------
397
+ let initialized = false;
398
+ let transportFormat = 'framed';
399
+
400
+ function send(msg) {
401
+ const json = JSON.stringify(msg);
402
+ process.stdout.write(transportFormat === 'ndjson' ? json+'\n' : `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
403
+ }
404
+
405
+ function sendResult(id, result) { send({ jsonrpc: "2.0", id, result }); }
406
+ function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
407
+
408
+ async function handleMessage(msg) {
409
+ const { id, method, params } = msg;
410
+
411
+ if (method === "initialize") {
412
+ initialized = true;
413
+ return sendResult(id, {
414
+ protocolVersion: "2024-11-05",
415
+ capabilities: { tools: {} },
416
+ serverInfo: { name: "browser-relay-mcp", version: PACKAGE_VERSION },
417
+ });
418
+ }
419
+
420
+ if (method === "notifications/initialized") return;
421
+ if (method === 'notifications/cancelled') {
422
+ const context=calls.get(params?.requestId);
423
+ if(context) {
424
+ context.controller.abort();
425
+ if(context.runtimeSession!==undefined)await scriptRuntime.reset(context.runtimeSession);
426
+ await Promise.allSettled(context.tasks.map(task=>callContext.exit(()=>relayRequest('POST',`/api/tasks/${encodeURIComponent(task.id)}/cancel`,{sessionId:task.sessionId},{timeoutMs:2500}))));
427
+ }
428
+ return;
429
+ }
430
+
431
+ if (method === "tools/list") {
432
+ return sendResult(id, {
433
+ tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
434
+ });
435
+ }
436
+
437
+ if (method === "tools/call") {
438
+ const toolName = params?.name;
439
+ const tool = toolMap.get(toolName);
440
+ if (!tool) {
441
+ return sendResult(id, { content: [{ type: "text", text: `Unknown tool: ${toolName}` }], isError: true });
442
+ }
443
+ const context={controller:new AbortController(),tasks:[],...(toolName==='browser_exec'?{runtimeSession:params?.arguments?.sessionId || 'default'}:{})};calls.set(id,context);
444
+ try {
445
+ const result = await callContext.run(context,()=>tool.handler(params?.arguments || {}));
446
+ return sendResult(id, toolContent(result));
447
+ } catch (err) {
448
+ if (context.controller.signal.aborted) {
449
+ const payload={...toolErrorPayload(err),code:'request_cancelled',message:'Request cancelled; completed actions were not undone',retryable:false};
450
+ return sendResult(id,{content:[{type:'text',text:JSON.stringify(payload,null,2)}],isError:true});
451
+ }
452
+ return sendResult(id, { content: [{ type: "text", text: JSON.stringify(toolErrorPayload(err), null, 2) }], isError: true });
453
+ } finally {calls.delete(id)}
454
+ }
455
+
456
+ if (method === "ping") return sendResult(id, {});
457
+
458
+ if (id !== undefined) sendError(id, -32601, `Method not found: ${method}`);
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Stdio transport: read Content-Length framed JSON-RPC messages
463
+ // ---------------------------------------------------------------------------
464
+ let buffer = Buffer.alloc(0);
465
+
466
+ process.stdin.on("data", (chunk) => {
467
+ buffer = Buffer.concat([buffer,chunk]);
468
+ while (true) {
469
+ if(buffer.length > 32*1024*1024) {process.stdin.destroy();void scriptRuntime.close();return;}
470
+ if(buffer[0]===123) {
471
+ const end=buffer.indexOf('\n');if(end===-1)break;
472
+ transportFormat='ndjson';
473
+ const line=buffer.subarray(0,end).toString('utf8');buffer=buffer.subarray(end+1);
474
+ try {const msg=JSON.parse(line);void handleMessage(msg).catch(error=>sendError(msg.id,-32603,error.message));}catch(error){sendError(null,-32700,error.message);}
475
+ continue;
476
+ }
477
+ const headerEnd = buffer.indexOf("\r\n\r\n");
478
+ if (headerEnd === -1) break;
479
+ const headerBlock = buffer.subarray(0, headerEnd).toString('ascii');
480
+ const match = headerBlock.match(/Content-Length:\s*(\d+)/i);
481
+ if (!match) { buffer = buffer.slice(headerEnd + 4); continue; }
482
+ const contentLength = parseInt(match[1], 10);
483
+ const bodyStart = headerEnd + 4;
484
+ if (buffer.length < bodyStart + contentLength) break;
485
+ const body = buffer.subarray(bodyStart, bodyStart + contentLength).toString('utf8');
486
+ buffer = buffer.slice(bodyStart + contentLength);
487
+ try {
488
+ const msg = JSON.parse(body);
489
+ handleMessage(msg).catch((err) => {
490
+ console.error("MCP handler error:", err);
491
+ if (msg.id !== undefined) sendError(msg.id, -32603, err.message || String(err));
492
+ });
493
+ } catch (err) {
494
+ console.error("MCP parse error:", err);
495
+ }
496
+ }
497
+ });
498
+
499
+ const heartbeat=setInterval(()=>{for(const sessionId of ownedSessions)void relayRequest('POST','/api/sessions',{sessionId,action:'heartbeat'},{timeoutMs:5000}).catch(()=>ownedSessions.delete(sessionId));},40000);
500
+ heartbeat.unref();
501
+ async function shutdown(){clearInterval(heartbeat);for(const context of calls.values())context.controller.abort();await scriptRuntime.close();await Promise.allSettled([...ownedSessions].map(sessionId=>relayRequest('POST','/api/sessions',{sessionId,action:'stop'},{timeoutMs:2500})));process.exit(0)}
502
+ process.stdin.on('end',shutdown);
503
+ process.on('SIGTERM',shutdown);
504
+ process.on('SIGINT',shutdown);
@@ -0,0 +1,96 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { statSync } from "node:fs";
3
+ import { platform } from "node:os";
4
+ import { basename, dirname, isAbsolute, join } from "node:path";
5
+
6
+ const WINDOWS_NPX_CLI_OVERRIDE = "BROWSER_RELAY_NPX_CLI";
7
+
8
+ function isRegularAbsoluteFile(candidate, statSyncFn) {
9
+ if (typeof candidate !== "string" || !isAbsolute(candidate)) return false;
10
+ try {
11
+ return statSyncFn(candidate).isFile();
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ function missingNpxCliError(message) {
18
+ return Object.assign(new Error(message), { code: "ENOENT" });
19
+ }
20
+
21
+ export function resolveWindowsNpxCli(options = {}) {
22
+ const sourceEnv = options.env || process.env;
23
+ const execPath = options.execPath || process.execPath;
24
+ const statSyncFn = options.statSyncFn || statSync;
25
+
26
+ if (Object.prototype.hasOwnProperty.call(sourceEnv, WINDOWS_NPX_CLI_OVERRIDE)) {
27
+ const override = sourceEnv[WINDOWS_NPX_CLI_OVERRIDE];
28
+ if (isRegularAbsoluteFile(override, statSyncFn)) return override;
29
+ throw missingNpxCliError(
30
+ `${WINDOWS_NPX_CLI_OVERRIDE} must point to an existing absolute npx-cli.js file`,
31
+ );
32
+ }
33
+
34
+ const npmExecPath = sourceEnv.npm_execpath;
35
+ if (
36
+ typeof npmExecPath === "string"
37
+ && isAbsolute(npmExecPath)
38
+ && basename(npmExecPath).toLowerCase() === "npm-cli.js"
39
+ ) {
40
+ const sibling = join(dirname(npmExecPath), "npx-cli.js");
41
+ if (isRegularAbsoluteFile(sibling, statSyncFn)) return sibling;
42
+ }
43
+
44
+ if (typeof execPath === "string" && isAbsolute(execPath)) {
45
+ const bundled = join(dirname(execPath), "node_modules", "npm", "bin", "npx-cli.js");
46
+ if (isRegularAbsoluteFile(bundled, statSyncFn)) return bundled;
47
+ }
48
+
49
+ throw missingNpxCliError(
50
+ `Could not locate npm's npx-cli.js for Node ${execPath}. Reinstall Node.js with npm or set ${WINDOWS_NPX_CLI_OVERRIDE}.`,
51
+ );
52
+ }
53
+
54
+ export function buildNpxInvocation(args, options = {}) {
55
+ const platformName = options.platformName || platform();
56
+ const sourceEnv = options.env || process.env;
57
+ if (platformName !== "win32") {
58
+ return { command: "npx", args: [...args], env: sourceEnv };
59
+ }
60
+
61
+ // Windows .cmd shims reparse `%*`, which can corrupt paths containing
62
+ // spaces or shell metacharacters. Execute npm's JavaScript npx entry point
63
+ // with the current Node process so every value remains a real argv item.
64
+ const execPath = options.execPath || process.execPath;
65
+ const npxCli = resolveWindowsNpxCli({
66
+ env: sourceEnv,
67
+ execPath,
68
+ statSyncFn: options.statSyncFn,
69
+ });
70
+ return {
71
+ command: execPath,
72
+ args: [npxCli, ...args],
73
+ env: sourceEnv,
74
+ };
75
+ }
76
+
77
+ export function runNpxSync(args, options = {}) {
78
+ const {
79
+ platformName,
80
+ env = process.env,
81
+ execPath,
82
+ statSyncFn,
83
+ spawnSyncFn = spawnSync,
84
+ ...spawnOptions
85
+ } = options;
86
+ let invocation;
87
+ try {
88
+ invocation = buildNpxInvocation(args, { platformName, env, execPath, statSyncFn });
89
+ } catch (error) {
90
+ return { error, status: null, signal: null, stdout: null, stderr: null };
91
+ }
92
+ return spawnSyncFn(invocation.command, invocation.args, {
93
+ ...spawnOptions,
94
+ env: invocation.env,
95
+ });
96
+ }